mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
added front - react+ts
ocr improved
This commit is contained in:
316
API_DOCS.md
316
API_DOCS.md
@@ -1,161 +1,164 @@
|
|||||||
# API Documentation: RMSER Backend
|
# RMSER Backend API Documentation (v2.0)
|
||||||
|
|
||||||
## 1. Общая информация
|
**Дата обновления:** Текущая
|
||||||
|
**Base URL:** `http://localhost:8080/api`
|
||||||
|
|
||||||
* **Base URL:** `http://localhost:8080/api`
|
## 1. Типы данных (TypeScript Interfaces)
|
||||||
* **Формат данных:** JSON.
|
|
||||||
* **CORS:** Разрешены запросы с `localhost:5173` (и любых других источников в режиме dev).
|
|
||||||
* **Auth:** На данный момент эндпоинты открыты. В будущем предполагается передача токена в заголовке `Authorization: Bearer <token>`.
|
|
||||||
|
|
||||||
### Обработка ошибок
|
Используйте эти интерфейсы для строгой типизации на клиенте.
|
||||||
В случае ошибки сервер возвращает HTTP код 4xx/5xx и JSON:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Описание ошибки"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. TypeScript Интерфейсы (Models)
|
|
||||||
|
|
||||||
Используйте эти интерфейсы для типизации данных на клиенте.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// --- Общие типы ---
|
// Базовые типы
|
||||||
|
|
||||||
// UUID (строка)
|
|
||||||
type UUID = string;
|
type UUID = string;
|
||||||
|
|
||||||
// Decimal (деньги/вес приходят строкой, чтобы избежать потери точность в JS)
|
// --- Каталог и Фасовки ---
|
||||||
type Decimal = string;
|
|
||||||
|
|
||||||
// --- Каталог и OCR ---
|
export interface MeasureUnit {
|
||||||
|
id: UUID;
|
||||||
|
name: string; // "кг", "л", "шт", "порц"
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductContainer {
|
||||||
|
id: UUID;
|
||||||
|
name: string; // "Коробка", "Пачка 200г"
|
||||||
|
count: number; // Коэффициент пересчета в базовые единицы (напр. 0.2 или 12.0)
|
||||||
|
}
|
||||||
|
|
||||||
// Товар для выпадающего списка (Autosuggest)
|
|
||||||
export interface CatalogItem {
|
export interface CatalogItem {
|
||||||
id: UUID;
|
id: UUID;
|
||||||
name: string;
|
name: string;
|
||||||
code: string; // Артикул или код быстрого набора
|
code: string;
|
||||||
|
measure_unit: string; // Название базовой единицы (из MainUnit)
|
||||||
|
containers: ProductContainer[]; // Доступные фасовки
|
||||||
}
|
}
|
||||||
|
|
||||||
// Связь "Текст из чека" -> "Товар iiko"
|
// --- Матчинг (Обучение) ---
|
||||||
export interface ProductMatch {
|
|
||||||
raw_name: string; // Текст из OCR (ключ)
|
export interface MatchRequest {
|
||||||
product_id: UUID;
|
raw_name: string; // Текст из чека
|
||||||
product?: CatalogItem; // Вложенный объект (при чтении)
|
product_id: UUID; // ID товара iiko
|
||||||
updated_at: string; // ISO Date
|
quantity: number; // Количество (по умолчанию 1.0)
|
||||||
|
container_id?: UUID; // Опционально: ID фасовки, если выбрана
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Рекомендации (Аналитика) ---
|
export interface SavedMatch {
|
||||||
|
raw_name: string;
|
||||||
export interface Recommendation {
|
product: CatalogItem; // Вложенный объект товара
|
||||||
ID: UUID;
|
quantity: number; // Сохраненный коэффициент/количество
|
||||||
Type: string; // Код проблемы (UNUSED_IN_RECIPES, NO_INCOMING, etc.)
|
container_id?: UUID;
|
||||||
ProductID: UUID;
|
container?: ProductContainer; // Вложенный объект фасовки (для отображения имени)
|
||||||
ProductName: string;
|
updated_at: string;
|
||||||
Reason: string; // Человекочитаемое описание проблемы
|
|
||||||
CreatedAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Накладные ---
|
// --- Нераспознанное ---
|
||||||
|
|
||||||
export interface InvoiceItemRequest {
|
export interface UnmatchedItem {
|
||||||
product_id: UUID;
|
raw_name: string;
|
||||||
amount: number; // или string, если нужна высокая точность
|
count: number; // Сколько раз встречалось в чеках
|
||||||
price: number;
|
last_seen: string; // ISO Date
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateInvoiceRequest {
|
|
||||||
document_number: string;
|
|
||||||
date_incoming: string; // Format: "YYYY-MM-DD"
|
|
||||||
supplier_id: UUID;
|
|
||||||
store_id: UUID;
|
|
||||||
items: InvoiceItemRequest[];
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Эндпоинты
|
## 2. Эндпоинты: OCR и Обучение
|
||||||
|
|
||||||
### 📊 Аналитика (Рекомендации)
|
### `GET /ocr/catalog`
|
||||||
|
Получить полный справочник товаров для поиска.
|
||||||
|
Теперь включает **единицы измерения** и **фасовки**.
|
||||||
|
|
||||||
#### Получить список рекомендаций
|
|
||||||
Возвращает список выявленных проблем в учете (товары без техкарт, ингредиенты без закупок и т.д.). Данные обновляются фоновым процессом на бэкенде.
|
|
||||||
|
|
||||||
* **URL:** `/recommendations`
|
|
||||||
* **Method:** `GET`
|
|
||||||
* **Response:** `200 OK`
|
* **Response:** `200 OK`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"ID": "uuid...",
|
"id": "uuid-butter...",
|
||||||
"Type": "UNUSED_IN_RECIPES",
|
"name": "Масло сливочное 82%",
|
||||||
"ProductID": "uuid...",
|
"code": "00123",
|
||||||
"ProductName": "Петрушка с/м",
|
"measure_unit": "кг",
|
||||||
"Reason": "Товар не используется ни в одной техкарте",
|
"containers": [
|
||||||
"CreatedAt": "2023-10-27T10:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 👁 OCR и Обучение (Matching)
|
|
||||||
|
|
||||||
#### 1. Получить справочник товаров (для селекта)
|
|
||||||
Используется для построения локального индекса поиска (autocomplete) на фронтенде, чтобы привязывать позиции. Возвращает только активные товары (`GOODS`).
|
|
||||||
|
|
||||||
* **URL:** `/ocr/catalog`
|
|
||||||
* **Method:** `GET`
|
|
||||||
* **Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
{
|
||||||
"id": "607a1e96-f539-45d2-8709-3919f94bdc3e",
|
"id": "uuid-pack...",
|
||||||
"name": "Молоко Домик в Деревне 3.2%",
|
"name": "Пачка 180г",
|
||||||
"code": "00123"
|
"count": 0.180
|
||||||
},
|
},
|
||||||
...
|
{
|
||||||
|
"id": "uuid-box...",
|
||||||
|
"name": "Коробка (20 пачек)",
|
||||||
|
"count": 3.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-milk...",
|
||||||
|
"name": "Молоко 3.2%",
|
||||||
|
"code": "00124",
|
||||||
|
"measure_unit": "л",
|
||||||
|
"containers": [] // Пустой массив, если фасовок нет
|
||||||
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Получить таблицу обучения (Matches)
|
---
|
||||||
Возвращает список уже созданных связей "Грязное название из чека" -> "Чистый товар iiko".
|
|
||||||
|
### `GET /ocr/matches`
|
||||||
|
Получить список уже обученных позиций. Используется для отображения таблицы "Ранее сохраненные связи".
|
||||||
|
|
||||||
* **URL:** `/ocr/matches`
|
|
||||||
* **Method:** `GET`
|
|
||||||
* **Response:** `200 OK`
|
* **Response:** `200 OK`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"raw_name": "молоко двд 3,2",
|
"raw_name": "Масло слив. 3 пачки",
|
||||||
"product_id": "607a1e96-f539-45d2-8709-3919f94bdc3e",
|
"product_id": "uuid-butter...",
|
||||||
"product": {
|
"product": {
|
||||||
"ID": "607a1e96-f539-45d2-8709-3919f94bdc3e",
|
"id": "uuid-butter...",
|
||||||
"Name": "Молоко Домик в Деревне 3.2%",
|
"name": "Масло сливочное 82%",
|
||||||
...
|
"measure_unit": "кг"
|
||||||
|
// ...остальные поля product
|
||||||
},
|
},
|
||||||
"updated_at": "..."
|
"quantity": 3,
|
||||||
|
"container_id": "uuid-pack...",
|
||||||
|
"container": {
|
||||||
|
"id": "uuid-pack...",
|
||||||
|
"name": "Пачка 180г",
|
||||||
|
"count": 0.180
|
||||||
|
},
|
||||||
|
"updated_at": "2023-10-27T10:00:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
> **Логика отображения на фронте:**
|
||||||
|
> Если `container` пришел (не null) -> отображаем: `Quantity` x `Container.Name` (3 x Пачка 180г).
|
||||||
|
> Если `container` null -> отображаем: `Quantity` `Product.MeasureUnit` (0.54 кг).
|
||||||
|
|
||||||
#### 3. Создать/Обновить привязку
|
---
|
||||||
Сохраняет правило: "Если в чеке встретишь этот текст, считай это вот этим товаром".
|
|
||||||
|
### `POST /ocr/match`
|
||||||
|
Создать или обновить привязку.
|
||||||
|
|
||||||
* **URL:** `/ocr/match`
|
|
||||||
* **Method:** `POST`
|
|
||||||
* **Body:**
|
* **Body:**
|
||||||
|
|
||||||
|
**Вариант 1: Базовая единица (без фасовки)**
|
||||||
|
Пользователь выбрал "Петрушка", ввел "0.5" (кг).
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"raw_name": "молоко двд 3,2",
|
"raw_name": "петрушка вес",
|
||||||
"product_id": "607a1e96-f539-45d2-8709-3919f94bdc3e"
|
"product_id": "uuid-parsley...",
|
||||||
|
"quantity": 0.5
|
||||||
|
// container_id не передаем или null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 2: С фасовкой**
|
||||||
|
Пользователь выбрал "Масло", выбрал фасовку "Коробка", ввел "2" (штуки).
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"raw_name": "масло коробка",
|
||||||
|
"product_id": "uuid-butter...",
|
||||||
|
"quantity": 2,
|
||||||
|
"container_id": "uuid-box..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -163,78 +166,79 @@ export interface CreateInvoiceRequest {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 📄 Накладные (Invoices)
|
### `GET /ocr/unmatched`
|
||||||
|
Получить список частых нераспознанных товаров.
|
||||||
|
Используется для автодополнения (Suggest) в поле ввода "Текст из чека", чтобы пользователь не вводил название вручную.
|
||||||
|
|
||||||
#### Отправить накладную в iikoRMS
|
* **Response:** `200 OK`
|
||||||
Создает черновик приходной накладной в системе iiko.
|
|
||||||
|
|
||||||
* **URL:** `/invoices/send`
|
```json
|
||||||
* **Method:** `POST`
|
[
|
||||||
* **Body:**
|
{
|
||||||
|
"raw_name": "Хлеб Бородинский нар.",
|
||||||
|
"count": 12,
|
||||||
|
"last_seen": "2023-10-27T12:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"raw_name": "Пакет майка",
|
||||||
|
"count": 5,
|
||||||
|
"last_seen": "2023-10-26T09:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Эндпоинты: Накладные (Invoices)
|
||||||
|
|
||||||
|
### `POST /invoices/send`
|
||||||
|
Отправка накладной в iiko.
|
||||||
|
|
||||||
|
**Важно:** При формировании накладной на фронте, вы должны пересчитывать количество в базовые единицы, либо (в будущем) мы научим бэкенд принимать ID фасовки в `items`.
|
||||||
|
На данный момент API ожидает `amount` и `price` уже приведенными к единому знаменателю, либо iiko сама разберется, если мы передадим ContainerID (этот функционал в разработке на стороне `rms_client`, пока шлите базовые единицы).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"document_number": "INV-12345",
|
"document_number": "INV-100",
|
||||||
"date_incoming": "2023-10-27",
|
"date_incoming": "2023-10-27",
|
||||||
"supplier_id": "uuid-supplier...",
|
"supplier_id": "uuid...",
|
||||||
"store_id": "uuid-store...",
|
"store_id": "uuid...",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"product_id": "uuid-product...",
|
"product_id": "uuid...",
|
||||||
"amount": 10.5,
|
"amount": 1.5, // 1.5 кг
|
||||||
"price": 150.00
|
"price": 100 // Цена за кг
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
* **Response:** `200 OK`
|
---
|
||||||
|
|
||||||
|
## 4. Эндпоинты: Аналитика
|
||||||
|
|
||||||
|
### `GET /recommendations`
|
||||||
|
Возвращает список проблем (товары без техкарт, без закупок и т.д.).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
[
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"ID": "uuid...",
|
||||||
"created_number": "000123" // Номер документа, присвоенный iiko
|
"Type": "UNUSED_IN_RECIPES",
|
||||||
|
"ProductID": "uuid...",
|
||||||
|
"ProductName": "Лист салата",
|
||||||
|
"Reason": "Товар не используется ни в одной техкарте",
|
||||||
|
"CreatedAt": "..."
|
||||||
}
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### ⚙️ System
|
## 5. System
|
||||||
|
|
||||||
#### Healthcheck
|
|
||||||
Проверка доступности API.
|
|
||||||
|
|
||||||
* **URL:** `http://localhost:8080/health`
|
|
||||||
* **Method:** `GET`
|
|
||||||
* **Response:**
|
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
Проверка статуса.
|
||||||
```json
|
```json
|
||||||
{
|
{"status": "ok", "time": "..."}
|
||||||
"status": "ok",
|
|
||||||
"time": "2023-10-27T12:34:56+03:00"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Сценарии использования (Frontend Workflow)
|
|
||||||
|
|
||||||
### Сценарий А: "Обучение" (раздел Settings / OCR Learning)
|
|
||||||
1. При загрузке страницы вызвать `GET /api/ocr/matches` и отобразить таблицу.
|
|
||||||
2. Вызвать `GET /api/ocr/catalog` и сохранить в памяти для быстрого поиска (Combobox/Select).
|
|
||||||
3. Пользователь может добавить новую связь вручную:
|
|
||||||
* Вводит текст (например, "Хлеб Бородинский").
|
|
||||||
* Выбирает товар из выпадающего списка.
|
|
||||||
* Нажимает "Save" -> вызывается `POST /api/ocr/match`.
|
|
||||||
* Таблица обновляется.
|
|
||||||
|
|
||||||
### Сценарий Б: "Дашборд аналитика"
|
|
||||||
1. При загрузке главной страницы вызвать `GET /api/recommendations`.
|
|
||||||
2. Сгруппировать массив по полю `Type` или `Reason`.
|
|
||||||
3. Отобразить карточки: "Товары без техкарт (5 шт)", "Ингредиенты без закупок (12 шт)".
|
|
||||||
|
|
||||||
### Сценарий В: "Создание накладной" (Пока ручное)
|
|
||||||
*Пока нет загрузки фото через Web, предполагается ручной ввод или редактирование данных, полученных иным путем.*
|
|
||||||
1. Форма ввода номера, даты.
|
|
||||||
2. Таблица товаров (добавление строк через поиск по каталогу).
|
|
||||||
3. Кнопка "Отправить в iiko" -> `POST /api/invoices/send`.
|
|
||||||
@@ -161,6 +161,7 @@ func main() {
|
|||||||
api.GET("/ocr/catalog", ocrHandler.GetCatalog)
|
api.GET("/ocr/catalog", ocrHandler.GetCatalog)
|
||||||
api.GET("/ocr/matches", ocrHandler.GetMatches)
|
api.GET("/ocr/matches", ocrHandler.GetMatches)
|
||||||
api.POST("/ocr/match", ocrHandler.SaveMatch)
|
api.POST("/ocr/match", ocrHandler.SaveMatch)
|
||||||
|
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Простой хелсчек
|
// Простой хелсчек
|
||||||
|
|||||||
@@ -7,27 +7,51 @@ import (
|
|||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MeasureUnit - Единица измерения (kg, l, pcs)
|
||||||
|
type MeasureUnit struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
|
Code string `gorm:"type:varchar(50)" json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductContainer - Фасовка (упаковка) товара
|
||||||
|
type ProductContainer struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
ProductID uuid.UUID `gorm:"type:uuid;index;not null" json:"product_id"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
|
Count decimal.Decimal `gorm:"type:numeric(19,4);not null" json:"count"` // Коэфф. пересчета
|
||||||
|
}
|
||||||
|
|
||||||
// Product - Номенклатура
|
// Product - Номенклатура
|
||||||
type Product struct {
|
type Product struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
ParentID *uuid.UUID `gorm:"type:uuid;index"`
|
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||||
Name string `gorm:"type:varchar(255);not null"`
|
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
||||||
Type string `gorm:"type:varchar(50);index"` // GOODS, DISH, PREPARED, etc.
|
Type string `gorm:"type:varchar(50);index" json:"type"` // GOODS, DISH, PREPARED
|
||||||
Num string `gorm:"type:varchar(50)"`
|
Num string `gorm:"type:varchar(50)" json:"num"`
|
||||||
Code string `gorm:"type:varchar(50)"`
|
Code string `gorm:"type:varchar(50)" json:"code"`
|
||||||
UnitWeight decimal.Decimal `gorm:"type:numeric(19,4)"`
|
UnitWeight decimal.Decimal `gorm:"type:numeric(19,4)" json:"unit_weight"`
|
||||||
UnitCapacity decimal.Decimal `gorm:"type:numeric(19,4)"`
|
UnitCapacity decimal.Decimal `gorm:"type:numeric(19,4)" json:"unit_capacity"`
|
||||||
IsDeleted bool `gorm:"default:false"`
|
|
||||||
|
|
||||||
Parent *Product `gorm:"foreignKey:ParentID"`
|
// Связь с единицей измерения
|
||||||
Children []*Product `gorm:"foreignKey:ParentID"`
|
MainUnitID *uuid.UUID `gorm:"type:uuid;index" json:"main_unit_id"`
|
||||||
|
MainUnit *MeasureUnit `gorm:"foreignKey:MainUnitID" json:"main_unit,omitempty"`
|
||||||
|
|
||||||
CreatedAt time.Time
|
// Фасовки
|
||||||
UpdatedAt time.Time
|
Containers []ProductContainer `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE" json:"containers,omitempty"`
|
||||||
|
|
||||||
|
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
|
||||||
|
|
||||||
|
Parent *Product `gorm:"foreignKey:ParentID" json:"-"`
|
||||||
|
Children []*Product `gorm:"foreignKey:ParentID" json:"-"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repository интерфейс для каталога
|
// Repository интерфейс для каталога
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
|
SaveMeasureUnits(units []MeasureUnit) error
|
||||||
SaveProducts(products []Product) error
|
SaveProducts(products []Product) error
|
||||||
GetAll() ([]Product, error)
|
GetAll() ([]Product, error)
|
||||||
GetActiveGoods() ([]Product, error)
|
GetActiveGoods() ([]Product, error)
|
||||||
|
|||||||
@@ -3,32 +3,44 @@ package ocr
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"rmser/internal/domain/catalog"
|
"rmser/internal/domain/catalog"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProductMatch связывает текст из чека с конкретным товаром в iiko
|
// ProductMatch связывает текст из чека с конкретным товаром в iiko
|
||||||
type ProductMatch struct {
|
type ProductMatch struct {
|
||||||
// RawName - распознанный текст (ключ).
|
RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"`
|
||||||
// Лучше хранить в нижнем регистре и без лишних пробелов.
|
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
|
||||||
RawName string `gorm:"type:varchar(255);primary_key"`
|
Product catalog.Product `gorm:"foreignKey:ProductID" json:"product"`
|
||||||
|
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
// Количество и фасовки
|
||||||
|
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:1" json:"quantity"`
|
||||||
|
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
|
||||||
|
|
||||||
// Product - связь для GORM
|
// Для подгрузки данных о фасовке при чтении
|
||||||
Product catalog.Product `gorm:"foreignKey:ProductID"`
|
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
|
||||||
|
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmatchedItem хранит строки, которые не удалось распознать, для подсказок
|
||||||
|
type UnmatchedItem struct {
|
||||||
|
RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"`
|
||||||
|
Count int `gorm:"default:1" json:"count"` // Сколько раз встречалось
|
||||||
|
LastSeen time.Time `json:"last_seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
// SaveMatch сохраняет или обновляет привязку
|
// SaveMatch теперь принимает quantity и containerID
|
||||||
SaveMatch(rawName string, productID uuid.UUID) error
|
SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error
|
||||||
|
|
||||||
// FindMatch ищет товар по точному совпадению названия
|
FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty
|
||||||
FindMatch(rawName string) (*uuid.UUID, error)
|
|
||||||
|
|
||||||
// GetAllMatches возвращает все существующие привязки
|
|
||||||
GetAllMatches() ([]ProductMatch, error)
|
GetAllMatches() ([]ProductMatch, error)
|
||||||
|
|
||||||
|
UpsertUnmatched(rawName string) error
|
||||||
|
GetTopUnmatched(limit int) ([]UnmatchedItem, error)
|
||||||
|
DeleteUnmatched(rawName string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
|||||||
// 4. Автомиграция
|
// 4. Автомиграция
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&catalog.Product{},
|
&catalog.Product{},
|
||||||
|
&catalog.MeasureUnit{},
|
||||||
|
&catalog.ProductContainer{},
|
||||||
&recipes.Recipe{},
|
&recipes.Recipe{},
|
||||||
&recipes.RecipeItem{},
|
&recipes.RecipeItem{},
|
||||||
&invoices.Invoice{},
|
&invoices.Invoice{},
|
||||||
@@ -53,6 +55,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
|||||||
&operations.StoreOperation{},
|
&operations.StoreOperation{},
|
||||||
&recommendations.Recommendation{},
|
&recommendations.Recommendation{},
|
||||||
&ocr.ProductMatch{},
|
&ocr.ProductMatch{},
|
||||||
|
&ocr.UnmatchedItem{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("ошибка миграции БД: %v", err))
|
panic(fmt.Sprintf("ошибка миграции БД: %v", err))
|
||||||
|
|||||||
@@ -16,22 +16,53 @@ func NewRepository(db *gorm.DB) catalog.Repository {
|
|||||||
return &pgRepository{db: db}
|
return &pgRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) SaveProducts(products []catalog.Product) error {
|
func (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error {
|
||||||
// Сортировка (родители -> дети)
|
if len(units) == 0 {
|
||||||
sorted := sortProductsByHierarchy(products)
|
return nil
|
||||||
|
}
|
||||||
return r.db.Clauses(clause.OnConflict{
|
return r.db.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
UpdateAll: true,
|
UpdateAll: true,
|
||||||
}).CreateInBatches(sorted, 100).Error
|
}).CreateInBatches(units, 100).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) SaveProducts(products []catalog.Product) error {
|
||||||
|
sorted := sortProductsByHierarchy(products)
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Сохраняем продукты (без контейнеров, чтобы ускорить и не дублировать)
|
||||||
|
if err := tx.Omit("Containers").Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).CreateInBatches(sorted, 100).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Собираем все контейнеры в один слайс
|
||||||
|
var allContainers []catalog.ProductContainer
|
||||||
|
for _, p := range products {
|
||||||
|
allContainers = append(allContainers, p.Containers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Сохраняем контейнеры
|
||||||
|
if len(allContainers) > 0 {
|
||||||
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).CreateInBatches(allContainers, 100).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetAll() ([]catalog.Product, error) {
|
func (r *pgRepository) GetAll() ([]catalog.Product, error) {
|
||||||
var products []catalog.Product
|
var products []catalog.Product
|
||||||
err := r.db.Find(&products).Error
|
err := r.db.Preload("MainUnit").Find(&products).Error
|
||||||
return products, err
|
return products, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вспомогательная функция сортировки
|
// Вспомогательная функция сортировки (оставляем как была)
|
||||||
func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
|
func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
|
||||||
if len(products) == 0 {
|
if len(products) == 0 {
|
||||||
return products
|
return products
|
||||||
@@ -73,11 +104,14 @@ func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveGoods возвращает только активные товары (не удаленные, тип GOODS)
|
// GetActiveGoods возвращает только активные товары c подгруженной единицей измерения
|
||||||
|
// GetActiveGoods оптимизирован: подгружаем Units и Containers
|
||||||
func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) {
|
func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) {
|
||||||
var products []catalog.Product
|
var products []catalog.Product
|
||||||
// iikoRMS: GOODS - товары, PREPARED - заготовки (иногда их тоже покупают)
|
err := r.db.
|
||||||
err := r.db.Where("is_deleted = ? AND type IN ?", false, []string{"GOODS"}).
|
Preload("MainUnit").
|
||||||
|
Preload("Containers"). // <-- Подгружаем фасовки
|
||||||
|
Where("is_deleted = ? AND type IN ?", false, []string{"GOODS"}).
|
||||||
Order("name ASC").
|
Order("name ASC").
|
||||||
Find(&products).Error
|
Find(&products).Error
|
||||||
return products, err
|
return products, err
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ocr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"rmser/internal/domain/ocr"
|
"rmser/internal/domain/ocr"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pgRepository struct {
|
type pgRepository struct {
|
||||||
@@ -19,39 +21,88 @@ func NewRepository(db *gorm.DB) ocr.Repository {
|
|||||||
return &pgRepository{db: db}
|
return &pgRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID) error {
|
func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
match := ocr.ProductMatch{
|
match := ocr.ProductMatch{
|
||||||
RawName: normalized,
|
RawName: normalized,
|
||||||
ProductID: productID,
|
ProductID: productID,
|
||||||
|
Quantity: quantity,
|
||||||
|
ContainerID: containerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert: если такая строка уже была, обновляем ссылку на товар
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
return r.db.Clauses(clause.OnConflict{
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "raw_name"}},
|
Columns: []clause.Column{{Name: "raw_name"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"product_id", "updated_at"}),
|
DoUpdates: clause.AssignmentColumns([]string{"product_id", "quantity", "container_id", "updated_at"}),
|
||||||
}).Create(&match).Error
|
}).Create(&match).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) FindMatch(rawName string) (*uuid.UUID, error) {
|
if err := tx.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
var match ocr.ProductMatch
|
var match ocr.ProductMatch
|
||||||
|
|
||||||
err := r.db.Where("raw_name = ?", normalized).First(&match).Error
|
// Preload Container на случай, если нам сразу нужна инфа
|
||||||
|
err := r.db.Preload("Container").Where("raw_name = ?", normalized).First(&match).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return &match, nil
|
||||||
return &match.ProductID, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetAllMatches() ([]ocr.ProductMatch, error) {
|
func (r *pgRepository) GetAllMatches() ([]ocr.ProductMatch, error) {
|
||||||
var matches []ocr.ProductMatch
|
var matches []ocr.ProductMatch
|
||||||
// Preload("Product") загружает связанную сущность товара,
|
// Подгружаем Товар, Единицу и Фасовку
|
||||||
// чтобы мы видели не только ID, но и название товара из каталога.
|
err := r.db.
|
||||||
err := r.db.Preload("Product").Order("updated_at DESC").Find(&matches).Error
|
Preload("Product").
|
||||||
|
Preload("Product.MainUnit").
|
||||||
|
Preload("Container").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Find(&matches).Error
|
||||||
return matches, err
|
return matches, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertUnmatched увеличивает счетчик встречаемости
|
||||||
|
func (r *pgRepository) UpsertUnmatched(rawName string) error {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
|
if normalized == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем сырой SQL или GORM upsert expression
|
||||||
|
// PostgreSQL: INSERT ... ON CONFLICT DO UPDATE SET count = count + 1
|
||||||
|
item := ocr.UnmatchedItem{
|
||||||
|
RawName: normalized,
|
||||||
|
Count: 1,
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "raw_name"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
|
"count": gorm.Expr("unmatched_items.count + 1"),
|
||||||
|
"last_seen": time.Now(),
|
||||||
|
}),
|
||||||
|
}).Create(&item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetTopUnmatched(limit int) ([]ocr.UnmatchedItem, error) {
|
||||||
|
var items []ocr.UnmatchedItem
|
||||||
|
err := r.db.Order("count DESC, last_seen DESC").Limit(limit).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) DeleteUnmatched(rawName string) error {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
|
return r.db.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type ClientI interface {
|
|||||||
Auth() error
|
Auth() error
|
||||||
Logout() error
|
Logout() error
|
||||||
FetchCatalog() ([]catalog.Product, error)
|
FetchCatalog() ([]catalog.Product, error)
|
||||||
|
FetchMeasureUnits() ([]catalog.MeasureUnit, error)
|
||||||
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
|
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
|
||||||
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
||||||
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
|
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
|
||||||
@@ -295,6 +296,29 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
|
|||||||
parentID = &pid
|
parentID = &pid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработка MainUnit
|
||||||
|
var mainUnitID *uuid.UUID
|
||||||
|
if p.MainUnit != nil {
|
||||||
|
if uid, err := uuid.Parse(*p.MainUnit); err == nil {
|
||||||
|
mainUnitID = &uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маппинг фасовок
|
||||||
|
var containers []catalog.ProductContainer
|
||||||
|
for _, contDto := range p.Containers {
|
||||||
|
cID, err := uuid.Parse(contDto.ID)
|
||||||
|
if err == nil {
|
||||||
|
containers = append(containers, catalog.ProductContainer{
|
||||||
|
ID: cID,
|
||||||
|
ProductID: id,
|
||||||
|
Name: contDto.Name,
|
||||||
|
Count: decimal.NewFromFloat(contDto.Count),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
products = append(products, catalog.Product{
|
products = append(products, catalog.Product{
|
||||||
ID: id,
|
ID: id,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
@@ -304,6 +328,8 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
|
|||||||
Type: p.Type,
|
Type: p.Type,
|
||||||
UnitWeight: decimal.NewFromFloat(p.UnitWeight),
|
UnitWeight: decimal.NewFromFloat(p.UnitWeight),
|
||||||
UnitCapacity: decimal.NewFromFloat(p.UnitCapacity),
|
UnitCapacity: decimal.NewFromFloat(p.UnitCapacity),
|
||||||
|
MainUnitID: mainUnitID,
|
||||||
|
Containers: containers,
|
||||||
IsDeleted: p.Deleted,
|
IsDeleted: p.Deleted,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -311,6 +337,38 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
|
|||||||
return products, nil
|
return products, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchMeasureUnits загружает справочник единиц измерения
|
||||||
|
func (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {
|
||||||
|
// rootType=MeasureUnit согласно документации iiko
|
||||||
|
resp, err := c.doRequest("GET", "/resto/api/v2/entities/list", map[string]string{
|
||||||
|
"rootType": "MeasureUnit",
|
||||||
|
"includeDeleted": "false",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get measure units error: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var dtos []GenericEntityDTO
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {
|
||||||
|
return nil, fmt.Errorf("json decode error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []catalog.MeasureUnit
|
||||||
|
for _, d := range dtos {
|
||||||
|
id, err := uuid.Parse(d.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, catalog.MeasureUnit{
|
||||||
|
ID: id,
|
||||||
|
Name: d.Name,
|
||||||
|
Code: d.Code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) {
|
func (c *Client) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"dateFrom": dateFrom.Format("2006-01-02"),
|
"dateFrom": dateFrom.Format("2006-01-02"),
|
||||||
|
|||||||
@@ -15,9 +15,26 @@ type ProductDTO struct {
|
|||||||
Type string `json:"type"` // GOODS, DISH, PREPARED, etc.
|
Type string `json:"type"` // GOODS, DISH, PREPARED, etc.
|
||||||
UnitWeight float64 `json:"unitWeight"`
|
UnitWeight float64 `json:"unitWeight"`
|
||||||
UnitCapacity float64 `json:"unitCapacity"`
|
UnitCapacity float64 `json:"unitCapacity"`
|
||||||
|
MainUnit *string `json:"mainUnit"`
|
||||||
|
Containers []ContainerDTO `json:"containers"`
|
||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenericEntityDTO используется для простых справочников (MeasureUnit и др.)
|
||||||
|
type GenericEntityDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerDTO - фасовка из iiko
|
||||||
|
type ContainerDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"` // Название фасовки (напр. "Коробка")
|
||||||
|
Count float64 `json:"count"` // Сколько базовых единиц в фасовке
|
||||||
|
}
|
||||||
|
|
||||||
type GroupDTO struct {
|
type GroupDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ParentID *string `json:"parent"`
|
ParentID *string `json:"parent"`
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
var processed []ProcessedItem
|
var processed []ProcessedItem
|
||||||
|
|
||||||
// 2. Обрабатываем каждую строку
|
|
||||||
for _, rawItem := range rawResult.Items {
|
for _, rawItem := range rawResult.Items {
|
||||||
item := ProcessedItem{
|
item := ProcessedItem{
|
||||||
RawName: rawItem.RawName,
|
RawName: rawItem.RawName,
|
||||||
@@ -52,26 +50,24 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]Pr
|
|||||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Ищем соответствие
|
match, err := s.ocrRepo.FindMatch(rawItem.RawName)
|
||||||
// Сначала проверяем таблицу ручного обучения (product_matches)
|
|
||||||
matchID, err := s.ocrRepo.FindMatch(rawItem.RawName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("db error finding match", zap.Error(err))
|
logger.Log.Error("db error finding match", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if matchID != nil {
|
if match != nil {
|
||||||
// Нашли в обучении
|
item.ProductID = &match.ProductID
|
||||||
item.ProductID = matchID
|
|
||||||
item.IsMatched = true
|
item.IsMatched = true
|
||||||
item.MatchSource = "learned"
|
item.MatchSource = "learned"
|
||||||
|
// Здесь мы могли бы подтянуть quantity/container из матча,
|
||||||
|
// но пока фронт сам это сделает, запросив /ocr/matches или получив подсказку.
|
||||||
} else {
|
} else {
|
||||||
// Если не нашли, пробуем найти точное совпадение по имени в каталоге (на всякий случай)
|
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
|
||||||
// (В реальном проекте тут может быть нечеткий поиск, но пока точный)
|
logger.Log.Warn("failed to save unmatched", zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processed = append(processed, item)
|
processed = append(processed, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
return processed, nil
|
return processed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +83,21 @@ type ProcessedItem struct {
|
|||||||
MatchSource string // "learned", "auto", "manual"
|
MatchSource string // "learned", "auto", "manual"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductForIndex DTO для внешнего сервиса
|
type ContainerForIndex struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count float64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProductForIndex struct {
|
type ProductForIndex struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
MeasureUnit string `json:"measure_unit"`
|
||||||
|
Containers []ContainerForIndex `json:"containers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCatalogForIndexing возвращает список товаров для построения индекса
|
// GetCatalogForIndexing - возвращает облегченный каталог
|
||||||
func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
||||||
products, err := s.catalogRepo.GetActiveGoods()
|
products, err := s.catalogRepo.GetActiveGoods()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,18 +106,35 @@ func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
|||||||
|
|
||||||
result := make([]ProductForIndex, 0, len(products))
|
result := make([]ProductForIndex, 0, len(products))
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
|
uom := ""
|
||||||
|
if p.MainUnit != nil {
|
||||||
|
uom = p.MainUnit.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
var conts []ContainerForIndex
|
||||||
|
for _, c := range p.Containers {
|
||||||
|
cnt, _ := c.Count.Float64()
|
||||||
|
conts = append(conts, ContainerForIndex{
|
||||||
|
ID: c.ID.String(),
|
||||||
|
Name: c.Name,
|
||||||
|
Count: cnt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
result = append(result, ProductForIndex{
|
result = append(result, ProductForIndex{
|
||||||
ID: p.ID.String(),
|
ID: p.ID.String(),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Code: p.Code,
|
Code: p.Code,
|
||||||
|
MeasureUnit: uom,
|
||||||
|
Containers: conts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveMapping сохраняет связь "Текст из чека" -> "Наш товар"
|
// SaveMapping сохраняет привязку с количеством и фасовкой
|
||||||
func (s *Service) SaveMapping(rawName string, productID uuid.UUID) error {
|
func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||||
return s.ocrRepo.SaveMatch(rawName, productID)
|
return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetKnownMatches возвращает список всех обученных связей
|
// GetKnownMatches возвращает список всех обученных связей
|
||||||
@@ -122,8 +142,14 @@ func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) {
|
|||||||
return s.ocrRepo.GetAllMatches()
|
return s.ocrRepo.GetAllMatches()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUnmatchedItems возвращает список частых нераспознанных строк
|
||||||
|
func (s *Service) GetUnmatchedItems() ([]ocr.UnmatchedItem, error) {
|
||||||
|
// Берем топ 50 нераспознанных
|
||||||
|
return s.ocrRepo.GetTopUnmatched(50)
|
||||||
|
}
|
||||||
|
|
||||||
// FindKnownMatch ищет, знаем ли мы уже этот товар
|
// FindKnownMatch ищет, знаем ли мы уже этот товар
|
||||||
func (s *Service) FindKnownMatch(rawName string) (*uuid.UUID, error) {
|
func (s *Service) FindKnownMatch(rawName string) (*ocr.ProductMatch, error) {
|
||||||
return s.ocrRepo.FindMatch(rawName)
|
return s.ocrRepo.FindMatch(rawName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,15 @@ func NewService(
|
|||||||
|
|
||||||
// SyncCatalog загружает номенклатуру и сохраняет в БД
|
// SyncCatalog загружает номенклатуру и сохраняет в БД
|
||||||
func (s *Service) SyncCatalog() error {
|
func (s *Service) SyncCatalog() error {
|
||||||
logger.Log.Info("Начало синхронизации номенклатуры")
|
logger.Log.Info("Начало синхронизации каталога...")
|
||||||
|
|
||||||
|
// 1. Сначала Единицы измерения (чтобы FK не ругался)
|
||||||
|
if err := s.syncMeasureUnits(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Товары
|
||||||
|
logger.Log.Info("Запрос товаров из RMS...")
|
||||||
products, err := s.rmsClient.FetchCatalog()
|
products, err := s.rmsClient.FetchCatalog()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
|
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
|
||||||
@@ -64,6 +71,19 @@ func (s *Service) SyncCatalog() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncMeasureUnits() error {
|
||||||
|
logger.Log.Info("Синхронизация единиц измерения...")
|
||||||
|
units, err := s.rmsClient.FetchMeasureUnits()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка получения ед.изм: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.catalogRepo.SaveMeasureUnits(units); err != nil {
|
||||||
|
return fmt.Errorf("ошибка сохранения ед.изм: %w", err)
|
||||||
|
}
|
||||||
|
logger.Log.Info("Единицы измерения обновлены", zap.Int("count", len(units)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)
|
// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)
|
||||||
func (s *Service) SyncRecipes() error {
|
func (s *Service) SyncRecipes() error {
|
||||||
logger.Log.Info("Начало синхронизации техкарт")
|
logger.Log.Info("Начало синхронизации техкарт")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
ocrService "rmser/internal/services/ocr"
|
ocrService "rmser/internal/services/ocr"
|
||||||
@@ -33,6 +34,8 @@ func (h *OCRHandler) GetCatalog(c *gin.Context) {
|
|||||||
type MatchRequest struct {
|
type MatchRequest struct {
|
||||||
RawName string `json:"raw_name" binding:"required"`
|
RawName string `json:"raw_name" binding:"required"`
|
||||||
ProductID string `json:"product_id" binding:"required"`
|
ProductID string `json:"product_id" binding:"required"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
ContainerID *string `json:"container_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveMatch сохраняет привязку (обучение)
|
// SaveMatch сохраняет привязку (обучение)
|
||||||
@@ -49,7 +52,19 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.SaveMapping(req.RawName, pID); err != nil {
|
qty := decimal.NewFromFloat(1.0)
|
||||||
|
if req.Quantity > 0 {
|
||||||
|
qty = decimal.NewFromFloat(req.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
var contID *uuid.UUID
|
||||||
|
if req.ContainerID != nil && *req.ContainerID != "" {
|
||||||
|
if uid, err := uuid.Parse(*req.ContainerID); err == nil {
|
||||||
|
contID = &uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.SaveMapping(req.RawName, pID, qty, contID); err != nil {
|
||||||
logger.Log.Error("Ошибка сохранения матчинга", zap.Error(err))
|
logger.Log.Error("Ошибка сохранения матчинга", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -69,3 +84,14 @@ func (h *OCRHandler) GetMatches(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, matches)
|
c.JSON(http.StatusOK, matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUnmatched возвращает список нераспознанных позиций для подсказок
|
||||||
|
func (h *OCRHandler) GetUnmatched(c *gin.Context) {
|
||||||
|
items, err := h.service.GetUnmatchedItems()
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Ошибка получения списка unmatched", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|||||||
117
project_dump.py
117
project_dump.py
File diff suppressed because one or more lines are too long
3
rmser-view/.env
Normal file
3
rmser-view/.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Используем относительный путь.
|
||||||
|
# Браузер сам подставит текущий домен: https://rmser.your-domain.com/api
|
||||||
|
VITE_API_URL=/api
|
||||||
24
rmser-view/.gitignore
vendored
Normal file
24
rmser-view/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
rmser-view/README.md
Normal file
73
rmser-view/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
rmser-view/eslint.config.js
Normal file
23
rmser-view/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
16
rmser-view/index.html
Normal file
16
rmser-view/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RMSer App</title>
|
||||||
|
<!-- Скрипт Telegram WebApp (желательно добавить) -->
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!-- ВОТ ЗДЕСЬ ДОЛЖЕН БЫТЬ ПРАВИЛЬНЫЙ ПУТЬ -->
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4604
rmser-view/package-lock.json
generated
Normal file
4604
rmser-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
rmser-view/package.json
Normal file
37
rmser-view/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "rmser-view",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
"@twa-dev/sdk": "^8.0.2",
|
||||||
|
"antd": "^6.1.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.10.1",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
rmser-view/public/vite.svg
Normal file
1
rmser-view/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
27
rmser-view/src/App.tsx
Normal file
27
rmser-view/src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Providers } from './components/layout/Providers';
|
||||||
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
|
import { Dashboard } from './pages/Dashboard'; // Импортируем созданную страницу
|
||||||
|
import { OcrLearning } from './pages/OcrLearning';
|
||||||
|
|
||||||
|
// Заглушки для остальных страниц пока оставим
|
||||||
|
const InvoicesPage = () => <h2>Список накладных</h2>;
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Providers>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppLayout />}>
|
||||||
|
<Route index element={<Dashboard />} /> {/* Используем компонент */}
|
||||||
|
<Route path="ocr" element={<OcrLearning />} />
|
||||||
|
<Route path="invoices" element={<InvoicesPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Providers>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
57
rmser-view/src/components/layout/AppLayout.tsx
Normal file
57
rmser-view/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layout, Menu, theme } from 'antd';
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { BarChartOutlined, ScanOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Header, Content, Footer } = Layout;
|
||||||
|
|
||||||
|
export const AppLayout: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Получаем токены темы (чтобы подстроить AntD под Telegram можно позже настроить ConfigProvider)
|
||||||
|
const {
|
||||||
|
token: { colorBgContainer, borderRadiusLG },
|
||||||
|
} = theme.useToken();
|
||||||
|
|
||||||
|
// Определяем активный пункт меню
|
||||||
|
const selectedKey = location.pathname === '/' ? 'dashboard'
|
||||||
|
: location.pathname.startsWith('/ocr') ? 'ocr'
|
||||||
|
: location.pathname.startsWith('/invoices') ? 'invoices'
|
||||||
|
: 'dashboard';
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: 'dashboard', icon: <BarChartOutlined />, label: 'Дашборд', onClick: () => navigate('/') },
|
||||||
|
{ key: 'ocr', icon: <ScanOutlined />, label: 'Обучение', onClick: () => navigate('/ocr') },
|
||||||
|
{ key: 'invoices', icon: <FileTextOutlined />, label: 'Накладные', onClick: () => navigate('/invoices') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Header style={{ display: 'flex', alignItems: 'center', padding: 0 }}>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="horizontal"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
items={menuItems}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</Header>
|
||||||
|
<Content style={{ padding: '16px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: colorBgContainer,
|
||||||
|
minHeight: 280,
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: borderRadiusLG,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
<Footer style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||||
|
RMSer ©{new Date().getFullYear()}
|
||||||
|
</Footer>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
rmser-view/src/components/layout/Providers.tsx
Normal file
43
rmser-view/src/components/layout/Providers.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import WebApp from '@twa-dev/sdk';
|
||||||
|
|
||||||
|
// Настройка клиента React Query
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false, // Не перезапрашивать при переключении вкладок
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ProvidersProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Инициализация Telegram Mini App
|
||||||
|
WebApp.ready();
|
||||||
|
WebApp.expand(); // Разворачиваем на весь экран
|
||||||
|
|
||||||
|
// Подстраиваем цвет хедера под тему Telegram
|
||||||
|
WebApp.setHeaderColor('secondary_bg_color');
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setIsReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return <div style={{ padding: 20, textAlign: 'center' }}>Loading Telegram SDK...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
143
rmser-view/src/components/ocr/AddMatchForm.tsx
Normal file
143
rmser-view/src/components/ocr/AddMatchForm.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'; // Убрали useEffect
|
||||||
|
import { Card, Button, Flex, AutoComplete, InputNumber, Typography, Select } from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { CatalogSelect } from './CatalogSelect';
|
||||||
|
import type { CatalogItem, UnmatchedItem } from '../../services/types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
catalog: CatalogItem[];
|
||||||
|
unmatched?: UnmatchedItem[];
|
||||||
|
onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => {
|
||||||
|
const [rawName, setRawName] = useState('');
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined);
|
||||||
|
const [quantity, setQuantity] = useState<number | null>(1);
|
||||||
|
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const unmatchedOptions = useMemo(() => {
|
||||||
|
return unmatched.map(item => ({
|
||||||
|
value: item.raw_name,
|
||||||
|
label: item.count ? `${item.raw_name} (${item.count} шт)` : item.raw_name
|
||||||
|
}));
|
||||||
|
}, [unmatched]);
|
||||||
|
|
||||||
|
const selectedCatalogItem = useMemo(() => {
|
||||||
|
if (!selectedProduct) return null;
|
||||||
|
return catalog.find(item => item.id === selectedProduct || item.ID === selectedProduct);
|
||||||
|
}, [selectedProduct, catalog]);
|
||||||
|
|
||||||
|
// Хендлер смены товара: сразу сбрасываем фасовку
|
||||||
|
const handleProductChange = (val: string) => {
|
||||||
|
setSelectedProduct(val);
|
||||||
|
setSelectedContainer(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Мемоизируем список контейнеров, чтобы он был стабильной зависимостью
|
||||||
|
const containers = useMemo(() => {
|
||||||
|
return selectedCatalogItem?.containers || selectedCatalogItem?.Containers || [];
|
||||||
|
}, [selectedCatalogItem]);
|
||||||
|
|
||||||
|
const baseUom = selectedCatalogItem?.measure_unit || selectedCatalogItem?.MeasureUnit || 'ед.';
|
||||||
|
|
||||||
|
const currentUomName = useMemo(() => {
|
||||||
|
if (selectedContainer) {
|
||||||
|
const cont = containers.find(c => c.id === selectedContainer);
|
||||||
|
return cont ? cont.name : baseUom;
|
||||||
|
}
|
||||||
|
return baseUom;
|
||||||
|
}, [selectedContainer, containers, baseUom]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (rawName.trim() && selectedProduct && quantity && quantity > 0) {
|
||||||
|
onSave(rawName, selectedProduct, quantity, selectedContainer || undefined);
|
||||||
|
|
||||||
|
setRawName('');
|
||||||
|
setSelectedProduct(undefined);
|
||||||
|
setQuantity(1);
|
||||||
|
setSelectedContainer(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isButtonDisabled = !rawName.trim() || !selectedProduct || !quantity || quantity <= 0 || isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Добавить новую связь" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Flex vertical gap="middle">
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Текст из чека:</div>
|
||||||
|
<AutoComplete
|
||||||
|
placeholder="Например: Масло слив. коробка"
|
||||||
|
options={unmatchedOptions}
|
||||||
|
value={rawName}
|
||||||
|
onChange={setRawName}
|
||||||
|
filterOption={(inputValue, option) =>
|
||||||
|
!inputValue || (option?.value as string).toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div>
|
||||||
|
<CatalogSelect
|
||||||
|
catalog={catalog}
|
||||||
|
value={selectedProduct}
|
||||||
|
onChange={handleProductChange} // Используем новый хендлер
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{containers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Единица измерения / Фасовка:</div>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedContainer}
|
||||||
|
onChange={setSelectedContainer}
|
||||||
|
options={[
|
||||||
|
{ value: null, label: `Базовая единица (${baseUom})` },
|
||||||
|
...containers.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: `${c.name} (=${c.count} ${baseUom})`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>
|
||||||
|
Количество (в выбранных единицах):
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<InputNumber
|
||||||
|
min={0.001}
|
||||||
|
step={selectedContainer ? 1 : 0.1}
|
||||||
|
value={quantity}
|
||||||
|
onChange={setQuantity}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
<Text strong>{currentUomName}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Сохранить связь
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
rmser-view/src/components/ocr/CatalogSelect.tsx
Normal file
56
rmser-view/src/components/ocr/CatalogSelect.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Select } from 'antd';
|
||||||
|
import type { CatalogItem } from '../../services/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
catalog: CatalogItem[];
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CatalogSelect: React.FC<Props> = ({ catalog, value, onChange, disabled }) => {
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return catalog.map((item) => {
|
||||||
|
const name = item.name || item.Name || 'Неизвестный товар';
|
||||||
|
// Гарантируем строку. Если ID нет, будет пустая строка, которую мы отфильтруем.
|
||||||
|
const id = item.id || item.ID || '';
|
||||||
|
const code = item.code || item.Code || '';
|
||||||
|
// const uom = item.measure_unit || item.MeasureUnit || ''; // Можно добавить в label
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: code ? `${name} [${code}]` : name,
|
||||||
|
value: id,
|
||||||
|
code: code,
|
||||||
|
name: name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// TypeScript Predicate: явно говорим компилятору, что после фильтра value точно string (и не пустая)
|
||||||
|
.filter((opt): opt is { label: string; value: string; code: string; name: string } => !!opt.value);
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
|
const filterOption = (input: string, option?: { label: string; value: string; code: string; name: string }) => {
|
||||||
|
if (!option) return false;
|
||||||
|
|
||||||
|
const search = input.toLowerCase();
|
||||||
|
const name = (option.name || '').toLowerCase();
|
||||||
|
const code = (option.code || '').toLowerCase();
|
||||||
|
|
||||||
|
return name.includes(search) || code.includes(search);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="Выберите товар из iiko"
|
||||||
|
optionFilterProp="children"
|
||||||
|
filterOption={filterOption}
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
listHeight={256}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
rmser-view/src/components/ocr/MatchList.tsx
Normal file
79
rmser-view/src/components/ocr/MatchList.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { List, Typography, Tag, Input, Empty } from 'antd';
|
||||||
|
import { ArrowRightOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import type { ProductMatch } from '../../services/types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
matches: ProductMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||||
|
const [searchText, setSearchText] = React.useState('');
|
||||||
|
|
||||||
|
const filteredData = matches.filter(item => {
|
||||||
|
const raw = (item.raw_name || item.RawName || '').toLowerCase();
|
||||||
|
const prod = item.product || item.Product;
|
||||||
|
const prodName = (prod?.name || prod?.Name || '').toLowerCase();
|
||||||
|
const search = searchText.toLowerCase();
|
||||||
|
return raw.includes(search) || prodName.includes(search);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по связям..."
|
||||||
|
prefix={<SearchOutlined style={{ color: '#ccc' }} />}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
|
||||||
|
<List
|
||||||
|
itemLayout="vertical"
|
||||||
|
dataSource={filteredData}
|
||||||
|
locale={{ emptyText: <Empty description="Нет данных" /> }}
|
||||||
|
pagination={{ pageSize: 10, size: "small", simple: true }}
|
||||||
|
renderItem={(item) => {
|
||||||
|
// Унификация полей
|
||||||
|
const rawName = item.raw_name || item.RawName || 'Без названия';
|
||||||
|
const product = item.product || item.Product;
|
||||||
|
const productName = product?.name || product?.Name || 'Товар не найден';
|
||||||
|
const qty = item.quantity || item.Quantity || 1;
|
||||||
|
|
||||||
|
// Логика отображения Единицы или Фасовки
|
||||||
|
const container = item.container || item.Container;
|
||||||
|
let displayUnit = '';
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
// Если есть фасовка: "Пачка 180г"
|
||||||
|
displayUnit = container.name;
|
||||||
|
} else {
|
||||||
|
// Иначе базовая ед.: "кг"
|
||||||
|
displayUnit = product?.measure_unit || product?.MeasureUnit || 'ед.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item style={{ background: '#fff', padding: 12, marginBottom: 8, borderRadius: 8 }}>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<Tag color="geekblue">Чек</Tag>
|
||||||
|
<Text strong>{rawName}</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#888' }}>
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
<Text>
|
||||||
|
{productName}
|
||||||
|
<Text strong style={{ color: '#555', marginLeft: 6 }}>
|
||||||
|
x {qty} {displayUnit}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Tag, Typography, Button } from 'antd';
|
||||||
|
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||||
|
import type { Recommendation } from '../../services/types';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Recommendation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecommendationCard: React.FC<Props> = ({ item }) => {
|
||||||
|
// Выбираем цвет тега в зависимости от типа проблемы
|
||||||
|
const getTagColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'UNUSED_IN_RECIPES': return 'volcano';
|
||||||
|
case 'NO_INCOMING': return 'gold';
|
||||||
|
default: return 'blue';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
return type === 'UNUSED_IN_RECIPES' ? <WarningOutlined /> : <InfoCircleOutlined />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{getIcon(item.Type)}
|
||||||
|
<Text strong ellipsis>{item.ProductName}</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
extra={<Tag color={getTagColor(item.Type)}>{item.Type}</Tag>}
|
||||||
|
style={{ marginBottom: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}
|
||||||
|
>
|
||||||
|
<Paragraph style={{ marginBottom: 8 }}>
|
||||||
|
{item.Reason}
|
||||||
|
</Paragraph>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{new Date(item.CreatedAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
{/* Кнопка действия (заглушка на будущее) */}
|
||||||
|
<Button size="small" type="link">Исправить</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
rmser-view/src/hooks/useOcr.ts
Normal file
48
rmser-view/src/hooks/useOcr.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
import type { MatchRequest, ProductMatch, CatalogItem, UnmatchedItem } from '../services/types';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
export const useOcr = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const catalogQuery = useQuery<CatalogItem[], Error>({
|
||||||
|
queryKey: ['catalog'],
|
||||||
|
queryFn: api.getCatalogItems,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchesQuery = useQuery<ProductMatch[], Error>({
|
||||||
|
queryKey: ['matches'],
|
||||||
|
queryFn: api.getMatches,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unmatchedQuery = useQuery<UnmatchedItem[], Error>({
|
||||||
|
queryKey: ['unmatched'],
|
||||||
|
queryFn: api.getUnmatched,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMatchMutation = useMutation({
|
||||||
|
// Теперь типы совпадают, any не нужен
|
||||||
|
mutationFn: (newMatch: MatchRequest) => api.createMatch(newMatch),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('Связь сохранена');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['matches'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['unmatched'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error('Ошибка при сохранении');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog: catalogQuery.data || [],
|
||||||
|
matches: matchesQuery.data || [],
|
||||||
|
unmatched: unmatchedQuery.data || [],
|
||||||
|
isLoading: catalogQuery.isPending || matchesQuery.isPending,
|
||||||
|
isError: catalogQuery.isError || matchesQuery.isError,
|
||||||
|
createMatch: createMatchMutation.mutate,
|
||||||
|
isCreating: createMatchMutation.isPending,
|
||||||
|
};
|
||||||
|
};
|
||||||
12
rmser-view/src/hooks/useRecommendations.ts
Normal file
12
rmser-view/src/hooks/useRecommendations.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
import type { Recommendation } from '../services/types';
|
||||||
|
|
||||||
|
export const useRecommendations = () => {
|
||||||
|
return useQuery<Recommendation[], Error>({
|
||||||
|
queryKey: ['recommendations'],
|
||||||
|
queryFn: api.getRecommendations,
|
||||||
|
// Обновлять данные каждые 30 секунд, если вкладка активна
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
};
|
||||||
12
rmser-view/src/main.tsx
Normal file
12
rmser-view/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
// Если есть глобальные стили, они подключаются тут.
|
||||||
|
// Если файла index.css нет, убери эту строку.
|
||||||
|
// import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
59
rmser-view/src/pages/Dashboard.tsx
Normal file
59
rmser-view/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Typography, Row, Col, Statistic, Spin, Alert, Empty } from 'antd';
|
||||||
|
import { useRecommendations } from '../hooks/useRecommendations';
|
||||||
|
import { RecommendationCard } from '../components/recommendations/RecommendationCard';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const { data: recommendations, isPending, isError, error } = useRecommendations();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||||
|
<Spin size="large" tip="Загрузка аналитики..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="Ошибка загрузки"
|
||||||
|
description={error?.message || 'Не удалось получить данные с сервера'}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группировка для статистики
|
||||||
|
const unusedCount = recommendations?.filter(r => r.Type === 'UNUSED_IN_RECIPES').length || 0;
|
||||||
|
const noIncomingCount = recommendations?.filter(r => r.Type === 'NO_INCOMING').length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginTop: 0 }}>Сводка проблем</Title>
|
||||||
|
|
||||||
|
{/* Блок статистики */}
|
||||||
|
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic title="Без техкарт" value={unusedCount} valueStyle={{ color: '#cf1322' }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic title="Без закупок" value={noIncomingCount} valueStyle={{ color: '#d48806' }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Title level={5}>Рекомендации ({recommendations?.length})</Title>
|
||||||
|
|
||||||
|
{recommendations && recommendations.length > 0 ? (
|
||||||
|
recommendations.map((rec) => (
|
||||||
|
<RecommendationCard key={rec.ID} item={rec} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Empty description="Проблем не обнаружено" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
52
rmser-view/src/pages/OcrLearning.tsx
Normal file
52
rmser-view/src/pages/OcrLearning.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Spin, Alert } from 'antd';
|
||||||
|
import { useOcr } from '../hooks/useOcr';
|
||||||
|
import { AddMatchForm } from '../components/ocr/AddMatchForm';
|
||||||
|
import { MatchList } from '../components/ocr/MatchList';
|
||||||
|
|
||||||
|
export const OcrLearning: React.FC = () => {
|
||||||
|
const {
|
||||||
|
catalog,
|
||||||
|
matches,
|
||||||
|
unmatched,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
createMatch,
|
||||||
|
isCreating
|
||||||
|
} = useOcr();
|
||||||
|
|
||||||
|
if (isLoading && matches.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<span style={{ color: '#888' }}>Загрузка справочников...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert message="Ошибка" description="Не удалось загрузить данные." type="error" showIcon style={{ margin: 16 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 20 }}>
|
||||||
|
<AddMatchForm
|
||||||
|
catalog={catalog}
|
||||||
|
unmatched={unmatched}
|
||||||
|
// Передаем containerId
|
||||||
|
onSave={(raw, prodId, qty, contId) => createMatch({
|
||||||
|
raw_name: raw,
|
||||||
|
product_id: prodId,
|
||||||
|
quantity: qty,
|
||||||
|
container_id: contId
|
||||||
|
})}
|
||||||
|
isLoading={isCreating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>Обученные позиции ({matches.length})</h3>
|
||||||
|
<MatchList matches={matches} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
rmser-view/src/services/api.ts
Normal file
67
rmser-view/src/services/api.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
CatalogItem,
|
||||||
|
CreateInvoiceRequest,
|
||||||
|
MatchRequest, // Используем новый тип
|
||||||
|
HealthResponse,
|
||||||
|
InvoiceResponse,
|
||||||
|
ProductMatch,
|
||||||
|
Recommendation,
|
||||||
|
UnmatchedItem
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Базовый URL
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
checkHealth: async (): Promise<HealthResponse> => {
|
||||||
|
const { data } = await apiClient.get<HealthResponse>('/health');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRecommendations: async (): Promise<Recommendation[]> => {
|
||||||
|
const { data } = await apiClient.get<Recommendation[]>('/recommendations');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCatalogItems: async (): Promise<CatalogItem[]> => {
|
||||||
|
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMatches: async (): Promise<ProductMatch[]> => {
|
||||||
|
const { data } = await apiClient.get<ProductMatch[]>('/ocr/matches');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getUnmatched: async (): Promise<UnmatchedItem[]> => {
|
||||||
|
const { data } = await apiClient.get<UnmatchedItem[]>('/ocr/unmatched');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновили тип аргумента payload
|
||||||
|
createMatch: async (payload: MatchRequest): Promise<{ status: string }> => {
|
||||||
|
const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createInvoice: async (payload: CreateInvoiceRequest): Promise<InvoiceResponse> => {
|
||||||
|
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
98
rmser-view/src/services/types.ts
Normal file
98
rmser-view/src/services/types.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// --- Общие типы ---
|
||||||
|
|
||||||
|
export type UUID = string;
|
||||||
|
|
||||||
|
// --- Каталог и Фасовки (API v2.0) ---
|
||||||
|
|
||||||
|
export interface ProductContainer {
|
||||||
|
id: UUID;
|
||||||
|
name: string; // "Пачка 180г"
|
||||||
|
count: number; // 0.180
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogItem {
|
||||||
|
// Основные поля (snake_case)
|
||||||
|
id: UUID;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
measure_unit: string; // "кг", "л"
|
||||||
|
containers: ProductContainer[]; // Массив фасовок
|
||||||
|
|
||||||
|
// Fallback (на всякий случай)
|
||||||
|
ID?: UUID;
|
||||||
|
Name?: string;
|
||||||
|
Code?: string;
|
||||||
|
MeasureUnit?: string;
|
||||||
|
Containers?: ProductContainer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Матчинг (Обучение) ---
|
||||||
|
|
||||||
|
export interface MatchRequest {
|
||||||
|
raw_name: string;
|
||||||
|
product_id: UUID;
|
||||||
|
quantity: number;
|
||||||
|
container_id?: UUID; // Новое поле
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductMatch {
|
||||||
|
// snake_case (v2.0)
|
||||||
|
raw_name: string;
|
||||||
|
product_id: UUID;
|
||||||
|
product?: CatalogItem;
|
||||||
|
quantity: number;
|
||||||
|
container_id?: UUID;
|
||||||
|
container?: ProductContainer;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
RawName?: string;
|
||||||
|
ProductID?: UUID;
|
||||||
|
Product?: CatalogItem;
|
||||||
|
Quantity?: number;
|
||||||
|
ContainerId?: UUID;
|
||||||
|
Container?: ProductContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Нераспознанное ---
|
||||||
|
|
||||||
|
export interface UnmatchedItem {
|
||||||
|
raw_name: string;
|
||||||
|
count: number;
|
||||||
|
last_seen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Остальные типы (без изменений) ---
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
ID: UUID;
|
||||||
|
Type: string;
|
||||||
|
ProductID: UUID;
|
||||||
|
ProductName: string;
|
||||||
|
Reason: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceItemRequest {
|
||||||
|
product_id: UUID;
|
||||||
|
amount: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceRequest {
|
||||||
|
document_number: string;
|
||||||
|
date_incoming: string;
|
||||||
|
supplier_id: UUID;
|
||||||
|
store_id: UUID;
|
||||||
|
items: InvoiceItemRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceResponse {
|
||||||
|
status: string;
|
||||||
|
created_number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
status: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
28
rmser-view/tsconfig.app.json
Normal file
28
rmser-view/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
rmser-view/tsconfig.json
Normal file
7
rmser-view/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
rmser-view/tsconfig.node.json
Normal file
26
rmser-view/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
rmser-view/vite.config.ts
Normal file
13
rmser-view/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
// Разрешаем наш домен
|
||||||
|
allowedHosts: ['rmser.serty.top'],
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user