пофиксил неправильный пересчет фасовок в накладной

This commit is contained in:
2025-12-27 09:24:21 +03:00
parent dfd855cb6e
commit c2d382cb6a
12 changed files with 461 additions and 144 deletions

View File

@@ -92,7 +92,7 @@ func main() {
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo) syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
recService := recServicePkg.NewService(recRepo) recService := recServicePkg.NewService(recRepo)
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath) ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath)
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory, billingService) draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, invoicesRepo, rmsFactory, billingService)
// 7. Handlers // 7. Handlers
draftsHandler := handlers.NewDraftsHandler(draftsService) draftsHandler := handlers.NewDraftsHandler(draftsService)
@@ -173,9 +173,10 @@ func main() {
// Manual Sync Trigger // Manual Sync Trigger
api.POST("/sync/all", func(c *gin.Context) { api.POST("/sync/all", func(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID) userID := c.MustGet("userID").(uuid.UUID)
force := c.Query("force") == "true"
// Запускаем в горутине, чтобы не держать соединение // Запускаем в горутине, чтобы не держать соединение
go func() { go func() {
if err := syncService.SyncAllData(userID); err != nil { if err := syncService.SyncAllData(userID, force); err != nil {
logger.Log.Error("Manual sync failed", zap.Error(err)) logger.Log.Error("Manual sync failed", zap.Error(err))
} }
}() }()

View File

@@ -11,14 +11,15 @@ import (
// Invoice - Приходная накладная // Invoice - Приходная накладная
type Invoice struct { type Invoice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;"` ID uuid.UUID `gorm:"type:uuid;primary_key;"`
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"` RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"`
DocumentNumber string `gorm:"type:varchar(100);index"` DocumentNumber string `gorm:"type:varchar(100);index"`
DateIncoming time.Time `gorm:"index"` IncomingDocumentNumber string `gorm:"type:varchar(100)"`
SupplierID uuid.UUID `gorm:"type:uuid;index"` DateIncoming time.Time `gorm:"index"`
DefaultStoreID uuid.UUID `gorm:"type:uuid;index"` SupplierID uuid.UUID `gorm:"type:uuid;index"`
Status string `gorm:"type:varchar(50)"` DefaultStoreID uuid.UUID `gorm:"type:uuid;index"`
Comment string `gorm:"type:text"` 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"`
@@ -42,6 +43,7 @@ type InvoiceItem struct {
type Repository interface { type Repository interface {
GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error)
GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error)
SaveInvoices(invoices []Invoice) error SaveInvoices(invoices []Invoice) error
CountRecent(serverID uuid.UUID, days int) (int64, error) CountRecent(serverID uuid.UUID, days int) (int64, error)
} }

View File

@@ -31,6 +31,16 @@ func (r *pgRepository) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error
return &inv.DateIncoming, nil return &inv.DateIncoming, nil
} }
func (r *pgRepository) GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]invoices.Invoice, error) {
var list []invoices.Invoice
err := r.db.
Preload("Items").
Where("rms_server_id = ? AND date_incoming BETWEEN ? AND ?", serverID, from, to).
Order("date_incoming DESC").
Find(&list).Error
return list, err
}
func (r *pgRepository) SaveInvoices(list []invoices.Invoice) error { func (r *pgRepository) SaveInvoices(list []invoices.Invoice) error {
return r.db.Transaction(func(tx *gorm.DB) error { return r.db.Transaction(func(tx *gorm.DB) error {
for _, inv := range list { for _, inv := range list {

View File

@@ -518,13 +518,14 @@ func (c *Client) FetchInvoices(from, to time.Time) ([]invoices.Invoice, error) {
} }
allinvoices = append(allinvoices, invoices.Invoice{ allinvoices = append(allinvoices, invoices.Invoice{
ID: docID, ID: docID,
DocumentNumber: doc.DocumentNumber, DocumentNumber: doc.DocumentNumber,
DateIncoming: dateInc, IncomingDocumentNumber: doc.IncomingDocumentNumber,
SupplierID: supID, DateIncoming: dateInc,
DefaultStoreID: storeID, SupplierID: supID,
Status: doc.Status, DefaultStoreID: storeID,
Items: items, Status: doc.Status,
Items: items,
}) })
} }

View File

@@ -142,13 +142,14 @@ type IncomingInvoiceListXML struct {
} }
type IncomingInvoiceXML struct { type IncomingInvoiceXML struct {
ID string `xml:"id"` ID string `xml:"id"`
DocumentNumber string `xml:"documentNumber"` DocumentNumber string `xml:"documentNumber"`
DateIncoming string `xml:"dateIncoming"` // Format: yyyy-MM-ddTHH:mm:ss IncomingDocumentNumber string `xml:"incomingDocumentNumber"`
Status string `xml:"status"` // PROCESSED, NEW, DELETED DateIncoming string `xml:"dateIncoming"` // Format: yyyy-MM-ddTHH:mm:ss
Supplier string `xml:"supplier"` // GUID Status string `xml:"status"` // PROCESSED, NEW, DELETED
DefaultStore string `xml:"defaultStore"` // GUID Supplier string `xml:"supplier"` // GUID
Items []InvoiceItemXML `xml:"items>item"` DefaultStore string `xml:"defaultStore"` // GUID
Items []InvoiceItemXML `xml:"items>item"`
} }
type InvoiceItemXML struct { type InvoiceItemXML struct {

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -27,6 +28,7 @@ type Service struct {
catalogRepo catalog.Repository catalogRepo catalog.Repository
accountRepo account.Repository accountRepo account.Repository
supplierRepo suppliers.Repository supplierRepo suppliers.Repository
invoiceRepo invoices.Repository
rmsFactory *rms.Factory rmsFactory *rms.Factory
billingService *billing.Service billingService *billing.Service
} }
@@ -37,6 +39,7 @@ func NewService(
catalogRepo catalog.Repository, catalogRepo catalog.Repository,
accountRepo account.Repository, accountRepo account.Repository,
supplierRepo suppliers.Repository, supplierRepo suppliers.Repository,
invoiceRepo invoices.Repository,
rmsFactory *rms.Factory, rmsFactory *rms.Factory,
billingService *billing.Service, billingService *billing.Service,
) *Service { ) *Service {
@@ -46,6 +49,7 @@ func NewService(
catalogRepo: catalogRepo, catalogRepo: catalogRepo,
accountRepo: accountRepo, accountRepo: accountRepo,
supplierRepo: supplierRepo, supplierRepo: supplierRepo,
invoiceRepo: invoiceRepo,
rmsFactory: rmsFactory, rmsFactory: rmsFactory,
billingService: billingService, billingService: billingService,
} }
@@ -220,6 +224,7 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price) return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
} }
// CommitDraft отправляет накладную
// CommitDraft отправляет накладную // CommitDraft отправляет накладную
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
// 1. Получаем сервер и права // 1. Получаем сервер и права
@@ -285,11 +290,39 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
sum = dItem.Quantity.Mul(dItem.Price) sum = dItem.Quantity.Mul(dItem.Price)
} }
// Инициализируем значениями из черновика (по умолчанию для базовых единиц)
amountToSend := dItem.Quantity
priceToSend := dItem.Price
// ЛОГИКА ПЕРЕСЧЕТА ДЛЯ ФАСОВОК (СОГЛАСНО ДОКУМЕНТАЦИИ IIKO)
// Если указан ContainerID, iiko требует:
// <amount> = кол-во упаковок * вес упаковки (итоговое кол-во в базовых единицах)
// <price> = цена за упаковку / вес упаковки (цена за базовую единицу)
// <containerId> = ID фасовки
if dItem.ContainerID != nil && *dItem.ContainerID != uuid.Nil {
// Проверяем, что Container загружен (Preload в репозитории)
if dItem.Container != nil {
if !dItem.Container.Count.IsZero() {
// 1. Пересчитываем кол-во: 5 ящиков * 10 кг = 50 кг
amountToSend = dItem.Quantity.Mul(dItem.Container.Count)
// 2. Пересчитываем цену: 1000 руб/ящ / 10 кг = 100 руб/кг
priceToSend = dItem.Price.Div(dItem.Container.Count)
}
} else {
// Если фасовка есть в ID, но не подгрузилась структура - это ошибка данных.
// Логируем варнинг, но пробуем отправить как есть (iiko может отвергнуть или посчитать криво)
logger.Log.Warn("Container struct is nil for item with ContainerID",
zap.String("item_id", dItem.ID.String()),
zap.String("container_id", dItem.ContainerID.String()))
}
}
invItem := invoices.InvoiceItem{ invItem := invoices.InvoiceItem{
ProductID: *dItem.ProductID, ProductID: *dItem.ProductID,
Amount: dItem.Quantity, Amount: amountToSend, // Отправляем ПЕРЕСЧИТАННЫЙ вес/объем
Price: dItem.Price, Price: priceToSend, // Отправляем ПЕРЕСЧИТАННУЮ цену за базовую ед.
Sum: sum, Sum: sum, // Сумма остается неизменной (Total)
ContainerID: dItem.ContainerID, ContainerID: dItem.ContainerID,
} }
inv.Items = append(inv.Items, invItem) inv.Items = append(inv.Items, invItem)
@@ -424,3 +457,99 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID,
return createdID, nil return createdID, nil
} }
// Добавим новый DTO для единого списка (Frontend Contract)
type UnifiedInvoiceDTO struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"` // "DRAFT" или "SYNCED"
DocumentNumber string `json:"document_number"`
IncomingNumber string `json:"incoming_number"`
DateIncoming time.Time `json:"date_incoming"`
Status string `json:"status"`
TotalSum float64 `json:"total_sum"`
StoreName string `json:"store_name"`
ItemsCount int `json:"items_count"`
CreatedAt time.Time `json:"created_at"`
IsAppCreated bool `json:"is_app_created"`
}
func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) {
server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
return nil, errors.New("активный сервер не выбран")
}
// 1. Получаем черновики (их обычно немного, берем все активные)
draftsList, err := s.draftRepo.GetActive(server.ID)
if err != nil {
return nil, err
}
// 2. Получаем синхронизированные накладные за период
invoicesList, err := s.invoiceRepo.GetByPeriod(server.ID, from, to)
if err != nil {
return nil, err
}
result := make([]UnifiedInvoiceDTO, 0, len(draftsList)+len(invoicesList))
// Маппим черновики
for _, d := range draftsList {
var sum decimal.Decimal
for _, it := range d.Items {
if !it.Sum.IsZero() {
sum = sum.Add(it.Sum)
} else {
sum = sum.Add(it.Quantity.Mul(it.Price))
}
}
val, _ := sum.Float64()
date := time.Now()
if d.DateIncoming != nil {
date = *d.DateIncoming
}
result = append(result, UnifiedInvoiceDTO{
ID: d.ID,
Type: "DRAFT",
DocumentNumber: d.DocumentNumber,
IncomingNumber: "", // В черновиках пока не разделяем
DateIncoming: date,
Status: d.Status,
TotalSum: val,
StoreName: "", // Можно подгрузить из d.Store.Name если сделан Preload
ItemsCount: len(d.Items),
CreatedAt: d.CreatedAt,
IsAppCreated: true,
})
}
// Маппим проведенные
for _, inv := range invoicesList {
var sum decimal.Decimal
for _, it := range inv.Items {
sum = sum.Add(it.Sum)
}
val, _ := sum.Float64()
isOurs := strings.Contains(strings.ToUpper(inv.Comment), "RMSER")
result = append(result, UnifiedInvoiceDTO{
ID: inv.ID,
Type: "SYNCED",
DocumentNumber: inv.DocumentNumber,
IncomingNumber: inv.IncomingDocumentNumber,
DateIncoming: inv.DateIncoming,
Status: inv.Status,
TotalSum: val,
ItemsCount: len(inv.Items),
CreatedAt: inv.CreatedAt,
IsAppCreated: isOurs,
})
}
// Сортировка по дате накладной (desc)
// (Здесь можно добавить библиотеку sort или оставить как есть, если БД уже отсортировала части)
return result, nil
}

View File

@@ -54,8 +54,8 @@ func NewService(
} }
// SyncAllData запускает полную синхронизацию для конкретного пользователя // SyncAllData запускает полную синхронизацию для конкретного пользователя
func (s *Service) SyncAllData(userID uuid.UUID) error { func (s *Service) SyncAllData(userID uuid.UUID, force bool) error {
logger.Log.Info("Запуск полной синхронизации", zap.String("user_id", userID.String())) logger.Log.Info("Запуск синхронизации", zap.String("user_id", userID.String()), zap.Bool("force", force))
// 1. Получаем клиент и инфо о сервере // 1. Получаем клиент и инфо о сервере
client, err := s.rmsFactory.GetClientForUser(userID) client, err := s.rmsFactory.GetClientForUser(userID)
@@ -92,7 +92,7 @@ func (s *Service) SyncAllData(userID uuid.UUID) error {
} }
// 6. Накладные (история) // 6. Накладные (история)
if err := s.syncInvoices(client, serverID); err != nil { if err := s.syncInvoices(client, serverID, force); err != nil {
logger.Log.Error("Sync Invoices failed", zap.Error(err)) logger.Log.Error("Sync Invoices failed", zap.Error(err))
} }
@@ -172,19 +172,24 @@ func (s *Service) syncRecipes(c rms.ClientI, serverID uuid.UUID) error {
return s.recipeRepo.SaveRecipes(recipesList) return s.recipeRepo.SaveRecipes(recipesList)
} }
func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID) error { func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID, force bool) error {
lastDate, err := s.invoiceRepo.GetLastInvoiceDate(serverID)
if err != nil {
return err
}
var from time.Time var from time.Time
to := time.Now() to := time.Now()
if lastDate != nil { if force {
from = *lastDate // Принудительная перезагрузка за последние 40 дней
from = time.Now().AddDate(0, 0, -40)
logger.Log.Info("Force sync invoices", zap.String("from", from.Format("2006-01-02")))
} else { } else {
from = time.Now().AddDate(0, 0, -45) // 45 дней по дефолту lastDate, err := s.invoiceRepo.GetLastInvoiceDate(serverID)
if err != nil {
return err
}
if lastDate != nil {
from = *lastDate
} else {
from = time.Now().AddDate(0, 0, -45)
}
} }
invs, err := c.FetchInvoices(from, to) invs, err := c.FetchInvoices(from, to)
@@ -194,10 +199,10 @@ func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID) error {
for i := range invs { for i := range invs {
invs[i].RMSServerID = serverID invs[i].RMSServerID = serverID
// В Items пока не добавляли ServerID
} }
if len(invs) > 0 { if len(invs) > 0 {
// Репозиторий использует OnConflict(UpdateAll), поэтому существующие записи обновятся
return s.invoiceRepo.SaveInvoices(invs) return s.invoiceRepo.SaveInvoices(invs)
} }
return nil return nil

View File

@@ -253,49 +253,28 @@ type DraftListItemDTO struct {
TotalSum float64 `json:"total_sum"` TotalSum float64 `json:"total_sum"`
StoreName string `json:"store_name"` StoreName string `json:"store_name"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
IsAppCreated bool `json:"is_app_created"`
} }
func (h *DraftsHandler) GetDrafts(c *gin.Context) { func (h *DraftsHandler) GetDrafts(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID) userID := c.MustGet("userID").(uuid.UUID)
list, err := h.service.GetActiveDrafts(userID)
// Читаем параметры периода из Query (default: 30 days)
fromStr := c.DefaultQuery("from", time.Now().AddDate(0, 0, -30).Format("2006-01-02"))
toStr := c.DefaultQuery("to", time.Now().Format("2006-01-02"))
from, _ := time.Parse("2006-01-02", fromStr)
to, _ := time.Parse("2006-01-02", toStr)
// Устанавливаем конец дня для 'to'
to = to.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
list, err := h.service.GetUnifiedList(userID, from, to)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
response := make([]DraftListItemDTO, 0, len(list)) c.JSON(http.StatusOK, list)
for _, d := range list {
var totalSum decimal.Decimal
for _, item := range d.Items {
if !item.Sum.IsZero() {
totalSum = totalSum.Add(item.Sum)
} else {
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
}
}
sumFloat, _ := totalSum.Float64()
dateStr := ""
if d.DateIncoming != nil {
dateStr = d.DateIncoming.Format("2006-01-02")
}
storeName := ""
if d.Store != nil {
storeName = d.Store.Name
}
response = append(response, DraftListItemDTO{
ID: d.ID.String(),
DocumentNumber: d.DocumentNumber,
DateIncoming: dateStr,
Status: d.Status,
ItemsCount: len(d.Items),
TotalSum: sumFloat,
StoreName: storeName,
CreatedAt: d.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, response)
} }
func (h *DraftsHandler) DeleteDraft(c *gin.Context) { func (h *DraftsHandler) DeleteDraft(c *gin.Context) {

View File

@@ -97,10 +97,12 @@ func (bot *Bot) initMenus() {
bot.menuServers = &tele.ReplyMarkup{} bot.menuServers = &tele.ReplyMarkup{}
bot.menuDicts = &tele.ReplyMarkup{} bot.menuDicts = &tele.ReplyMarkup{}
btnSync := bot.menuDicts.Data("⚡️ Обновить данные", "act_sync") btnSync := bot.menuDicts.Data("⚡️ Быстрое обновление", "act_sync")
btnFullSync := bot.menuDicts.Data("♻️ Полная перезагрузка", "act_full_sync")
btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main") btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main")
bot.menuDicts.Inline( bot.menuDicts.Inline(
bot.menuDicts.Row(btnSync), bot.menuDicts.Row(btnSync),
bot.menuDicts.Row(btnFullSync),
bot.menuDicts.Row(btnBack), bot.menuDicts.Row(btnBack),
) )
@@ -122,6 +124,7 @@ func (bot *Bot) initHandlers() {
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_full_sync"}, bot.triggerFullSync)
bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu) 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_yes"}, bot.handleConfirmNameYes)
bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo) bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo)
@@ -571,6 +574,26 @@ func (bot *Bot) NotifySuccess(userID uuid.UUID, amount float64, newBalance int,
bot.b.Send(&tele.User{ID: user.TelegramID}, msg, tele.ModeHTML) bot.b.Send(&tele.User{ID: user.TelegramID}, msg, tele.ModeHTML)
} }
func (bot *Bot) triggerFullSync(c tele.Context) error {
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
server, _ := bot.accountRepo.GetActiveServer(userDB.ID)
if server == nil {
return c.Respond(&tele.CallbackResponse{Text: "Нет активного сервера"})
}
c.Respond(&tele.CallbackResponse{Text: "Запущена полная перезагрузка данных...", ShowAlert: false})
c.Send("⏳ <b>Полная синхронизация</b>\\nОбновляю историю накладных за 60 дней и справочники. Это может занять до 1 минуты.", tele.ModeHTML)
go func() {
if err := bot.syncService.SyncAllData(userDB.ID, true); err != nil {
bot.b.Send(c.Sender(), "❌ Ошибка при полной синхронизации: "+err.Error())
} else {
bot.b.Send(c.Sender(), "✅ Все данные успешно обновлены!")
}
}()
return nil
}
func (bot *Bot) handleBillingCallbacks(c tele.Context, data string, userDB *account.User) error { func (bot *Bot) handleBillingCallbacks(c tele.Context, data string, userDB *account.User) error {
if data == "bill_topup" { if data == "bill_topup" {
return bot.renderTariffShowcase(c, "") return bot.renderTariffShowcase(c, "")
@@ -670,7 +693,7 @@ func (bot *Bot) triggerSync(c tele.Context) error {
} }
c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."}) c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."})
go func() { go func() {
if err := bot.syncService.SyncAllData(userDB.ID); err != nil { if err := bot.syncService.SyncAllData(userDB.ID, false); err != nil {
logger.Log.Error("Manual sync failed", zap.Error(err)) logger.Log.Error("Manual sync failed", zap.Error(err))
bot.b.Send(c.Sender(), "❌ Ошибка синхронизации. Проверьте настройки сервера.") bot.b.Send(c.Sender(), "❌ Ошибка синхронизации. Проверьте настройки сервера.")
} else { } else {
@@ -867,7 +890,7 @@ func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string)
successMsg += "Начинаю первичную синхронизацию данных..." successMsg += "Начинаю первичную синхронизацию данных..."
c.Send(successMsg, tele.ModeHTML) c.Send(successMsg, tele.ModeHTML)
go bot.syncService.SyncAllData(userDB.ID) go bot.syncService.SyncAllData(userDB.ID, false)
return bot.renderMainMenu(c) return bot.renderMainMenu(c)
} }

View File

@@ -1,86 +1,233 @@
import React from 'react'; // src/pages/DraftsList.tsx
import { useQuery } from '@tanstack/react-query';
import { List, Typography, Tag, Spin, Empty } from 'antd'; import React, { useState } from "react";
import { useNavigate } from 'react-router-dom'; import { useQuery } from "@tanstack/react-query";
import { ArrowRightOutlined } from '@ant-design/icons'; import {
import { api } from '../services/api'; List,
Typography,
Tag,
Spin,
Empty,
DatePicker,
Flex,
message,
} from "antd";
import { useNavigate } from "react-router-dom";
import {
ArrowRightOutlined,
ThunderboltFilled,
HistoryOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import dayjs, { Dayjs } from "dayjs";
import { api } from "../services/api";
import type { UnifiedInvoice } from "../services/types";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { RangePicker } = DatePicker;
export const DraftsList: React.FC = () => { export const DraftsList: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: drafts, isLoading, isError } = useQuery({ // Состояние фильтра дат: по умолчанию последние 30 дней
queryKey: ['drafts'], const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
queryFn: api.getDrafts, dayjs().subtract(30, "day"),
refetchOnWindowFocus: true dayjs(),
]);
// Запрос данных с учетом дат (даты в ключе обеспечивают авто-перезапрос)
const {
data: invoices,
isLoading,
isError,
} = useQuery({
queryKey: [
"drafts",
dateRange[0].format("YYYY-MM-DD"),
dateRange[1].format("YYYY-MM-DD"),
],
queryFn: () =>
api.getDrafts(
dateRange[0].format("YYYY-MM-DD"),
dateRange[1].format("YYYY-MM-DD")
),
}); });
const getStatusTag = (status: string) => { const getStatusTag = (item: UnifiedInvoice) => {
switch (status) { if (item.type === "SYNCED") {
case 'PROCESSING': return <Tag color="blue">Обработка</Tag>; return (
case 'READY_TO_VERIFY': return <Tag color="orange">Проверка</Tag>; <Tag icon={<HistoryOutlined />} color="success">
case 'COMPLETED': return <Tag color="green">Готово</Tag>; Синхронизировано
case 'ERROR': return <Tag color="red">Ошибка</Tag>; </Tag>
case 'CANCELED': return <Tag color="default" style={{ color: '#999' }}>Отменен</Tag>; );
default: return <Tag>{status}</Tag>; }
switch (item.status) {
case "PROCESSING":
return <Tag color="blue">Обработка</Tag>;
case "READY_TO_VERIFY":
return <Tag color="orange">Проверка</Tag>;
case "COMPLETED":
return <Tag color="green">Готово</Tag>;
case "ERROR":
return <Tag color="red">Ошибка</Tag>;
case "CANCELED":
return <Tag color="default">Отменен</Tag>;
default:
return <Tag>{item.status}</Tag>;
} }
}; };
if (isLoading) { const handleInvoiceClick = (item: UnifiedInvoice) => {
return <div style={{ textAlign: 'center', padding: 40 }}><Spin size="large" /></div>; if (item.type === "SYNCED") {
} message.info("История доступна только для просмотра");
return;
}
navigate(`/invoice/${item.id}`);
};
if (isError) { if (isError) {
return <div style={{ padding: 20, textAlign: 'center' }}>Ошибка загрузки списка</div>; return (
<div style={{ padding: 20 }}>
<Text type="danger">Ошибка загрузки списка накладных</Text>
</div>
);
} }
return ( return (
<div style={{ padding: '0 16px 20px' }}> <div style={{ padding: "0 4px 20px" }}>
<Title level={4} style={{ marginTop: 16, marginBottom: 16 }}>Черновики накладных</Title> <Title level={4} style={{ marginTop: 16, marginBottom: 16 }}>
Накладные
{(!drafts || drafts.length === 0) ? ( </Title>
<Empty description="Нет активных черновиков" />
{/* Фильтр дат */}
<div
style={{
marginBottom: 16,
background: "#fff",
padding: 12,
borderRadius: 8,
}}
>
<Text
type="secondary"
style={{ display: "block", marginBottom: 8, fontSize: 12 }}
>
Период загрузки:
</Text>
<RangePicker
value={dateRange}
onChange={(dates) => dates && setDateRange([dates[0]!, dates[1]!])}
style={{ width: "100%" }}
allowClear={false}
format="DD.MM.YYYY"
/>
</div>
{isLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin size="large" />
</div>
) : !invoices || invoices.length === 0 ? (
<Empty description="Нет данных за выбранный период" />
) : ( ) : (
<List <List
itemLayout="horizontal" dataSource={invoices}
dataSource={drafts} renderItem={(item) => {
renderItem={(item) => ( const isSynced = item.type === "SYNCED";
<List.Item
style={{ return (
background: '#fff', <List.Item
padding: 12, style={{
marginBottom: 8, background: isSynced ? "#fafafa" : "#fff",
borderRadius: 8, padding: 12,
cursor: 'pointer', marginBottom: 10,
opacity: item.status === 'CANCELED' ? 0.6 : 1 // Делаем отмененные бледными borderRadius: 12,
}} cursor: isSynced ? "default" : "pointer",
onClick={() => navigate(`/invoice/${item.id}`)} border: isSynced ? "1px solid #f0f0f0" : "1px solid #e6f7ff",
> boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
<div style={{ width: '100%' }}> display: "block",
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}> }}
<Text strong style={{ fontSize: 16, textDecoration: item.status === 'CANCELED' ? 'line-through' : 'none' }}> onClick={() => handleInvoiceClick(item)}
{item.document_number || 'Без номера'} >
<Flex vertical gap={4}>
<Flex justify="space-between" align="start">
<Flex vertical>
<Flex align="center" gap={8}>
<Text strong style={{ fontSize: 16 }}>
{item.document_number || "Без номера"}
</Text>
{item.is_app_created && (
<ThunderboltFilled
style={{ color: "#faad14" }}
title="Создано в RMSer"
/>
)}
</Flex>
{item.incoming_number && (
<Text type="secondary" style={{ fontSize: 12 }}>
Вх. {item.incoming_number}
</Text>
)}
</Flex>
{getStatusTag(item)}
</Flex>
<Flex justify="space-between" style={{ marginTop: 4 }}>
<Flex gap={8} align="center">
<FileTextOutlined style={{ color: "#888" }} />
<Text type="secondary" style={{ fontSize: 13 }}>
{item.items_count} поз.
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{dayjs(item.date_incoming).format("DD.MM.YYYY")}
</Text>
</Flex>
{item.store_name && (
<Tag
style={{
margin: 0,
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.store_name}
</Tag>
)}
</Flex>
<Flex
justify="space-between"
align="center"
style={{ marginTop: 8 }}
>
<Text
strong
style={{
fontSize: 17,
color: isSynced ? "#595959" : "#1890ff",
}}
>
{item.total_sum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
})}
</Text> </Text>
{getStatusTag(item.status)} {!isSynced && (
</div> <ArrowRightOutlined style={{ color: "#1890ff" }} />
)}
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#888', fontSize: 13 }}> </Flex>
<span>{new Date(item.date_incoming).toLocaleDateString()}</span> </Flex>
<span>{item.items_count} поз.</span> </List.Item>
</div> );
}}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6, alignItems: 'center' }}>
<Text strong>
{item.total_sum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</Text>
<ArrowRightOutlined style={{ color: '#1890ff' }} />
</div>
</div>
</List.Item>
)}
/> />
)} )}
</div> </div>
); );
}; };

View File

@@ -22,7 +22,7 @@ import type {
AddContainerRequest, AddContainerRequest,
AddContainerResponse, AddContainerResponse,
DictionariesResponse, DictionariesResponse,
DraftSummary, UnifiedInvoice,
ServerUser, ServerUser,
UserRole UserRole
} from './types'; } from './types';
@@ -159,8 +159,11 @@ export const api = {
return data.suppliers; return data.suppliers;
}, },
getDrafts: async (): Promise<DraftSummary[]> => { // Обновленный метод получения списка накладных с фильтрацией
const { data } = await apiClient.get<DraftSummary[]>('/drafts'); getDrafts: async (from?: string, to?: string): Promise<UnifiedInvoice[]> => {
const { data } = await apiClient.get<UnifiedInvoice[]>('/drafts', {
params: { from, to }
});
return data; return data;
}, },

View File

@@ -233,4 +233,20 @@ export interface MainUnit {
id: UUID; id: UUID;
name: string; // "кг" name: string; // "кг"
code: string; code: string;
}
export type InvoiceType = 'DRAFT' | 'SYNCED'; // Тип записи: Черновик или Синхронизировано из iiko
export interface UnifiedInvoice {
id: UUID;
type: InvoiceType; // Новый признак типа
document_number: string; // Внутренний номер iiko или ID черновика
incoming_number: string; // Входящий номер накладной от поставщика
date_incoming: string;
status: DraftStatus;
items_count: number;
total_sum: number;
store_name?: string;
created_at: string;
is_app_created: boolean; // Создано ли через наше приложение
} }