From 38a51439023ce0918e926f9ce44cf6ef55b47137 Mon Sep 17 00:00:00 2001 From: SERTY Date: Tue, 27 Jan 2026 06:31:38 +0300 Subject: [PATCH] =?UTF-8?q?2701-=D0=B5=D1=81=D1=82=D1=8C=20=D1=84=D0=BB?= =?UTF-8?q?=D0=BE=D1=83=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=B8=20=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=B8=D0=B2=D1=8B=D0=B9=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=B0=D0=BA=D0=BB=D0=B0=D0=B4=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + cmd/main.go | 2 +- config.yaml | 2 +- internal/domain/account/entity.go | 11 + internal/domain/drafts/entity.go | 1 + .../repository/account/postgres.go | 22 + internal/services/drafts/service.go | 165 ++++ internal/transport/telegram/bot.go | 59 +- internal/transport/telegram/draft_editor.go | 826 ++++++++++++++++++ internal/transport/telegram/fsm.go | 65 +- rmser-view/src/pages/DraftsList.tsx | 512 ++++++++--- 11 files changed, 1508 insertions(+), 158 deletions(-) create mode 100644 internal/transport/telegram/draft_editor.go diff --git a/.gitignore b/.gitignore index 97efebf..1199d92 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ pack_py_files.py pack_react_files.py ocr-service/python_project_dump.py project_dump.py +python_project_dump.py temp node_modules \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 45fea63..503bcbd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -113,7 +113,7 @@ func main() { // 8. Telegram Bot (Передаем syncService) if cfg.Telegram.Token != "" { - bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, cfg.App.MaintenanceMode, cfg.App.DevIDs) + bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, draftsService, cfg.App.MaintenanceMode, cfg.App.DevIDs) if err != nil { logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err)) } diff --git a/config.yaml b/config.yaml index 9ac3362..9b5b24d 100644 --- a/config.yaml +++ b/config.yaml @@ -5,7 +5,7 @@ app: storage_path: "./uploads" public_url: "https://rmser.serty.top" maintenance_mode: true - dev_ids: [665599275] # Укажите здесь ваш ID и ID тестировщиков + dev_ids: [665599275,2126923472] # Укажите здесь ваш ID и ID тестировщиков db: dsn: "host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow" diff --git a/internal/domain/account/entity.go b/internal/domain/account/entity.go index 6e8c042..043d795 100644 --- a/internal/domain/account/entity.go +++ b/internal/domain/account/entity.go @@ -39,6 +39,9 @@ type ServerUser struct { Role Role `gorm:"type:varchar(20);default:'OPERATOR'"` IsActive bool `gorm:"default:false"` // Выбран ли этот сервер сейчас + // === Настройки уведомлений === + MuteDraftNotifications bool `gorm:"default:false" json:"mute_draft_notifications"` // Не получать уведомления о новых черновиках + // Персональные данные для подключения (могут быть null у операторов) Login string `gorm:"type:varchar(100)"` EncryptedPassword string `gorm:"type:text"` @@ -123,4 +126,12 @@ type Repository interface { // GetConnectionByID получает связь ServerUser по её ID (нужно для админки, чтобы сократить callback_data) GetConnectionByID(id uuid.UUID) (*ServerUser, error) + + // === Уведомления о черновиках === + // GetServerUsersForDraftNotification возвращает Admin/Owner пользователей, + // которым нужно отправить уведомление о новом черновике + GetServerUsersForDraftNotification(serverID uuid.UUID, excludeUserID uuid.UUID) ([]ServerUser, error) + + // SetMuteDraftNotifications включает/выключает уведомления для пользователя + SetMuteDraftNotifications(userID, serverID uuid.UUID, mute bool) error } diff --git a/internal/domain/drafts/entity.go b/internal/domain/drafts/entity.go index 281e409..b2b1752 100644 --- a/internal/domain/drafts/entity.go +++ b/internal/domain/drafts/entity.go @@ -10,6 +10,7 @@ import ( ) const ( + StatusDraft = "DRAFT" StatusProcessing = "PROCESSING" StatusReadyToVerify = "READY_TO_VERIFY" StatusCompleted = "COMPLETED" diff --git a/internal/infrastructure/repository/account/postgres.go b/internal/infrastructure/repository/account/postgres.go index ef2d59d..155bcdc 100644 --- a/internal/infrastructure/repository/account/postgres.go +++ b/internal/infrastructure/repository/account/postgres.go @@ -435,3 +435,25 @@ func (r *pgRepository) DecrementBalance(serverID uuid.UUID) error { Where("id = ? AND balance > 0", serverID). UpdateColumn("balance", gorm.Expr("balance - ?", 1)).Error } + +// === Уведомления о черновиках === + +// GetServerUsersForDraftNotification возвращает Admin/Owner пользователей, +// которым нужно отправить уведомление о новом черновике +func (r *pgRepository) GetServerUsersForDraftNotification(serverID uuid.UUID, excludeUserID uuid.UUID) ([]account.ServerUser, error) { + var users []account.ServerUser + err := r.db.Preload("User"). + Where("server_id = ?", serverID). + Where("role IN ?", []account.Role{account.RoleOwner, account.RoleAdmin}). + Where("mute_draft_notifications = ?", false). + Where("user_id != ?", excludeUserID). + Find(&users).Error + return users, err +} + +// SetMuteDraftNotifications включает/выключает уведомления для пользователя +func (r *pgRepository) SetMuteDraftNotifications(userID, serverID uuid.UUID, mute bool) error { + return r.db.Model(&account.ServerUser{}). + Where("user_id = ? AND server_id = ?", userID, serverID). + Update("mute_draft_notifications", mute).Error +} diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index a87f5ba..98ebe5a 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -689,3 +689,168 @@ func (s *Service) GetInvoiceDetails(invoiceID, userID uuid.UUID) (*invoices.Invo return inv, photoURL, nil } + +// ============================================ +// === МЕТОДЫ ДЛЯ IN-CHAT DRAFT EDITOR === +// ============================================ + +// CreateDraft создаёт новый пустой черновик для пользователя +func (s *Service) CreateDraft(userID uuid.UUID) (*drafts.DraftInvoice, error) { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, errors.New("нет активного сервера") + } + + draft := &drafts.DraftInvoice{ + ID: uuid.New(), + UserID: userID, + RMSServerID: server.ID, + Status: drafts.StatusProcessing, + DateIncoming: func() *time.Time { + t := time.Now() + return &t + }(), + } + + if err := s.draftRepo.Create(draft); err != nil { + return nil, err + } + + return draft, nil +} + +// GetDraftForEditor возвращает черновик для отображения в редакторе. +// Доступен ВСЕМ ролям (Owner, Admin, Operator). +// Проверяет только принадлежность черновика к активному серверу пользователя. +func (s *Service) GetDraftForEditor(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) { + draft, err := s.draftRepo.GetByID(draftID) + if err != nil { + return nil, err + } + + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, errors.New("нет активного сервера") + } + + if draft.RMSServerID != server.ID { + return nil, errors.New("черновик не принадлежит активному серверу") + } + + return draft, nil +} + +// validateItemBelongsToDraft проверяет, что позиция принадлежит указанному черновику +func (s *Service) validateItemBelongsToDraft(item *drafts.DraftInvoiceItem, draftID uuid.UUID) error { + if item.DraftID != draftID { + return errors.New("позиция не принадлежит указанному черновику") + } + return nil +} + +// UpdateItemRawName обновляет raw_name позиции черновика. +// Возвращает обновлённую позицию. +func (s *Service) UpdateItemRawName(draftID, itemID uuid.UUID, newName string) (*drafts.DraftInvoiceItem, error) { + item, err := s.draftRepo.GetItemByID(itemID) + if err != nil { + return nil, err + } + + if err := s.validateItemBelongsToDraft(item, draftID); err != nil { + return nil, err + } + + item.RawName = strings.TrimSpace(newName) + if item.RawName == "" { + return nil, errors.New("название позиции не может быть пустым") + } + + updates := map[string]interface{}{ + "raw_name": item.RawName, + } + + if err := s.draftRepo.UpdateItem(itemID, updates); err != nil { + return nil, err + } + + return item, nil +} + +// UpdateItemQuantity обновляет количество позиции и пересчитывает сумму. +// Возвращает обновлённую позицию. +func (s *Service) UpdateItemQuantity(draftID, itemID uuid.UUID, qty decimal.Decimal) (*drafts.DraftInvoiceItem, error) { + item, err := s.draftRepo.GetItemByID(itemID) + if err != nil { + return nil, err + } + + if err := s.validateItemBelongsToDraft(item, draftID); err != nil { + return nil, err + } + + item.Quantity = qty + s.RecalculateItemFields(item, drafts.FieldQuantity) + + updates := map[string]interface{}{ + "quantity": item.Quantity, + "sum": item.Sum, + "last_edited_field1": item.LastEditedField1, + "last_edited_field2": item.LastEditedField2, + } + + if err := s.draftRepo.UpdateItem(itemID, updates); err != nil { + return nil, err + } + + return item, nil +} + +// UpdateItemPrice обновляет цену позиции и пересчитывает сумму. +// Возвращает обновлённую позицию. +func (s *Service) UpdateItemPrice(draftID, itemID uuid.UUID, price decimal.Decimal) (*drafts.DraftInvoiceItem, error) { + item, err := s.draftRepo.GetItemByID(itemID) + if err != nil { + return nil, err + } + + if err := s.validateItemBelongsToDraft(item, draftID); err != nil { + return nil, err + } + + item.Price = price + s.RecalculateItemFields(item, drafts.FieldPrice) + + updates := map[string]interface{}{ + "price": item.Price, + "sum": item.Sum, + "last_edited_field1": item.LastEditedField1, + "last_edited_field2": item.LastEditedField2, + } + + if err := s.draftRepo.UpdateItem(itemID, updates); err != nil { + return nil, err + } + + return item, nil +} + +// SetDraftReadyToVerify переводит черновик в статус READY_TO_VERIFY. +// Вызывается при подтверждении оператором. +// Возвращает обновлённый черновик. +func (s *Service) SetDraftReadyToVerify(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) { + draft, err := s.GetDraftForEditor(draftID, userID) + if err != nil { + return nil, err + } + + if draft.Status == drafts.StatusCompleted { + return nil, errors.New("черновик уже завершён") + } + + draft.Status = drafts.StatusReadyToVerify + if err := s.draftRepo.Update(draft); err != nil { + return nil, err + } + + return draft, nil +} diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go index 2aebebc..c2a97ed 100644 --- a/internal/transport/telegram/bot.go +++ b/internal/transport/telegram/bot.go @@ -20,12 +20,24 @@ import ( "rmser/internal/domain/account" "rmser/internal/infrastructure/rms" "rmser/internal/services/billing" + draftsService "rmser/internal/services/drafts" "rmser/internal/services/ocr" "rmser/internal/services/sync" "rmser/pkg/crypto" "rmser/pkg/logger" ) +const DraftEditorPageSize = 10 + +// shortUUID возвращает первые 8 символов UUID для callback data +func shortUUID(id uuid.UUID) string { + s := id.String() + if len(s) >= 8 { + return s[:8] + } + return s +} + type Bot struct { b *tele.Bot ocrService *ocr.Service @@ -34,6 +46,8 @@ type Bot struct { accountRepo account.Repository rmsFactory *rms.Factory cryptoManager *crypto.CryptoManager + draftsService *draftsService.Service + draftEditor *DraftEditor fsm *StateManager adminIDs map[int64]struct{} @@ -54,6 +68,7 @@ func NewBot( accountRepo account.Repository, rmsFactory *rms.Factory, cryptoManager *crypto.CryptoManager, + draftsService *draftsService.Service, maintenanceMode bool, devIDs []int64, ) (*Bot, error) { @@ -89,6 +104,7 @@ func NewBot( accountRepo: accountRepo, rmsFactory: rmsFactory, cryptoManager: cryptoManager, + draftsService: draftsService, fsm: NewStateManager(), adminIDs: admins, devIDs: devs, @@ -100,6 +116,8 @@ func NewBot( bot.webAppURL = "http://example.com" } + bot.draftEditor = NewDraftEditor(bot.b, bot.draftsService, bot.accountRepo, bot.fsm) + bot.initMenus() bot.initHandlers() return bot, nil @@ -133,6 +151,8 @@ func (bot *Bot) initHandlers() { bot.b.Handle("/start", bot.handleStartCommand) bot.b.Handle("/admin", bot.handleAdminCommand) + bot.b.Handle("/draft", bot.draftEditor.handleDraftCommand) + bot.b.Handle("/newdraft", bot.draftEditor.handleNewDraftCommand) bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu) bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu) @@ -152,6 +172,10 @@ func (bot *Bot) initHandlers() { bot.b.Handle(tele.OnText, bot.handleText) bot.b.Handle(tele.OnPhoto, bot.handlePhoto) bot.b.Handle(tele.OnDocument, bot.handleDocument) + + bot.b.Handle(&tele.Btn{Unique: "noop"}, func(c tele.Context) error { + return c.Respond() + }) } func (bot *Bot) Start() { @@ -471,6 +495,15 @@ func (bot *Bot) handleCallback(c tele.Context) error { return c.Respond(&tele.CallbackResponse{Text: "Сервис на обслуживании"}) } + // === DRAFT EDITOR CALLBACKS === + // Проверяем префиксы редактора черновиков + draftPrefixes := []string{"di:", "dp:", "da:", "dc:", "dx:", "den:", "deq:", "dep:", "did:", "dib:"} + for _, prefix := range draftPrefixes { + if strings.HasPrefix(data, prefix) { + return bot.draftEditor.handleDraftEditorCallback(c, data) + } + } + // --- INTEGRATION: Billing Callbacks --- if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") { return bot.handleBillingCallbacks(c, data, userDB) @@ -749,6 +782,14 @@ func (bot *Bot) handleText(c tele.Context) error { return bot.renderMainMenu(c) } + // === DRAFT EDITOR TEXT HANDLERS === + // Проверяем, находимся ли мы в одном из состояний редактирования черновика + if state == StateDraftEditItemName || + state == StateDraftEditItemQty || + state == StateDraftEditItemPrice { + return bot.draftEditor.handleDraftEditorText(c) + } + if state == StateNone { return c.Send("Используйте меню для навигации 👇") } @@ -864,6 +905,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error { matchedCount++ } } + // Для разработчиков отправляем debug-информацию if bot.isDev(userID) { baseURL := strings.TrimRight(bot.webAppURL, "/") fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) @@ -881,10 +923,12 @@ func (bot *Bot) handlePhoto(c tele.Context) error { } else { msgText += "\n\n(Редактирование доступно Администратору)" } - return c.Send(msgText, menu, tele.ModeHTML) - } else { - return c.Send("✅ **Фото принято!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML) + c.Send(msgText, menu, tele.ModeHTML) } + // Инициализируем FSM редактора для всех пользователей + bot.fsm.InitDraftEditor(userID, draft.ID) + // Показываем меню редактора + return bot.draftEditor.renderDraftEditorMenu(c, draft, 0) } func (bot *Bot) handleDocument(c tele.Context) error { @@ -942,6 +986,7 @@ func (bot *Bot) handleDocument(c tele.Context) error { matchedCount++ } } + // Для разработчиков отправляем debug-информацию if bot.isDev(userID) { baseURL := strings.TrimRight(bot.webAppURL, "/") fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) @@ -959,10 +1004,12 @@ func (bot *Bot) handleDocument(c tele.Context) error { } else { msgText += "\n\n(Редактирование доступно Администратору)" } - return c.Send(msgText, menu, tele.ModeHTML) - } else { - return c.Send("✅ **Документ принят!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML) + c.Send(msgText, menu, tele.ModeHTML) } + // Инициализируем FSM редактора для всех пользователей + bot.fsm.InitDraftEditor(userID, draft.ID) + // Показываем меню редактора + return bot.draftEditor.renderDraftEditorMenu(c, draft, 0) } func (bot *Bot) handleConfirmNameYes(c tele.Context) error { diff --git a/internal/transport/telegram/draft_editor.go b/internal/transport/telegram/draft_editor.go new file mode 100644 index 0000000..8f45715 --- /dev/null +++ b/internal/transport/telegram/draft_editor.go @@ -0,0 +1,826 @@ +// internal/transport/telegram/draft_editor.go + +package telegram + +import ( + "errors" + "fmt" + "html" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + "go.uber.org/zap" + tele "gopkg.in/telebot.v3" + + "rmser/internal/domain/account" + "rmser/internal/domain/drafts" + draftsService "rmser/internal/services/drafts" + "rmser/pkg/logger" +) + +// DraftEditor управляет редактированием черновиков накладных +type DraftEditor struct { + bot *tele.Bot + draftsService *draftsService.Service + accountRepo account.Repository + fsm *StateManager +} + +// NewDraftEditor создаёт новый экземпляр редактора черновиков +func NewDraftEditor(bot *tele.Bot, draftsService *draftsService.Service, accountRepo account.Repository, fsm *StateManager) *DraftEditor { + return &DraftEditor{ + bot: bot, + draftsService: draftsService, + accountRepo: accountRepo, + fsm: fsm, + } +} + +// ============================================ +// === КОМАНДЫ ЗАПУСКА РЕДАКТОРА ЧЕРНОВИКОВ === +// ============================================ + +// handleDraftCommand обрабатывает команду /draft +// Запускает редактор для указанного черновика +func (de *DraftEditor) handleDraftCommand(c tele.Context) error { + userID := c.Sender().ID + + // Получаем аргументы команды + args := c.Args() + if len(args) == 0 { + return c.Send("❌ Укажите ID черновика.\n\nПример: /draft 123e4567-e89b-12d3-a456-426614174000") + } + + draftIDStr := args[0] + + // Парсим UUID + draftID, err := uuid.Parse(draftIDStr) + if err != nil { + return c.Send("❌ Некорректный формат ID черновика.\n\nПример: /draft 123e4567-e89b-12d3-a456-426614174000") + } + + // Получаем пользователя + userDB, err := de.accountRepo.GetUserByTelegramID(userID) + if err != nil { + return c.Send("❌ Ошибка получения данных пользователя") + } + + // Получаем черновик + draft, err := de.draftsService.GetDraftForEditor(draftID, userDB.ID) + if err != nil { + return c.Send("❌ Черновик не найден или у вас нет прав на его редактирование") + } + + // Проверяем статус черновика + if draft.Status == drafts.StatusCanceled { + return c.Send("❌ Этот черновик нельзя редактировать. Текущий статус: " + string(draft.Status)) + } + + // Инициализируем FSM для редактора + de.fsm.InitDraftEditor(userID, draft.ID) + + // Показываем редактор + return de.renderDraftEditorMenu(c, draft, 0) +} + +// handleNewDraftCommand обрабатывает команду /newdraft +// Создаёт новый черновик и запускает редактор +func (de *DraftEditor) handleNewDraftCommand(c tele.Context) error { + userID := c.Sender().ID + + // Получаем пользователя + userDB, err := de.accountRepo.GetUserByTelegramID(userID) + if err != nil { + return c.Send("❌ Ошибка получения данных пользователя") + } + + // Создаём новый черновик + draft, err := de.draftsService.CreateDraft(userDB.ID) + if err != nil { + return c.Send("❌ Ошибка создания черновика: " + err.Error()) + } + + // Инициализируем FSM для редактора + de.fsm.InitDraftEditor(userID, draft.ID) + + // Показываем редактор + return de.renderDraftEditorMenu(c, draft, 0) +} + +// ============================================ +// === РЕНДЕРИНГ МЕНЮ РЕДАКТОРА ЧЕРНОВИКОВ === +// ============================================ + +// renderDraftEditorMenu отображает интерактивный список позиций черновика +// с пагинацией и кнопками управления. +// Параметры: +// - c: контекст Telegram +// - draft: черновик для отображения +// - page: номер страницы (0-based) +// +// Сохраняет ID сообщения в FSM для последующего редактирования. +func (de *DraftEditor) renderDraftEditorMenu(c tele.Context, draft *drafts.DraftInvoice, page int) error { + // Расчёт пагинации + totalItems := len(draft.Items) + totalPages := (totalItems + DraftEditorPageSize - 1) / DraftEditorPageSize + if totalPages == 0 { + totalPages = 1 + } + if page < 0 { + page = 0 + } + if page >= totalPages { + page = totalPages - 1 + } + startIdx := page * DraftEditorPageSize + endIdx := startIdx + DraftEditorPageSize + if endIdx > totalItems { + endIdx = totalItems + } + + // Расчёт общей суммы + 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)) + } + } + + // Формирование текста заголовка + dateStr := "—" + if draft.DateIncoming != nil { + dateStr = draft.DateIncoming.Format("02.01.2006") + } + + txt := fmt.Sprintf( + "📄 Черновик от %s\n"+ + "💰 Сумма: %.2f ₽ | 📦 Позиций: %d\n\n"+ + "👇 Нажмите на позицию для редактирования:\n", + dateStr, + totalSum.InexactFloat64(), + totalItems, + ) + + // Формирование кнопок позиций + menu := &tele.ReplyMarkup{} + var rows []tele.Row + draftShort := shortUUID(draft.ID) + + pageItems := draft.Items[startIdx:endIdx] + for i, item := range pageItems { + globalIdx := startIdx + i + 1 // 1-based для отображения + + // Формируем label: "1. Название — 2 шт × 80.00 ₽" + qtyStr := item.Quantity.StringFixed(2) + priceStr := item.Price.StringFixed(2) + + // Обрезаем название если слишком длинное + name := item.RawName + if len(name) > 25 { + name = name[:22] + "..." + } + + label := fmt.Sprintf("%d. %s — %s × %s", globalIdx, name, qtyStr, priceStr) + + callbackData := fmt.Sprintf("di:%s:%d", draftShort, globalIdx) + btn := menu.Data(label, callbackData) + rows = append(rows, menu.Row(btn)) + } + + // Кнопки пагинации (если нужно) + if totalPages > 1 { + var navRow []tele.Btn + + if page > 0 { + navRow = append(navRow, menu.Data("◀️ Назад", fmt.Sprintf("dp:%s:%d", draftShort, page-1))) + } + + // Индикатор страницы (не кнопка, а часть текста - но в Telegram нельзя смешивать) + // Поэтому добавляем как неактивную кнопку или в текст сообщения + pageIndicator := menu.Data(fmt.Sprintf("📄 %d/%d", page+1, totalPages), "noop") + navRow = append(navRow, pageIndicator) + + if page < totalPages-1 { + navRow = append(navRow, menu.Data("Вперёд ▶️", fmt.Sprintf("dp:%s:%d", draftShort, page+1))) + } + + rows = append(rows, navRow) + } + + // Кнопки управления + // Кнопка "Добавить позицию" + btnAdd := menu.Data("➕ Добавить позицию", fmt.Sprintf("da:%s", draftShort)) + rows = append(rows, menu.Row(btnAdd)) + + // Кнопки "Подтвердить" и "Отменить" + btnConfirm := menu.Data("✅ Подтвердить", fmt.Sprintf("dc:%s", draftShort)) + btnCancel := menu.Data("❌ Отменить", fmt.Sprintf("dx:%s", draftShort)) + rows = append(rows, menu.Row(btnConfirm, btnCancel)) + + menu.Inline(rows...) + + // Обновляем контекст FSM + de.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) { + ctx.EditingDraftID = draft.ID + ctx.DraftCurrentPage = page + }) + + // Отправляем или редактируем сообщение + return c.EditOrSend(txt, menu, tele.ModeHTML) +} + +// renderItemEditMenu отображает меню редактирования конкретной позиции черновика. +// Параметры: +// - c: контекст Telegram +// - draft: черновик (для получения draftShort) +// - item: редактируемая позиция +// - itemIndex: порядковый номер позиции (1-based) +func (de *DraftEditor) renderItemEditMenu(c tele.Context, draft *drafts.DraftInvoice, item *drafts.DraftInvoiceItem, itemIndex int) error { + // Вычисление суммы + sum := item.Sum + if sum.IsZero() { + sum = item.Quantity.Mul(item.Price) + } + + // Формирование текста + txt := fmt.Sprintf( + "✏️ Редактирование позиции №%d\n\n"+ + "📝 Название: %s\n"+ + "📦 Количество: %s\n"+ + "💰 Цена: %s ₽\n"+ + "💵 Сумма: %s ₽\n\n"+ + "Выберите, что изменить:", + itemIndex, + html.EscapeString(item.RawName), + item.Quantity.StringFixed(2), + item.Price.StringFixed(2), + sum.StringFixed(2), + ) + + // Формирование кнопок + menu := &tele.ReplyMarkup{} + draftShort := shortUUID(draft.ID) + + btnName := menu.Data("📝 Название", fmt.Sprintf("den:%s:%d", draftShort, itemIndex)) + btnQty := menu.Data("📦 Количество", fmt.Sprintf("deq:%s:%d", draftShort, itemIndex)) + btnPrice := menu.Data("💰 Цена", fmt.Sprintf("dep:%s:%d", draftShort, itemIndex)) + btnDelete := menu.Data("🗑 Удалить", fmt.Sprintf("did:%s:%d", draftShort, itemIndex)) + btnBack := menu.Data("🔙 Назад", fmt.Sprintf("dib:%s", draftShort)) + + menu.Inline( + menu.Row(btnName, btnQty, btnPrice), + menu.Row(btnDelete), + menu.Row(btnBack), + ) + + // Обновление FSM + de.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) { + ctx.EditingItemID = item.ID + ctx.EditingItemIndex = itemIndex + }) + + return c.Edit(txt, menu, tele.ModeHTML) +} + +// ============================================ +// === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ РЕДАКТОРА === +// ============================================ + +// getItemByIndex возвращает позицию черновика по её порядковому номеру (1-based) +func (de *DraftEditor) getItemByIndex(draft *drafts.DraftInvoice, index int) *drafts.DraftInvoiceItem { + if index < 1 || index > len(draft.Items) { + return nil + } + return &draft.Items[index-1] +} + +// promptForItemName запрашивает ввод нового названия позиции +func (de *DraftEditor) promptForItemName(c tele.Context, item *drafts.DraftInvoiceItem, itemIndex int) error { + de.fsm.SetState(c.Sender().ID, StateDraftEditItemName) + + txt := fmt.Sprintf( + "📝 Изменение названия позиции №%d\n\n"+ + "Текущее название: %s\n\n"+ + "Введите новое название:", + itemIndex, + html.EscapeString(item.RawName), + ) + + // Кнопка отмены + menu := &tele.ReplyMarkup{} + btnCancel := menu.Data("❌ Отмена", fmt.Sprintf("dib:%s", shortUUID(de.fsm.GetContext(c.Sender().ID).EditingDraftID))) + menu.Inline(menu.Row(btnCancel)) + + return c.Edit(txt, menu, tele.ModeHTML) +} + +// promptForItemQuantity запрашивает ввод нового количества +func (de *DraftEditor) promptForItemQuantity(c tele.Context, item *drafts.DraftInvoiceItem, itemIndex int) error { + de.fsm.SetState(c.Sender().ID, StateDraftEditItemQty) + + txt := fmt.Sprintf( + "📦 Изменение количества позиции №%d\n\n"+ + "Текущее количество: %s\n\n"+ + "Введите новое количество (число):", + itemIndex, + item.Quantity.StringFixed(2), + ) + + menu := &tele.ReplyMarkup{} + btnCancel := menu.Data("❌ Отмена", fmt.Sprintf("dib:%s", shortUUID(de.fsm.GetContext(c.Sender().ID).EditingDraftID))) + menu.Inline(menu.Row(btnCancel)) + + return c.Edit(txt, menu, tele.ModeHTML) +} + +// promptForItemPrice запрашивает ввод новой цены +func (de *DraftEditor) promptForItemPrice(c tele.Context, item *drafts.DraftInvoiceItem, itemIndex int) error { + de.fsm.SetState(c.Sender().ID, StateDraftEditItemPrice) + + txt := fmt.Sprintf( + "💰 Изменение цены позиции №%d\n\n"+ + "Текущая цена: %s ₽\n\n"+ + "Введите новую цену (число):", + itemIndex, + item.Price.StringFixed(2), + ) + + menu := &tele.ReplyMarkup{} + btnCancel := menu.Data("❌ Отмена", fmt.Sprintf("dib:%s", shortUUID(de.fsm.GetContext(c.Sender().ID).EditingDraftID))) + menu.Inline(menu.Row(btnCancel)) + + return c.Edit(txt, menu, tele.ModeHTML) +} + +// ============================================ +// === ОБРАБОТЧИКИ CALLBACK'ОВ РЕДАКТОРА ЧЕРНОВИКОВ === +// ============================================ + +// parseDraftCallback разбирает callback data редактора черновиков. +// Формат: "prefix:draftShort:param" или "prefix:draftShort" +// Возвращает: prefix, draftShort, param (если есть), error +func (de *DraftEditor) parseDraftCallback(data string) (prefix, draftShort string, param int, err error) { + // Убираем возможный префикс \f от telebot + if len(data) > 0 && data[0] == '\f' { + data = data[1:] + } + + parts := strings.Split(data, ":") + if len(parts) < 2 { + return "", "", 0, errors.New("invalid callback format") + } + + prefix = parts[0] + draftShort = parts[1] + + if len(parts) >= 3 { + param, err = strconv.Atoi(parts[2]) + if err != nil { + return "", "", 0, fmt.Errorf("invalid param: %w", err) + } + } + + return prefix, draftShort, param, nil +} + +// findDraftByShortID ищет черновик по первым 8 символам UUID. +// Использует EditingDraftID из FSM контекста для точного совпадения. +func (de *DraftEditor) findDraftByShortID(userID int64, draftShort string) (*drafts.DraftInvoice, error) { + ctx := de.fsm.GetContext(userID) + + // Проверяем, совпадает ли shortUUID с сохранённым в контексте + if shortUUID(ctx.EditingDraftID) == draftShort { + userDB, err := de.accountRepo.GetUserByTelegramID(userID) + if err != nil { + return nil, err + } + return de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID) + } + + return nil, errors.New("черновик не найден или сессия истекла") +} + +// handleDraftEditorCallback обрабатывает все callback'ы редактора черновиков +func (de *DraftEditor) handleDraftEditorCallback(c tele.Context, data string) error { + prefix, draftShort, param, err := de.parseDraftCallback(data) + if err != nil { + return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()}) + } + + userID := c.Sender().ID + + // Получаем черновик + draft, err := de.findDraftByShortID(userID, draftShort) + if err != nil { + return c.Respond(&tele.CallbackResponse{Text: "Черновик не найден. Начните заново."}) + } + + switch prefix { + case "di": // Draft Item — открыть меню позиции + return de.handleDraftItemSelect(c, draft, param) + + case "dp": // Draft Page — пагинация + return de.handleDraftPageChange(c, draft, param) + + case "da": // Draft Add — добавить позицию + return de.handleDraftAddItem(c, draft) + + case "dc": // Draft Confirm — подтвердить + return de.handleDraftConfirm(c, draft) + + case "dx": // Draft eXit — отменить + return de.handleDraftCancel(c, draft) + + case "den": // Draft Edit Name + return de.handleDraftEditName(c, draft, param) + + case "deq": // Draft Edit Quantity + return de.handleDraftEditQuantity(c, draft, param) + + case "dep": // Draft Edit Price + return de.handleDraftEditPrice(c, draft, param) + + case "did": // Draft Item Delete + return de.handleDraftItemDelete(c, draft, param) + + case "dib": // Draft Item Back — назад к списку + return de.handleDraftItemBack(c, draft) + } + + return nil +} + +// handleDraftItemSelect — открытие меню позиции +func (de *DraftEditor) handleDraftItemSelect(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error { + item := de.getItemByIndex(draft, itemIndex) + if item == nil { + return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"}) + } + + c.Respond() // Убираем "часики" + return de.renderItemEditMenu(c, draft, item, itemIndex) +} + +// handleDraftPageChange — переход на страницу +func (de *DraftEditor) handleDraftPageChange(c tele.Context, draft *drafts.DraftInvoice, page int) error { + c.Respond() + return de.renderDraftEditorMenu(c, draft, page) +} + +// handleDraftAddItem — добавление позиции +func (de *DraftEditor) handleDraftAddItem(c tele.Context, draft *drafts.DraftInvoice) error { + userDB, _ := de.accountRepo.GetUserByTelegramID(c.Sender().ID) + + _, err := de.draftsService.AddItem(draft.ID) + if err != nil { + return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()}) + } + + c.Respond(&tele.CallbackResponse{Text: "✅ Позиция добавлена"}) + + // Перезагружаем черновик и переходим на последнюю страницу + updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID) + lastPage := (len(updatedDraft.Items) - 1) / DraftEditorPageSize + + return de.renderDraftEditorMenu(c, updatedDraft, lastPage) +} + +// handleDraftConfirm — подтверждение черновика +func (de *DraftEditor) handleDraftConfirm(c tele.Context, draft *drafts.DraftInvoice) error { + userDB, _ := de.accountRepo.GetUserByTelegramID(c.Sender().ID) + + // Переводим в статус READY_TO_VERIFY + updatedDraft, err := de.draftsService.SetDraftReadyToVerify(draft.ID, userDB.ID) + if err != nil { + return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()}) + } + + // Сбрасываем FSM + de.fsm.ResetDraftEditor(c.Sender().ID) + + c.Respond(&tele.CallbackResponse{Text: "✅ Черновик сохранён!"}) + + // Отправляем уведомление админам (будет реализовано на Этапе 9) + go de.notifyAdminsAboutDraft(updatedDraft, userDB) + + // Получаем информацию о сервере для проверки авто-процессинга + server, err := de.accountRepo.GetServerByID(updatedDraft.RMSServerID) + if err != nil { + logger.Log.Warn("Не удалось получить информацию о сервере", zap.Error(err)) + } + + // Формируем финальное сообщение + txt := fmt.Sprintf( + "✅ Черновик успешно сохранён!\n\n"+ + "📊 Статус: READY_TO_VERIFY\n"+ + "📦 Позиций: %d\n"+ + "📅 Дата: %s\n\n", + len(updatedDraft.Items), + updatedDraft.DateIncoming.Format("02.01.2006"), + ) + + // Добавляем информацию о дальнейшей обработке + if server != nil && server.AutoProcess { + txt += "🔄 Авто-процессинг в iiko включён.\n" + + "Черновик будет автоматически отправлен в систему iiko после проверки администратором.\n\n" + } else { + txt += "📋 Черновик готов к дальнейшей обработке.\n" + + "Администраторы получили уведомление и скоро проверят накладную.\n\n" + } + + txt += "Вы можете вернуться к работе через меню накладных." + + return c.Edit(txt, tele.ModeHTML) +} + +// handleDraftCancel — отмена редактирования +func (de *DraftEditor) handleDraftCancel(c tele.Context, draft *drafts.DraftInvoice) error { + de.fsm.ResetDraftEditor(c.Sender().ID) + + c.Respond(&tele.CallbackResponse{Text: "Редактирование отменено"}) + + txt := "❌ Редактирование отменено\n\n" + + "Черновик сохранён. Вы можете вернуться к нему позже через меню накладных." + + return c.Edit(txt, tele.ModeHTML) +} + +// handleDraftEditName — переход к вводу названия +func (de *DraftEditor) handleDraftEditName(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error { + item := de.getItemByIndex(draft, itemIndex) + if item == nil { + return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"}) + } + + c.Respond() + return de.promptForItemName(c, item, itemIndex) +} + +// handleDraftEditQuantity — переход к вводу количества +func (de *DraftEditor) handleDraftEditQuantity(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error { + item := de.getItemByIndex(draft, itemIndex) + if item == nil { + return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"}) + } + + c.Respond() + return de.promptForItemQuantity(c, item, itemIndex) +} + +// handleDraftEditPrice — переход к вводу цены +func (de *DraftEditor) handleDraftEditPrice(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error { + item := de.getItemByIndex(draft, itemIndex) + if item == nil { + return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"}) + } + + c.Respond() + return de.promptForItemPrice(c, item, itemIndex) +} + +// handleDraftItemDelete — удаление позиции +func (de *DraftEditor) handleDraftItemDelete(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error { + item := de.getItemByIndex(draft, itemIndex) + if item == nil { + return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"}) + } + + userDB, _ := de.accountRepo.GetUserByTelegramID(c.Sender().ID) + + _, err := de.draftsService.DeleteItem(draft.ID, item.ID) + if err != nil { + return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления: " + err.Error()}) + } + + c.Respond(&tele.CallbackResponse{Text: "🗑 Позиция удалена"}) + + // Перезагружаем черновик и возвращаемся к списку + updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID) + + // Корректируем страницу если нужно + ctx := de.fsm.GetContext(c.Sender().ID) + page := ctx.DraftCurrentPage + totalPages := (len(updatedDraft.Items) + DraftEditorPageSize - 1) / DraftEditorPageSize + if page >= totalPages && page > 0 { + page = totalPages - 1 + } + + return de.renderDraftEditorMenu(c, updatedDraft, page) +} + +// handleDraftItemBack — возврат к списку +func (de *DraftEditor) handleDraftItemBack(c tele.Context, draft *drafts.DraftInvoice) error { + c.Respond() + + ctx := de.fsm.GetContext(c.Sender().ID) + return de.renderDraftEditorMenu(c, draft, ctx.DraftCurrentPage) +} + +// notifyAdminsAboutDraft отправляет уведомление администраторам о новом черновике +// +// Функция должна: +// 1. Получить список администраторов и владельцев сервера (исключая автора черновика) +// 2. Отправить каждому администратору уведомление с информацией о черновике +// 3. Учесть настройку MuteDraftNotifications для каждого пользователя +// +// TODO: Полная реализация на Этапе 9 +// Для реализации использовать метод accountRepo.GetServerUsersForDraftNotification() +func (de *DraftEditor) notifyAdminsAboutDraft(draft *drafts.DraftInvoice, author *account.User) { + // Заглушка — будет реализовано позже + logger.Log.Info("Draft ready for review", + zap.String("draft_id", draft.ID.String()), + zap.String("author", author.FirstName), + ) +} + +// ============================================ +// === ОБРАБОТЧИКИ ТЕКСТОВЫХ СООБЩЕНИЙ РЕДАКТОРА ЧЕРНОВИКОВ === +// ============================================ + +// handleDraftEditorText обрабатывает текстовые сообщения в состояниях редактора черновиков +func (de *DraftEditor) handleDraftEditorText(c tele.Context) error { + userID := c.Sender().ID + ctx := de.fsm.GetContext(userID) + + // Проверяем, находимся ли мы в одном из состояний редактирования + switch ctx.State { + case StateDraftEditItemName: + return de.handleDraftNameInput(c, ctx) + + case StateDraftEditItemQty: + return de.handleDraftQuantityInput(c, ctx) + + case StateDraftEditItemPrice: + return de.handleDraftPriceInput(c, ctx) + } + + return nil // Не наше сообщение +} + +// handleDraftNameInput обрабатывает ввод названия позиции +func (de *DraftEditor) handleDraftNameInput(c tele.Context, ctx *UserContext) error { + userID := c.Sender().ID + text := c.Text() + + // Валидация + if strings.TrimSpace(text) == "" { + return c.Send("❌ Название не может быть пустым. Попробуйте ещё раз:") + } + + if len(text) > 200 { + return c.Send("❌ Название слишком длинное (максимум 200 символов). Попробуйте ещё раз:") + } + + // Получаем пользователя + userDB, err := de.accountRepo.GetUserByTelegramID(userID) + if err != nil { + return c.Send("❌ Ошибка получения данных пользователя") + } + + // Получаем черновик + draft, err := de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID) + if err != nil { + de.fsm.ResetDraftEditor(userID) + return c.Send("❌ Черновик не найден. Редактирование отменено.") + } + + // Получаем позицию + item := de.getItemByIndex(draft, ctx.EditingItemIndex) + if item == nil { + de.fsm.ResetDraftEditor(userID) + return c.Send("❌ Позиция не найдена. Редактирование отменено.") + } + + // Обновляем название + _, err = de.draftsService.UpdateItemRawName(draft.ID, item.ID, text) + if err != nil { + return c.Send("❌ Ошибка обновления названия: " + err.Error()) + } + + // Сбрасываем состояние редактирования + de.fsm.SetState(userID, StateNone) + + // Перезагружаем черновик и показываем меню позиции + updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID) + updatedItem := de.getItemByIndex(updatedDraft, ctx.EditingItemIndex) + + return de.renderItemEditMenu(c, updatedDraft, updatedItem, ctx.EditingItemIndex) +} + +// handleDraftQuantityInput обрабатывает ввод количества позиции +func (de *DraftEditor) handleDraftQuantityInput(c tele.Context, ctx *UserContext) error { + userID := c.Sender().ID + text := c.Text() + + // Парсинг количества + qty, err := strconv.ParseFloat(text, 64) + if err != nil { + return c.Send("❌ Некорректное значение. Введите число (например: 10 или 5.5):") + } + + // Валидация + if qty <= 0 { + return c.Send("❌ Количество должно быть больше 0. Попробуйте ещё раз:") + } + + if qty > 1000000 { + return c.Send("❌ Слишком большое количество. Попробуйте ещё раз:") + } + + // Получаем пользователя + userDB, err := de.accountRepo.GetUserByTelegramID(userID) + if err != nil { + return c.Send("❌ Ошибка получения данных пользователя") + } + + // Получаем черновик + draft, err := de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID) + if err != nil { + de.fsm.ResetDraftEditor(userID) + return c.Send("❌ Черновик не найден. Редактирование отменено.") + } + + // Получаем позицию + item := de.getItemByIndex(draft, ctx.EditingItemIndex) + if item == nil { + de.fsm.ResetDraftEditor(userID) + return c.Send("❌ Позиция не найдена. Редактирование отменено.") + } + + // Обновляем количество + qtyDecimal := decimal.NewFromFloat(qty) + _, err = de.draftsService.UpdateItemQuantity(draft.ID, item.ID, qtyDecimal) + if err != nil { + return c.Send("❌ Ошибка обновления количества: " + err.Error()) + } + + // Сбрасываем состояние редактирования + de.fsm.SetState(userID, StateNone) + + // Перезагружаем черновик и показываем меню позиции + updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID) + updatedItem := de.getItemByIndex(updatedDraft, ctx.EditingItemIndex) + + return de.renderItemEditMenu(c, updatedDraft, updatedItem, ctx.EditingItemIndex) +} + +// handleDraftPriceInput обрабатывает ввод цены позиции +func (de *DraftEditor) handleDraftPriceInput(c tele.Context, ctx *UserContext) error { + userID := c.Sender().ID + text := c.Text() + + // Парсинг цены + price, err := strconv.ParseFloat(text, 64) + if err != nil { + return c.Send("❌ Некорректное значение. Введите число (например: 100.50):") + } + + // Валидация + if price < 0 { + return c.Send("❌ Цена не может быть отрицательной. Попробуйте ещё раз:") + } + + if price > 1000000000 { + return c.Send("❌ Слишком большая цена. Попробуйте ещё раз:") + } + + // Получаем пользователя + userDB, err := de.accountRepo.GetUserByTelegramID(userID) + if err != nil { + return c.Send("❌ Ошибка получения данных пользователя") + } + + // Получаем черновик + draft, err := de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID) + if err != nil { + de.fsm.ResetDraftEditor(userID) + return c.Send("❌ Черновик не найден. Редактирование отменено.") + } + + // Получаем позицию + item := de.getItemByIndex(draft, ctx.EditingItemIndex) + if item == nil { + de.fsm.ResetDraftEditor(userID) + return c.Send("❌ Позиция не найдена. Редактирование отменено.") + } + + // Обновляем цену + priceDecimal := decimal.NewFromFloat(price) + _, err = de.draftsService.UpdateItemPrice(draft.ID, item.ID, priceDecimal) + if err != nil { + return c.Send("❌ Ошибка обновления цены: " + err.Error()) + } + + // Сбрасываем состояние редактирования + de.fsm.SetState(userID, StateNone) + + // Перезагружаем черновик и показываем меню позиции + updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID) + updatedItem := de.getItemByIndex(updatedDraft, ctx.EditingItemIndex) + + return de.renderItemEditMenu(c, updatedDraft, updatedItem, ctx.EditingItemIndex) +} diff --git a/internal/transport/telegram/fsm.go b/internal/transport/telegram/fsm.go index 202945f..10a20aa 100644 --- a/internal/transport/telegram/fsm.go +++ b/internal/transport/telegram/fsm.go @@ -1,6 +1,10 @@ package telegram -import "sync" +import ( + "sync" + + "github.com/google/uuid" +) // Состояния пользователя type State int @@ -13,16 +17,32 @@ const ( StateAddServerConfirmName StateAddServerInputName StateBillingGiftURL + + // Состояния редактора черновиков (начиная с 100) + StateDraftEditItemName State = 100 // Ожидание ввода нового названия позиции + StateDraftEditItemQty State = 101 // Ожидание ввода количества + StateDraftEditItemPrice State = 102 // Ожидание ввода цены ) // UserContext хранит временные данные в процессе диалога type UserContext struct { - State State - TempURL string - TempLogin string - TempPassword string - TempServerName string + State State + + // Поля для добавления сервера + TempURL string + TempLogin string + TempPassword string + TempServerName string + + // Поля для биллинга BillingTargetURL string + + // Поля редактора черновиков + EditingDraftID uuid.UUID // ID редактируемого черновика + EditingItemID uuid.UUID // ID редактируемой позиции (DraftInvoiceItem) + EditingItemIndex int // Порядковый номер позиции (1-based, для отображения пользователю) + DraftMenuMsgID int // ID сообщения со списком позиций (для обновления через EditMessage) + DraftCurrentPage int // Текущая страница пагинации (0-based) } // StateManager управляет состояниями @@ -80,3 +100,36 @@ func (sm *StateManager) Reset(userID int64) { defer sm.mu.Unlock() delete(sm.states, userID) } + +// ResetDraftEditor сбрасывает только поля редактора черновика +func (sm *StateManager) ResetDraftEditor(userID int64) { + sm.mu.Lock() + defer sm.mu.Unlock() + + if ctx, ok := sm.states[userID]; ok { + ctx.State = StateNone + ctx.EditingDraftID = uuid.Nil + ctx.EditingItemID = uuid.Nil + ctx.EditingItemIndex = 0 + ctx.DraftMenuMsgID = 0 + ctx.DraftCurrentPage = 0 + } +} + +// InitDraftEditor инициализирует FSM для редактора черновика +func (sm *StateManager) InitDraftEditor(userID int64, draftID uuid.UUID) { + sm.mu.Lock() + defer sm.mu.Unlock() + + if _, ok := sm.states[userID]; !ok { + sm.states[userID] = &UserContext{} + } + + ctx := sm.states[userID] + ctx.State = StateNone + ctx.EditingDraftID = draftID + ctx.EditingItemID = uuid.Nil + ctx.EditingItemIndex = 0 + ctx.DraftMenuMsgID = 0 + ctx.DraftCurrentPage = 0 +} diff --git a/rmser-view/src/pages/DraftsList.tsx b/rmser-view/src/pages/DraftsList.tsx index 9e816a2..3e439e0 100644 --- a/rmser-view/src/pages/DraftsList.tsx +++ b/rmser-view/src/pages/DraftsList.tsx @@ -1,6 +1,6 @@ // src/pages/DraftsList.tsx -import React, { useState } from "react"; +import React, { useState, useMemo, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { List, @@ -8,13 +8,13 @@ import { Tag, Spin, Empty, - DatePicker, Flex, Button, + Select, + DatePicker, } from "antd"; import { useNavigate } from "react-router-dom"; import { - ArrowRightOutlined, CheckCircleOutlined, DeleteOutlined, PlusOutlined, @@ -25,21 +25,58 @@ import { SyncOutlined, CloudServerOutlined, } from "@ant-design/icons"; -import dayjs, { Dayjs } from "dayjs"; +import dayjs from "dayjs"; +import "dayjs/locale/ru"; import { api } from "../services/api"; import type { UnifiedInvoice } from "../services/types"; const { Title, Text } = Typography; +type FilterType = "ALL" | "DRAFT" | "SYNCED"; + +dayjs.locale("ru"); + +const DayDivider: React.FC<{ date: string }> = ({ date }) => { + const d = dayjs(date); + const dayOfWeek = d.format("dddd"); + const formattedDate = d.format("D MMMM YYYY"); + + return ( +
+ + {formattedDate} + + + {dayOfWeek} + +
+ ); +}; + export const DraftsList: React.FC = () => { const navigate = useNavigate(); - // Состояние фильтра дат: по умолчанию последние 7 дней - const [startDate, setStartDate] = useState(dayjs().subtract(7, "day")); - const [endDate, setEndDate] = useState(dayjs()); const [syncLoading, setSyncLoading] = useState(false); + const [filterType, setFilterType] = useState("ALL"); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [startDate, setStartDate] = useState( + dayjs().subtract(30, "day") + ); + const [endDate, setEndDate] = useState(dayjs()); + + const touchStartX = useRef(0); + const touchEndX = useRef(0); - // Запрос данных с учетом дат (даты в ключе обеспечивают авто-перезапрос) const { data: invoices, isLoading, @@ -48,13 +85,13 @@ export const DraftsList: React.FC = () => { } = useQuery({ queryKey: [ "drafts", - startDate.format("YYYY-MM-DD"), - endDate.format("YYYY-MM-DD"), + startDate?.format("YYYY-MM-DD"), + endDate?.format("YYYY-MM-DD"), ], queryFn: () => api.getDrafts( - startDate.format("YYYY-MM-DD"), - endDate.format("YYYY-MM-DD") + startDate?.format("YYYY-MM-DD"), + endDate?.format("YYYY-MM-DD") ), staleTime: 0, refetchOnMount: true, @@ -134,6 +171,106 @@ export const DraftsList: React.FC = () => { } }; + const handleFilterChange = (value: FilterType) => { + setFilterType(value); + setCurrentPage(1); + }; + + const handlePageSizeChange = (value: number) => { + setPageSize(value); + setCurrentPage(1); + }; + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchStartX.current = e.changedTouches[0].screenX; + }, []); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + touchEndX.current = e.changedTouches[0].screenX; + }, []); + + const getItemDate = (item: UnifiedInvoice) => + item.type === "DRAFT" ? item.created_at : item.date_incoming; + + const filteredAndSortedInvoices = useMemo(() => { + if (!invoices || invoices.length === 0) return []; + + let result = [...invoices]; + + if (filterType !== "ALL") { + result = result.filter((item) => item.type === filterType); + } + + result.sort((a, b) => { + const dateA = dayjs(getItemDate(a)).startOf("day"); + const dateB = dayjs(getItemDate(b)).startOf("day"); + + // Сначала по дате DESC + if (!dateA.isSame(dateB)) { + return dateB.valueOf() - dateA.valueOf(); + } + + // Внутри дня: DRAFT < SYNCED + if (a.type !== b.type) { + return a.type === "DRAFT" ? -1 : 1; + } + + // Внутри типа: по номеру DESC + return (b.document_number || "").localeCompare( + a.document_number || "", + "ru", + { numeric: true } + ); + }); + + return result; + }, [invoices, filterType]); + + const handleTouchEnd = useCallback(() => { + const diff = touchStartX.current - touchEndX.current; + const totalPages = Math.ceil( + (filteredAndSortedInvoices.length || 0) / pageSize + ); + + if (Math.abs(diff) > 50) { + if (diff > 0 && currentPage < totalPages) { + setCurrentPage((prev) => prev + 1); + } else if (diff < 0 && currentPage > 1) { + setCurrentPage((prev) => prev - 1); + } + } + }, [currentPage, pageSize, filteredAndSortedInvoices]); + + const paginatedInvoices = useMemo(() => { + const startIndex = (currentPage - 1) * pageSize; + return filteredAndSortedInvoices.slice(startIndex, startIndex + pageSize); + }, [filteredAndSortedInvoices, currentPage, pageSize]); + + const groupedInvoices = useMemo(() => { + const groups: { [key: string]: UnifiedInvoice[] } = {}; + paginatedInvoices.forEach((item) => { + const dateKey = dayjs(getItemDate(item)).format("YYYY-MM-DD"); + if (!groups[dateKey]) { + groups[dateKey] = []; + } + groups[dateKey].push(item); + }); + return groups; + }, [paginatedInvoices]); + + const filterCounts = useMemo(() => { + if (!invoices) return { all: 0, draft: 0, synced: 0 }; + return { + all: invoices.length, + draft: invoices.filter((item) => item.type === "DRAFT").length, + synced: invoices.filter((item) => item.type === "SYNCED").length, + }; + }, [invoices]); + + const totalPages = Math.ceil( + (filteredAndSortedInvoices.length || 0) / pageSize + ); + if (isError) { return (
@@ -144,48 +281,60 @@ export const DraftsList: React.FC = () => { return (
- - - Накладные - - + + + + )} + )}
);