2701-есть флоу для оператора и красивый список накладных

This commit is contained in:
2026-01-27 06:31:38 +03:00
parent 8332b6ecda
commit 38a5143902
11 changed files with 1508 additions and 158 deletions

View File

@@ -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
}

View File

@@ -10,6 +10,7 @@ import (
)
const (
StatusDraft = "DRAFT"
StatusProcessing = "PROCESSING"
StatusReadyToVerify = "READY_TO_VERIFY"
StatusCompleted = "COMPLETED"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<i>(Редактирование доступно Администратору)</i>"
}
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<i>(Редактирование доступно Администратору)</i>"
}
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 {

View File

@@ -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 <draft_id>
// Запускает редактор для указанного черновика
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(
"📄 <b>Черновик</b> от %s\n"+
"💰 Сумма: <b>%.2f ₽</b> | 📦 Позиций: <b>%d</b>\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(
"✏️ <b>Редактирование позиции №%d</b>\n\n"+
"📝 Название: <code>%s</code>\n"+
"📦 Количество: <b>%s</b>\n"+
"💰 Цена: <b>%s ₽</b>\n"+
"💵 Сумма: <b>%s ₽</b>\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(
"📝 <b>Изменение названия позиции №%d</b>\n\n"+
"Текущее название: <code>%s</code>\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(
"📦 <b>Изменение количества позиции №%d</b>\n\n"+
"Текущее количество: <b>%s</b>\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(
"💰 <b>Изменение цены позиции №%d</b>\n\n"+
"Текущая цена: <b>%s ₽</b>\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(
"✅ <b>Черновик успешно сохранён!</b>\n\n"+
"📊 <b>Статус:</b> <code>READY_TO_VERIFY</code>\n"+
"📦 <b>Позиций:</b> %d\n"+
"📅 <b>Дата:</b> %s\n\n",
len(updatedDraft.Items),
updatedDraft.DateIncoming.Format("02.01.2006"),
)
// Добавляем информацию о дальнейшей обработке
if server != nil && server.AutoProcess {
txt += "🔄 <b>Авто-процессинг в iiko включён.</b>\n" +
"Черновик будет автоматически отправлен в систему iiko после проверки администратором.\n\n"
} else {
txt += "📋 <b>Черновик готов к дальнейшей обработке.</b>\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 := "❌ <b>Редактирование отменено</b>\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)
}

View File

@@ -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
}