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:
@@ -113,7 +113,7 @@ func main() {
|
|||||||
|
|
||||||
// 8. Telegram Bot (Передаем syncService)
|
// 8. Telegram Bot (Передаем syncService)
|
||||||
if cfg.Telegram.Token != "" {
|
if cfg.Telegram.Token != "" {
|
||||||
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager)
|
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, cfg.App.MaintenanceMode, cfg.App.DevIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
184
go_backend_dump.py
Normal file
184
go_backend_dump.py
Normal file
File diff suppressed because one or more lines are too long
@@ -2,7 +2,9 @@ package ocr_client
|
|||||||
|
|
||||||
// RecognitionResult - ответ от Python сервиса
|
// RecognitionResult - ответ от Python сервиса
|
||||||
type RecognitionResult struct {
|
type RecognitionResult struct {
|
||||||
Items []RecognizedItem `json:"items"`
|
Items []RecognizedItem `json:"items"`
|
||||||
|
DocNumber string `json:"doc_number"`
|
||||||
|
DocDate string `json:"doc_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecognizedItem struct {
|
type RecognizedItem struct {
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessReceiptImage - Доступно всем (включая Операторов)
|
// ProcessDocument - Доступно всем (включая Операторов)
|
||||||
func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
|
func (s *Service) ProcessDocument(ctx context.Context, userID uuid.UUID, imgData []byte, filename string) (*drafts.DraftInvoice, error) {
|
||||||
server, err := s.accountRepo.GetActiveServer(userID)
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
if err != nil || server == nil {
|
if err != nil || server == nil {
|
||||||
return nil, fmt.Errorf("no active server for user")
|
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()
|
photoID := uuid.New()
|
||||||
draftID := 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)
|
filePath := filepath.Join(s.storagePath, serverID.String(), fileName)
|
||||||
|
|
||||||
// 2. Создаем директорию если не существует
|
// 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 {
|
if err := os.WriteFile(filePath, imgData, 0644); err != nil {
|
||||||
return nil, fmt.Errorf("failed to save image: %w", err)
|
return nil, fmt.Errorf("failed to save image: %w", err)
|
||||||
}
|
}
|
||||||
fileURL := "/uploads/" + fileName
|
fileURL := fmt.Sprintf("/uploads/%s/%s", serverID.String(), fileName)
|
||||||
|
|
||||||
// 4. Создаем запись ReceiptPhoto
|
// 4. Создаем запись ReceiptPhoto
|
||||||
photo := &photos.ReceiptPhoto{
|
photo := &photos.ReceiptPhoto{
|
||||||
@@ -111,7 +111,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
|||||||
UploadedBy: userID,
|
UploadedBy: userID,
|
||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
FileURL: fileURL,
|
FileURL: fileURL,
|
||||||
FileName: fileName,
|
FileName: filename,
|
||||||
FileSize: int64(len(imgData)),
|
FileSize: int64(len(imgData)),
|
||||||
DraftID: &draftID, // Сразу связываем с будущим черновиком
|
DraftID: &draftID, // Сразу связываем с будущим черновиком
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -140,14 +140,26 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Отправляем в Python OCR
|
// 6. Отправляем в Python OCR
|
||||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
draft.Status = drafts.StatusError
|
draft.Status = drafts.StatusError
|
||||||
_ = s.draftRepo.Update(draft)
|
_ = s.draftRepo.Update(draft)
|
||||||
return nil, fmt.Errorf("python ocr error: %w", err)
|
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
|
var draftItems []drafts.DraftInvoiceItem
|
||||||
for _, rawItem := range rawResult.Items {
|
for _, rawItem := range rawResult.Items {
|
||||||
item := drafts.DraftInvoiceItem{
|
item := drafts.DraftInvoiceItem{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -34,9 +35,11 @@ type Bot struct {
|
|||||||
rmsFactory *rms.Factory
|
rmsFactory *rms.Factory
|
||||||
cryptoManager *crypto.CryptoManager
|
cryptoManager *crypto.CryptoManager
|
||||||
|
|
||||||
fsm *StateManager
|
fsm *StateManager
|
||||||
adminIDs map[int64]struct{}
|
adminIDs map[int64]struct{}
|
||||||
webAppURL string
|
devIDs map[int64]struct{}
|
||||||
|
maintenanceMode bool
|
||||||
|
webAppURL string
|
||||||
|
|
||||||
menuServers *tele.ReplyMarkup
|
menuServers *tele.ReplyMarkup
|
||||||
menuDicts *tele.ReplyMarkup
|
menuDicts *tele.ReplyMarkup
|
||||||
@@ -51,6 +54,8 @@ func NewBot(
|
|||||||
accountRepo account.Repository,
|
accountRepo account.Repository,
|
||||||
rmsFactory *rms.Factory,
|
rmsFactory *rms.Factory,
|
||||||
cryptoManager *crypto.CryptoManager,
|
cryptoManager *crypto.CryptoManager,
|
||||||
|
maintenanceMode bool,
|
||||||
|
devIDs []int64,
|
||||||
) (*Bot, error) {
|
) (*Bot, error) {
|
||||||
|
|
||||||
pref := tele.Settings{
|
pref := tele.Settings{
|
||||||
@@ -71,17 +76,24 @@ func NewBot(
|
|||||||
admins[id] = struct{}{}
|
admins[id] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
devs := make(map[int64]struct{})
|
||||||
|
for _, id := range devIDs {
|
||||||
|
devs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
bot := &Bot{
|
bot := &Bot{
|
||||||
b: b,
|
b: b,
|
||||||
ocrService: ocrService,
|
ocrService: ocrService,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
billingService: billingService,
|
billingService: billingService,
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
rmsFactory: rmsFactory,
|
rmsFactory: rmsFactory,
|
||||||
cryptoManager: cryptoManager,
|
cryptoManager: cryptoManager,
|
||||||
fsm: NewStateManager(),
|
fsm: NewStateManager(),
|
||||||
adminIDs: admins,
|
adminIDs: admins,
|
||||||
webAppURL: cfg.WebAppURL,
|
devIDs: devs,
|
||||||
|
maintenanceMode: maintenanceMode,
|
||||||
|
webAppURL: cfg.WebAppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if bot.webAppURL == "" {
|
if bot.webAppURL == "" {
|
||||||
@@ -93,6 +105,11 @@ func NewBot(
|
|||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) isDev(userID int64) bool {
|
||||||
|
_, ok := bot.devIDs[userID]
|
||||||
|
return !bot.maintenanceMode || ok
|
||||||
|
}
|
||||||
|
|
||||||
func (bot *Bot) initMenus() {
|
func (bot *Bot) initMenus() {
|
||||||
bot.menuServers = &tele.ReplyMarkup{}
|
bot.menuServers = &tele.ReplyMarkup{}
|
||||||
|
|
||||||
@@ -134,6 +151,7 @@ func (bot *Bot) initHandlers() {
|
|||||||
|
|
||||||
bot.b.Handle(tele.OnText, bot.handleText)
|
bot.b.Handle(tele.OnText, bot.handleText)
|
||||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||||
|
bot.b.Handle(tele.OnDocument, bot.handleDocument)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) Start() {
|
func (bot *Bot) Start() {
|
||||||
@@ -160,6 +178,11 @@ func (bot *Bot) handleStartCommand(c tele.Context) error {
|
|||||||
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
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" +
|
welcomeTxt := "🚀 <b>RMSer — ваш умный ассистент для iiko</b>\n\n" +
|
||||||
"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" +
|
"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" +
|
||||||
"<b>Почему это удобно:</b>\n" +
|
"<b>Почему это удобно:</b>\n" +
|
||||||
@@ -443,6 +466,10 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
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 ---
|
// --- INTEGRATION: Billing Callbacks ---
|
||||||
if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") {
|
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)
|
state := bot.fsm.GetState(userID)
|
||||||
text := strings.TrimSpace(c.Text())
|
text := strings.TrimSpace(c.Text())
|
||||||
|
|
||||||
|
if bot.maintenanceMode && !bot.isDev(userID) {
|
||||||
|
return c.Send("Сервис на обслуживании", tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" {
|
if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" {
|
||||||
bot.fsm.Reset(userID)
|
bot.fsm.Reset(userID)
|
||||||
return bot.renderMainMenu(c)
|
return bot.renderMainMenu(c)
|
||||||
@@ -799,6 +830,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка базы данных пользователей")
|
return c.Send("Ошибка базы данных пользователей")
|
||||||
}
|
}
|
||||||
|
userID := c.Sender().ID
|
||||||
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.")
|
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)
|
c.Send("⏳ <b>ИИ анализирует документ...</b>\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||||
@@ -832,23 +864,105 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
matchedCount++
|
matchedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
if bot.isDev(userID) {
|
||||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||||
var msgText string
|
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||||
if matchedCount == len(draft.Items) {
|
var msgText string
|
||||||
msgText = fmt.Sprintf("✅ <b>Все позиции распознаны!</b>\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items))
|
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 {
|
} 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 {
|
func (bot *Bot) handleDocument(c tele.Context) error {
|
||||||
btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL})
|
userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "")
|
||||||
menu.Inline(menu.Row(btnOpen))
|
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 {
|
} 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 {
|
func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
|
||||||
|
|||||||
@@ -2,13 +2,10 @@
|
|||||||
FROM python:3.10-slim
|
FROM python:3.10-slim
|
||||||
|
|
||||||
# Установка системных зависимостей
|
# Установка системных зависимостей
|
||||||
# tesseract-ocr + rus: для распознавания текста
|
|
||||||
# libgl1, libglib2.0-0: для работы OpenCV
|
# libgl1, libglib2.0-0: для работы OpenCV
|
||||||
# libzbar0: для сканирования QR-кодов
|
# libzbar0: для сканирования QR-кодов
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
curl \
|
curl \
|
||||||
tesseract-ocr \
|
|
||||||
tesseract-ocr-rus \
|
|
||||||
libgl1 \
|
libgl1 \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libzbar0 \
|
libzbar0 \
|
||||||
@@ -31,4 +28,4 @@ COPY . .
|
|||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Запускаем приложение
|
# Запускаем приложение
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||||
@@ -8,13 +8,12 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
# Импортируем модули
|
# Импортируем модули
|
||||||
from imgproc import preprocess_image
|
from app.schemas.models import ParsedItem, RecognitionResult
|
||||||
from parser import parse_receipt_text, ParsedItem, extract_fiscal_data
|
from app.services.qr import detect_and_decode_qr, fetch_data_from_api, extract_fiscal_data
|
||||||
from ocr import ocr_engine
|
|
||||||
from qr_manager import detect_and_decode_qr, fetch_data_from_api
|
|
||||||
# Импортируем новый модуль
|
# Импортируем новый модуль
|
||||||
from yandex_ocr import yandex_engine
|
from app.services.ocr import yandex_engine
|
||||||
from llm_parser import llm_parser
|
from app.services.llm import llm_parser
|
||||||
|
from app.services.excel import extract_text_from_excel
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -22,12 +21,7 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(title="RMSER OCR Service (Hybrid: QR + Yandex + Tesseract)")
|
app = FastAPI(title="RMSER OCR Service (Cloud-only: QR + Yandex + GigaChat)")
|
||||||
|
|
||||||
class RecognitionResult(BaseModel):
|
|
||||||
source: str # 'qr_api', 'yandex_vision', 'tesseract_ocr'
|
|
||||||
items: List[ParsedItem]
|
|
||||||
raw_text: str = ""
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
@@ -37,20 +31,52 @@ def health_check():
|
|||||||
async def recognize_receipt(image: UploadFile = File(...)):
|
async def recognize_receipt(image: UploadFile = File(...)):
|
||||||
"""
|
"""
|
||||||
Стратегия:
|
Стратегия:
|
||||||
1. QR Code + FNS API (Приоритет 1 - Идеальная точность)
|
1. Excel файл (.xlsx) -> Извлечение текста -> LLM парсинг
|
||||||
2. Yandex Vision OCR (Приоритет 2 - Высокая точность, если настроен)
|
2. QR Code + FNS API (Приоритет 1 - Идеальная точность)
|
||||||
3. Tesseract OCR (Приоритет 3 - Локальный фолбэк)
|
3. Yandex Vision OCR + LLM (Приоритет 2 - Высокая точность, если настроен)
|
||||||
|
Если ничего не найдено, возвращает пустой результат.
|
||||||
"""
|
"""
|
||||||
logger.info(f"Received file: {image.filename}, content_type: {image.content_type}")
|
logger.info(f"Received file: {image.filename}, content_type: {image.content_type}")
|
||||||
|
|
||||||
|
# Проверка на Excel файл
|
||||||
|
if image.filename and image.filename.lower().endswith('.xlsx'):
|
||||||
|
logger.info("Processing Excel file...")
|
||||||
|
try:
|
||||||
|
content = await image.read()
|
||||||
|
excel_text = extract_text_from_excel(content)
|
||||||
|
|
||||||
|
if excel_text:
|
||||||
|
logger.info(f"Excel text extracted, length: {len(excel_text)}")
|
||||||
|
logger.info("Calling LLM Manager to parse Excel text...")
|
||||||
|
excel_result = llm_parser.parse_receipt(excel_text)
|
||||||
|
|
||||||
|
return RecognitionResult(
|
||||||
|
source="excel_llm",
|
||||||
|
items=excel_result["items"],
|
||||||
|
raw_text=excel_text,
|
||||||
|
doc_number=excel_result["doc_number"],
|
||||||
|
doc_date=excel_result["doc_date"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Excel file is empty or contains no text")
|
||||||
|
return RecognitionResult(
|
||||||
|
source="none",
|
||||||
|
items=[],
|
||||||
|
raw_text=""
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Excel file: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error processing Excel file: {str(e)}")
|
||||||
|
|
||||||
|
# Проверка на изображение
|
||||||
if not image.content_type.startswith("image/"):
|
if not image.content_type.startswith("image/"):
|
||||||
raise HTTPException(status_code=400, detail="File must be an image")
|
raise HTTPException(status_code=400, detail="File must be an image or .xlsx file")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Читаем сырые байты
|
# Читаем сырые байты
|
||||||
content = await image.read()
|
content = await image.read()
|
||||||
|
|
||||||
# Конвертируем в numpy для QR и локального препроцессинга
|
# Конвертируем в numpy для QR
|
||||||
nparr = np.frombuffer(content, np.uint8)
|
nparr = np.frombuffer(content, np.uint8)
|
||||||
original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
@@ -78,7 +104,7 @@ async def recognize_receipt(image: UploadFile = File(...)):
|
|||||||
logger.info("QR code not found. Proceeding to OCR.")
|
logger.info("QR code not found. Proceeding to OCR.")
|
||||||
|
|
||||||
# --- ЭТАП 2: OCR + Virtual QR Strategy ---
|
# --- ЭТАП 2: OCR + Virtual QR Strategy ---
|
||||||
if yandex_engine.oauth_token and yandex_engine.folder_id:
|
if yandex_engine.is_configured():
|
||||||
logger.info("--- Stage 2: Yandex Vision OCR + Virtual QR ---")
|
logger.info("--- Stage 2: Yandex Vision OCR + Virtual QR ---")
|
||||||
yandex_text = yandex_engine.recognize(content)
|
yandex_text = yandex_engine.recognize(content)
|
||||||
|
|
||||||
@@ -98,42 +124,31 @@ async def recognize_receipt(image: UploadFile = File(...)):
|
|||||||
raw_text=yandex_text
|
raw_text=yandex_text
|
||||||
)
|
)
|
||||||
|
|
||||||
# Если виртуальный QR не сработал, пробуем Regex
|
# Вызываем LLM для парсинга текста
|
||||||
yandex_items = parse_receipt_text(yandex_text)
|
logger.info("Calling LLM Manager to parse text...")
|
||||||
|
yandex_result = llm_parser.parse_receipt(yandex_text)
|
||||||
# Если Regex пуст — вызываем LLM (GigaChat / YandexGPT)
|
|
||||||
if not yandex_items:
|
|
||||||
logger.info("Regex found nothing. Calling LLM Manager...")
|
|
||||||
iam_token = yandex_engine._get_iam_token()
|
|
||||||
yandex_items = llm_parser.parse_with_priority(yandex_text, iam_token)
|
|
||||||
|
|
||||||
return RecognitionResult(
|
return RecognitionResult(
|
||||||
source="yandex_vision_llm",
|
source="yandex_vision_llm",
|
||||||
items=yandex_items,
|
items=yandex_result["items"],
|
||||||
raw_text=yandex_text
|
raw_text=yandex_text,
|
||||||
|
doc_number=yandex_result["doc_number"],
|
||||||
|
doc_date=yandex_result["doc_date"]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("Yandex Vision returned empty text or failed. Falling back to Tesseract.")
|
logger.warning("Yandex Vision returned empty text or failed. No fallback available.")
|
||||||
|
return RecognitionResult(
|
||||||
|
source="none",
|
||||||
|
items=[],
|
||||||
|
raw_text=""
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Yandex Vision credentials not set. Skipping Stage 2.")
|
logger.info("Yandex Vision credentials not set. No OCR available.")
|
||||||
|
return RecognitionResult(
|
||||||
# --- ЭТАП 3: Tesseract Strategy (Local Fallback) ---
|
source="none",
|
||||||
logger.info("--- Stage 3: Tesseract OCR (Local) ---")
|
items=[],
|
||||||
|
raw_text=""
|
||||||
# 1. Image Processing (бинаризация, выравнивание)
|
)
|
||||||
processed_img = preprocess_image(content)
|
|
||||||
|
|
||||||
# 2. OCR
|
|
||||||
tesseract_text = ocr_engine.recognize(processed_img)
|
|
||||||
|
|
||||||
# 3. Parsing
|
|
||||||
ocr_items = parse_receipt_text(tesseract_text)
|
|
||||||
|
|
||||||
return RecognitionResult(
|
|
||||||
source="tesseract_ocr",
|
|
||||||
items=ocr_items,
|
|
||||||
raw_text=tesseract_text
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing request: {e}", exc_info=True)
|
logger.error(f"Error processing request: {e}", exc_info=True)
|
||||||
15
ocr-service/app/schemas/models.py
Normal file
15
ocr-service/app/schemas/models.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class ParsedItem(BaseModel):
|
||||||
|
raw_name: str
|
||||||
|
amount: float
|
||||||
|
price: float
|
||||||
|
sum: float
|
||||||
|
|
||||||
|
class RecognitionResult(BaseModel):
|
||||||
|
source: str # 'qr_api', 'virtual_qr_api', 'yandex_vision_llm', 'none'
|
||||||
|
items: List[ParsedItem]
|
||||||
|
raw_text: str = ""
|
||||||
|
doc_number: str = "" # Номер документа
|
||||||
|
doc_date: str = "" # Дата документа
|
||||||
68
ocr-service/app/services/auth.py
Normal file
68
ocr-service/app/services/auth.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IAM_TOKEN_URL = "https://iam.api.cloud.yandex.net/iam/v1/tokens"
|
||||||
|
|
||||||
|
class YandexAuthManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.oauth_token = os.getenv("YANDEX_OAUTH_TOKEN")
|
||||||
|
|
||||||
|
# Кэширование IAM токена
|
||||||
|
self._iam_token = None
|
||||||
|
self._token_expire_time = 0
|
||||||
|
|
||||||
|
if not self.oauth_token:
|
||||||
|
logger.warning("YANDEX_OAUTH_TOKEN not set. Yandex services will be unavailable.")
|
||||||
|
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return bool(self.oauth_token)
|
||||||
|
|
||||||
|
def reset_token(self):
|
||||||
|
"""Сбрасывает кэшированный токен, заставляя получить новый при следующем вызове."""
|
||||||
|
self._iam_token = None
|
||||||
|
self._token_expire_time = 0
|
||||||
|
|
||||||
|
def get_iam_token(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Получает IAM-токен. Если есть живой кэшированный — возвращает его.
|
||||||
|
Если нет — обменивает OAuth на IAM.
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Если токен есть и он "свежий" (с запасом в 5 минут)
|
||||||
|
if self._iam_token and current_time < self._token_expire_time - 300:
|
||||||
|
return self._iam_token
|
||||||
|
|
||||||
|
if not self.oauth_token:
|
||||||
|
logger.error("OAuth token not available.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("Obtaining new IAM token from Yandex...")
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
IAM_TOKEN_URL,
|
||||||
|
json={"yandexPassportOauthToken": self.oauth_token},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self._iam_token = data["iamToken"]
|
||||||
|
|
||||||
|
# Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно,
|
||||||
|
# или просто поставим таймер. Для простоты берем 1 час жизни кэша.
|
||||||
|
self._token_expire_time = current_time + 3600
|
||||||
|
|
||||||
|
logger.info("IAM token received successfully.")
|
||||||
|
return self._iam_token
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get IAM token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Глобальный инстанс
|
||||||
|
yandex_auth = YandexAuthManager()
|
||||||
46
ocr-service/app/services/excel.py
Normal file
46
ocr-service/app/services/excel.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_excel(content: bytes) -> str:
|
||||||
|
"""
|
||||||
|
Извлекает текстовое содержимое из Excel файла (.xlsx).
|
||||||
|
|
||||||
|
Проходит по всем строкам активного листа, собирает значения ячеек
|
||||||
|
и формирует текстовую строку для передачи в LLM парсер.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Байтовое содержимое Excel файла
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Строка с текстовым представлением содержимого Excel файла
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Загружаем workbook из байтов, data_only=True берет значения, а не формулы
|
||||||
|
wb = load_workbook(filename=io.BytesIO(content), data_only=True)
|
||||||
|
sheet = wb.active
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for row in sheet.iter_rows(values_only=True):
|
||||||
|
# Собираем непустые значения в строку через разделитель
|
||||||
|
row_text = " | ".join([
|
||||||
|
str(cell).strip()
|
||||||
|
for cell in row
|
||||||
|
if cell is not None and str(cell).strip() != ""
|
||||||
|
])
|
||||||
|
# Простая эвристика: строка должна содержать хотя бы одну букву (кириллица/латиница) И хотя бы одну цифру.
|
||||||
|
# Это отсеет пустые разделители и чистые заголовки.
|
||||||
|
if row_text and re.search(r'[a-zA-Zа-яА-Я]', row_text) and re.search(r'\d', row_text):
|
||||||
|
lines.append(row_text)
|
||||||
|
|
||||||
|
result = "\n".join(lines)
|
||||||
|
logger.info(f"Extracted {len(lines)} lines from Excel file")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting text from Excel: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
237
ocr-service/app/services/llm.py
Normal file
237
ocr-service/app/services/llm.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import SSLError
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app.schemas.models import ParsedItem
|
||||||
|
from app.services.auth import yandex_auth
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
YANDEX_GPT_URL = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
|
||||||
|
GIGACHAT_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
|
||||||
|
GIGACHAT_COMPLETION_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions"
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, system_prompt: str, user_text: str) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class YandexGPTProvider(LLMProvider):
|
||||||
|
def __init__(self):
|
||||||
|
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
|
||||||
|
|
||||||
|
def generate(self, system_prompt: str, user_text: str) -> str:
|
||||||
|
iam_token = yandex_auth.get_iam_token()
|
||||||
|
if not iam_token:
|
||||||
|
raise Exception("Failed to get IAM token")
|
||||||
|
|
||||||
|
prompt = {
|
||||||
|
"modelUri": f"gpt://{self.folder_id}/yandexgpt/latest",
|
||||||
|
"completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": "2000"},
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "text": system_prompt},
|
||||||
|
{"role": "user", "text": user_text}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {iam_token}",
|
||||||
|
"x-folder-id": self.folder_id
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()['result']['alternatives'][0]['message']['text']
|
||||||
|
return content
|
||||||
|
|
||||||
|
class GigaChatProvider(LLMProvider):
|
||||||
|
def __init__(self):
|
||||||
|
self.auth_key = os.getenv("GIGACHAT_AUTH_KEY")
|
||||||
|
self._access_token = None
|
||||||
|
self._expires_at = 0
|
||||||
|
|
||||||
|
def _get_token(self) -> Optional[str]:
|
||||||
|
if self._access_token and time.time() < self._expires_at:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
logger.info("Obtaining GigaChat access token...")
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'RqUID': str(uuid.uuid4()),
|
||||||
|
'Authorization': f'Basic {self.auth_key}'
|
||||||
|
}
|
||||||
|
payload = {'scope': 'GIGACHAT_API_PERS'}
|
||||||
|
|
||||||
|
response = requests.post(GIGACHAT_OAUTH_URL, headers=headers, data=payload, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
self._access_token = data['access_token']
|
||||||
|
self._expires_at = data['expires_at'] / 1000 # Переводим мс в сек
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
def generate(self, system_prompt: str, user_text: str) -> str:
|
||||||
|
token = self._get_token()
|
||||||
|
if not token:
|
||||||
|
raise Exception("Failed to get GigaChat token")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': f'Bearer {token}'
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": "GigaChat",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_text}
|
||||||
|
],
|
||||||
|
"temperature": 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(GIGACHAT_COMPLETION_URL, headers=headers, json=payload, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()['choices'][0]['message']['content']
|
||||||
|
return content
|
||||||
|
except SSLError as e:
|
||||||
|
logger.error("SSL Error with GigaChat. Check certificates")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
class LLMManager:
|
||||||
|
def __init__(self):
|
||||||
|
engine = os.getenv("LLM_ENGINE", "yandex").lower()
|
||||||
|
self.engine = engine
|
||||||
|
if engine == "gigachat":
|
||||||
|
self.provider = GigaChatProvider()
|
||||||
|
else:
|
||||||
|
self.provider = YandexGPTProvider()
|
||||||
|
logger.info(f"LLM Engine initialized: {self.engine}")
|
||||||
|
|
||||||
|
def parse_receipt(self, raw_text: str) -> dict:
|
||||||
|
system_prompt = """
|
||||||
|
Ты — профессиональный бухгалтер. Твоя задача — извлечь из "сырого" текста (OCR или Excel) данные о товарах и реквизиты документа.
|
||||||
|
|
||||||
|
ВХОДНЫЕ ДАННЫЕ:
|
||||||
|
Текст чека, накладной или УПД. В Excel-файлах колонки разделены символом "|".
|
||||||
|
|
||||||
|
ФОРМАТ ОТВЕТА (JSON):
|
||||||
|
{
|
||||||
|
"doc_number": "Номер документа (или ФД для чеков)",
|
||||||
|
"doc_date": "Дата документа (DD.MM.YYYY)",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"raw_name": "Название товара",
|
||||||
|
"amount": 1.0,
|
||||||
|
"price": 100.0, // Цена за единицу (с учетом скидок)
|
||||||
|
"sum": 100.0 // Стоимость позиции (ВСЕГО с НДС)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ПРАВИЛА ПОИСКА РЕКВИЗИТОВ:
|
||||||
|
1. Номер документа: Ищи "Счет-фактура №", "УПД №", "Накладная №", "ФД:", "Документ №".
|
||||||
|
- Пример: "УПД № 556430/123" -> "556430/123"
|
||||||
|
- Пример: "ФД: 12345" -> "12345"
|
||||||
|
2. Дата: Ищи рядом с номером. Преобразуй в формат DD.MM.YYYY.
|
||||||
|
|
||||||
|
ОБЩИЕ ПРАВИЛА ОБРАБОТКИ ТОВАРОВ:
|
||||||
|
1. **ЗАПРЕТ ГРУППИРОВКИ:** Если в чеке 5 раз подряд идет "Масло сливочное" (даже с разными кодами), ты должен вернуть **5 отдельных объектов**. НИКОГДА не объединяй их и не суммируй количество, если это не указано явно в одной строке (типа "5 шт x 100").
|
||||||
|
2. **ОЧИСТКА:** Игнорируй строки "ИТОГ", "СУММА", "НДС", "Всего к оплате", "Грузоотправитель", "Продавец". Числа приводи к float.
|
||||||
|
|
||||||
|
СТРАТЕГИЯ ДЛЯ EXCEL (УПД):
|
||||||
|
1. Разделитель колонок — "|".
|
||||||
|
2. Название — самая длинная текстовая ячейка.
|
||||||
|
3. Сумма — колонка "Стоимость с налогом - всего" (обычно крайняя правая сумма в строке). Если есть "без налога" и "с налогом", бери С НАЛОГОМ.
|
||||||
|
|
||||||
|
СТРАТЕГИЯ ДЛЯ ЧЕКОВ (OCR):
|
||||||
|
1. **МАРКЕР НАЧАЛА:** Часто строка товара начинается с цифрового кода (артикула). Пример: `6328 Масло...`. Если видишь число в начале строки, за которым идет текст — это НАЧАЛО нового товара.
|
||||||
|
2. **МНОГОСТРОЧНОСТЬ:** Название товара может быть разбито на 2-4 строки.
|
||||||
|
- Если видишь строку с цифрами (цена, кол-во), а перед ней текст — это конец описания товара. Склей текст с предыдущими строками.
|
||||||
|
- Пример:
|
||||||
|
`6298 Масло`
|
||||||
|
`ТРАД.сл.`
|
||||||
|
`1 шт 174.24`
|
||||||
|
-> Название: "6298 Масло ТРАД.сл."
|
||||||
|
3. **ЦЕНА:** Если указана "Цена со скидкой", бери её.
|
||||||
|
|
||||||
|
ПРИМЕР 1 (УПД):
|
||||||
|
Вход:
|
||||||
|
Код | Товар | Кол-во | Цена | Сумма без НДС | Сумма с НДС
|
||||||
|
1 | Сок ананасовый | 4 | 533.64 | 2134.56 | 2348.00
|
||||||
|
Выход:
|
||||||
|
{
|
||||||
|
"doc_number": "", "doc_date": "",
|
||||||
|
"items": [
|
||||||
|
{"raw_name": "Сок ананасовый", "amount": 4.0, "price": 533.64, "sum": 2348.00}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ПРИМЕР 2 (Сложный чек):
|
||||||
|
Вход:
|
||||||
|
5603 СЫР ПЛАВ.
|
||||||
|
45% 200Г
|
||||||
|
169.99 1шт 169.99
|
||||||
|
6328 Масло ТРАД.
|
||||||
|
Сл.82.5% 175г
|
||||||
|
204.99 30.75 174.24 1шт 174.24
|
||||||
|
6298 Масло ТРАД.
|
||||||
|
Сл.82.5%
|
||||||
|
175г
|
||||||
|
1шт 174.24
|
||||||
|
Выход:
|
||||||
|
{
|
||||||
|
"doc_number": "", "doc_date": "",
|
||||||
|
"items": [
|
||||||
|
{"raw_name": "5603 СЫР ПЛАВ. 45% 200Г", "amount": 1.0, "price": 169.99, "sum": 169.99},
|
||||||
|
{"raw_name": "6328 Масло ТРАД. Сл.82.5% 175г", "amount": 1.0, "price": 174.24, "sum": 174.24},
|
||||||
|
{"raw_name": "6298 Масло ТРАД. Сл.82.5% 175г", "amount": 1.0, "price": 174.24, "sum": 174.24}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Parsing receipt using engine: {self.engine}")
|
||||||
|
response = self.provider.generate(system_prompt, raw_text)
|
||||||
|
# Очистка от Markdown
|
||||||
|
clean_json = response.replace("```json", "").replace("```", "").strip()
|
||||||
|
# Парсинг JSON
|
||||||
|
data = json.loads(clean_json)
|
||||||
|
|
||||||
|
# Обработка обратной совместимости: если вернулся список, оборачиваем в словарь
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = {"items": data, "doc_number": "", "doc_date": ""}
|
||||||
|
|
||||||
|
# Извлекаем товары
|
||||||
|
items_data = data.get("items", [])
|
||||||
|
|
||||||
|
# Пост-обработка: исправление чисел в товарах
|
||||||
|
for item in items_data:
|
||||||
|
for key in ['amount', 'price', 'sum']:
|
||||||
|
if isinstance(item[key], str):
|
||||||
|
# Удаляем пробелы внутри чисел
|
||||||
|
item[key] = float(re.sub(r'\s+', '', item[key]).replace(',', '.'))
|
||||||
|
elif isinstance(item[key], (int, float)):
|
||||||
|
item[key] = float(item[key])
|
||||||
|
|
||||||
|
# Формируем результат
|
||||||
|
result = {
|
||||||
|
"items": [ParsedItem(**item) for item in items_data],
|
||||||
|
"doc_number": data.get("doc_number", ""),
|
||||||
|
"doc_date": data.get("doc_date", "")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM Parsing error: {e}")
|
||||||
|
return {"items": [], "doc_number": "", "doc_date": ""}
|
||||||
|
|
||||||
|
llm_parser = LLMManager()
|
||||||
@@ -1,70 +1,47 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from typing import Optional
|
|
||||||
|
from app.services.auth import yandex_auth
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
IAM_TOKEN_URL = "https://iam.api.cloud.yandex.net/iam/v1/tokens"
|
|
||||||
VISION_URL = "https://ocr.api.cloud.yandex.net/ocr/v1/recognizeText"
|
VISION_URL = "https://ocr.api.cloud.yandex.net/ocr/v1/recognizeText"
|
||||||
|
|
||||||
class YandexOCREngine:
|
class OCREngine(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def recognize(self, image_bytes: bytes) -> str:
|
||||||
|
"""Распознает текст из изображения и возвращает строку."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""Проверяет, настроен ли движок (наличие ключей/настроек)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class YandexOCREngine(OCREngine):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.oauth_token = os.getenv("YANDEX_OAUTH_TOKEN")
|
|
||||||
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
|
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
|
||||||
|
|
||||||
# Кэширование IAM токена
|
if not yandex_auth.is_configured() or not self.folder_id:
|
||||||
self._iam_token = None
|
|
||||||
self._token_expire_time = 0
|
|
||||||
|
|
||||||
if not self.oauth_token or not self.folder_id:
|
|
||||||
logger.warning("Yandex OCR credentials (YANDEX_OAUTH_TOKEN, YANDEX_FOLDER_ID) not set. Yandex OCR will be unavailable.")
|
logger.warning("Yandex OCR credentials (YANDEX_OAUTH_TOKEN, YANDEX_FOLDER_ID) not set. Yandex OCR will be unavailable.")
|
||||||
|
|
||||||
def _get_iam_token(self) -> Optional[str]:
|
def is_configured(self) -> bool:
|
||||||
"""
|
return yandex_auth.is_configured() and bool(self.folder_id)
|
||||||
Получает IAM-токен. Если есть живой кэшированный — возвращает его.
|
|
||||||
Если нет — обменивает OAuth на IAM.
|
|
||||||
"""
|
|
||||||
current_time = time.time()
|
|
||||||
|
|
||||||
# Если токен есть и он "свежий" (с запасом в 5 минут)
|
|
||||||
if self._iam_token and current_time < self._token_expire_time - 300:
|
|
||||||
return self._iam_token
|
|
||||||
|
|
||||||
logger.info("Obtaining new IAM token from Yandex...")
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
IAM_TOKEN_URL,
|
|
||||||
json={"yandexPassportOauthToken": self.oauth_token},
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
self._iam_token = data["iamToken"]
|
|
||||||
|
|
||||||
# Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно,
|
|
||||||
# или просто поставим таймер. Для простоты берем 1 час жизни кэша.
|
|
||||||
self._token_expire_time = current_time + 3600
|
|
||||||
|
|
||||||
logger.info("IAM token received successfully.")
|
|
||||||
return self._iam_token
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get IAM token: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def recognize(self, image_bytes: bytes) -> str:
|
def recognize(self, image_bytes: bytes) -> str:
|
||||||
"""
|
"""
|
||||||
Отправляет изображение в Yandex Vision и возвращает полный текст.
|
Отправляет изображение в Yandex Vision и возвращает полный текст.
|
||||||
"""
|
"""
|
||||||
if not self.oauth_token or not self.folder_id:
|
if not self.is_configured():
|
||||||
logger.error("Yandex credentials missing.")
|
logger.error("Yandex credentials missing.")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
iam_token = self._get_iam_token()
|
iam_token = yandex_auth.get_iam_token()
|
||||||
if not iam_token:
|
if not iam_token:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -96,8 +73,8 @@ class YandexOCREngine:
|
|||||||
# Если 401 Unauthorized, возможно токен протух раньше времени (редко, но бывает)
|
# Если 401 Unauthorized, возможно токен протух раньше времени (редко, но бывает)
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
logger.warning("Got 401 from Yandex. Retrying with fresh token...")
|
logger.warning("Got 401 from Yandex. Retrying with fresh token...")
|
||||||
self._iam_token = None # сброс кэша
|
yandex_auth.reset_token() # сброс кэша
|
||||||
iam_token = self._get_iam_token()
|
iam_token = yandex_auth.get_iam_token()
|
||||||
if iam_token:
|
if iam_token:
|
||||||
headers["Authorization"] = f"Bearer {iam_token}"
|
headers["Authorization"] = f"Bearer {iam_token}"
|
||||||
response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)
|
response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)
|
||||||
@@ -1,18 +1,65 @@
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
|
import re
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from pyzbar.pyzbar import decode
|
from pyzbar.pyzbar import decode
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
# Импортируем модель из parser.py
|
from app.schemas.models import ParsedItem
|
||||||
from parser import ParsedItem
|
|
||||||
|
|
||||||
API_TOKEN = "36590.yqtiephCvvkYUKM2W"
|
API_TOKEN = "36590.yqtiephCvvkYUKM2W"
|
||||||
API_URL = "https://proverkacheka.com/api/v1/check/get"
|
API_URL = "https://proverkacheka.com/api/v1/check/get"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def extract_fiscal_data(text: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Ищет в тексте:
|
||||||
|
- Дата и время (t): 19.12.25 12:16 -> 20251219T1216
|
||||||
|
- Сумма (s): ИТОГ: 770.00
|
||||||
|
- ФН (fn): 16 цифр, начинается на 73
|
||||||
|
- ФД (i): до 8 цифр (ищем после Д: или ФД:)
|
||||||
|
- ФП (fp): 10 цифр (ищем после П: или ФП:)
|
||||||
|
|
||||||
|
Возвращает строку формата: t=20251219T1216&s=770.00&fn=7384440800514469&i=11194&fp=3334166168&n=1
|
||||||
|
"""
|
||||||
|
# 1. Поиск даты и времени
|
||||||
|
# Ищем форматы DD.MM.YY HH:MM или DD.MM.YYYY HH:MM
|
||||||
|
date_match = re.search(r'(\d{2})\.(\d{2})\.(\d{2,4})\s+(\d{2}):(\d{2})', text)
|
||||||
|
t_param = ""
|
||||||
|
if date_match:
|
||||||
|
d, m, y, hh, mm = date_match.groups()
|
||||||
|
if len(y) == 2: y = "20" + y
|
||||||
|
t_param = f"{y}{m}{d}T{hh}{mm}"
|
||||||
|
|
||||||
|
# 2. Поиск суммы (Итог)
|
||||||
|
# Ищем слово ИТОГ и число после него
|
||||||
|
sum_match = re.search(r'(?:ИТОГ|СУММА|СУММА:)\s*[:]*\s*(\d+[.,]\d{2})', text, re.IGNORECASE)
|
||||||
|
s_param = ""
|
||||||
|
if sum_match:
|
||||||
|
s_param = sum_match.group(1).replace(',', '.')
|
||||||
|
|
||||||
|
# 3. Поиск ФН (16 цифр, начинается с 73)
|
||||||
|
fn_match = re.search(r'\b(73\d{14})\b', text)
|
||||||
|
fn_param = fn_match.group(1) if fn_match else ""
|
||||||
|
|
||||||
|
# 4. Поиск ФД (i) - ищем после маркеров Д: или ФД:
|
||||||
|
# Берем набор цифр до 8 знаков
|
||||||
|
fd_match = re.search(r'(?:ФД|Д)[:\s]+(\d{1,8})\b', text)
|
||||||
|
i_param = fd_match.group(1) if fd_match else ""
|
||||||
|
|
||||||
|
# 5. Поиск ФП (fp) - ищем после маркеров П: или ФП:
|
||||||
|
# Строго 10 цифр
|
||||||
|
fp_match = re.search(r'(?:ФП|П)[:\s]+(\d{10})\b', text)
|
||||||
|
fp_param = fp_match.group(1) if fp_match else ""
|
||||||
|
|
||||||
|
# Валидация: для формирования запроса к API нам критически важны все параметры
|
||||||
|
if all([t_param, s_param, fn_param, i_param, fp_param]):
|
||||||
|
return f"t={t_param}&s={s_param}&fn={fn_param}&i={i_param}&fp={fp_param}&n=1"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def is_valid_fiscal_qr(qr_string: str) -> bool:
|
def is_valid_fiscal_qr(qr_string: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Проверяет, соответствует ли строка формату фискального чека ФНС.
|
Проверяет, соответствует ли строка формату фискального чека ФНС.
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def order_points(pts):
|
|
||||||
rect = np.zeros((4, 2), dtype="float32")
|
|
||||||
s = pts.sum(axis=1)
|
|
||||||
rect[0] = pts[np.argmin(s)]
|
|
||||||
rect[2] = pts[np.argmax(s)]
|
|
||||||
diff = np.diff(pts, axis=1)
|
|
||||||
rect[1] = pts[np.argmin(diff)]
|
|
||||||
rect[3] = pts[np.argmax(diff)]
|
|
||||||
return rect
|
|
||||||
|
|
||||||
def four_point_transform(image, pts):
|
|
||||||
rect = order_points(pts)
|
|
||||||
(tl, tr, br, bl) = rect
|
|
||||||
|
|
||||||
widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))
|
|
||||||
widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))
|
|
||||||
maxWidth = max(int(widthA), int(widthB))
|
|
||||||
|
|
||||||
heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))
|
|
||||||
heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))
|
|
||||||
maxHeight = max(int(heightA), int(heightB))
|
|
||||||
|
|
||||||
dst = np.array([
|
|
||||||
[0, 0],
|
|
||||||
[maxWidth - 1, 0],
|
|
||||||
[maxWidth - 1, maxHeight - 1],
|
|
||||||
[0, maxHeight - 1]], dtype="float32")
|
|
||||||
|
|
||||||
M = cv2.getPerspectiveTransform(rect, dst)
|
|
||||||
warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))
|
|
||||||
return warped
|
|
||||||
|
|
||||||
def preprocess_image(image_bytes: bytes) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
Возвращает БИНАРНОЕ (Ч/Б) изображение для Tesseract.
|
|
||||||
"""
|
|
||||||
nparr = np.frombuffer(image_bytes, np.uint8)
|
|
||||||
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
||||||
|
|
||||||
if image is None:
|
|
||||||
raise ValueError("Could not decode image")
|
|
||||||
|
|
||||||
# Ресайз для поиска контуров
|
|
||||||
ratio = image.shape[0] / 500.0
|
|
||||||
orig = image.copy()
|
|
||||||
image_small = cv2.resize(image, (int(image.shape[1] / ratio), 500))
|
|
||||||
|
|
||||||
gray = cv2.cvtColor(image_small, cv2.COLOR_BGR2GRAY)
|
|
||||||
gray = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
||||||
edged = cv2.Canny(gray, 75, 200)
|
|
||||||
|
|
||||||
cnts, _ = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:5]
|
|
||||||
|
|
||||||
screenCnt = None
|
|
||||||
found = False
|
|
||||||
|
|
||||||
for c in cnts:
|
|
||||||
peri = cv2.arcLength(c, True)
|
|
||||||
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
|
|
||||||
if len(approx) == 4:
|
|
||||||
screenCnt = approx
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
# Изображение, с которым будем работать дальше
|
|
||||||
target_img = None
|
|
||||||
|
|
||||||
if found:
|
|
||||||
logger.info("Receipt contour found (Tesseract mode).")
|
|
||||||
target_img = four_point_transform(orig, screenCnt.reshape(4, 2) * ratio)
|
|
||||||
else:
|
|
||||||
logger.warning("Receipt contour NOT found. Using full image.")
|
|
||||||
target_img = orig
|
|
||||||
|
|
||||||
# --- Подготовка для Tesseract (Бинаризация) ---
|
|
||||||
# Переводим в Gray
|
|
||||||
gray_final = cv2.cvtColor(target_img, cv2.COLOR_BGR2GRAY)
|
|
||||||
|
|
||||||
# Адаптивный порог (превращаем в чисто черное и белое)
|
|
||||||
# block_size=11, C=2 - классические параметры для текста
|
|
||||||
thresh = cv2.adaptiveThreshold(
|
|
||||||
gray_final, 255,
|
|
||||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
||||||
cv2.THRESH_BINARY, 11, 2
|
|
||||||
)
|
|
||||||
|
|
||||||
# Немного убираем шум
|
|
||||||
# thresh = cv2.medianBlur(thresh, 3)
|
|
||||||
|
|
||||||
return thresh
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import os
|
|
||||||
import requests
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
from typing import List, Optional
|
|
||||||
from parser import ParsedItem
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
YANDEX_GPT_URL = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
|
|
||||||
GIGACHAT_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
|
|
||||||
GIGACHAT_COMPLETION_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions"
|
|
||||||
|
|
||||||
class YandexGPTParser:
|
|
||||||
def __init__(self):
|
|
||||||
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
|
|
||||||
self.api_key = os.getenv("YANDEX_OAUTH_TOKEN")
|
|
||||||
|
|
||||||
def parse(self, raw_text: str, iam_token: str) -> List[ParsedItem]:
|
|
||||||
if not iam_token:
|
|
||||||
return []
|
|
||||||
|
|
||||||
prompt = {
|
|
||||||
"modelUri": f"gpt://{self.folder_id}/yandexgpt/latest",
|
|
||||||
"completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": "2000"},
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"text": (
|
|
||||||
"Ты — помощник по бухгалтерии. Извлеки список товаров из текста документа. "
|
|
||||||
"Верни ответ строго в формате JSON: "
|
|
||||||
'[{"raw_name": string, "amount": float, "price": float, "sum": float}]. '
|
|
||||||
"Если количество не указано, считай 1.0. Не пиши ничего, кроме JSON."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{"role": "user", "text": raw_text}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {iam_token}",
|
|
||||||
"x-folder-id": self.folder_id
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.json()['result']['alternatives'][0]['message']['text']
|
|
||||||
clean_json = content.replace("```json", "").replace("```", "").strip()
|
|
||||||
return [ParsedItem(**item) for item in json.loads(clean_json)]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"YandexGPT Parsing error: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
class GigaChatParser:
|
|
||||||
def __init__(self):
|
|
||||||
self.auth_key = os.getenv("GIGACHAT_AUTH_KEY")
|
|
||||||
self._access_token = None
|
|
||||||
self._expires_at = 0
|
|
||||||
|
|
||||||
def _get_token(self) -> Optional[str]:
|
|
||||||
if self._access_token and time.time() < self._expires_at:
|
|
||||||
return self._access_token
|
|
||||||
|
|
||||||
logger.info("Obtaining GigaChat access token...")
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'RqUID': str(uuid.uuid4()),
|
|
||||||
'Authorization': f'Basic {self.auth_key}'
|
|
||||||
}
|
|
||||||
payload = {'scope': 'GIGACHAT_API_PERS'}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# verify=False может понадобиться, если сертификаты Минцифры не в системном хранилище,
|
|
||||||
# но вы указали, что установите их в контейнер.
|
|
||||||
response = requests.post(GIGACHAT_OAUTH_URL, headers=headers, data=payload, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
self._access_token = data['access_token']
|
|
||||||
self._expires_at = data['expires_at'] / 1000 # Переводим мс в сек
|
|
||||||
return self._access_token
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"GigaChat Auth error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse(self, raw_text: str) -> List[ParsedItem]:
|
|
||||||
token = self._get_token()
|
|
||||||
if not token:
|
|
||||||
return []
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Authorization': f'Bearer {token}'
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": "GigaChat",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"Ты — эксперт по распознаванию чеков. Извлеки товары из текста. "
|
|
||||||
"Верни ТОЛЬКО JSON массив объектов с полями: raw_name (строка), "
|
|
||||||
"amount (число), price (число), sum (число). "
|
|
||||||
"Если данных нет, верни []. Никаких пояснений."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{"role": "user", "content": raw_text}
|
|
||||||
],
|
|
||||||
"temperature": 0.1
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(GIGACHAT_COMPLETION_URL, headers=headers, json=payload, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.json()['choices'][0]['message']['content']
|
|
||||||
clean_json = content.replace("```json", "").replace("```", "").strip()
|
|
||||||
return [ParsedItem(**item) for item in json.loads(clean_json)]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"GigaChat Parsing error: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
class LLMManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.yandex = YandexGPTParser()
|
|
||||||
self.giga = GigaChatParser()
|
|
||||||
self.engine = os.getenv("LLM_ENGINE", "yandex").lower()
|
|
||||||
|
|
||||||
def parse_with_priority(self, raw_text: str, yandex_iam_token: Optional[str] = None) -> List[ParsedItem]:
|
|
||||||
if self.engine == "gigachat":
|
|
||||||
logger.info("Using GigaChat as primary LLM")
|
|
||||||
items = self.giga.parse(raw_text)
|
|
||||||
if not items and yandex_iam_token:
|
|
||||||
logger.info("GigaChat failed, falling back to YandexGPT")
|
|
||||||
items = self.yandex.parse(raw_text, yandex_iam_token)
|
|
||||||
return items
|
|
||||||
else:
|
|
||||||
logger.info("Using YandexGPT as primary LLM")
|
|
||||||
items = self.yandex.parse(raw_text, yandex_iam_token) if yandex_iam_token else []
|
|
||||||
if not items:
|
|
||||||
logger.info("YandexGPT failed, falling back to GigaChat")
|
|
||||||
items = self.giga.parse(raw_text)
|
|
||||||
return items
|
|
||||||
|
|
||||||
llm_parser = LLMManager()
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import logging
|
|
||||||
import pytesseract
|
|
||||||
from PIL import Image
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Если tesseract не в PATH, раскомментируй и укажи путь:
|
|
||||||
# pytesseract.pytesseract.tesseract_cmd = r'/usr/bin/tesseract'
|
|
||||||
|
|
||||||
class OCREngine:
|
|
||||||
def __init__(self):
|
|
||||||
logger.info("Initializing Tesseract OCR wrapper...")
|
|
||||||
# Tesseract не требует загрузки моделей в память,
|
|
||||||
# проверка версии просто чтобы убедиться, что он установлен
|
|
||||||
try:
|
|
||||||
version = pytesseract.get_tesseract_version()
|
|
||||||
logger.info(f"Tesseract version found: {version}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Tesseract not found! Make sure it is installed (apt install tesseract-ocr).")
|
|
||||||
raise e
|
|
||||||
|
|
||||||
def recognize(self, image: np.ndarray) -> str:
|
|
||||||
"""
|
|
||||||
Принимает бинарное изображение (numpy array).
|
|
||||||
"""
|
|
||||||
# Tesseract работает лучше с PIL Image
|
|
||||||
pil_img = Image.fromarray(image)
|
|
||||||
|
|
||||||
# Конфигурация:
|
|
||||||
# -l rus+eng: русский и английский
|
|
||||||
# --psm 6: Assume a single uniform block of text (хорошо для чеков)
|
|
||||||
custom_config = r'--oem 3 --psm 6'
|
|
||||||
|
|
||||||
text = pytesseract.image_to_string(pil_img, lang='rus+eng', config=custom_config)
|
|
||||||
return text
|
|
||||||
|
|
||||||
ocr_engine = OCREngine()
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import re
|
|
||||||
from typing import List, Optional
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class ParsedItem(BaseModel):
|
|
||||||
raw_name: str
|
|
||||||
amount: float
|
|
||||||
price: float
|
|
||||||
sum: float
|
|
||||||
|
|
||||||
# Регулярка для поиска чисел с плавающей точкой: 123.00, 123,00, 10.5
|
|
||||||
FLOAT_RE = r'\d+[.,]\d{2}'
|
|
||||||
|
|
||||||
def clean_text(text: str) -> str:
|
|
||||||
"""Удаляет лишние символы из названия товара."""
|
|
||||||
return re.sub(r'[^\w\s.,%/-]', '', text).strip()
|
|
||||||
|
|
||||||
def parse_float(val: str) -> float:
|
|
||||||
"""Преобразует строку '123,45' или '123.45' в float."""
|
|
||||||
if not val:
|
|
||||||
return 0.0
|
|
||||||
return float(val.replace(',', '.').replace(' ', ''))
|
|
||||||
|
|
||||||
def extract_fiscal_data(text: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Ищет в тексте:
|
|
||||||
- Дата и время (t): 19.12.25 12:16 -> 20251219T1216
|
|
||||||
- Сумма (s): ИТОГ: 770.00
|
|
||||||
- ФН (fn): 16 цифр, начинается на 73
|
|
||||||
- ФД (i): до 8 цифр (ищем после Д: или ФД:)
|
|
||||||
- ФП (fp): 10 цифр (ищем после П: или ФП:)
|
|
||||||
|
|
||||||
Возвращает строку формата: t=20251219T1216&s=770.00&fn=7384440800514469&i=11194&fp=3334166168&n=1
|
|
||||||
"""
|
|
||||||
# 1. Поиск даты и времени
|
|
||||||
# Ищем форматы DD.MM.YY HH:MM или DD.MM.YYYY HH:MM
|
|
||||||
date_match = re.search(r'(\d{2})\.(\d{2})\.(\d{2,4})\s+(\d{2}):(\d{2})', text)
|
|
||||||
t_param = ""
|
|
||||||
if date_match:
|
|
||||||
d, m, y, hh, mm = date_match.groups()
|
|
||||||
if len(y) == 2: y = "20" + y
|
|
||||||
t_param = f"{y}{m}{d}T{hh}{mm}"
|
|
||||||
|
|
||||||
# 2. Поиск суммы (Итог)
|
|
||||||
# Ищем слово ИТОГ и число после него
|
|
||||||
sum_match = re.search(r'(?:ИТОГ|СУММА|СУММА:)\s*[:]*\s*(\d+[.,]\d{2})', text, re.IGNORECASE)
|
|
||||||
s_param = ""
|
|
||||||
if sum_match:
|
|
||||||
s_param = sum_match.group(1).replace(',', '.')
|
|
||||||
|
|
||||||
# 3. Поиск ФН (16 цифр, начинается с 73)
|
|
||||||
fn_match = re.search(r'\b(73\d{14})\b', text)
|
|
||||||
fn_param = fn_match.group(1) if fn_match else ""
|
|
||||||
|
|
||||||
# 4. Поиск ФД (i) - ищем после маркеров Д: или ФД:
|
|
||||||
# Берем набор цифр до 8 знаков
|
|
||||||
fd_match = re.search(r'(?:ФД|Д)[:\s]+(\d{1,8})\b', text)
|
|
||||||
i_param = fd_match.group(1) if fd_match else ""
|
|
||||||
|
|
||||||
# 5. Поиск ФП (fp) - ищем после маркеров П: или ФП:
|
|
||||||
# Строго 10 цифр
|
|
||||||
fp_match = re.search(r'(?:ФП|П)[:\s]+(\d{10})\b', text)
|
|
||||||
fp_param = fp_match.group(1) if fp_match else ""
|
|
||||||
|
|
||||||
# Валидация: для формирования запроса к API нам критически важны все параметры
|
|
||||||
if all([t_param, s_param, fn_param, i_param, fp_param]):
|
|
||||||
return f"t={t_param}&s={s_param}&fn={fn_param}&i={i_param}&fp={fp_param}&n=1"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse_receipt_text(text: str) -> List[ParsedItem]:
|
|
||||||
"""
|
|
||||||
Парсит текст чека построчно (Regex-метод).
|
|
||||||
"""
|
|
||||||
lines = text.split('\n')
|
|
||||||
items = []
|
|
||||||
name_buffer = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
floats = re.findall(FLOAT_RE, line)
|
|
||||||
is_price_line = False
|
|
||||||
|
|
||||||
if len(floats) >= 2:
|
|
||||||
is_price_line = True
|
|
||||||
vals = [parse_float(f) for f in floats]
|
|
||||||
|
|
||||||
price = 0.0
|
|
||||||
amount = 1.0
|
|
||||||
total = vals[-1]
|
|
||||||
|
|
||||||
if len(vals) == 2:
|
|
||||||
price = vals[0]
|
|
||||||
amount = 1.0
|
|
||||||
if total > price and price > 0:
|
|
||||||
calc_amount = total / price
|
|
||||||
if abs(round(calc_amount) - calc_amount) < 0.05:
|
|
||||||
amount = float(round(calc_amount))
|
|
||||||
elif len(vals) >= 3:
|
|
||||||
v1, v2 = vals[-3], vals[-2]
|
|
||||||
if abs(v1 * v2 - total) < 0.5:
|
|
||||||
price, amount = v1, v2
|
|
||||||
elif abs(v2 * v1 - total) < 0.5:
|
|
||||||
price, amount = v2, v1
|
|
||||||
else:
|
|
||||||
price, amount = vals[-2], 1.0
|
|
||||||
|
|
||||||
full_name = " ".join(name_buffer).strip()
|
|
||||||
if not full_name:
|
|
||||||
text_without_floats = re.sub(FLOAT_RE, '', line)
|
|
||||||
full_name = clean_text(text_without_floats)
|
|
||||||
|
|
||||||
if len(full_name) > 2 and total > 0:
|
|
||||||
items.append(ParsedItem(
|
|
||||||
raw_name=full_name,
|
|
||||||
amount=amount,
|
|
||||||
price=price,
|
|
||||||
sum=total
|
|
||||||
))
|
|
||||||
name_buffer = []
|
|
||||||
else:
|
|
||||||
upper_line = line.upper()
|
|
||||||
if any(stop in upper_line for stop in ["ИТОГ", "СУММА", "ПРИХОД"]):
|
|
||||||
name_buffer = []
|
|
||||||
continue
|
|
||||||
name_buffer.append(line)
|
|
||||||
|
|
||||||
return items
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@ python-multipart
|
|||||||
pydantic
|
pydantic
|
||||||
numpy
|
numpy
|
||||||
opencv-python-headless
|
opencv-python-headless
|
||||||
pytesseract
|
|
||||||
requests
|
requests
|
||||||
pyzbar
|
pyzbar
|
||||||
pillow
|
pillow
|
||||||
certifi
|
certifi
|
||||||
|
openpyxl
|
||||||
67
ocr-service/scripts/collect_data_raw.py
Normal file
67
ocr-service/scripts/collect_data_raw.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
# Папка, куда вы положите фото чеков для теста
|
||||||
|
INPUT_DIR = "./test_receipts"
|
||||||
|
# Папка, куда сохраним сырой текст
|
||||||
|
OUTPUT_DIR = "./raw_outputs"
|
||||||
|
# Адрес запущенного OCR сервиса
|
||||||
|
API_URL = "http://10.25.100.250:5006/recognize"
|
||||||
|
|
||||||
|
def process_images():
|
||||||
|
if not os.path.exists(OUTPUT_DIR):
|
||||||
|
os.makedirs(OUTPUT_DIR)
|
||||||
|
|
||||||
|
if not os.path.exists(INPUT_DIR):
|
||||||
|
os.makedirs(INPUT_DIR)
|
||||||
|
print(f"Папка {INPUT_DIR} создана. Положите туда фото чеков и перезапустите скрипт.")
|
||||||
|
return
|
||||||
|
|
||||||
|
files = [f for f in os.listdir(INPUT_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.xlsx'))]
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
print(f"В папке {INPUT_DIR} нет изображений.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Найдено {len(files)} файлов. Начинаю обработку...")
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
file_path = os.path.join(INPUT_DIR, filename)
|
||||||
|
|
||||||
|
# Явное определение mime_type для Excel файлов
|
||||||
|
if filename.lower().endswith('.xlsx'):
|
||||||
|
mime_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
else:
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
mime_type = mime_type or 'image/jpeg'
|
||||||
|
|
||||||
|
print(f"Processing {filename}...", end=" ")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'image': (filename, f, mime_type or 'image/jpeg')}
|
||||||
|
response = requests.post(API_URL, files=files, timeout=30)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
raw_text = data.get("raw_text", "")
|
||||||
|
source = data.get("source", "unknown")
|
||||||
|
|
||||||
|
# Сохраняем RAW текст
|
||||||
|
out_name = f"{filename}_RAW.txt"
|
||||||
|
with open(os.path.join(OUTPUT_DIR, out_name), "w", encoding="utf-8") as out:
|
||||||
|
out.write(f"Source: {source}\n")
|
||||||
|
out.write("="*20 + "\n")
|
||||||
|
out.write(raw_text)
|
||||||
|
|
||||||
|
print(f"OK ({source}) -> {out_name}")
|
||||||
|
else:
|
||||||
|
print(f"FAIL: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
process_images()
|
||||||
62
ocr-service/scripts/test_parsing_quality.py
Normal file
62
ocr-service/scripts/test_parsing_quality.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
# Папка с фото/excel
|
||||||
|
INPUT_DIR = "./test_receipts"
|
||||||
|
# Папка для результатов
|
||||||
|
OUTPUT_DIR = "./json_results"
|
||||||
|
# Адрес сервиса
|
||||||
|
API_URL = "http://10.25.100.250:5006/recognize"
|
||||||
|
|
||||||
|
def test_parsing():
|
||||||
|
if not os.path.exists(OUTPUT_DIR):
|
||||||
|
os.makedirs(OUTPUT_DIR)
|
||||||
|
|
||||||
|
if not os.path.exists(INPUT_DIR):
|
||||||
|
print(f"Папка {INPUT_DIR} не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
files = [f for f in os.listdir(INPUT_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.xlsx'))]
|
||||||
|
|
||||||
|
print(f"Найдено {len(files)} файлов. Тестируем парсинг...")
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
file_path = os.path.join(INPUT_DIR, filename)
|
||||||
|
|
||||||
|
# Определение MIME
|
||||||
|
if filename.lower().endswith('.xlsx'):
|
||||||
|
mime_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
else:
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
mime_type = mime_type or 'image/jpeg'
|
||||||
|
|
||||||
|
print(f"Processing {filename} ({mime_type})...", end=" ")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'image': (filename, f, mime_type)}
|
||||||
|
# Тайм-аут побольше, так как Excel + LLM может быть долгим
|
||||||
|
response = requests.post(API_URL, files=files, timeout=60)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
items = data.get("items", [])
|
||||||
|
source = data.get("source", "unknown")
|
||||||
|
doc_number = data.get("doc_number", "")
|
||||||
|
|
||||||
|
# Сохраняем JSON
|
||||||
|
out_name = f"{filename}_RESULT.json"
|
||||||
|
with open(os.path.join(OUTPUT_DIR, out_name), "w", encoding="utf-8") as out:
|
||||||
|
json.dump(data, out, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"OK ({source}) -> Found {len(items)} items. Doc#: {doc_number}")
|
||||||
|
else:
|
||||||
|
print(f"FAIL: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_parsing()
|
||||||
1
rmser-view/.gitignore
vendored
1
rmser-view/.gitignore
vendored
@@ -23,3 +23,4 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
*.txt
|
*.txt
|
||||||
|
*.py
|
||||||
Reference in New Issue
Block a user