mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2612-есть ок OCR, нужно допиливать бота под новый flow для операторов
This commit is contained in:
@@ -2,7 +2,9 @@ package ocr_client
|
||||
|
||||
// RecognitionResult - ответ от Python сервиса
|
||||
type RecognitionResult struct {
|
||||
Items []RecognizedItem `json:"items"`
|
||||
Items []RecognizedItem `json:"items"`
|
||||
DocNumber string `json:"doc_number"`
|
||||
DocDate string `json:"doc_date"`
|
||||
}
|
||||
|
||||
type RecognizedItem struct {
|
||||
|
||||
@@ -78,8 +78,8 @@ func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessReceiptImage - Доступно всем (включая Операторов)
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
|
||||
// ProcessDocument - Доступно всем (включая Операторов)
|
||||
func (s *Service) ProcessDocument(ctx context.Context, userID uuid.UUID, imgData []byte, filename string) (*drafts.DraftInvoice, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no active server for user")
|
||||
@@ -90,7 +90,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
photoID := uuid.New()
|
||||
draftID := uuid.New()
|
||||
|
||||
fileName := fmt.Sprintf("receipt_%s.jpg", photoID.String())
|
||||
fileName := fmt.Sprintf("receipt_%s_%s", photoID.String(), filename)
|
||||
filePath := filepath.Join(s.storagePath, serverID.String(), fileName)
|
||||
|
||||
// 2. Создаем директорию если не существует
|
||||
@@ -102,7 +102,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
if err := os.WriteFile(filePath, imgData, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to save image: %w", err)
|
||||
}
|
||||
fileURL := "/uploads/" + fileName
|
||||
fileURL := fmt.Sprintf("/uploads/%s/%s", serverID.String(), fileName)
|
||||
|
||||
// 4. Создаем запись ReceiptPhoto
|
||||
photo := &photos.ReceiptPhoto{
|
||||
@@ -111,7 +111,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
UploadedBy: userID,
|
||||
FilePath: filePath,
|
||||
FileURL: fileURL,
|
||||
FileName: fileName,
|
||||
FileName: filename,
|
||||
FileSize: int64(len(imgData)),
|
||||
DraftID: &draftID, // Сразу связываем с будущим черновиком
|
||||
CreatedAt: time.Now(),
|
||||
@@ -140,14 +140,26 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
}
|
||||
|
||||
// 6. Отправляем в Python OCR
|
||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, filename)
|
||||
if err != nil {
|
||||
draft.Status = drafts.StatusError
|
||||
_ = s.draftRepo.Update(draft)
|
||||
return nil, fmt.Errorf("python ocr error: %w", err)
|
||||
}
|
||||
|
||||
// 6. Матчинг и сохранение позиций
|
||||
// Парсим дату документа
|
||||
var dateIncoming *time.Time
|
||||
if rawResult.DocDate != "" {
|
||||
if t, err := time.Parse("02.01.2006", rawResult.DocDate); err == nil {
|
||||
dateIncoming = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Заполняем номер и дату документа
|
||||
draft.IncomingDocumentNumber = rawResult.DocNumber
|
||||
draft.DateIncoming = dateIncoming
|
||||
|
||||
// 7. Матчинг и сохранение позиций
|
||||
var draftItems []drafts.DraftInvoiceItem
|
||||
for _, rawItem := range rawResult.Items {
|
||||
item := drafts.DraftInvoiceItem{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,9 +35,11 @@ type Bot struct {
|
||||
rmsFactory *rms.Factory
|
||||
cryptoManager *crypto.CryptoManager
|
||||
|
||||
fsm *StateManager
|
||||
adminIDs map[int64]struct{}
|
||||
webAppURL string
|
||||
fsm *StateManager
|
||||
adminIDs map[int64]struct{}
|
||||
devIDs map[int64]struct{}
|
||||
maintenanceMode bool
|
||||
webAppURL string
|
||||
|
||||
menuServers *tele.ReplyMarkup
|
||||
menuDicts *tele.ReplyMarkup
|
||||
@@ -51,6 +54,8 @@ func NewBot(
|
||||
accountRepo account.Repository,
|
||||
rmsFactory *rms.Factory,
|
||||
cryptoManager *crypto.CryptoManager,
|
||||
maintenanceMode bool,
|
||||
devIDs []int64,
|
||||
) (*Bot, error) {
|
||||
|
||||
pref := tele.Settings{
|
||||
@@ -71,17 +76,24 @@ func NewBot(
|
||||
admins[id] = struct{}{}
|
||||
}
|
||||
|
||||
devs := make(map[int64]struct{})
|
||||
for _, id := range devIDs {
|
||||
devs[id] = struct{}{}
|
||||
}
|
||||
|
||||
bot := &Bot{
|
||||
b: b,
|
||||
ocrService: ocrService,
|
||||
syncService: syncService,
|
||||
billingService: billingService,
|
||||
accountRepo: accountRepo,
|
||||
rmsFactory: rmsFactory,
|
||||
cryptoManager: cryptoManager,
|
||||
fsm: NewStateManager(),
|
||||
adminIDs: admins,
|
||||
webAppURL: cfg.WebAppURL,
|
||||
b: b,
|
||||
ocrService: ocrService,
|
||||
syncService: syncService,
|
||||
billingService: billingService,
|
||||
accountRepo: accountRepo,
|
||||
rmsFactory: rmsFactory,
|
||||
cryptoManager: cryptoManager,
|
||||
fsm: NewStateManager(),
|
||||
adminIDs: admins,
|
||||
devIDs: devs,
|
||||
maintenanceMode: maintenanceMode,
|
||||
webAppURL: cfg.WebAppURL,
|
||||
}
|
||||
|
||||
if bot.webAppURL == "" {
|
||||
@@ -93,6 +105,11 @@ func NewBot(
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
func (bot *Bot) isDev(userID int64) bool {
|
||||
_, ok := bot.devIDs[userID]
|
||||
return !bot.maintenanceMode || ok
|
||||
}
|
||||
|
||||
func (bot *Bot) initMenus() {
|
||||
bot.menuServers = &tele.ReplyMarkup{}
|
||||
|
||||
@@ -134,6 +151,7 @@ func (bot *Bot) initHandlers() {
|
||||
|
||||
bot.b.Handle(tele.OnText, bot.handleText)
|
||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||
bot.b.Handle(tele.OnDocument, bot.handleDocument)
|
||||
}
|
||||
|
||||
func (bot *Bot) Start() {
|
||||
@@ -160,6 +178,11 @@ func (bot *Bot) handleStartCommand(c tele.Context) error {
|
||||
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
||||
}
|
||||
|
||||
userID := c.Sender().ID
|
||||
if bot.maintenanceMode && !bot.isDev(userID) {
|
||||
return c.Send("🛠 **Сервис на техническом обслуживании** Мы проводим плановые работы... 📸 **Вы можете отправить фото чека или накладной** прямо в этот чат — наша команда обработает его и добавит в систему вручную.", tele.ModeHTML)
|
||||
}
|
||||
|
||||
welcomeTxt := "🚀 <b>RMSer — ваш умный ассистент для iiko</b>\n\n" +
|
||||
"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" +
|
||||
"<b>Почему это удобно:</b>\n" +
|
||||
@@ -443,6 +466,10 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
||||
}
|
||||
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
userID := c.Sender().ID
|
||||
if bot.maintenanceMode && !bot.isDev(userID) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Сервис на обслуживании"})
|
||||
}
|
||||
|
||||
// --- INTEGRATION: Billing Callbacks ---
|
||||
if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") {
|
||||
@@ -713,6 +740,10 @@ func (bot *Bot) handleText(c tele.Context) error {
|
||||
state := bot.fsm.GetState(userID)
|
||||
text := strings.TrimSpace(c.Text())
|
||||
|
||||
if bot.maintenanceMode && !bot.isDev(userID) {
|
||||
return c.Send("Сервис на обслуживании", tele.ModeHTML)
|
||||
}
|
||||
|
||||
if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" {
|
||||
bot.fsm.Reset(userID)
|
||||
return bot.renderMainMenu(c)
|
||||
@@ -799,6 +830,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
if err != nil {
|
||||
return c.Send("Ошибка базы данных пользователей")
|
||||
}
|
||||
userID := c.Sender().ID
|
||||
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
||||
if err != nil {
|
||||
return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.")
|
||||
@@ -821,7 +853,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
c.Send("⏳ <b>ИИ анализирует документ...</b>\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)
|
||||
draft, err := bot.ocrService.ProcessDocument(ctx, userDB.ID, imgData, "photo.jpg")
|
||||
if err != nil {
|
||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||
@@ -832,23 +864,105 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
matchedCount++
|
||||
}
|
||||
}
|
||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||
var msgText string
|
||||
if matchedCount == len(draft.Items) {
|
||||
msgText = fmt.Sprintf("✅ <b>Все позиции распознаны!</b>\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items))
|
||||
if bot.isDev(userID) {
|
||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||
var msgText string
|
||||
if matchedCount == len(draft.Items) {
|
||||
msgText = fmt.Sprintf("✅ <b>Все позиции распознаны!</b>\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items))
|
||||
} else {
|
||||
msgText = fmt.Sprintf("⚠️ <b>Распознано позиций: %d из %d</b>\n\nОстальные товары я вижу впервые. Воспользуйтесь <b>удобным интерфейсом сопоставления</b> в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items))
|
||||
}
|
||||
menu := &tele.ReplyMarkup{}
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
||||
if role != account.RoleOperator {
|
||||
btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL})
|
||||
menu.Inline(menu.Row(btnOpen))
|
||||
} else {
|
||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||
}
|
||||
return c.Send(msgText, menu, tele.ModeHTML)
|
||||
} else {
|
||||
msgText = fmt.Sprintf("⚠️ <b>Распознано позиций: %d из %d</b>\n\nОстальные товары я вижу впервые. Воспользуйтесь <b>удобным интерфейсом сопоставления</b> в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items))
|
||||
return c.Send("✅ **Фото принято!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML)
|
||||
}
|
||||
menu := &tele.ReplyMarkup{}
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
||||
if role != account.RoleOperator {
|
||||
btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL})
|
||||
menu.Inline(menu.Row(btnOpen))
|
||||
}
|
||||
|
||||
func (bot *Bot) handleDocument(c tele.Context) error {
|
||||
userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "")
|
||||
if err != nil {
|
||||
return c.Send("Ошибка базы данных пользователей")
|
||||
}
|
||||
userID := c.Sender().ID
|
||||
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
||||
if err != nil {
|
||||
return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.")
|
||||
}
|
||||
|
||||
doc := c.Message().Document
|
||||
filename := doc.FileName
|
||||
|
||||
// Проверяем расширение файла
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
allowedExtensions := map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".xlsx": true,
|
||||
}
|
||||
if !allowedExtensions[ext] {
|
||||
return c.Send("❌ Неподдерживаемый формат файла. Пожалуйста, отправьте изображение (.jpg, .jpeg, .png) или Excel файл (.xlsx).")
|
||||
}
|
||||
|
||||
file, err := bot.b.FileByID(doc.FileID)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка доступа к файлу.")
|
||||
}
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath)
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка скачивания файла.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
fileData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка чтения файла.")
|
||||
}
|
||||
|
||||
c.Send("⏳ <b>ИИ анализирует документ...</b>\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
draft, err := bot.ocrService.ProcessDocument(ctx, userDB.ID, fileData, filename)
|
||||
if err != nil {
|
||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||
}
|
||||
matchedCount := 0
|
||||
for _, item := range draft.Items {
|
||||
if item.IsMatched {
|
||||
matchedCount++
|
||||
}
|
||||
}
|
||||
if bot.isDev(userID) {
|
||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||
var msgText string
|
||||
if matchedCount == len(draft.Items) {
|
||||
msgText = fmt.Sprintf("✅ <b>Все позиции распознаны!</b>\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items))
|
||||
} else {
|
||||
msgText = fmt.Sprintf("⚠️ <b>Распознано позиций: %d из %d</b>\n\nОстальные товары я вижу впервые. Воспользуйтесь <b>удобным интерфейсом сопоставления</b> в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items))
|
||||
}
|
||||
menu := &tele.ReplyMarkup{}
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
||||
if role != account.RoleOperator {
|
||||
btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL})
|
||||
menu.Inline(menu.Row(btnOpen))
|
||||
} else {
|
||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||
}
|
||||
return c.Send(msgText, menu, tele.ModeHTML)
|
||||
} else {
|
||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||
return c.Send("✅ **Документ принят!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML)
|
||||
}
|
||||
return c.Send(msgText, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
|
||||
|
||||
Reference in New Issue
Block a user