2612-есть ок OCR, нужно допиливать бота под новый flow для операторов

This commit is contained in:
2026-01-27 00:17:10 +03:00
parent 7d2ffb54b5
commit 1843cb9c20
22 changed files with 1011 additions and 577 deletions

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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 {