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