mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Настройки работают
Иерархия групп работает Полностью завязано на пользователя и серверы
This commit is contained in:
@@ -88,6 +88,7 @@ func main() {
|
|||||||
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
||||||
ocrHandler := handlers.NewOCRHandler(ocrService)
|
ocrHandler := handlers.NewOCRHandler(ocrService)
|
||||||
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
||||||
|
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
||||||
|
|
||||||
// 8. Telegram Bot (Передаем syncService)
|
// 8. Telegram Bot (Передаем syncService)
|
||||||
if cfg.Telegram.Token != "" {
|
if cfg.Telegram.Token != "" {
|
||||||
@@ -120,12 +121,20 @@ func main() {
|
|||||||
api.GET("/drafts", draftsHandler.GetDrafts)
|
api.GET("/drafts", draftsHandler.GetDrafts)
|
||||||
api.GET("/drafts/:id", draftsHandler.GetDraft)
|
api.GET("/drafts/:id", draftsHandler.GetDraft)
|
||||||
api.DELETE("/drafts/:id", draftsHandler.DeleteDraft)
|
api.DELETE("/drafts/:id", draftsHandler.DeleteDraft)
|
||||||
|
// Items CRUD
|
||||||
|
api.POST("/drafts/:id/items", draftsHandler.AddDraftItem)
|
||||||
|
api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem)
|
||||||
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
|
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
|
||||||
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
|
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
|
||||||
api.POST("/drafts/container", draftsHandler.AddContainer)
|
api.POST("/drafts/container", draftsHandler.AddContainer)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
api.GET("/settings", settingsHandler.GetSettings)
|
||||||
|
api.POST("/settings", settingsHandler.UpdateSettings)
|
||||||
|
|
||||||
// Dictionaries
|
// Dictionaries
|
||||||
api.GET("/dictionaries", draftsHandler.GetDictionaries)
|
api.GET("/dictionaries", draftsHandler.GetDictionaries)
|
||||||
|
api.GET("/dictionaries/groups", settingsHandler.GetGroupsTree)
|
||||||
api.GET("/dictionaries/stores", draftsHandler.GetStores)
|
api.GET("/dictionaries/stores", draftsHandler.GetStores)
|
||||||
|
|
||||||
// Recommendations
|
// Recommendations
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ type RMSServer struct {
|
|||||||
Login string `gorm:"type:varchar(100);not null" json:"login"`
|
Login string `gorm:"type:varchar(100);not null" json:"login"`
|
||||||
EncryptedPassword string `gorm:"type:text;not null" json:"-"` // Пароль храним зашифрованным
|
EncryptedPassword string `gorm:"type:text;not null" json:"-"` // Пароль храним зашифрованным
|
||||||
|
|
||||||
|
DefaultStoreID *uuid.UUID `gorm:"type:uuid" json:"default_store_id"` // Склад для подстановки
|
||||||
|
RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"` // ID корневой папки для поиска товаров
|
||||||
|
AutoProcess bool `gorm:"default:false" json:"auto_process"` // Пытаться сразу проводить накладную
|
||||||
|
|
||||||
// Billing / Stats
|
// Billing / Stats
|
||||||
InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных
|
InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ type Repository interface {
|
|||||||
SaveProducts(products []Product) error
|
SaveProducts(products []Product) error
|
||||||
SaveContainer(container ProductContainer) error
|
SaveContainer(container ProductContainer) error
|
||||||
|
|
||||||
Search(serverID uuid.UUID, query string) ([]Product, error)
|
Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]Product, error)
|
||||||
GetActiveGoods(serverID uuid.UUID) ([]Product, error)
|
GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]Product, error)
|
||||||
|
|
||||||
|
GetGroups(serverID uuid.UUID) ([]Product, error)
|
||||||
|
|
||||||
SaveStores(stores []Store) error
|
SaveStores(stores []Store) error
|
||||||
GetActiveStores(serverID uuid.UUID) ([]Store, error)
|
GetActiveStores(serverID uuid.UUID) ([]Store, error)
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ type Repository interface {
|
|||||||
Update(draft *DraftInvoice) error
|
Update(draft *DraftInvoice) error
|
||||||
CreateItems(items []DraftInvoiceItem) error
|
CreateItems(items []DraftInvoiceItem) error
|
||||||
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
|
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
|
||||||
|
CreateItem(item *DraftInvoiceItem) error
|
||||||
|
DeleteItem(itemID uuid.UUID) error
|
||||||
Delete(id uuid.UUID) error
|
Delete(id uuid.UUID) error
|
||||||
GetActive(userID uuid.UUID) ([]DraftInvoice, error)
|
GetActive(userID uuid.UUID) ([]DraftInvoice, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Invoice struct {
|
|||||||
SupplierID uuid.UUID `gorm:"type:uuid;index"`
|
SupplierID uuid.UUID `gorm:"type:uuid;index"`
|
||||||
DefaultStoreID uuid.UUID `gorm:"type:uuid;index"`
|
DefaultStoreID uuid.UUID `gorm:"type:uuid;index"`
|
||||||
Status string `gorm:"type:varchar(50)"`
|
Status string `gorm:"type:varchar(50)"`
|
||||||
|
Comment string `gorm:"type:text"`
|
||||||
|
|
||||||
Items []InvoiceItem `gorm:"foreignKey:InvoiceID;constraint:OnDelete:CASCADE"`
|
Items []InvoiceItem `gorm:"foreignKey:InvoiceID;constraint:OnDelete:CASCADE"`
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,11 @@ func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, er
|
|||||||
return &server, nil
|
return &server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllServers возвращает ВСЕ серверы пользователя, чтобы можно было переключаться
|
||||||
func (r *pgRepository) GetAllServers(userID uuid.UUID) ([]account.RMSServer, error) {
|
func (r *pgRepository) GetAllServers(userID uuid.UUID) ([]account.RMSServer, error) {
|
||||||
var servers []account.RMSServer
|
var servers []account.RMSServer
|
||||||
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Find(&servers).Error
|
// Убрали фильтр AND is_active = true, теперь возвращает весь список
|
||||||
|
err := r.db.Where("user_id = ?", userID).Find(&servers).Error
|
||||||
return servers, err
|
return servers, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,14 +86,25 @@ func (r *pgRepository) GetAll() ([]catalog.Product, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetActiveGoods(serverID uuid.UUID) ([]catalog.Product, error) {
|
func (r *pgRepository) GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]catalog.Product, error) {
|
||||||
var products []catalog.Product
|
var products []catalog.Product
|
||||||
err := r.db.
|
db := r.db.Preload("MainUnit").Preload("Containers").
|
||||||
Preload("MainUnit").
|
Where("rms_server_id = ? AND is_deleted = ? AND type IN ?", serverID, false, []string{"GOODS"})
|
||||||
Preload("Containers").
|
|
||||||
Where("rms_server_id = ? AND is_deleted = ? AND type IN ?", serverID, false, []string{"GOODS"}).
|
if rootGroupID != nil && *rootGroupID != uuid.Nil {
|
||||||
Order("name ASC").
|
// Используем Recursive CTE для поиска всех дочерних элементов папки
|
||||||
Find(&products).Error
|
subQuery := r.db.Raw(`
|
||||||
|
WITH RECURSIVE subnodes AS (
|
||||||
|
SELECT id FROM products WHERE id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id
|
||||||
|
)
|
||||||
|
SELECT id FROM subnodes
|
||||||
|
`, rootGroupID)
|
||||||
|
db = db.Where("id IN (?)", subQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Order("name ASC").Find(&products).Error
|
||||||
return products, err
|
return products, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,19 +114,27 @@ func (r *pgRepository) GetActiveStores(serverID uuid.UUID) ([]catalog.Store, err
|
|||||||
return stores, err
|
return stores, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) Search(serverID uuid.UUID, query string) ([]catalog.Product, error) {
|
func (r *pgRepository) Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]catalog.Product, error) {
|
||||||
var products []catalog.Product
|
var products []catalog.Product
|
||||||
q := "%" + query + "%"
|
q := "%" + query + "%"
|
||||||
|
|
||||||
err := r.db.
|
db := r.db.Preload("MainUnit").Preload("Containers").
|
||||||
Preload("MainUnit").
|
|
||||||
Preload("Containers").
|
|
||||||
Where("rms_server_id = ? AND is_deleted = ? AND type = ?", serverID, false, "GOODS").
|
Where("rms_server_id = ? AND is_deleted = ? AND type = ?", serverID, false, "GOODS").
|
||||||
Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q).
|
Where("(name ILIKE ? OR code ILIKE ? OR num ILIKE ?)", q, q, q)
|
||||||
Order("name ASC").
|
|
||||||
Limit(20).
|
|
||||||
Find(&products).Error
|
|
||||||
|
|
||||||
|
if rootGroupID != nil && *rootGroupID != uuid.Nil {
|
||||||
|
subQuery := r.db.Raw(`
|
||||||
|
WITH RECURSIVE subnodes AS (
|
||||||
|
SELECT id FROM products WHERE id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id
|
||||||
|
)
|
||||||
|
SELECT id FROM subnodes
|
||||||
|
`, rootGroupID)
|
||||||
|
db = db.Where("id IN (?)", subQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Order("name ASC").Limit(20).Find(&products).Error
|
||||||
return products, err
|
return products, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,3 +195,12 @@ func (r *pgRepository) CountStores(serverID uuid.UUID) (int64, error) {
|
|||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetGroups(serverID uuid.UUID) ([]catalog.Product, error) {
|
||||||
|
var groups []catalog.Product
|
||||||
|
// iiko присылает группы с типом "GROUP"
|
||||||
|
err := r.db.Where("rms_server_id = ? AND type = ? AND is_deleted = ?", serverID, "GROUP", false).
|
||||||
|
Order("name ASC").
|
||||||
|
Find(&groups).Error
|
||||||
|
return groups, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ func (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error {
|
|||||||
return r.db.CreateInBatches(items, 100).Error
|
return r.db.CreateInBatches(items, 100).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) CreateItem(item *drafts.DraftInvoiceItem) error {
|
||||||
|
return r.db.Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) DeleteItem(itemID uuid.UUID) error {
|
||||||
|
return r.db.Delete(&drafts.DraftInvoiceItem{}, itemID).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||||
sum := qty.Mul(price)
|
sum := qty.Mul(price)
|
||||||
isMatched := productID != nil
|
isMatched := productID != nil
|
||||||
|
|||||||
@@ -557,13 +557,26 @@ func (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]St
|
|||||||
// CreateIncomingInvoice отправляет накладную в iiko
|
// CreateIncomingInvoice отправляет накладную в iiko
|
||||||
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
||||||
// 1. Маппинг Domain -> XML DTO
|
// 1. Маппинг Domain -> XML DTO
|
||||||
|
|
||||||
|
// Статус по умолчанию NEW, если не передан
|
||||||
|
status := inv.Status
|
||||||
|
if status == "" {
|
||||||
|
status = "NEW"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Комментарий по умолчанию, если пустой
|
||||||
|
comment := inv.Comment
|
||||||
|
if comment == "" {
|
||||||
|
comment = "Loaded via RMSER OCR"
|
||||||
|
}
|
||||||
|
|
||||||
reqDTO := IncomingInvoiceImportXML{
|
reqDTO := IncomingInvoiceImportXML{
|
||||||
DocumentNumber: inv.DocumentNumber,
|
DocumentNumber: inv.DocumentNumber,
|
||||||
DateIncoming: inv.DateIncoming.Format("02.01.2006"),
|
DateIncoming: inv.DateIncoming.Format("02.01.2006"),
|
||||||
DefaultStore: inv.DefaultStoreID.String(),
|
DefaultStore: inv.DefaultStoreID.String(),
|
||||||
Supplier: inv.SupplierID.String(),
|
Supplier: inv.SupplierID.String(),
|
||||||
Status: "NEW",
|
Status: status,
|
||||||
Comment: "Loaded via RMSER OCR",
|
Comment: comment,
|
||||||
}
|
}
|
||||||
|
|
||||||
if inv.ID != uuid.Nil {
|
if inv.ID != uuid.Nil {
|
||||||
@@ -758,6 +771,49 @@ func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
|
|||||||
return result.Response, nil
|
return result.Response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetServerInfo пытается получить информацию о сервере (имя, версия) без авторизации.
|
||||||
|
// Использует endpoint /resto/getServerMonitoringInfo.jsp
|
||||||
|
func GetServerInfo(baseURL string) (*ServerMonitoringInfoDTO, error) {
|
||||||
|
// Формируем URL. Убираем слэш в конце, если есть.
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/resto/getServerMonitoringInfo.jsp"
|
||||||
|
|
||||||
|
logger.Log.Info("RMS: Requesting server info", zap.String("url", url))
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("RMS: Monitoring connection failed", zap.Error(err))
|
||||||
|
return nil, fmt.Errorf("connection error: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log.Info("RMS: Monitoring Response",
|
||||||
|
zap.Int("status", resp.StatusCode),
|
||||||
|
zap.String("body", string(bodyBytes)))
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("status code %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ServerMonitoringInfoDTO
|
||||||
|
|
||||||
|
// Пробуем JSON (так как в логе пришел JSON)
|
||||||
|
if err := json.Unmarshal(bodyBytes, &info); err != nil {
|
||||||
|
// Если вдруг JSON не прошел, можно попробовать XML как фоллбек (для старых версий)
|
||||||
|
logger.Log.Warn("RMS: JSON decode failed, trying XML...", zap.Error(err))
|
||||||
|
if xmlErr := xml.Unmarshal(bodyBytes, &info); xmlErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode error (json & xml failed): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FetchSuppliers загружает список поставщиков через XML API
|
// FetchSuppliers загружает список поставщиков через XML API
|
||||||
func (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) {
|
func (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) {
|
||||||
// Endpoint /resto/api/suppliers
|
// Endpoint /resto/api/suppliers
|
||||||
|
|||||||
@@ -244,6 +244,13 @@ type ErrorDTO struct {
|
|||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerMonitoringInfoDTO используется для парсинга ответа мониторинга
|
||||||
|
// iiko может отдавать JSON, поэтому ставим json теги.
|
||||||
|
type ServerMonitoringInfoDTO struct {
|
||||||
|
ServerName string `json:"serverName" xml:"serverName"`
|
||||||
|
Version string `json:"version" xml:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
// --- Suppliers XML (Legacy API /resto/api/suppliers) ---
|
// --- Suppliers XML (Legacy API /resto/api/suppliers) ---
|
||||||
|
|
||||||
type SuppliersListXML struct {
|
type SuppliersListXML struct {
|
||||||
|
|||||||
@@ -108,6 +108,56 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
|
|||||||
return s.draftRepo.Update(draft)
|
return s.draftRepo.Update(draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddItem добавляет пустую строку в черновик
|
||||||
|
func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
|
||||||
|
// Проверка статуса драфта (можно добавить)
|
||||||
|
|
||||||
|
newItem := &drafts.DraftInvoiceItem{
|
||||||
|
ID: uuid.New(),
|
||||||
|
DraftID: draftID,
|
||||||
|
RawName: "Новая позиция",
|
||||||
|
RawAmount: decimal.NewFromFloat(1),
|
||||||
|
RawPrice: decimal.Zero,
|
||||||
|
Quantity: decimal.NewFromFloat(1),
|
||||||
|
Price: decimal.Zero,
|
||||||
|
Sum: decimal.Zero,
|
||||||
|
IsMatched: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.draftRepo.CreateItem(newItem); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return newItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteItem удаляет строку и возвращает обновленную сумму черновика
|
||||||
|
func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {
|
||||||
|
// 1. Удаляем
|
||||||
|
if err := s.draftRepo.DeleteItem(itemID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получаем драфт заново для пересчета суммы
|
||||||
|
// Это самый надежный способ, чем считать в памяти
|
||||||
|
draft, err := s.draftRepo.GetByID(draftID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Считаем сумму
|
||||||
|
var totalSum decimal.Decimal
|
||||||
|
for _, item := range draft.Items {
|
||||||
|
if !item.Sum.IsZero() {
|
||||||
|
totalSum = totalSum.Add(item.Sum)
|
||||||
|
} else {
|
||||||
|
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sumFloat, _ := totalSum.Float64()
|
||||||
|
return sumFloat, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||||
draft, err := s.draftRepo.GetByID(draftID)
|
draft, err := s.draftRepo.GetByID(draftID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,6 +187,16 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
return "", errors.New("накладная уже отправлена")
|
return "", errors.New("накладная уже отправлена")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("active server not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetStatus := "NEW"
|
||||||
|
if server.AutoProcess {
|
||||||
|
targetStatus = "PROCESSED"
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Сборка Invoice
|
// 3. Сборка Invoice
|
||||||
inv := invoices.Invoice{
|
inv := invoices.Invoice{
|
||||||
ID: uuid.Nil,
|
ID: uuid.Nil,
|
||||||
@@ -144,7 +204,8 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
DateIncoming: *draft.DateIncoming,
|
DateIncoming: *draft.DateIncoming,
|
||||||
SupplierID: *draft.SupplierID,
|
SupplierID: *draft.SupplierID,
|
||||||
DefaultStoreID: *draft.StoreID,
|
DefaultStoreID: *draft.StoreID,
|
||||||
Status: "NEW",
|
Status: targetStatus, // <-- Передаем статус из настроек
|
||||||
|
Comment: draft.Comment, // <-- Передаем комментарий из черновика
|
||||||
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,19 +240,15 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Обновление статуса
|
// 5. Обновление статуса черновика
|
||||||
draft.Status = drafts.StatusCompleted
|
draft.Status = drafts.StatusCompleted
|
||||||
s.draftRepo.Update(draft)
|
s.draftRepo.Update(draft)
|
||||||
|
|
||||||
// 6. БИЛЛИНГ: Увеличиваем счетчик накладных
|
// 6. БИЛЛИНГ и Обучение
|
||||||
server, _ := s.accountRepo.GetActiveServer(userID)
|
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||||
if server != nil {
|
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||||
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
|
||||||
logger.Log.Error("Billing increment failed", zap.Error(err))
|
|
||||||
}
|
|
||||||
// 7. Обучение (передаем ID сервера для сохранения маппинга)
|
|
||||||
go s.learnFromDraft(draft, server.ID)
|
|
||||||
}
|
}
|
||||||
|
go s.learnFromDraft(draft, server.ID)
|
||||||
|
|
||||||
return docNum, nil
|
return docNum, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
|||||||
|
|
||||||
// 2. Создаем черновик
|
// 2. Создаем черновик
|
||||||
draft := &drafts.DraftInvoice{
|
draft := &drafts.DraftInvoice{
|
||||||
UserID: userID, // <-- Исправлено с ChatID на UserID
|
UserID: userID,
|
||||||
RMSServerID: serverID, // <-- NEW
|
RMSServerID: serverID,
|
||||||
Status: drafts.StatusProcessing,
|
Status: drafts.StatusProcessing,
|
||||||
|
StoreID: server.DefaultStoreID,
|
||||||
}
|
}
|
||||||
if err := s.draftRepo.Create(draft); err != nil {
|
if err := s.draftRepo.Create(draft); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create draft: %w", err)
|
return nil, fmt.Errorf("failed to create draft: %w", err)
|
||||||
@@ -79,7 +80,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
|||||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||||
}
|
}
|
||||||
|
|
||||||
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) // <-- ServerID
|
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName)
|
||||||
|
|
||||||
if match != nil {
|
if match != nil {
|
||||||
item.IsMatched = true
|
item.IsMatched = true
|
||||||
@@ -97,6 +98,8 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
|||||||
s.draftRepo.Update(draft)
|
s.draftRepo.Update(draft)
|
||||||
s.draftRepo.CreateItems(draftItems)
|
s.draftRepo.CreateItems(draftItems)
|
||||||
|
|
||||||
|
draft.Items = draftItems
|
||||||
|
|
||||||
return draft, nil
|
return draft, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +125,7 @@ func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, er
|
|||||||
return nil, fmt.Errorf("no server")
|
return nil, fmt.Errorf("no server")
|
||||||
}
|
}
|
||||||
|
|
||||||
products, err := s.catalogRepo.GetActiveGoods(server.ID)
|
products, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -163,7 +166,7 @@ func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Prod
|
|||||||
if err != nil || server == nil {
|
if err != nil || server == nil {
|
||||||
return nil, fmt.Errorf("no server")
|
return nil, fmt.Errorf("no server")
|
||||||
}
|
}
|
||||||
return s.catalogRepo.Search(server.ID, query)
|
return s.catalogRepo.Search(server.ID, query, server.RootGroupGUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||||
|
|||||||
@@ -77,6 +77,51 @@ type UpdateItemDTO struct {
|
|||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddDraftItem - POST /api/drafts/:id/items
|
||||||
|
func (h *DraftsHandler) AddDraftItem(c *gin.Context) {
|
||||||
|
draftID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := h.service.AddItem(draftID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Failed to add item", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDraftItem - DELETE /api/drafts/:id/items/:itemId
|
||||||
|
func (h *DraftsHandler) DeleteDraftItem(c *gin.Context) {
|
||||||
|
draftID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemID, err := uuid.Parse(c.Param("itemId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newTotal, err := h.service.DeleteItem(draftID, itemID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Failed to delete item", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "deleted",
|
||||||
|
"id": itemID.String(),
|
||||||
|
"total_sum": newTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
||||||
// userID := c.MustGet("userID").(uuid.UUID) // Пока не используется в UpdateItem, но можно добавить проверку владельца
|
// userID := c.MustGet("userID").(uuid.UUID) // Пока не используется в UpdateItem, но можно добавить проверку владельца
|
||||||
draftID, _ := uuid.Parse(c.Param("id"))
|
draftID, _ := uuid.Parse(c.Param("id"))
|
||||||
|
|||||||
158
internal/transport/http/handlers/settings.go
Normal file
158
internal/transport/http/handlers/settings.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/internal/domain/catalog"
|
||||||
|
"rmser/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsHandler struct {
|
||||||
|
accountRepo account.Repository
|
||||||
|
catalogRepo catalog.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler {
|
||||||
|
return &SettingsHandler{
|
||||||
|
accountRepo: accRepo,
|
||||||
|
catalogRepo: catRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings возвращает настройки активного сервера
|
||||||
|
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
|
server, err := h.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if server == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingsDTO
|
||||||
|
type UpdateSettingsDTO struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DefaultStoreID string `json:"default_store_id"`
|
||||||
|
RootGroupID string `json:"root_group_id"`
|
||||||
|
AutoProcess bool `json:"auto_process"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings сохраняет настройки
|
||||||
|
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
|
var req UpdateSettingsDTO
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := h.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем поля
|
||||||
|
if req.Name != "" {
|
||||||
|
server.Name = req.Name
|
||||||
|
}
|
||||||
|
server.AutoProcess = req.AutoProcess
|
||||||
|
|
||||||
|
if req.DefaultStoreID != "" {
|
||||||
|
if uid, err := uuid.Parse(req.DefaultStoreID); err == nil {
|
||||||
|
server.DefaultStoreID = &uid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
server.DefaultStoreID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Теперь правильно ловим ID группы
|
||||||
|
if req.RootGroupID != "" {
|
||||||
|
if uid, err := uuid.Parse(req.RootGroupID); err == nil {
|
||||||
|
server.RootGroupGUID = &uid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
server.RootGroupGUID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.accountRepo.SaveServer(server); err != nil {
|
||||||
|
logger.Log.Error("Failed to save settings", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group Tree Logic ---
|
||||||
|
|
||||||
|
type GroupNode struct {
|
||||||
|
Key string `json:"key"` // ID for Ant Design TreeSelect
|
||||||
|
Value string `json:"value"` // ID value
|
||||||
|
Title string `json:"title"` // Name
|
||||||
|
Children []*GroupNode `json:"children"` // Sub-groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupsTree возвращает иерархию групп
|
||||||
|
func (h *SettingsHandler) GetGroupsTree(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
server, err := h.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groups, err := h.catalogRepo.GetGroups(server.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := buildTree(groups)
|
||||||
|
c.JSON(http.StatusOK, tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTree(flat []catalog.Product) []*GroupNode {
|
||||||
|
// 1. Map ID -> Node
|
||||||
|
nodeMap := make(map[uuid.UUID]*GroupNode)
|
||||||
|
for _, g := range flat {
|
||||||
|
nodeMap[g.ID] = &GroupNode{
|
||||||
|
Key: g.ID.String(),
|
||||||
|
Value: g.ID.String(),
|
||||||
|
Title: g.Name,
|
||||||
|
Children: make([]*GroupNode, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var roots []*GroupNode
|
||||||
|
|
||||||
|
// 2. Build Hierarchy
|
||||||
|
for _, g := range flat {
|
||||||
|
node := nodeMap[g.ID]
|
||||||
|
if g.ParentID != nil {
|
||||||
|
if parent, exists := nodeMap[*g.ParentID]; exists {
|
||||||
|
parent.Children = append(parent.Children, node)
|
||||||
|
} else {
|
||||||
|
// Если родителя нет в списке (например, он удален или мы выбрали подмножество),
|
||||||
|
// считаем узлом верхнего уровня
|
||||||
|
roots = append(roots, node)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roots = append(roots, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
@@ -141,6 +141,9 @@ func (bot *Bot) initHandlers() {
|
|||||||
// Actions Callbacks
|
// Actions Callbacks
|
||||||
bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow)
|
bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync)
|
bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "confirm_name_yes"}, bot.handleConfirmNameYes)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error {
|
bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"})
|
return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"})
|
||||||
})
|
})
|
||||||
@@ -151,6 +154,7 @@ func (bot *Bot) initHandlers() {
|
|||||||
// Input Handlers
|
// Input Handlers
|
||||||
bot.b.Handle(tele.OnText, bot.handleText)
|
bot.b.Handle(tele.OnText, bot.handleText)
|
||||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) Start() {
|
func (bot *Bot) Start() {
|
||||||
@@ -205,9 +209,10 @@ func (bot *Bot) renderServersMenu(c tele.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
||||||
|
btnDel := menu.Data("🗑 Удалить", "act_del_server_menu")
|
||||||
btnBack := menu.Data("🔙 Назад", "nav_main")
|
btnBack := menu.Data("🔙 Назад", "nav_main")
|
||||||
|
|
||||||
rows = append(rows, menu.Row(btnAdd))
|
rows = append(rows, menu.Row(btnAdd, btnDel))
|
||||||
rows = append(rows, menu.Row(btnBack))
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
|
||||||
menu.Inline(rows...)
|
menu.Inline(rows...)
|
||||||
@@ -264,18 +269,21 @@ func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
|||||||
func (bot *Bot) handleCallback(c tele.Context) error {
|
func (bot *Bot) handleCallback(c tele.Context) error {
|
||||||
data := c.Callback().Data
|
data := c.Callback().Data
|
||||||
|
|
||||||
|
// FIX: Telebot v3 добавляет префикс '\f' к Unique ID кнопки.
|
||||||
|
// Нам нужно удалить его, чтобы корректно парсить строку.
|
||||||
|
if len(data) > 0 && data[0] == '\f' {
|
||||||
|
data = data[1:]
|
||||||
|
}
|
||||||
|
|
||||||
// Обработка выбора сервера "set_server_..."
|
// Обработка выбора сервера "set_server_..."
|
||||||
if strings.HasPrefix(data, "set_server_") {
|
if strings.HasPrefix(data, "set_server_") {
|
||||||
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
||||||
// Удаляем лишние пробелы/символы, которые telebot иногда добавляет (уникальный префикс \f)
|
|
||||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
// Telebot v3: Callback data is prefixed with \f followed by unique id.
|
|
||||||
// But here we use 'data' which is the payload.
|
|
||||||
// NOTE: data variable contains what we passed in .Data() second arg.
|
|
||||||
|
|
||||||
// Split by | just in case middleware adds something, but usually raw string is fine.
|
// Защита от старых форматов с разделителем |
|
||||||
parts := strings.Split(serverIDStr, "|") // Защита от старых форматов
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
serverIDStr = parts[0]
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
@@ -290,30 +298,74 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
|
logger.Log.Warn("User tried to select unknown server",
|
||||||
|
zap.Int64("user_tg_id", c.Sender().ID),
|
||||||
|
zap.String("server_id_req", serverIDStr))
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Сервер не найден или доступ запрещен"})
|
return c.Respond(&tele.CallbackResponse{Text: "Сервер не найден или доступ запрещен"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Делаем активным
|
// 2. Делаем активным
|
||||||
// Важно: нужно спарсить UUID
|
targetID := parseUUID(serverIDStr)
|
||||||
// Telebot sometimes sends garbage if Unique is not handled properly.
|
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
|
||||||
// But we handle OnCallback generally.
|
|
||||||
|
|
||||||
// Fix: В Telebot 3 Data() возвращает payload как есть.
|
|
||||||
// Но лучше быть аккуратным.
|
|
||||||
|
|
||||||
if err := bot.accountRepo.SetActiveServer(userDB.ID, parseUUID(serverIDStr)); err != nil {
|
|
||||||
logger.Log.Error("Failed to set active server", zap.Error(err))
|
logger.Log.Error("Failed to set active server", zap.Error(err))
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Сбрасываем кэш фабрики клиентов (чтобы при следующем запросе создался клиент с новыми кредами, если бы они поменялись,
|
// 3. Успех
|
||||||
// но тут меняется сам сервер, так что Factory.GetClientForUser просто возьмет другой сервер)
|
|
||||||
// Для надежности можно ничего не делать, Factory сама разберется.
|
|
||||||
|
|
||||||
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
||||||
return bot.renderServersMenu(c) // Перерисовываем меню
|
return bot.renderServersMenu(c) // Перерисовываем меню
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ЛОГИКА УДАЛЕНИЯ (новая) ---
|
||||||
|
if strings.HasPrefix(data, "do_del_server_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "do_del_server_")
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
|
||||||
|
// Очистка от мусора
|
||||||
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID := parseUUID(serverIDStr)
|
||||||
|
if targetID == uuid.Nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Некорректный ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Проверяем, активен ли он сейчас
|
||||||
|
// Нам нужно знать это ДО удаления, чтобы переключить активность
|
||||||
|
// Но проще удалить, а потом проверить, остался ли активный сервер
|
||||||
|
|
||||||
|
// Удаляем
|
||||||
|
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
||||||
|
logger.Log.Error("Failed to delete server", zap.Error(err))
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем кэш клиента в фабрике
|
||||||
|
bot.rmsFactory.ClearCache(targetID)
|
||||||
|
|
||||||
|
// 2. Проверяем, есть ли активный сервер у пользователя
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
active, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
|
|
||||||
|
// Если активного нет (мы удалили активный) или ошибка - назначаем новый
|
||||||
|
if active == nil || err != nil {
|
||||||
|
all, _ := bot.accountRepo.GetAllServers(userDB.ID)
|
||||||
|
if len(all) > 0 {
|
||||||
|
// Делаем активным первый попавшийся
|
||||||
|
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Активным назначен " + all[0].Name})
|
||||||
|
} else {
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Список пуст."})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Сервер удален"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаемся в меню удаления (обновляем список)
|
||||||
|
return bot.renderDeleteServerMenu(c)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,55 +418,65 @@ func (bot *Bot) handleText(c tele.Context) error {
|
|||||||
ctx.TempURL = strings.TrimRight(text, "/")
|
ctx.TempURL = strings.TrimRight(text, "/")
|
||||||
ctx.State = StateAddServerLogin
|
ctx.State = StateAddServerLogin
|
||||||
})
|
})
|
||||||
return c.Send("👤 Введите <b>логин</b> пользователя iiko:")
|
return c.Send("👤 Введите логин пользователя iiko:")
|
||||||
|
|
||||||
case StateAddServerLogin:
|
case StateAddServerLogin:
|
||||||
bot.fsm.UpdateContext(userID, func(ctx *UserContext) {
|
bot.fsm.UpdateContext(userID, func(ctx *UserContext) {
|
||||||
ctx.TempLogin = text
|
ctx.TempLogin = text
|
||||||
ctx.State = StateAddServerPassword
|
ctx.State = StateAddServerPassword
|
||||||
})
|
})
|
||||||
return c.Send("🔑 Введите <b>пароль</b>:")
|
return c.Send("🔑 Введите пароль:")
|
||||||
|
|
||||||
case StateAddServerPassword:
|
case StateAddServerPassword:
|
||||||
password := text
|
password := text
|
||||||
ctx := bot.fsm.GetContext(userID)
|
ctx := bot.fsm.GetContext(userID)
|
||||||
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
||||||
|
|
||||||
// Check connection
|
// 1. Проверяем авторизацию (креды)
|
||||||
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
||||||
if err := tempClient.Auth(); err != nil {
|
if err := tempClient.Auth(); err != nil {
|
||||||
bot.b.Delete(msg)
|
bot.b.Delete(msg)
|
||||||
return c.Send(fmt.Sprintf("❌ Ошибка: %v\nПопробуйте ввести пароль снова или начните сначала /add_server", err))
|
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save
|
// 2. Пробуем узнать имя сервера
|
||||||
encPass, _ := bot.cryptoManager.Encrypt(password)
|
var detectedName string
|
||||||
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
info, err := rms.GetServerInfo(ctx.TempURL)
|
||||||
|
if err == nil && info.ServerName != "" {
|
||||||
newServer := &account.RMSServer{
|
detectedName = info.ServerName
|
||||||
UserID: userDB.ID,
|
|
||||||
Name: "iiko Server " + time.Now().Format("15:04"), // Генерируем имя, чтобы не спрашивать лишнего
|
|
||||||
BaseURL: ctx.TempURL,
|
|
||||||
Login: ctx.TempLogin,
|
|
||||||
EncryptedPassword: encPass,
|
|
||||||
IsActive: true, // Сразу делаем активным
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сначала сохраняем, потом делаем активным (через репо сохранения)
|
|
||||||
if err := bot.accountRepo.SaveServer(newServer); err != nil {
|
|
||||||
return c.Send("Ошибка БД: " + err.Error())
|
|
||||||
}
|
|
||||||
// Устанавливаем активным (сбрасывая другие)
|
|
||||||
bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID)
|
|
||||||
|
|
||||||
bot.fsm.Reset(userID)
|
|
||||||
bot.b.Delete(msg)
|
bot.b.Delete(msg)
|
||||||
c.Send("✅ <b>Сервер добавлен и выбран активным!</b>", tele.ModeHTML)
|
|
||||||
|
|
||||||
// Auto-sync
|
// Сохраняем пароль во временный контекст, он нам пригодится при финальном сохранении
|
||||||
go bot.syncService.SyncAllData(userDB.ID)
|
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
|
||||||
|
uCtx.TempPassword = password
|
||||||
|
uCtx.TempServerName = detectedName
|
||||||
|
})
|
||||||
|
|
||||||
return bot.renderMainMenu(c)
|
// Если имя нашли - предлагаем выбор
|
||||||
|
if detectedName != "" {
|
||||||
|
bot.fsm.SetState(userID, StateAddServerConfirmName)
|
||||||
|
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
btnYes := menu.Data("✅ Да, использовать это имя", "confirm_name_yes")
|
||||||
|
btnNo := menu.Data("✏️ Ввести другое", "confirm_name_no")
|
||||||
|
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
||||||
|
|
||||||
|
return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если имя не нашли - просим ввести вручную
|
||||||
|
bot.fsm.SetState(userID, StateAddServerInputName)
|
||||||
|
return c.Send("🏷 Введите <b>название</b> для этого сервера (для вашего удобства):")
|
||||||
|
|
||||||
|
case StateAddServerInputName:
|
||||||
|
// Пользователь ввел свое название
|
||||||
|
name := text
|
||||||
|
if len(name) < 3 {
|
||||||
|
return c.Send("⚠️ Название слишком короткое.")
|
||||||
|
}
|
||||||
|
return bot.saveServerFinal(c, userID, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -488,3 +550,78 @@ func parseUUID(s string) uuid.UUID {
|
|||||||
id, _ := uuid.Parse(s)
|
id, _ := uuid.Parse(s)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
ctx := bot.fsm.GetContext(userID)
|
||||||
|
if ctx.State != StateAddServerConfirmName {
|
||||||
|
return c.Respond()
|
||||||
|
}
|
||||||
|
return bot.saveServerFinal(c, userID, ctx.TempServerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) handleConfirmNameNo(c tele.Context) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
bot.fsm.SetState(userID, StateAddServerInputName)
|
||||||
|
return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveServerFinal - общая логика сохранения в БД
|
||||||
|
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
|
||||||
|
ctx := bot.fsm.GetContext(userID)
|
||||||
|
|
||||||
|
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
|
||||||
|
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
||||||
|
|
||||||
|
newServer := &account.RMSServer{
|
||||||
|
UserID: userDB.ID,
|
||||||
|
Name: serverName,
|
||||||
|
BaseURL: ctx.TempURL,
|
||||||
|
Login: ctx.TempLogin,
|
||||||
|
EncryptedPassword: encPass,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bot.accountRepo.SaveServer(newServer); err != nil {
|
||||||
|
return c.Send("Ошибка сохранения в БД: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID)
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
|
||||||
|
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> успешно добавлен!", serverName), tele.ModeHTML)
|
||||||
|
|
||||||
|
// Auto-sync
|
||||||
|
go bot.syncService.SyncAllData(userDB.ID)
|
||||||
|
|
||||||
|
return bot.renderMainMenu(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
servers, err := bot.accountRepo.GetAllServers(userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("Ошибка БД: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servers) == 0 {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Список серверов пуст"})
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
var rows []tele.Row
|
||||||
|
|
||||||
|
for _, s := range servers {
|
||||||
|
// Кнопка удаления для каждого сервера
|
||||||
|
// Префикс do_del_server_
|
||||||
|
btn := menu.Data(fmt.Sprintf("❌ %s", s.Name), "do_del_server_"+s.ID.String())
|
||||||
|
rows = append(rows, menu.Row(btn))
|
||||||
|
}
|
||||||
|
|
||||||
|
btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
|
||||||
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
|
||||||
|
menu.Inline(rows...)
|
||||||
|
|
||||||
|
return c.EditOrSend("🗑 <b>Удаление сервера</b>\n\nНажмите на сервер, который хотите удалить.\nЭто действие нельзя отменить.", menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ const (
|
|||||||
StateAddServerURL
|
StateAddServerURL
|
||||||
StateAddServerLogin
|
StateAddServerLogin
|
||||||
StateAddServerPassword
|
StateAddServerPassword
|
||||||
|
StateAddServerConfirmName
|
||||||
|
StateAddServerInputName
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserContext хранит временные данные в процессе диалога
|
// UserContext хранит временные данные в процессе диалога
|
||||||
type UserContext struct {
|
type UserContext struct {
|
||||||
State State
|
State State
|
||||||
TempURL string
|
TempURL string
|
||||||
TempLogin string
|
TempLogin string
|
||||||
TempPassword string
|
TempPassword string
|
||||||
|
TempServerName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateManager управляет состояниями
|
// StateManager управляет состояниями
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { Result, Button } from 'antd';
|
import { Result, Button } from "antd";
|
||||||
import { Providers } from './components/layout/Providers';
|
import { Providers } from "./components/layout/Providers";
|
||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from "./components/layout/AppLayout";
|
||||||
import { OcrLearning } from './pages/OcrLearning';
|
import { OcrLearning } from "./pages/OcrLearning";
|
||||||
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
|
import { InvoiceDraftPage } from "./pages/InvoiceDraftPage";
|
||||||
import { DraftsList } from './pages/DraftsList';
|
import { DraftsList } from "./pages/DraftsList";
|
||||||
import { UNAUTHORIZED_EVENT } from './services/api';
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
|
import { UNAUTHORIZED_EVENT } from "./services/api";
|
||||||
|
|
||||||
// Компонент заглушки для 401 ошибки
|
// Компонент заглушки для 401 ошибки
|
||||||
const UnauthorizedScreen = () => (
|
const UnauthorizedScreen = () => (
|
||||||
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Result
|
<Result
|
||||||
status="403"
|
status="403"
|
||||||
title="Доступ запрещен"
|
title="Доступ запрещен"
|
||||||
@@ -53,6 +62,7 @@ function App() {
|
|||||||
<Route path="ocr" element={<OcrLearning />} />
|
<Route path="ocr" element={<OcrLearning />} />
|
||||||
<Route path="invoices" element={<DraftsList />} />
|
<Route path="invoices" element={<DraftsList />} />
|
||||||
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,48 +1,86 @@
|
|||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
import { Card, Flex, InputNumber, Typography, Select, Tag, Button, Divider, Modal } from 'antd';
|
import {
|
||||||
import { SyncOutlined, PlusOutlined, WarningFilled } from '@ant-design/icons';
|
Card,
|
||||||
import { CatalogSelect } from '../ocr/CatalogSelect';
|
Flex,
|
||||||
import { CreateContainerModal } from './CreateContainerModal';
|
InputNumber,
|
||||||
import type { DraftItem, UpdateDraftItemRequest, ProductSearchResult, ProductContainer, Recommendation } from '../../services/types';
|
Typography,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
SyncOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
WarningFilled,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { CatalogSelect } from "../ocr/CatalogSelect";
|
||||||
|
import { CreateContainerModal } from "./CreateContainerModal";
|
||||||
|
import type {
|
||||||
|
DraftItem,
|
||||||
|
UpdateDraftItemRequest,
|
||||||
|
ProductSearchResult,
|
||||||
|
ProductContainer,
|
||||||
|
Recommendation,
|
||||||
|
} from "../../services/types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: DraftItem;
|
item: DraftItem;
|
||||||
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
|
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
|
||||||
|
onDelete: (itemId: string) => void;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
recommendations?: Recommendation[]; // Новый проп
|
recommendations?: Recommendation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, recommendations = [] }) => {
|
export const DraftItemRow: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
isUpdating,
|
||||||
|
recommendations = [],
|
||||||
|
}) => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
// State Input
|
// State Input
|
||||||
const [localQuantity, setLocalQuantity] = useState<string | null>(item.quantity?.toString() ?? null);
|
const [localQuantity, setLocalQuantity] = useState<string | null>(
|
||||||
const [localPrice, setLocalPrice] = useState<string | null>(item.price?.toString() ?? null);
|
item.quantity?.toString() ?? null
|
||||||
|
);
|
||||||
|
const [localPrice, setLocalPrice] = useState<string | null>(
|
||||||
|
item.price?.toString() ?? null
|
||||||
|
);
|
||||||
|
|
||||||
// Sync Effect
|
// Sync Effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const serverQty = item.quantity;
|
const serverQty = item.quantity;
|
||||||
const currentLocal = parseFloat(localQuantity?.replace(',', '.') || '0');
|
const currentLocal = parseFloat(localQuantity?.replace(",", ".") || "0");
|
||||||
if (Math.abs(serverQty - currentLocal) > 0.001) setLocalQuantity(serverQty.toString().replace('.', ','));
|
if (Math.abs(serverQty - currentLocal) > 0.001)
|
||||||
|
setLocalQuantity(serverQty.toString().replace(".", ","));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [item.quantity]);
|
}, [item.quantity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const serverPrice = item.price;
|
const serverPrice = item.price;
|
||||||
const currentLocal = parseFloat(localPrice?.replace(',', '.') || '0');
|
const currentLocal = parseFloat(localPrice?.replace(",", ".") || "0");
|
||||||
if (Math.abs(serverPrice - currentLocal) > 0.001) setLocalPrice(serverPrice.toString().replace('.', ','));
|
if (Math.abs(serverPrice - currentLocal) > 0.001)
|
||||||
|
setLocalPrice(serverPrice.toString().replace(".", ","));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [item.price]);
|
}, [item.price]);
|
||||||
|
|
||||||
|
|
||||||
// Product Logic
|
// Product Logic
|
||||||
const [searchedProduct, setSearchedProduct] = useState<ProductSearchResult | null>(null);
|
const [searchedProduct, setSearchedProduct] =
|
||||||
const [addedContainers, setAddedContainers] = useState<Record<string, ProductContainer[]>>({});
|
useState<ProductSearchResult | null>(null);
|
||||||
|
const [addedContainers, setAddedContainers] = useState<
|
||||||
|
Record<string, ProductContainer[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
const activeProduct = useMemo(() => {
|
const activeProduct = useMemo(() => {
|
||||||
if (searchedProduct && searchedProduct.id === item.product_id) return searchedProduct;
|
if (searchedProduct && searchedProduct.id === item.product_id)
|
||||||
|
return searchedProduct;
|
||||||
return item.product as unknown as ProductSearchResult | undefined;
|
return item.product as unknown as ProductSearchResult | undefined;
|
||||||
}, [searchedProduct, item.product, item.product_id]);
|
}, [searchedProduct, item.product, item.product_id]);
|
||||||
|
|
||||||
@@ -51,28 +89,35 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
|||||||
const baseContainers = activeProduct.containers || [];
|
const baseContainers = activeProduct.containers || [];
|
||||||
const manuallyAdded = addedContainers[activeProduct.id] || [];
|
const manuallyAdded = addedContainers[activeProduct.id] || [];
|
||||||
const combined = [...baseContainers];
|
const combined = [...baseContainers];
|
||||||
manuallyAdded.forEach(c => {
|
manuallyAdded.forEach((c) => {
|
||||||
if (!combined.find(existing => existing.id === c.id)) combined.push(c);
|
if (!combined.find((existing) => existing.id === c.id)) combined.push(c);
|
||||||
});
|
});
|
||||||
return combined;
|
return combined;
|
||||||
}, [activeProduct, addedContainers]);
|
}, [activeProduct, addedContainers]);
|
||||||
|
|
||||||
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.';
|
const baseUom =
|
||||||
|
activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед.";
|
||||||
|
|
||||||
const containerOptions = useMemo(() => {
|
const containerOptions = useMemo(() => {
|
||||||
if (!activeProduct) return [];
|
if (!activeProduct) return [];
|
||||||
const opts = [
|
const opts = [
|
||||||
{ value: 'BASE_UNIT', label: `Базовая (${baseUom})` },
|
{ value: "BASE_UNIT", label: `Базовая (${baseUom})` },
|
||||||
...containers.map(c => ({
|
...containers.map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: `${c.name} (=${Number(c.count)} ${baseUom})`
|
label: `${c.name} (=${Number(c.count)} ${baseUom})`,
|
||||||
}))
|
})),
|
||||||
];
|
];
|
||||||
if (item.container_id && item.container && !containers.find(c => c.id === item.container_id)) {
|
if (
|
||||||
opts.push({
|
item.container_id &&
|
||||||
value: item.container.id,
|
item.container &&
|
||||||
label: `${item.container.name} (=${Number(item.container.count)} ${baseUom})`
|
!containers.find((c) => c.id === item.container_id)
|
||||||
});
|
) {
|
||||||
|
opts.push({
|
||||||
|
value: item.container.id,
|
||||||
|
label: `${item.container.name} (=${Number(
|
||||||
|
item.container.count
|
||||||
|
)} ${baseUom})`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return opts;
|
return opts;
|
||||||
}, [activeProduct, containers, baseUom, item.container_id, item.container]);
|
}, [activeProduct, containers, baseUom, item.container_id, item.container]);
|
||||||
@@ -80,102 +125,166 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
|||||||
// --- WARNING LOGIC ---
|
// --- WARNING LOGIC ---
|
||||||
const activeWarning = useMemo(() => {
|
const activeWarning = useMemo(() => {
|
||||||
if (!item.product_id) return null;
|
if (!item.product_id) return null;
|
||||||
return recommendations.find(r => r.ProductID === item.product_id);
|
return recommendations.find((r) => r.ProductID === item.product_id);
|
||||||
}, [item.product_id, recommendations]);
|
}, [item.product_id, recommendations]);
|
||||||
|
|
||||||
const showWarningModal = () => {
|
const showWarningModal = () => {
|
||||||
if (!activeWarning) return;
|
if (!activeWarning) return;
|
||||||
Modal.warning({
|
Modal.warning({
|
||||||
title: 'Внимание: проблемный товар',
|
title: "Внимание: проблемный товар",
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<p><b>{activeWarning.ProductName}</b></p>
|
<p>
|
||||||
|
<b>{activeWarning.ProductName}</b>
|
||||||
|
</p>
|
||||||
<p>{activeWarning.Reason}</p>
|
<p>{activeWarning.Reason}</p>
|
||||||
<p><Tag color="orange">{activeWarning.Type}</Tag></p>
|
<p>
|
||||||
|
<Tag color="orange">{activeWarning.Type}</Tag>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: 'Понятно',
|
okText: "Понятно",
|
||||||
maskClosable: true
|
maskClosable: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
const parseToNum = (val: string | null | undefined): number => {
|
const parseToNum = (val: string | null | undefined): number => {
|
||||||
if (!val) return 0;
|
if (!val) return 0;
|
||||||
return parseFloat(val.replace(',', '.'));
|
return parseFloat(val.replace(",", "."));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUpdatePayload = (overrides: Partial<UpdateDraftItemRequest>): UpdateDraftItemRequest => {
|
const getUpdatePayload = (
|
||||||
const currentQty = localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
|
overrides: Partial<UpdateDraftItemRequest>
|
||||||
const currentPrice = localPrice !== null ? parseToNum(localPrice) : item.price;
|
): UpdateDraftItemRequest => {
|
||||||
|
const currentQty =
|
||||||
|
localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
|
||||||
|
const currentPrice =
|
||||||
|
localPrice !== null ? parseToNum(localPrice) : item.price;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
product_id: item.product_id || undefined,
|
product_id: item.product_id || undefined,
|
||||||
container_id: item.container_id,
|
container_id: item.container_id,
|
||||||
quantity: currentQty ?? 1,
|
quantity: currentQty ?? 1,
|
||||||
price: currentPrice ?? 0,
|
price: currentPrice ?? 0,
|
||||||
...overrides
|
...overrides,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
const handleProductChange = (prodId: string, productObj?: ProductSearchResult) => {
|
const handleProductChange = (
|
||||||
|
prodId: string,
|
||||||
|
productObj?: ProductSearchResult
|
||||||
|
) => {
|
||||||
if (productObj) setSearchedProduct(productObj);
|
if (productObj) setSearchedProduct(productObj);
|
||||||
onUpdate(item.id, getUpdatePayload({ product_id: prodId, container_id: null }));
|
onUpdate(
|
||||||
|
item.id,
|
||||||
|
getUpdatePayload({ product_id: prodId, container_id: null })
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContainerChange = (val: string) => {
|
const handleContainerChange = (val: string) => {
|
||||||
const newVal = val === 'BASE_UNIT' ? null : val;
|
const newVal = val === "BASE_UNIT" ? null : val;
|
||||||
onUpdate(item.id, getUpdatePayload({ container_id: newVal }));
|
onUpdate(item.id, getUpdatePayload({ container_id: newVal }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = (field: 'quantity' | 'price') => {
|
const handleBlur = (field: "quantity" | "price") => {
|
||||||
const localVal = field === 'quantity' ? localQuantity : localPrice;
|
const localVal = field === "quantity" ? localQuantity : localPrice;
|
||||||
if (localVal === null) return;
|
if (localVal === null) return;
|
||||||
const numVal = parseToNum(localVal);
|
const numVal = parseToNum(localVal);
|
||||||
if (numVal !== item[field]) {
|
if (numVal !== item[field]) {
|
||||||
onUpdate(item.id, getUpdatePayload({ [field]: numVal }));
|
onUpdate(item.id, getUpdatePayload({ [field]: numVal }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContainerCreated = (newContainer: ProductContainer) => {
|
const handleContainerCreated = (newContainer: ProductContainer) => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
if (activeProduct) {
|
if (activeProduct) {
|
||||||
setAddedContainers(prev => ({ ...prev, [activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer] }));
|
setAddedContainers((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
onUpdate(item.id, getUpdatePayload({ container_id: newContainer.id }));
|
onUpdate(item.id, getUpdatePayload({ container_id: newContainer.id }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9';
|
const cardBorderColor = !item.product_id
|
||||||
|
? "#ffa39e"
|
||||||
|
: item.is_matched
|
||||||
|
? "#b7eb8f"
|
||||||
|
: "#d9d9d9";
|
||||||
const uiSum = parseToNum(localQuantity) * parseToNum(localPrice);
|
const uiSum = parseToNum(localQuantity) * parseToNum(localPrice);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginBottom: 8, borderLeft: `4px solid ${cardBorderColor}`, background: item.product_id ? '#fff' : '#fff1f0' }}
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
borderLeft: `4px solid ${cardBorderColor}`,
|
||||||
|
background: item.product_id ? "#fff" : "#fff1f0",
|
||||||
|
}}
|
||||||
bodyStyle={{ padding: 12 }}
|
bodyStyle={{ padding: 12 }}
|
||||||
>
|
>
|
||||||
<Flex vertical gap={10}>
|
<Flex vertical gap={10}>
|
||||||
<Flex justify="space-between" align="start">
|
<Flex justify="space-between" align="start">
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.2, display: 'block' }}>{item.raw_name}</Text>
|
{/* Показываем raw_name только если это OCR строка. Если создана вручную и пустая - плейсхолдер */}
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, lineHeight: 1.2, display: "block" }}
|
||||||
|
>
|
||||||
|
{item.raw_name || "Новая позиция"}
|
||||||
|
</Text>
|
||||||
{item.raw_amount > 0 && (
|
{item.raw_amount > 0 && (
|
||||||
<Text type="secondary" style={{ fontSize: 10, display: 'block' }}>(чек: {item.raw_amount} x {item.raw_price})</Text>
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 10, display: "block" }}
|
||||||
|
>
|
||||||
|
(чек: {item.raw_amount} x {item.raw_price})
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div
|
||||||
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUpdating && <SyncOutlined spin style={{ color: "#1890ff" }} />}
|
||||||
|
|
||||||
{/* Warning Icon */}
|
{/* Warning Icon */}
|
||||||
{activeWarning && (
|
{activeWarning && (
|
||||||
<WarningFilled
|
<WarningFilled
|
||||||
style={{ color: '#faad14', fontSize: 16, cursor: 'pointer' }}
|
style={{ color: "#faad14", fontSize: 16, cursor: "pointer" }}
|
||||||
onClick={showWarningModal}
|
onClick={showWarningModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!item.product_id && <Tag color="error" style={{ margin: 0 }}>?</Tag>}
|
{!item.product_id && (
|
||||||
|
<Tag color="error" style={{ margin: 0 }}>
|
||||||
|
?
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка удаления */}
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить строку?"
|
||||||
|
onConfirm={() => onDelete(item.id)}
|
||||||
|
okText="Да"
|
||||||
|
cancelText="Нет"
|
||||||
|
placement="left"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
danger
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
@@ -187,16 +296,22 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
|||||||
|
|
||||||
{activeProduct && (
|
{activeProduct && (
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: "100%" }}
|
||||||
placeholder="Выберите единицу измерения"
|
placeholder="Выберите единицу измерения"
|
||||||
options={containerOptions}
|
options={containerOptions}
|
||||||
value={item.container_id || 'BASE_UNIT'}
|
value={item.container_id || "BASE_UNIT"}
|
||||||
onChange={handleContainerChange}
|
onChange={handleContainerChange}
|
||||||
dropdownRender={(menu) => (
|
dropdownRender={(menu) => (
|
||||||
<>
|
<>
|
||||||
{menu}
|
{menu}
|
||||||
<Divider style={{ margin: '4px 0' }} />
|
<Divider style={{ margin: "4px 0" }} />
|
||||||
<Button type="text" block icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} style={{ textAlign: 'left' }}>
|
<Button
|
||||||
|
type="text"
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
style={{ textAlign: "left" }}
|
||||||
|
>
|
||||||
Добавить фасовку...
|
Добавить фасовку...
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -204,27 +319,52 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
style={{
|
||||||
background: '#fafafa', margin: '0 -12px -12px -12px', padding: '8px 12px',
|
display: "flex",
|
||||||
borderTop: '1px solid #f0f0f0', borderBottomLeftRadius: 8, borderBottomRightRadius: 8
|
alignItems: "center",
|
||||||
}}>
|
justifyContent: "space-between",
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
background: "#fafafa",
|
||||||
<InputNumber<string>
|
margin: "0 -12px -12px -12px",
|
||||||
style={{ width: 60 }} controls={false} placeholder="Кол" stringMode decimalSeparator=","
|
padding: "8px 12px",
|
||||||
value={localQuantity || ''} onChange={(val) => setLocalQuantity(val)} onBlur={() => handleBlur('quantity')}
|
borderTop: "1px solid #f0f0f0",
|
||||||
/>
|
borderBottomLeftRadius: 8,
|
||||||
<Text type="secondary">x</Text>
|
borderBottomRightRadius: 8,
|
||||||
<InputNumber<string>
|
}}
|
||||||
style={{ width: 70 }} controls={false} placeholder="Цена" stringMode decimalSeparator=","
|
>
|
||||||
value={localPrice || ''} onChange={(val) => setLocalPrice(val)} onBlur={() => handleBlur('price')}
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
/>
|
<InputNumber<string>
|
||||||
</div>
|
style={{ width: 60 }}
|
||||||
<div style={{ textAlign: 'right' }}>
|
controls={false}
|
||||||
<Text strong style={{ fontSize: 16 }}>
|
placeholder="Кол"
|
||||||
{uiSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
stringMode
|
||||||
</Text>
|
decimalSeparator=","
|
||||||
</div>
|
value={localQuantity || ""}
|
||||||
|
onChange={(val) => setLocalQuantity(val)}
|
||||||
|
onBlur={() => handleBlur("quantity")}
|
||||||
|
/>
|
||||||
|
<Text type="secondary">x</Text>
|
||||||
|
<InputNumber<string>
|
||||||
|
style={{ width: 70 }}
|
||||||
|
controls={false}
|
||||||
|
placeholder="Цена"
|
||||||
|
stringMode
|
||||||
|
decimalSeparator=","
|
||||||
|
value={localPrice || ""}
|
||||||
|
onChange={(val) => setLocalPrice(val)}
|
||||||
|
onBlur={() => handleBlur("price")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
{uiSum.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Layout, theme } from 'antd';
|
import { Layout, theme } from "antd";
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { ScanOutlined, FileTextOutlined, SettingOutlined } from '@ant-design/icons';
|
import {
|
||||||
|
ScanOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
@@ -14,27 +18,46 @@ export const AppLayout: React.FC = () => {
|
|||||||
} = theme.useToken();
|
} = theme.useToken();
|
||||||
|
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
let activeKey = 'invoices';
|
let activeKey = "invoices";
|
||||||
if (path.startsWith('/ocr')) activeKey = 'ocr';
|
if (path.startsWith("/ocr")) activeKey = "ocr";
|
||||||
else if (path.startsWith('/settings')) activeKey = 'settings';
|
else if (path.startsWith("/settings")) activeKey = "settings";
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: 'invoices', icon: <FileTextOutlined style={{ fontSize: 20 }} />, label: 'Накладные', path: '/invoices' },
|
{
|
||||||
{ key: 'ocr', icon: <ScanOutlined style={{ fontSize: 20 }} />, label: 'Обучение', path: '/ocr' },
|
key: "invoices",
|
||||||
{ key: 'settings', icon: <SettingOutlined style={{ fontSize: 20 }} />, label: 'Настройки', path: '#' },
|
icon: <FileTextOutlined style={{ fontSize: 20 }} />,
|
||||||
|
label: "Накладные",
|
||||||
|
path: "/invoices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ocr",
|
||||||
|
icon: <ScanOutlined style={{ fontSize: 20 }} />,
|
||||||
|
label: "Обучение",
|
||||||
|
path: "/ocr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "settings",
|
||||||
|
icon: <SettingOutlined style={{ fontSize: 20 }} />,
|
||||||
|
label: "Настройки",
|
||||||
|
path: "/settings",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<Layout
|
||||||
|
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
{/* Верхнюю шапку (Header) удалили для экономии места */}
|
{/* Верхнюю шапку (Header) удалили для экономии места */}
|
||||||
|
|
||||||
<Content style={{ padding: '0', flex: 1, overflowY: 'auto', marginBottom: 60 }}>
|
<Content
|
||||||
|
style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }}
|
||||||
|
>
|
||||||
{/* Убрали лишние паддинги вокруг контента для мобилок */}
|
{/* Убрали лишние паддинги вокруг контента для мобилок */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: colorBgContainer,
|
background: colorBgContainer,
|
||||||
minHeight: '100%',
|
minHeight: "100%",
|
||||||
padding: '12px 12px 80px 12px', // Добавили отступ снизу, чтобы контент не перекрывался меню
|
padding: "12px 12px 80px 12px", // Добавили отступ снизу, чтобы контент не перекрывался меню
|
||||||
borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны
|
borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -43,38 +66,46 @@ export const AppLayout: React.FC = () => {
|
|||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
{/* Нижний Таб-бар */}
|
{/* Нижний Таб-бар */}
|
||||||
<div style={{
|
<div
|
||||||
position: 'fixed',
|
style={{
|
||||||
bottom: 0,
|
position: "fixed",
|
||||||
width: '100%',
|
bottom: 0,
|
||||||
zIndex: 1000,
|
width: "100%",
|
||||||
background: '#fff',
|
zIndex: 1000,
|
||||||
borderTop: '1px solid #f0f0f0',
|
background: "#fff",
|
||||||
display: 'flex',
|
borderTop: "1px solid #f0f0f0",
|
||||||
justifyContent: 'space-around',
|
display: "flex",
|
||||||
alignItems: 'center',
|
justifyContent: "space-around",
|
||||||
padding: '8px 0',
|
alignItems: "center",
|
||||||
height: 60,
|
padding: "8px 0",
|
||||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.05)'
|
height: 60,
|
||||||
}}>
|
boxShadow: "0 -2px 8px rgba(0,0,0,0.05)",
|
||||||
{menuItems.map(item => {
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item) => {
|
||||||
const isActive = activeKey === item.key;
|
const isActive = activeKey === item.key;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(item.path)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
width: '33%',
|
width: "33%",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
color: isActive ? colorPrimary : colorTextSecondary
|
color: isActive ? colorPrimary : colorTextSecondary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
<span style={{ fontSize: 10, marginTop: 2, fontWeight: isActive ? 500 : 400 }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
marginTop: 2,
|
||||||
|
fontWeight: isActive ? 500 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Spin, Alert, Button, Form, Select, DatePicker, Input,
|
Spin,
|
||||||
Typography, message, Row, Col, Affix, Modal, Tag
|
Alert,
|
||||||
} from 'antd';
|
Button,
|
||||||
import { ArrowLeftOutlined, CheckOutlined, DeleteOutlined, ExclamationCircleFilled, RestOutlined } from '@ant-design/icons';
|
Form,
|
||||||
import dayjs from 'dayjs';
|
Select,
|
||||||
import { api } from '../services/api';
|
DatePicker,
|
||||||
import { DraftItemRow } from '../components/invoices/DraftItemRow';
|
Input,
|
||||||
import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types';
|
Typography,
|
||||||
|
message,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Affix,
|
||||||
|
Modal,
|
||||||
|
Tag,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ExclamationCircleFilled,
|
||||||
|
RestOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import { DraftItemRow } from "../components/invoices/DraftItemRow";
|
||||||
|
import type {
|
||||||
|
UpdateDraftItemRequest,
|
||||||
|
CommitDraftRequest,
|
||||||
|
} from "../services/types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -25,22 +47,24 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
|
|
||||||
// --- ЗАПРОСЫ ---
|
// --- ЗАПРОСЫ ---
|
||||||
|
|
||||||
// Получаем сразу все справочники одним запросом
|
|
||||||
const dictQuery = useQuery({
|
const dictQuery = useQuery({
|
||||||
queryKey: ['dictionaries'],
|
queryKey: ["dictionaries"],
|
||||||
queryFn: api.getDictionaries,
|
queryFn: api.getDictionaries,
|
||||||
staleTime: 1000 * 60 * 5 // Кэшируем на 5 минут
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
|
const recommendationsQuery = useQuery({
|
||||||
|
queryKey: ["recommendations"],
|
||||||
|
queryFn: api.getRecommendations,
|
||||||
|
});
|
||||||
|
|
||||||
const draftQuery = useQuery({
|
const draftQuery = useQuery({
|
||||||
queryKey: ['draft', id],
|
queryKey: ["draft", id],
|
||||||
queryFn: () => api.getDraft(id!),
|
queryFn: () => api.getDraft(id!),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const status = query.state.data?.status;
|
const status = query.state.data?.status;
|
||||||
return status === 'PROCESSING' ? 3000 : false;
|
return status === "PROCESSING" ? 3000 : false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,111 +72,154 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
const stores = dictQuery.data?.stores || [];
|
const stores = dictQuery.data?.stores || [];
|
||||||
const suppliers = dictQuery.data?.suppliers || [];
|
const suppliers = dictQuery.data?.suppliers || [];
|
||||||
|
|
||||||
|
// --- МУТАЦИИ ---
|
||||||
|
|
||||||
const updateItemMutation = useMutation({
|
const updateItemMutation = useMutation({
|
||||||
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
||||||
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
||||||
onMutate: async ({ itemId }) => {
|
onMutate: async ({ itemId }) => {
|
||||||
setUpdatingItems(prev => new Set(prev).add(itemId));
|
setUpdatingItems((prev) => new Set(prev).add(itemId));
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['draft', id] });
|
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
message.error('Не удалось сохранить строку');
|
message.error("Не удалось сохранить строку");
|
||||||
},
|
},
|
||||||
onSettled: (_data, _err, vars) => {
|
onSettled: (_data, _err, vars) => {
|
||||||
setUpdatingItems(prev => {
|
setUpdatingItems((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(vars.itemId);
|
next.delete(vars.itemId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ДОБАВЛЕНО: Добавление строки
|
||||||
|
const addItemMutation = useMutation({
|
||||||
|
mutationFn: () => api.addDraftItem(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("Строка добавлена");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||||
|
// Можно сделать скролл вниз, но пока оставим как есть
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("Ошибка создания строки");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ДОБАВЛЕНО: Удаление строки
|
||||||
|
const deleteItemMutation = useMutation({
|
||||||
|
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("Строка удалена");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("Ошибка удаления строки");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const commitMutation = useMutation({
|
const commitMutation = useMutation({
|
||||||
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
|
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
message.success(`Накладная ${data.document_number} создана!`);
|
message.success(`Накладная ${data.document_number} создана!`);
|
||||||
navigate('/invoices');
|
navigate("/invoices");
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
message.error('Ошибка при создании накладной');
|
message.error("Ошибка при создании накладной");
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteDraftMutation = useMutation({
|
const deleteDraftMutation = useMutation({
|
||||||
mutationFn: () => api.deleteDraft(id!),
|
mutationFn: () => api.deleteDraft(id!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (draft?.status === 'CANCELED') {
|
if (draft?.status === "CANCELED") {
|
||||||
message.info('Черновик удален окончательно');
|
message.info("Черновик удален окончательно");
|
||||||
navigate('/invoices');
|
navigate("/invoices");
|
||||||
} else {
|
} else {
|
||||||
message.warning('Черновик отменен');
|
message.warning("Черновик отменен");
|
||||||
queryClient.invalidateQueries({ queryKey: ['draft', id] });
|
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
message.error('Ошибка при удалении');
|
message.error("Ошибка при удалении");
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- ЭФФЕКТЫ ---
|
// --- ЭФФЕКТЫ ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft) {
|
if (draft) {
|
||||||
const currentValues = form.getFieldsValue();
|
const currentValues = form.getFieldsValue();
|
||||||
if (!currentValues.store_id && draft.store_id) form.setFieldValue('store_id', draft.store_id);
|
if (!currentValues.store_id && draft.store_id)
|
||||||
if (!currentValues.supplier_id && draft.supplier_id) form.setFieldValue('supplier_id', draft.supplier_id);
|
form.setFieldValue("store_id", draft.store_id);
|
||||||
if (!currentValues.comment && draft.comment) form.setFieldValue('comment', draft.comment);
|
if (!currentValues.supplier_id && draft.supplier_id)
|
||||||
if (!currentValues.date_incoming) form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
|
form.setFieldValue("supplier_id", draft.supplier_id);
|
||||||
|
if (!currentValues.comment && draft.comment)
|
||||||
|
form.setFieldValue("comment", draft.comment);
|
||||||
|
if (!currentValues.date_incoming)
|
||||||
|
form.setFieldValue(
|
||||||
|
"date_incoming",
|
||||||
|
draft.date_incoming ? dayjs(draft.date_incoming) : dayjs()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [draft, form]);
|
}, [draft, form]);
|
||||||
|
|
||||||
// --- ХЕЛПЕРЫ ---
|
// --- ХЕЛПЕРЫ ---
|
||||||
const totalSum = useMemo(() => {
|
const totalSum = useMemo(() => {
|
||||||
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
|
return (
|
||||||
|
draft?.items.reduce(
|
||||||
|
(acc, item) => acc + Number(item.quantity) * Number(item.price),
|
||||||
|
0
|
||||||
|
) || 0
|
||||||
|
);
|
||||||
}, [draft?.items]);
|
}, [draft?.items]);
|
||||||
|
|
||||||
const invalidItemsCount = useMemo(() => {
|
const invalidItemsCount = useMemo(() => {
|
||||||
return draft?.items.filter(i => !i.product_id).length || 0;
|
return draft?.items.filter((i) => !i.product_id).length || 0;
|
||||||
}, [draft?.items]);
|
}, [draft?.items]);
|
||||||
|
|
||||||
const handleItemUpdate = (itemId: string, changes: UpdateDraftItemRequest) => {
|
const handleItemUpdate = (
|
||||||
|
itemId: string,
|
||||||
|
changes: UpdateDraftItemRequest
|
||||||
|
) => {
|
||||||
updateItemMutation.mutate({ itemId, payload: changes });
|
updateItemMutation.mutate({ itemId, payload: changes });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
try {
|
try {
|
||||||
// Валидируем форму (включая нового поставщика)
|
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
if (invalidItemsCount > 0) {
|
if (invalidItemsCount > 0) {
|
||||||
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
|
message.warning(
|
||||||
|
`Осталось ${invalidItemsCount} нераспознанных товаров!`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
commitMutation.mutate({
|
commitMutation.mutate({
|
||||||
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
|
date_incoming: values.date_incoming.format("YYYY-MM-DD"),
|
||||||
store_id: values.store_id,
|
store_id: values.store_id,
|
||||||
supplier_id: values.supplier_id,
|
supplier_id: values.supplier_id,
|
||||||
comment: values.comment || '',
|
comment: values.comment || "",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Заполните обязательные поля (Склад, Поставщик)');
|
message.error("Заполните обязательные поля (Склад, Поставщик)");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCanceled = draft?.status === 'CANCELED';
|
const isCanceled = draft?.status === "CANCELED";
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
confirm({
|
confirm({
|
||||||
title: isCanceled ? 'Удалить окончательно?' : 'Отменить черновик?',
|
title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?",
|
||||||
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
|
icon: <ExclamationCircleFilled style={{ color: "red" }} />,
|
||||||
content: isCanceled
|
content: isCanceled
|
||||||
? 'Черновик пропадет из списка навсегда.'
|
? "Черновик пропадет из списка навсегда."
|
||||||
: 'Черновик получит статус "Отменен", но останется в списке.',
|
: 'Черновик получит статус "Отменен", но останется в списке.',
|
||||||
okText: isCanceled ? 'Удалить навсегда' : 'Отменить',
|
okText: isCanceled ? "Удалить навсегда" : "Отменить",
|
||||||
okType: 'danger',
|
okType: "danger",
|
||||||
cancelText: 'Назад',
|
cancelText: "Назад",
|
||||||
onOk() {
|
onOk() {
|
||||||
deleteDraftMutation.mutate();
|
deleteDraftMutation.mutate();
|
||||||
},
|
},
|
||||||
@@ -160,10 +227,17 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- RENDER ---
|
// --- RENDER ---
|
||||||
const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0));
|
const showSpinner =
|
||||||
|
draftQuery.isLoading ||
|
||||||
|
(draft?.status === "PROCESSING" &&
|
||||||
|
(!draft?.items || draft.items.length === 0));
|
||||||
|
|
||||||
if (showSpinner) {
|
if (showSpinner) {
|
||||||
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: 50 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftQuery.isError || !draft) {
|
if (draftQuery.isError || !draft) {
|
||||||
@@ -173,116 +247,233 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div style={{ paddingBottom: 60 }}>
|
<div style={{ paddingBottom: 60 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
<div
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
style={{
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
|
marginBottom: 12,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate("/invoices")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
<div
|
||||||
<span style={{ fontSize: 18, fontWeight: 'bold', whiteSpace: 'nowrap' }}>
|
style={{
|
||||||
{draft.document_number ? `№${draft.document_number}` : 'Черновик'}
|
display: "flex",
|
||||||
</span>
|
alignItems: "center",
|
||||||
{draft.status === 'PROCESSING' && <Spin size="small" />}
|
gap: 6,
|
||||||
{isCanceled && <Tag color="red" style={{ margin: 0 }}>ОТМЕНЕН</Tag>}
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 18, fontWeight: "bold", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{draft.document_number ? `№${draft.document_number}` : "Черновик"}
|
||||||
|
</span>
|
||||||
|
{draft.status === "PROCESSING" && <Spin size="small" />}
|
||||||
|
{isCanceled && (
|
||||||
|
<Tag color="red" style={{ margin: 0 }}>
|
||||||
|
ОТМЕНЕН
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
danger={isCanceled}
|
danger={isCanceled}
|
||||||
type={isCanceled ? 'primary' : 'default'}
|
type={isCanceled ? "primary" : "default"}
|
||||||
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
loading={deleteDraftMutation.isPending}
|
loading={deleteDraftMutation.isPending}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{isCanceled ? 'Удалить' : 'Отмена'}
|
{isCanceled ? "Удалить" : "Отмена"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form: Склады и Поставщики */}
|
{/* Form: Склады и Поставщики */}
|
||||||
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
|
<div
|
||||||
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
|
style={{
|
||||||
<Row gutter={10}>
|
background: "#fff",
|
||||||
<Col span={12}>
|
padding: 12,
|
||||||
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
|
borderRadius: 8,
|
||||||
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" size="middle" />
|
marginBottom: 12,
|
||||||
</Form.Item>
|
opacity: isCanceled ? 0.6 : 1,
|
||||||
</Col>
|
}}
|
||||||
<Col span={12}>
|
>
|
||||||
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]} style={{ marginBottom: 8 }}>
|
<Form
|
||||||
<Select
|
form={form}
|
||||||
placeholder="Куда?"
|
layout="vertical"
|
||||||
loading={dictQuery.isLoading}
|
initialValues={{ date_incoming: dayjs() }}
|
||||||
options={stores.map(s => ({ label: s.name, value: s.id }))}
|
>
|
||||||
size="middle"
|
<Row gutter={10}>
|
||||||
/>
|
<Col span={12}>
|
||||||
</Form.Item>
|
<Form.Item
|
||||||
</Col>
|
label="Дата"
|
||||||
</Row>
|
name="date_incoming"
|
||||||
{/* Поле Поставщика (Обязательное) */}
|
rules={[{ required: true }]}
|
||||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]} style={{ marginBottom: 8 }}>
|
style={{ marginBottom: 8 }}
|
||||||
<Select
|
>
|
||||||
placeholder="От кого?"
|
<DatePicker
|
||||||
loading={dictQuery.isLoading}
|
style={{ width: "100%" }}
|
||||||
options={suppliers.map(s => ({ label: s.name, value: s.id }))}
|
format="DD.MM.YYYY"
|
||||||
size="middle"
|
size="middle"
|
||||||
showSearch
|
/>
|
||||||
filterOption={(input, option) =>
|
</Form.Item>
|
||||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
</Col>
|
||||||
}
|
<Col span={12}>
|
||||||
/>
|
<Form.Item
|
||||||
</Form.Item>
|
label="Склад"
|
||||||
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
|
name="store_id"
|
||||||
<TextArea rows={1} placeholder="Комментарий..." style={{ fontSize: 13 }} />
|
rules={[{ required: true, message: "Выберите склад" }]}
|
||||||
</Form.Item>
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Куда?"
|
||||||
|
loading={dictQuery.isLoading}
|
||||||
|
options={stores.map((s) => ({ label: s.name, value: s.id }))}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item
|
||||||
|
label="Поставщик"
|
||||||
|
name="supplier_id"
|
||||||
|
rules={[{ required: true, message: "Выберите поставщика" }]}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="От кого?"
|
||||||
|
loading={dictQuery.isLoading}
|
||||||
|
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
||||||
|
size="middle"
|
||||||
|
showSearch
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Комментарий"
|
||||||
|
name="comment"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={1}
|
||||||
|
placeholder="Комментарий..."
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items Header */}
|
{/* Items Header */}
|
||||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px' }}>
|
<div
|
||||||
<Text strong>Позиции ({draft.items.length})</Text>
|
style={{
|
||||||
{invalidItemsCount > 0 && <Text type="danger" style={{ fontSize: 12 }}>{invalidItemsCount} нераспознано</Text>}
|
marginBottom: 8,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong>Позиции ({draft.items.length})</Text>
|
||||||
|
{invalidItemsCount > 0 && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12 }}>
|
||||||
|
{invalidItemsCount} нераспознано
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items List */}
|
{/* Items List */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{draft.items.map(item => (
|
{draft.items.map((item) => (
|
||||||
<DraftItemRow
|
<DraftItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
onUpdate={handleItemUpdate}
|
onUpdate={handleItemUpdate}
|
||||||
|
// Передаем обработчик удаления
|
||||||
|
onDelete={(itemId) => deleteItemMutation.mutate(itemId)}
|
||||||
isUpdating={updatingItems.has(item.id)}
|
isUpdating={updatingItems.has(item.id)}
|
||||||
recommendations={recommendationsQuery.data || []}
|
recommendations={recommendationsQuery.data || []}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка добавления позиции */}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
style={{ marginTop: 12, marginBottom: 80, height: 48 }} // Увеличенный margin bottom для Affix
|
||||||
|
onClick={() => addItemMutation.mutate()}
|
||||||
|
loading={addItemMutation.isPending}
|
||||||
|
disabled={isCanceled}
|
||||||
|
>
|
||||||
|
Добавить товар
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
<Affix offsetBottom={60}>
|
<Affix offsetBottom={60}>
|
||||||
<div style={{
|
<div
|
||||||
background: '#fff',
|
style={{
|
||||||
padding: '8px 16px',
|
background: "#fff",
|
||||||
borderTop: '1px solid #eee',
|
padding: "8px 16px",
|
||||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
|
borderTop: "1px solid #eee",
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
boxShadow: "0 -2px 10px rgba(0,0,0,0.05)",
|
||||||
borderRadius: '8px 8px 0 0'
|
display: "flex",
|
||||||
}}>
|
justifyContent: "space-between",
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
alignItems: "center",
|
||||||
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
|
borderRadius: "8px 8px 0 0",
|
||||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff', lineHeight: 1.2 }}>
|
}}
|
||||||
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 })}
|
>
|
||||||
</span>
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
</div>
|
<span style={{ fontSize: 11, color: "#888", lineHeight: 1 }}>
|
||||||
|
Итого:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#1890ff",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totalSum.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CheckOutlined />}
|
icon={<CheckOutlined />}
|
||||||
onClick={handleCommit}
|
onClick={handleCommit}
|
||||||
loading={commitMutation.isPending}
|
loading={commitMutation.isPending}
|
||||||
disabled={invalidItemsCount > 0 || isCanceled}
|
disabled={invalidItemsCount > 0 || isCanceled}
|
||||||
style={{ height: 40, padding: '0 24px' }}
|
style={{ height: 40, padding: "0 24px" }}
|
||||||
>
|
>
|
||||||
{isCanceled ? 'Восстановить' : 'Отправить'}
|
{isCanceled ? "Восстановить" : "Отправить"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Affix>
|
</Affix>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
235
rmser-view/src/pages/SettingsPage.tsx
Normal file
235
rmser-view/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
TreeSelect,
|
||||||
|
Spin,
|
||||||
|
message,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
SaveOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import type { UserSettings } from "../services/types";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export const SettingsPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// --- Запросы ---
|
||||||
|
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: api.getSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statsQuery = useQuery({
|
||||||
|
queryKey: ["stats"],
|
||||||
|
queryFn: api.getStats,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dictQuery = useQuery({
|
||||||
|
queryKey: ["dictionaries"],
|
||||||
|
queryFn: api.getDictionaries,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ["productGroups"],
|
||||||
|
queryFn: api.getProductGroups,
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Мутации ---
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (vals: UserSettings) => api.updateSettings(vals),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("Настройки сохранены");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("Не удалось сохранить настройки");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Эффекты ---
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsQuery.data) {
|
||||||
|
form.setFieldsValue(settingsQuery.data);
|
||||||
|
}
|
||||||
|
}, [settingsQuery.data, form]);
|
||||||
|
|
||||||
|
// --- Хендлеры ---
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
saveMutation.mutate(values);
|
||||||
|
} catch {
|
||||||
|
// Ошибки валидации
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Рендер ---
|
||||||
|
|
||||||
|
if (settingsQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: 50 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "0 16px 80px" }}>
|
||||||
|
<Title level={4} style={{ marginTop: 16 }}>
|
||||||
|
<SettingOutlined /> Настройки
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
background: "#f0f5ff",
|
||||||
|
borderColor: "#d6e4ff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BarChartOutlined style={{ color: "#1890ff" }} />
|
||||||
|
<Text strong>Статистика накладных</Text>
|
||||||
|
</div>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic
|
||||||
|
title="За 24ч"
|
||||||
|
value={statsQuery.data?.last_24h || 0}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic
|
||||||
|
title="Месяц"
|
||||||
|
value={statsQuery.data?.last_month || 0}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic
|
||||||
|
title="Всего"
|
||||||
|
value={statsQuery.data?.total || 0}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Форма настроек */}
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title="Основные параметры"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Склад по умолчанию"
|
||||||
|
name="default_store_id"
|
||||||
|
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Не выбрано"
|
||||||
|
allowClear
|
||||||
|
loading={dictQuery.isLoading}
|
||||||
|
options={dictQuery.data?.stores.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
value: s.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Корневая группа товаров"
|
||||||
|
name="root_group_id"
|
||||||
|
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
|
||||||
|
>
|
||||||
|
<TreeSelect
|
||||||
|
showSearch
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
||||||
|
placeholder="Выберите папку"
|
||||||
|
allowClear
|
||||||
|
treeDefaultExpandAll={false}
|
||||||
|
treeData={groupsQuery.data}
|
||||||
|
// ИСПРАВЛЕНО: Маппинг полей под структуру JSON (title, value)
|
||||||
|
fieldNames={{
|
||||||
|
label: "title",
|
||||||
|
value: "value",
|
||||||
|
children: "children",
|
||||||
|
}}
|
||||||
|
treeNodeFilterProp="title"
|
||||||
|
suffixIcon={<FolderOpenOutlined />}
|
||||||
|
loading={groupsQuery.isLoading}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="auto_conduct"
|
||||||
|
valuePropName="checked"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text>Проводить накладные автоматически</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Если выключено, накладные в iiko будут создаваться как
|
||||||
|
"Непроведенные"
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch />
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Сохранить настройки
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,9 +8,13 @@ import type {
|
|||||||
ProductMatch,
|
ProductMatch,
|
||||||
Recommendation,
|
Recommendation,
|
||||||
UnmatchedItem,
|
UnmatchedItem,
|
||||||
|
UserSettings,
|
||||||
|
InvoiceStats,
|
||||||
|
ProductGroup,
|
||||||
Store,
|
Store,
|
||||||
Supplier,
|
Supplier,
|
||||||
DraftInvoice,
|
DraftInvoice,
|
||||||
|
DraftItem,
|
||||||
UpdateDraftItemRequest,
|
UpdateDraftItemRequest,
|
||||||
CommitDraftRequest,
|
CommitDraftRequest,
|
||||||
ProductSearchResult,
|
ProductSearchResult,
|
||||||
@@ -151,6 +155,15 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addDraftItem: async (draftId: string): Promise<DraftItem> => {
|
||||||
|
const { data } = await apiClient.post<DraftItem>(`/drafts/${draftId}/items`, {});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDraftItem: async (draftId: string, itemId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/drafts/${draftId}/items/${itemId}`);
|
||||||
|
},
|
||||||
|
|
||||||
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
|
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
|
||||||
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
|
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
|
||||||
return data;
|
return data;
|
||||||
@@ -159,4 +172,26 @@ export const api = {
|
|||||||
deleteDraft: async (id: string): Promise<void> => {
|
deleteDraft: async (id: string): Promise<void> => {
|
||||||
await apiClient.delete(`/drafts/${id}`);
|
await apiClient.delete(`/drafts/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Настройки и Статистика ---
|
||||||
|
|
||||||
|
getSettings: async (): Promise<UserSettings> => {
|
||||||
|
const { data } = await apiClient.get<UserSettings>('/settings');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSettings: async (payload: UserSettings): Promise<UserSettings> => {
|
||||||
|
const { data } = await apiClient.post<UserSettings>('/settings', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats: async (): Promise<InvoiceStats> => {
|
||||||
|
const { data } = await apiClient.get<InvoiceStats>('/stats/invoices');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProductGroups: async (): Promise<ProductGroup[]> => {
|
||||||
|
const { data } = await apiClient.get<ProductGroup[]>('/dictionaries/groups');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -124,6 +124,29 @@ export interface Supplier {
|
|||||||
export interface DictionariesResponse {
|
export interface DictionariesResponse {
|
||||||
stores: Store[];
|
stores: Store[];
|
||||||
suppliers: Supplier[];
|
suppliers: Supplier[];
|
||||||
|
// product_groups?: ProductGroup[]; // пока не реализовано
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Настройки и Статистика ---
|
||||||
|
|
||||||
|
export interface UserSettings {
|
||||||
|
root_group_id: UUID | null;
|
||||||
|
default_store_id: UUID | null;
|
||||||
|
auto_conduct: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceStats {
|
||||||
|
last_month: number;
|
||||||
|
last_24h: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерфейс группы товаров (рекурсивный)
|
||||||
|
export interface ProductGroup {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
title: string;
|
||||||
|
children?: ProductGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Черновик Накладной (Draft) ---
|
// --- Черновик Накладной (Draft) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user