Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный

This commit is contained in:
2025-12-17 03:38:24 +03:00
parent fda30276a5
commit e2df2350f7
32 changed files with 1785 additions and 214 deletions

View File

@@ -55,4 +55,7 @@ type Repository interface {
SaveProducts(products []Product) error
GetAll() ([]Product, error)
GetActiveGoods() ([]Product, error)
// --- Stores ---
SaveStores(stores []Store) error
GetActiveStores() ([]Store, error)
}

View File

@@ -0,0 +1,18 @@
package catalog
import (
"time"
"github.com/google/uuid"
)
// Store - Склад (в терминологии iiko: Entity с типом Account и подтипом INVENTORY_ASSETS)
type Store struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
Name string `gorm:"type:varchar(255);not null" json:"name"`
ParentCorporateID uuid.UUID `gorm:"type:uuid;index" json:"parent_corporate_id"` // ID юр.лица/торгового предприятия
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,76 @@
package drafts
import (
"time"
"rmser/internal/domain/catalog"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
// Статусы черновика
const (
StatusProcessing = "PROCESSING" // OCR в процессе
StatusReadyToVerify = "READY_TO_VERIFY" // Распознано, ждет проверки пользователем
StatusCompleted = "COMPLETED" // Отправлено в RMS
StatusError = "ERROR" // Ошибка обработки
)
// DraftInvoice - Черновик накладной
type DraftInvoice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ChatID int64 `gorm:"index" json:"chat_id"` // ID чата в Telegram (кто прислал)
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото (если нужно отобразить на фронте)
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
// Данные для отправки в RMS (заполняются пользователем)
DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"`
DateIncoming *time.Time `json:"date_incoming"`
SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"`
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
Comment string `gorm:"type:text" json:"comment"`
// Связь с созданной накладной (когда статус COMPLETED)
RMSInvoiceID *uuid.UUID `gorm:"type:uuid" json:"rms_invoice_id"`
Items []DraftInvoiceItem `gorm:"foreignKey:DraftID;constraint:OnDelete:CASCADE" json:"items"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DraftInvoiceItem - Позиция черновика
type DraftInvoiceItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
DraftID uuid.UUID `gorm:"type:uuid;not null;index" json:"draft_id"`
// --- Результаты OCR (Исходные данные) ---
RawName string `gorm:"type:varchar(255);not null" json:"raw_name"` // Текст с чека
RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"` // Кол-во, которое увидел OCR
RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"` // Цена, которую увидел OCR
// --- Результат Матчинга и Выбора пользователя ---
ProductID *uuid.UUID `gorm:"type:uuid;index" json:"product_id"`
Product *catalog.Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
// Финальные цифры, которые пойдут в накладную
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"quantity"`
Price decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"price"`
Sum decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"sum"`
IsMatched bool `gorm:"default:false" json:"is_matched"` // Удалось ли системе найти пару автоматически
}
// Repository интерфейс
type Repository interface {
Create(draft *DraftInvoice) error
GetByID(id uuid.UUID) (*DraftInvoice, error)
Update(draft *DraftInvoice) error
CreateItems(items []DraftInvoiceItem) error
// UpdateItem обновляет конкретную строку (например, при ручном выборе товара)
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
Delete(id uuid.UUID) error
}

View File

@@ -36,7 +36,7 @@ type UnmatchedItem struct {
type Repository interface {
// SaveMatch теперь принимает quantity и containerID
SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error
DeleteMatch(rawName string) error
FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty
GetAllMatches() ([]ProductMatch, error)

View File

@@ -7,6 +7,7 @@ import (
"os"
"regexp"
"rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/invoices"
"rmser/internal/domain/ocr"
"rmser/internal/domain/operations"
@@ -48,10 +49,13 @@ func NewPostgresDB(dsn string) *gorm.DB {
&catalog.Product{},
&catalog.MeasureUnit{},
&catalog.ProductContainer{},
&catalog.Store{},
&recipes.Recipe{},
&recipes.RecipeItem{},
&invoices.Invoice{},
&invoices.InvoiceItem{},
&drafts.DraftInvoice{},
&drafts.DraftInvoiceItem{},
&operations.StoreOperation{},
&recommendations.Recommendation{},
&ocr.ProductMatch{},

View File

@@ -116,3 +116,19 @@ func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) {
Find(&products).Error
return products, err
}
func (r *pgRepository) SaveStores(stores []catalog.Store) error {
if len(stores) == 0 {
return nil
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).CreateInBatches(stores, 100).Error
}
func (r *pgRepository) GetActiveStores() ([]catalog.Store, error) {
var stores []catalog.Store
err := r.db.Where("is_deleted = ?", false).Order("name ASC").Find(&stores).Error
return stores, err
}

View File

@@ -0,0 +1,85 @@
package drafts
import (
"rmser/internal/domain/drafts"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type pgRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) drafts.Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) Create(draft *drafts.DraftInvoice) error {
return r.db.Create(draft).Error
}
func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
var draft drafts.DraftInvoice
err := r.db.
Preload("Items", func(db *gorm.DB) *gorm.DB {
return db.Order("draft_invoice_items.raw_name ASC")
}).
Preload("Items.Product").
Preload("Items.Product.MainUnit"). // Нужно для отображения единиц
Preload("Items.Container").
Where("id = ?", id).
First(&draft).Error
if err != nil {
return nil, err
}
return &draft, nil
}
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
// Обновляем только основные поля шапки
return r.db.Model(draft).Updates(map[string]interface{}{
"status": draft.Status,
"document_number": draft.DocumentNumber,
"date_incoming": draft.DateIncoming,
"supplier_id": draft.SupplierID,
"store_id": draft.StoreID,
"comment": draft.Comment,
"rms_invoice_id": draft.RMSInvoiceID,
"updated_at": gorm.Expr("NOW()"),
}).Error
}
func (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error {
if len(items) == 0 {
return nil
}
return r.db.CreateInBatches(items, 100).Error
}
func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
// Пересчитываем сумму
sum := qty.Mul(price)
// Определяем статус IsMatched: если productID задан - значит сматчено
isMatched := productID != nil
updates := map[string]interface{}{
"product_id": productID,
"container_id": containerID,
"quantity": qty,
"price": price,
"sum": sum,
"is_matched": isMatched,
}
return r.db.Model(&drafts.DraftInvoiceItem{}).
Where("id = ?", itemID).
Updates(updates).Error
}
func (r *pgRepository) Delete(id uuid.UUID) error {
return r.db.Delete(&drafts.DraftInvoice{}, id).Error
}

View File

@@ -45,6 +45,11 @@ func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity d
})
}
func (r *pgRepository) DeleteMatch(rawName string) error {
normalized := strings.ToLower(strings.TrimSpace(rawName))
return r.db.Where("raw_name = ?", normalized).Delete(&ocr.ProductMatch{}).Error
}
func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) {
normalized := strings.ToLower(strings.TrimSpace(rawName))
var match ocr.ProductMatch

View File

@@ -32,6 +32,7 @@ type ClientI interface {
Auth() error
Logout() error
FetchCatalog() ([]catalog.Product, error)
FetchStores() ([]catalog.Store, error)
FetchMeasureUnits() ([]catalog.MeasureUnit, error)
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
@@ -337,6 +338,52 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
return products, nil
}
// FetchStores загружает список складов (Account -> INVENTORY_ASSETS)
func (c *Client) FetchStores() ([]catalog.Store, error) {
resp, err := c.doRequest("GET", "/resto/api/v2/entities/list", map[string]string{
"rootType": "Account",
"includeDeleted": "false",
})
if err != nil {
return nil, fmt.Errorf("get stores error: %w", err)
}
defer resp.Body.Close()
var dtos []AccountDTO
if err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {
return nil, fmt.Errorf("json decode stores error: %w", err)
}
var stores []catalog.Store
for _, d := range dtos {
// Фильтруем только склады
if d.Type != "INVENTORY_ASSETS" {
continue
}
id, err := uuid.Parse(d.ID)
if err != nil {
continue
}
var parentCorpID uuid.UUID
if d.ParentCorporateID != nil {
if parsed, err := uuid.Parse(*d.ParentCorporateID); err == nil {
parentCorpID = parsed
}
}
stores = append(stores, catalog.Store{
ID: id,
Name: d.Name,
ParentCorporateID: parentCorpID,
IsDeleted: d.Deleted,
})
}
return stores, nil
}
// FetchMeasureUnits загружает справочник единиц измерения
func (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {
// rootType=MeasureUnit согласно документации iiko

View File

@@ -28,6 +28,15 @@ type GenericEntityDTO struct {
Deleted bool `json:"deleted"`
}
// AccountDTO используется для парсинга складов (INVENTORY_ASSETS)
type AccountDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // Нас интересует "INVENTORY_ASSETS"
ParentCorporateID *string `json:"parentCorporateId"`
Deleted bool `json:"deleted"`
}
// ContainerDTO - фасовка из iiko
type ContainerDTO struct {
ID string `json:"id"`

View File

@@ -0,0 +1,200 @@
package drafts
import (
"errors"
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/invoices"
"rmser/internal/domain/ocr"
"rmser/internal/infrastructure/rms"
"rmser/pkg/logger"
)
type Service struct {
draftRepo drafts.Repository
ocrRepo ocr.Repository
catalogRepo catalog.Repository
rmsClient rms.ClientI
}
func NewService(
draftRepo drafts.Repository,
ocrRepo ocr.Repository,
catalogRepo catalog.Repository,
rmsClient rms.ClientI,
) *Service {
return &Service{
draftRepo: draftRepo,
ocrRepo: ocrRepo,
catalogRepo: catalogRepo,
rmsClient: rmsClient,
}
}
// GetDraft возвращает черновик с позициями
func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
return s.draftRepo.GetByID(id)
}
// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
draft, err := s.draftRepo.GetByID(id)
if err != nil {
return err
}
if draft.Status == drafts.StatusCompleted {
return errors.New("черновик уже отправлен")
}
draft.StoreID = storeID
draft.SupplierID = supplierID
draft.DateIncoming = &date
draft.Comment = comment
return s.draftRepo.Update(draft)
}
// UpdateItem обновляет позицию (Без сохранения обучения!)
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
// Мы просто обновляем данные в черновике.
// Сохранение в базу знаний (OCR Matches) произойдет только при отправке накладной.
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
}
// CommitDraft отправляет накладную в RMS
func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
// 1. Загружаем актуальное состояние черновика
draft, err := s.draftRepo.GetByID(id)
if err != nil {
return "", err
}
if draft.Status == drafts.StatusCompleted {
return "", errors.New("накладная уже отправлена")
}
// Валидация
if draft.StoreID == nil || *draft.StoreID == uuid.Nil {
return "", errors.New("не выбран склад")
}
if draft.SupplierID == nil || *draft.SupplierID == uuid.Nil {
return "", errors.New("не выбран поставщик")
}
if draft.DateIncoming == nil {
return "", errors.New("не выбрана дата")
}
// Сборка Invoice для отправки
inv := invoices.Invoice{
ID: uuid.Nil, // iiko создаст новый
DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит
DateIncoming: *draft.DateIncoming,
SupplierID: *draft.SupplierID,
DefaultStoreID: *draft.StoreID,
Status: "NEW",
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
}
for _, dItem := range draft.Items {
if dItem.ProductID == nil {
// Пропускаем нераспознанные или кидаем ошибку?
// Лучше пропустить, чтобы не блокировать отправку частичного документа
continue
}
// Расчет суммы (если не задана, считаем)
sum := dItem.Sum
if sum.IsZero() {
sum = dItem.Quantity.Mul(dItem.Price)
}
// Важный момент с фасовками:
// Клиент RMS (CreateIncomingInvoice) у нас пока не поддерживает отправку container_id в явном виде,
// или мы его обновили? Проверим `internal/infrastructure/rms/client.go`.
// Там используется `IncomingInvoiceImportItemXML`. В ней нет поля ContainerID, но есть `AmountUnit`.
// Если мы хотим передать фасовку, нужно передавать Amount в базовых единицах,
// ЛИБО доработать клиент iiko, чтобы он принимал `amountUnit` (ID фасовки).
// СТРАТЕГИЯ СЕЙЧАС:
// Считаем, что FrontEnd/Service уже пересчитал кол-во в базовые единицы?
// НЕТ. DraftItem хранит Quantity в тех единицах, которые выбрал юзер (фасовках).
// Нам нужно конвертировать в базовые для отправки, если мы не умеем слать фасовки.
// Но погоди, в `ProductContainer` есть `Count` (коэффициент).
finalAmount := dItem.Quantity
if dItem.ContainerID != nil && dItem.Container != nil {
// Если выбрана фасовка, умножаем кол-во упаковок на коэффициент
finalAmount = finalAmount.Mul(dItem.Container.Count)
}
inv.Items = append(inv.Items, invoices.InvoiceItem{
ProductID: *dItem.ProductID,
Amount: finalAmount,
Price: dItem.Price, // Цена обычно за упаковку... А iiko ждет цену за базу?
// RMS API: Если мы шлем в базовых единицах, то и цену надо пересчитать за базовую.
// Price (base) = Price (pack) / Count
// ЛИБО: Мы шлем Sum, а iiko сама посчитает цену. Это надежнее.
Sum: sum,
})
}
if len(inv.Items) == 0 {
return "", errors.New("нет распознанных позиций для отправки")
}
// Отправка
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
if err != nil {
return "", err
}
// Обновление статуса
draft.Status = drafts.StatusCompleted
// Можно сохранить docNum, если бы было поле в Draft, но у нас есть rms_invoice_id (uuid),
// а возвращается строка номера. Ок, просто меняем статус.
if err := s.draftRepo.Update(draft); err != nil {
logger.Log.Error("Failed to update draft status after commit", zap.Error(err))
}
// 4. ОБУЧЕНИЕ (Deferred Learning)
// Запускаем в горутине, чтобы не задерживать ответ пользователю
go s.learnFromDraft(draft)
return docNum, nil
}
// learnFromDraft сохраняет новые связи на основе подтвержденного черновика
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
for _, item := range draft.Items {
// Учимся только если:
// 1. Есть RawName (текст из чека)
// 2. Пользователь (или OCR) выбрал ProductID
if item.RawName != "" && item.ProductID != nil {
// Если нужно запоминать коэффициент (например, всегда 1 или то, что ввел юзер),
// то берем item.Quantity. Но обычно для матчинга мы запоминаем факт связи,
// а дефолтное кол-во ставим 1.
qty := decimal.NewFromFloat(1.0)
err := s.ocrRepo.SaveMatch(item.RawName, *item.ProductID, qty, item.ContainerID)
if err != nil {
logger.Log.Warn("Failed to learn match",
zap.String("raw", item.RawName),
zap.Error(err))
} else {
logger.Log.Info("Learned match", zap.String("raw", item.RawName))
}
}
}
}
// GetActiveStores возвращает список складов
func (s *Service) GetActiveStores() ([]catalog.Store, error) {
return s.catalogRepo.GetActiveStores()
}

View File

@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/ocr"
"rmser/internal/infrastructure/ocr_client"
"rmser/pkg/logger"
@@ -18,57 +19,116 @@ import (
type Service struct {
ocrRepo ocr.Repository
catalogRepo catalog.Repository
draftRepo drafts.Repository
pyClient *ocr_client.Client // Клиент к Python сервису
}
func NewService(
ocrRepo ocr.Repository,
catalogRepo catalog.Repository,
draftRepo drafts.Repository,
pyClient *ocr_client.Client,
) *Service {
return &Service{
ocrRepo: ocrRepo,
catalogRepo: catalogRepo,
draftRepo: draftRepo,
pyClient: pyClient,
}
}
// ProcessReceiptImage - основной метод: Картинка -> Распознавание -> Матчинг
func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]ProcessedItem, error) {
// 1. Отправляем в Python
// ProcessReceiptImage - Создает черновик, распознает, сохраняет результаты
func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData []byte) (*drafts.DraftInvoice, error) {
// 1. Создаем заготовку черновика
draft := &drafts.DraftInvoice{
ChatID: chatID,
Status: drafts.StatusProcessing,
}
if err := s.draftRepo.Create(draft); err != nil {
return nil, fmt.Errorf("failed to create draft: %w", err)
}
logger.Log.Info("Создан черновик", zap.String("draft_id", draft.ID.String()))
// 2. Отправляем в Python OCR
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
if err != nil {
// Ставим статус ошибки
draft.Status = drafts.StatusError
_ = s.draftRepo.Update(draft)
return nil, fmt.Errorf("python ocr error: %w", err)
}
var processed []ProcessedItem
// 3. Обрабатываем результаты и создаем Items
var draftItems []drafts.DraftInvoiceItem
for _, rawItem := range rawResult.Items {
item := ProcessedItem{
RawName: rawItem.RawName,
Amount: decimal.NewFromFloat(rawItem.Amount),
Price: decimal.NewFromFloat(rawItem.Price),
Sum: decimal.NewFromFloat(rawItem.Sum),
item := drafts.DraftInvoiceItem{
DraftID: draft.ID,
RawName: rawItem.RawName,
RawAmount: decimal.NewFromFloat(rawItem.Amount),
RawPrice: decimal.NewFromFloat(rawItem.Price),
// Quantity/Price по умолчанию берем как Raw, если не будет пересчета
Quantity: decimal.NewFromFloat(rawItem.Amount),
Price: decimal.NewFromFloat(rawItem.Price),
Sum: decimal.NewFromFloat(rawItem.Sum),
}
// Пытаемся найти матчинг
match, err := s.ocrRepo.FindMatch(rawItem.RawName)
if err != nil {
logger.Log.Error("db error finding match", zap.Error(err))
}
if match != nil {
item.ProductID = &match.ProductID
item.IsMatched = true
item.MatchSource = "learned"
// Здесь мы могли бы подтянуть quantity/container из матча,
// но пока фронт сам это сделает, запросив /ocr/matches или получив подсказку.
item.ProductID = &match.ProductID
item.ContainerID = match.ContainerID
// Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт)
// это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама,
// либо мы можем пересчитать тут, если знаем коэффициент.
// Пока оставляем Quantity как есть (кол-во упаковок),
// так как ContainerID передается в iiko.
} else {
// Если не нашли - сохраняем в Unmatched для статистики и подсказок
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
logger.Log.Warn("failed to save unmatched", zap.Error(err))
}
}
processed = append(processed, item)
draftItems = append(draftItems, item)
}
return processed, nil
// 4. Сохраняем позиции в БД
// Примечание: GORM умеет сохранять вложенные структуры через Update родителя,
// но надежнее явно сохранить items, если мы не используем Session FullSaveAssociations.
// В данном случае мы уже создали Draft, теперь привяжем к нему items.
// Для простоты, так как у нас в Repo нет метода SaveItems,
// мы обновим драфт, добавив Items (GORM должен создать их).
draft.Status = drafts.StatusReadyToVerify
if err := s.draftRepo.Update(draft); err != nil {
return nil, fmt.Errorf("failed to update draft status: %w", err)
}
draft.Items = draftItems
// Используем хак GORM: при обновлении объекта с ассоциациями, он их создаст.
// Но надежнее расширить репозиторий. Давай используем Repository Update,
// но он у нас обновляет только шапку.
// Поэтому лучше расширим draftRepo методом SaveItems или используем прямую запись тут через items?
// Сделаем правильно: добавим AddItems в репозиторий прямо сейчас, или воспользуемся тем, что Items сохранятся
// если мы сделаем Save через GORM. В нашем Repo метод Create делает Create.
// Давайте сделаем SaveItems в репозитории drafts, чтобы было чисто.
// ВРЕМЕННОЕ РЕШЕНИЕ (чтобы не менять интерфейс снова):
// Мы можем создать items через repository, но там нет метода.
// Давай я добавлю метод в интерфейс репозитория Drafts в следующем блоке изменений.
// Пока предположим, что мы расширили репозиторий.
if err := s.draftRepo.CreateItems(draftItems); err != nil {
return nil, fmt.Errorf("failed to save items: %w", err)
}
return draft, nil
}
// ProcessedItem - результат обработки одной строки чека
@@ -137,6 +197,11 @@ func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity deci
return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID)
}
// DeleteMatch удаляет ошибочную привязку
func (s *Service) DeleteMatch(rawName string) error {
return s.ocrRepo.DeleteMatch(rawName)
}
// GetKnownMatches возвращает список всех обученных связей
func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) {
return s.ocrRepo.GetAllMatches()

View File

@@ -49,14 +49,20 @@ func NewService(
// SyncCatalog загружает номенклатуру и сохраняет в БД
func (s *Service) SyncCatalog() error {
logger.Log.Info("Начало синхронизации каталога...")
logger.Log.Info("Начало синхронизации справочников...")
// 1. Сначала Единицы измерения (чтобы FK не ругался)
// 1. Склады (INVENTORY_ASSETS) - важно для создания накладных
if err := s.SyncStores(); err != nil {
logger.Log.Error("Ошибка синхронизации складов", zap.Error(err))
// Не прерываем, идем дальше
}
// 2. Единицы измерения
if err := s.syncMeasureUnits(); err != nil {
return err
}
// 2. Товары
// 3. Товары
logger.Log.Info("Запрос товаров из RMS...")
products, err := s.rmsClient.FetchCatalog()
if err != nil {
@@ -187,6 +193,22 @@ func classifyOperation(docType string) operations.OperationType {
}
}
// SyncStores загружает список складов
func (s *Service) SyncStores() error {
logger.Log.Info("Синхронизация складов...")
stores, err := s.rmsClient.FetchStores()
if err != nil {
return fmt.Errorf("ошибка получения складов из RMS: %w", err)
}
if err := s.catalogRepo.SaveStores(stores); err != nil {
return fmt.Errorf("ошибка сохранения складов в БД: %w", err)
}
logger.Log.Info("Склады обновлены", zap.Int("count", len(stores)))
return nil
}
func (s *Service) SyncStoreOperations() error {
dateTo := time.Now()
dateFrom := dateTo.AddDate(0, 0, -30)

View File

@@ -0,0 +1,151 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"rmser/internal/services/drafts"
"rmser/pkg/logger"
)
type DraftsHandler struct {
service *drafts.Service
}
func NewDraftsHandler(service *drafts.Service) *DraftsHandler {
return &DraftsHandler{service: service}
}
// GetDraft возвращает полные данные черновика
func (h *DraftsHandler) GetDraft(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
draft, err := h.service.GetDraft(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "draft not found"})
return
}
c.JSON(http.StatusOK, draft)
}
// GetStores возвращает список складов
func (h *DraftsHandler) GetStores(c *gin.Context) {
stores, err := h.service.GetActiveStores()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stores)
}
// UpdateItemDTO - тело запроса на изменение строки
type UpdateItemDTO struct {
ProductID *string `json:"product_id"`
ContainerID *string `json:"container_id"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
}
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
draftID, _ := uuid.Parse(c.Param("id"))
itemID, _ := uuid.Parse(c.Param("itemId"))
var req UpdateItemDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var pID *uuid.UUID
if req.ProductID != nil && *req.ProductID != "" {
if uid, err := uuid.Parse(*req.ProductID); err == nil {
pID = &uid
}
}
var cID *uuid.UUID
if req.ContainerID != nil && *req.ContainerID != "" {
if uid, err := uuid.Parse(*req.ContainerID); err == nil {
cID = &uid
}
}
qty := decimal.NewFromFloat(req.Quantity)
price := decimal.NewFromFloat(req.Price)
if err := h.service.UpdateItem(draftID, itemID, pID, cID, qty, price); err != nil {
logger.Log.Error("Failed to update item", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
type CommitRequestDTO struct {
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
StoreID string `json:"store_id"`
SupplierID string `json:"supplier_id"`
Comment string `json:"comment"`
}
// CommitDraft сохраняет шапку и отправляет в RMS
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
draftID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
return
}
var req CommitRequestDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Парсинг данных шапки
date, err := time.Parse("2006-01-02", req.DateIncoming)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format (YYYY-MM-DD)"})
return
}
storeID, err := uuid.Parse(req.StoreID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid store id"})
return
}
supplierID, err := uuid.Parse(req.SupplierID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid supplier id"})
return
}
// 1. Обновляем шапку
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
return
}
// 2. Отправляем
docNum, err := h.service.CommitDraft(draftID)
if err != nil {
logger.Log.Error("Commit failed", zap.Error(err))
c.JSON(http.StatusBadGateway, gin.H{"error": "RMS error: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "completed",
"document_number": docNum,
})
}

View File

@@ -73,6 +73,25 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "saved"})
}
// DeleteMatch удаляет связь
func (h *OCRHandler) DeleteMatch(c *gin.Context) {
// Получаем raw_name из query параметров, так как в URL path могут быть спецсимволы
// Пример: DELETE /api/ocr/match?raw_name=Хлеб%20Бородинский
rawName := c.Query("raw_name")
if rawName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "raw_name is required"})
return
}
if err := h.service.DeleteMatch(rawName); err != nil {
logger.Log.Error("Ошибка удаления матча", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// GetMatches возвращает список всех обученных связей
func (h *OCRHandler) GetMatches(c *gin.Context) {
matches, err := h.service.GetKnownMatches()

View File

@@ -21,6 +21,7 @@ type Bot struct {
b *tele.Bot
ocrService *ocr.Service
adminIDs map[int64]struct{}
webAppURL string
}
func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
@@ -46,6 +47,13 @@ func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
b: b,
ocrService: ocrService,
adminIDs: admins,
webAppURL: cfg.WebAppURL,
}
// Если в конфиге пусто, ставим заглушку, чтобы не падало, но предупреждаем
if bot.webAppURL == "" {
logger.Log.Warn("Telegram WebAppURL не задан в конфиге! Кнопки работать не будут.")
bot.webAppURL = "http://example.com"
}
bot.initHandlers()
@@ -106,36 +114,49 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
return c.Send("Ошибка чтения файла.")
}
c.Send("⏳ Обрабатываю чек через OCR...")
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
// 2. Отправляем в сервис
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// 2. Отправляем в сервис (добавили ID чата)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) // Чуть увеличим таймаут
defer cancel()
items, err := bot.ocrService.ProcessReceiptImage(ctx, imgData)
draft, err := bot.ocrService.ProcessReceiptImage(ctx, c.Chat().ID, imgData)
if err != nil {
logger.Log.Error("OCR processing failed", zap.Error(err))
return c.Send("❌ Ошибка распознавания: " + err.Error())
return c.Send("❌ Ошибка обработки: " + err.Error())
}
// 3. Формируем отчет
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🧾 <b>Результат (%d поз.):</b>\n\n", len(items)))
// 3. Анализ результатов для сообщения
matchedCount := 0
for _, item := range items {
for _, item := range draft.Items {
if item.IsMatched {
matchedCount++
sb.WriteString(fmt.Sprintf("✅ %s\n └ <code>%s</code> x %s = %s\n",
item.RawName, item.Amount, item.Price, item.Sum))
} else {
sb.WriteString(fmt.Sprintf("❓ <b>%s</b>\n └ Нет привязки!\n", item.RawName))
}
}
sb.WriteString(fmt.Sprintf("\nРаспознано: %d/%d", matchedCount, len(items)))
// Формируем URL. Для Mini App это должен быть https URL вашего фронтенда.
// Фронтенд должен уметь роутить /invoice/:id
baseURL := strings.TrimRight(bot.webAppURL, "/")
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
// Тут можно добавить кнопки, если что-то не распознано
// Но для начала просто текст
return c.Send(sb.String(), tele.ModeHTML)
// Формируем текст сообщения
var msgText string
if matchedCount == len(draft.Items) {
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
} else {
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления. Нажмите кнопку ниже, чтобы исправить.", matchedCount, len(draft.Items))
}
menu := &tele.ReplyMarkup{}
// Используем WebApp, а не URL
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{
URL: fullURL,
})
menu.Inline(
menu.Row(btnOpen),
)
return c.Send(msgText, menu, tele.ModeHTML)
}