From 9637f6ec3a6f8579f1e43ff3ce432037370968f5 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sat, 3 Jan 2026 17:52:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=83=D0=BF=20=D0=BC=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/purchase_optional_details.go | 906 ++++++++++++++++-- .../screens/select_channels_for_purchase.go | 11 +- 2 files changed, 853 insertions(+), 64 deletions(-) diff --git a/telegram_bot/screens/purchase_optional_details.go b/telegram_bot/screens/purchase_optional_details.go index 04347ce..9b9b28c 100644 --- a/telegram_bot/screens/purchase_optional_details.go +++ b/telegram_bot/screens/purchase_optional_details.go @@ -11,26 +11,51 @@ import ( ) type PurchaseOptionalDetails struct { - WorkspaceID string - ProjectID string - ProjectTitle string - CreativeID string - CreativeTitle string - Channels []PurchaseChannelInput - PlacementDateTime *time.Time - PaymentDate *time.Time - CostType string - CostValue *float64 - CostBeforeBargain *float64 - PurchaseType string - Format string - Comment string - InputMode string - BackState bot.State + WorkspaceID string + ProjectID string + ProjectTitle string + CreativeID string + CreativeTitle string + Channels []PurchaseChannelInput + PlacementDateTime *time.Time + PaymentDate *time.Time + CostType string + CostValue *float64 + CostBeforeBargain *float64 + PurchaseType string + Format string + Comment string + InputMode string + CurrentParam string + CurrentChannel string + ParamPage int + ReturnMode string + PlacementMode string + CostMode string + CostBeforeMode string + FormatMode string + PlacementByChannel map[string]*time.Time + CostByChannel map[string]CostEntry + CostBeforeByChannel map[string]*float64 + FormatByChannel map[string]string + PlacementCopy *time.Time + CostCopy *CostEntry + CostBeforeCopy *float64 + FormatCopy *string + BackState bot.State +} + +type CostEntry struct { + Type string + Value *float64 } func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + s.ensureDefaults() if s.InputMode != "" { + if s.renderParamScreens(b, mode) { + return + } s.renderInputPrompt(b, mode) return } @@ -74,16 +99,17 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return } + s.ensureDefaults() switch u.CallbackQuery.Data { case "opt_datetime": - b.SetState(NewDateTimePicker(DateTimePickerConfig{ - Title: "Дата и время размещения", - Key: "placement_datetime", - IncludeTime: true, - Selected: s.PlacementDateTime, - BackState: s, - }), bot.EditMessage) + if len(s.Channels) > 1 { + s.InputMode = "placement_scope" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("placement", "common") + s.openCommonEditor(b, "placement") + } case "opt_payment_date": b.SetState(NewDateTimePicker(DateTimePickerConfig{ @@ -95,16 +121,26 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) }), bot.EditMessage) case "opt_cost": - s.InputMode = "cost_select" - s.Enter(b, bot.EditMessage) + if len(s.Channels) > 1 { + s.InputMode = "cost_scope" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("cost", "common") + s.openCommonEditor(b, "cost") + } case "opt_type": s.InputMode = "type" s.Enter(b, bot.EditMessage) case "opt_format": - s.InputMode = "format_select" - s.Enter(b, bot.EditMessage) + if len(s.Channels) > 1 { + s.InputMode = "format_scope" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("format", "common") + s.openCommonEditor(b, "format") + } case "opt_comment": s.InputMode = "comment" @@ -119,11 +155,7 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) s.Enter(b, bot.EditMessage) case "cost_type_toggle": - if s.costTypeLabel() == "СРМ" { - s.CostType = "fixed" - } else { - s.CostType = "cpm" - } + s.toggleCostType() s.Enter(b, bot.EditMessage) case "cost_value": @@ -131,8 +163,13 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) s.Enter(b, bot.EditMessage) case "cost_before": - s.InputMode = "cost_before" - s.Enter(b, bot.EditMessage) + if len(s.Channels) > 1 { + s.InputMode = "cost_before_scope" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("cost_before", "common") + s.openCommonEditor(b, "cost_before") + } case "delete_cost_before": s.CostBeforeBargain = nil @@ -140,6 +177,18 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) case "back_to_optional": s.InputMode = "" + s.ReturnMode = "" + s.CurrentChannel = "" + s.CurrentParam = "" + s.Enter(b, bot.EditMessage) + case "back_to_return": + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" s.Enter(b, bot.EditMessage) case "back": @@ -153,10 +202,19 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) BackState: s.BackState, }, bot.EditMessage) default: + if s.handleParamCallback(b, u.CallbackQuery.Data) { + return + } if strings.HasPrefix(u.CallbackQuery.Data, "format:") { value := strings.TrimPrefix(u.CallbackQuery.Data, "format:") - s.Format = value - s.InputMode = "" + s.setFormatValue(value) + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" s.Enter(b, bot.EditMessage) return } @@ -168,6 +226,7 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) if s.InputMode == "" || u.Message == nil || u.Message.Text == "" { return } + s.ensureDefaults() text := strings.TrimSpace(u.Message.Text) if text == "" { @@ -178,7 +237,7 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) case "type": s.PurchaseType = text case "format_custom": - s.Format = text + s.setFormatValue(text) case "comment": s.Comment = text case "cost_value": @@ -187,17 +246,23 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) s.renderInputPrompt(b, bot.EditMessage) return } - s.CostValue = &value - case "cost_before": + s.setCostValue(value) + case "cost_before_value": value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64) if err != nil { s.renderInputPrompt(b, bot.EditMessage) return } - s.CostBeforeBargain = &value + s.setCostBeforeValue(value) } - s.InputMode = "" + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" s.Enter(b, bot.NewMessage) } @@ -209,17 +274,23 @@ func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Ti s.PlacementDateTime = &value case "payment_date": s.PaymentDate = &value + default: + if strings.HasPrefix(key, "placement_datetime:") { + username := strings.TrimPrefix(key, "placement_datetime:") + s.ensureDefaults() + s.PlacementByChannel[username] = &value + } } } func (s *PurchaseOptionalDetails) formatOptionalSummary() string { lines := []string{ - fmt.Sprintf("Дата размещения: %s", s.formatDateTime(s.PlacementDateTime)), + fmt.Sprintf("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails()), fmt.Sprintf("Дата оплаты: %s", s.formatDateTime(s.PaymentDate)), - fmt.Sprintf("Стоимость: %s", s.formatCostValue()), - fmt.Sprintf("Стоимость до торга: %s", s.formatCostBefore()), + fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails()), + fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()), fmt.Sprintf("Тип закупа: %s", s.formatText(s.PurchaseType)), - fmt.Sprintf("Формат: %s", s.formatText(s.Format)), + fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails()), fmt.Sprintf("Комментарий: %s", s.formatText(s.Comment)), } return strings.Join(lines, "\n") @@ -262,7 +333,7 @@ func (s *PurchaseOptionalDetails) costTypeLabel() string { } func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButton { - if s.PlacementDateTime == nil { + if !s.hasPlacementValue() { return bot.Button("+ Дата размещения", "opt_datetime") } return bot.Button("✎ Дата размещения", "opt_datetime") @@ -276,14 +347,14 @@ func (s *PurchaseOptionalDetails) paymentButton() echotron.InlineKeyboardButton } func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton { - if s.CostValue == nil { + if !s.hasCostValue() { return bot.Button("+ Стоимость", "opt_cost") } return bot.Button("✎ Стоимость", "opt_cost") } func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButton { - if s.CostBeforeBargain == nil { + if !s.hasCostBeforeValue() { return bot.Button("+ До торга", "cost_before") } return bot.Button("✎ До торга", "cost_before") @@ -297,7 +368,7 @@ func (s *PurchaseOptionalDetails) typeButton() echotron.InlineKeyboardButton { } func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton { - if s.Format == "" { + if !s.hasFormatValue() { return bot.Button("+ Формат", "opt_format") } return bot.Button("✎ Формат", "opt_format") @@ -318,7 +389,7 @@ func (s *PurchaseOptionalDetails) commentDeleteRow() []echotron.InlineKeyboardBu } func (s *PurchaseOptionalDetails) costBeforeDeleteRow() []echotron.InlineKeyboardButton { - if s.CostBeforeBargain == nil { + if s.CostBeforeBargain == nil || s.CostBeforeMode == "per_channel" { return nil } return bot.Row(bot.Button("⌫ Удалить до торга", "delete_cost_before")) @@ -331,11 +402,18 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM text += "Введите тип закупа\n\nНапример: вп или стандарт" case "cost_select": text = "Страница ввода стоимости\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: @%s\n\n", s.CurrentChannel) + } text += "Ставка СРМ / Фикс цена (по умолчанию фикс)\n\n" + costValue := s.formatCostValue() + if s.CurrentChannel != "" { + costValue = s.formatCostForChannel(s.CurrentChannel) + } keyboard := bot.Keyboard( - bot.Row(bot.Button(fmt.Sprintf("Тип: %s", s.costTypeLabel()), "cost_type_toggle")), - bot.Row(bot.Button(fmt.Sprintf("Стоимость: %s", s.formatCostValue()), "cost_value")), - bot.Row(bot.Button("Назад", "back_to_optional")), + bot.Row(bot.Button(fmt.Sprintf("Тип: %s", s.costTypeLabelForCurrent()), "cost_type_toggle")), + bot.Row(bot.Button(fmt.Sprintf("Стоимость: %s", costValue), "cost_value")), + bot.Row(bot.Button("Назад", s.backAction())), ) if mode == bot.EditMessage { b.Edit(text, keyboard) @@ -345,10 +423,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM return case "cost_value": text += "Ввод стоимости (СРМ / фикс)\n\nНапример: 15000" - case "cost_before": + if s.CurrentChannel != "" { + text += fmt.Sprintf("\n\nКанал: @%s", s.CurrentChannel) + } + case "cost_before_value": text += "Ввод стоимости до торга\n\nНапример: 20000" + if s.CurrentChannel != "" { + text += fmt.Sprintf("\n\nКанал: @%s", s.CurrentChannel) + } case "format_select": text = "Страница ввода формата размещения\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: @%s\n\n", s.CurrentChannel) + } text += "Выберите формат\n\n" keyboard := bot.Keyboard( bot.Row( @@ -367,7 +454,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM bot.Button("2/72", "format:2/72"), ), bot.Row( - bot.Button("Назад", "back_to_optional"), + bot.Button("Назад", s.backAction()), bot.Button("Свой формат", "format_custom"), ), ) @@ -388,7 +475,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM } keyboard := bot.Keyboard( - bot.Row(bot.Button("← Назад", "back_to_optional")), + bot.Row(bot.Button("← Назад", s.backAction())), ) if mode == bot.EditMessage { @@ -397,3 +484,710 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM b.SendNew(text, keyboard) } } + +func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.RenderMode) bool { + switch s.InputMode { + case "placement_scope": + s.renderParamScope(b, mode, "placement") + return true + case "placement_channels": + s.renderParamChannels(b, mode, "placement") + return true + case "cost_scope": + s.renderParamScope(b, mode, "cost") + return true + case "cost_channels": + s.renderParamChannels(b, mode, "cost") + return true + case "cost_before_scope": + s.renderParamScope(b, mode, "cost_before") + return true + case "cost_before_channels": + s.renderParamChannels(b, mode, "cost_before") + return true + case "format_scope": + s.renderParamScope(b, mode, "format") + return true + case "format_channels": + s.renderParamChannels(b, mode, "format") + return true + default: + return false + } +} + +func (s *PurchaseOptionalDetails) renderParamScope(b *bot.Bot, mode bot.RenderMode, param string) { + title := s.paramTitle(param) + text := "… › Создание › Дополнительно\n\n" + text += fmt.Sprintf("%s\n\n", title) + text += "Выберите режим\n\n" + + commonLabel := "Общий" + perLabel := "Для каждого" + if s.paramMode(param) == "common" { + commonLabel = "● " + commonLabel + } else { + perLabel = "● " + perLabel + } + + rows := [][]echotron.InlineKeyboardButton{ + bot.Row( + bot.Button(commonLabel, fmt.Sprintf("param_mode_common:%s", param)), + bot.Button(perLabel, fmt.Sprintf("param_mode_per:%s", param)), + ), + bot.Row(bot.Button("← Назад", "back_to_optional")), + } + + b.Render(text, bot.Keyboard(rows...), mode) +} + +func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) { + s.CurrentParam = param + text := "… › Создание › Дополнительно\n\n" + text += fmt.Sprintf("%s — по каналам\n\n", s.paramTitle(param)) + text += s.renderParamChannelSummary(param) + "\n\n" + + const perPage = 5 + total := len(s.Channels) + if total == 0 { + text += "\nКаналы не выбраны" + b.Render(text, bot.Keyboard(bot.Row(bot.Button("← Назад", "back_to_optional"))), mode) + return + } + + start := s.ParamPage * perPage + if start >= total { + s.ParamPage = 0 + start = 0 + } + end := start + perPage + if end > total { + end = total + } + + var rows [][]echotron.InlineKeyboardButton + for i := start; i < end; i++ { + label := fmt.Sprintf("%d", i+1) + rows = append(rows, bot.Row( + bot.Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)), + bot.Button("⎘", fmt.Sprintf("param_copy:%s:%d", param, i)), + bot.Button("⤵", fmt.Sprintf("param_paste:%s:%d", param, i)), + bot.Button("✎", fmt.Sprintf("param_edit:%s:%d", param, i)), + bot.Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)), + )) + } + + pages := (total + perPage - 1) / perPage + if pages > 1 { + var navRow []echotron.InlineKeyboardButton + if s.ParamPage > 0 { + navRow = append(navRow, bot.Button("←", "param_prev")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + navRow = append(navRow, bot.Button(fmt.Sprintf("• %d/%d •", s.ParamPage+1, pages), "page_info")) + if s.ParamPage+1 < pages { + navRow = append(navRow, bot.Button("→", "param_next")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + rows = append(rows, navRow) + } + + rows = append(rows, bot.Row(bot.Button("Применить ко всем", fmt.Sprintf("param_apply_all:%s", param)))) + rows = append(rows, bot.Row( + bot.Button("← Назад", fmt.Sprintf("param_back:%s", param)), + )) + + b.Render(text, bot.Keyboard(rows...), mode) +} + +func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string { + var lines []string + for i, ch := range s.Channels { + value := s.formatParamValue(param, ch.Username) + lines = append(lines, fmt.Sprintf("%d. @%s — %s", i+1, ch.Username, value)) + } + return strings.Join(lines, "\n") +} + +func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) bool { + switch { + case strings.HasPrefix(data, "param_mode_common:"): + param := strings.TrimPrefix(data, "param_mode_common:") + s.setParamMode(param, "common") + s.CurrentChannel = "" + s.CurrentParam = param + s.openCommonEditor(b, param) + return true + case strings.HasPrefix(data, "param_mode_per:"): + param := strings.TrimPrefix(data, "param_mode_per:") + s.setParamMode(param, "per_channel") + s.CurrentParam = param + s.InputMode = param + "_channels" + s.Enter(b, bot.EditMessage) + return true + case strings.HasPrefix(data, "param_edit:"): + param, index, ok := s.parseParamIndex(data, "param_edit:") + if !ok { + return false + } + if index >= 0 && index < len(s.Channels) { + s.CurrentParam = param + s.CurrentChannel = s.Channels[index].Username + s.openChannelEditor(b, param, s.CurrentChannel) + return true + } + case strings.HasPrefix(data, "param_copy:"): + param, index, ok := s.parseParamIndex(data, "param_copy:") + if !ok { + return false + } + if index >= 0 && index < len(s.Channels) { + username := s.Channels[index].Username + s.copyParamValue(param, username) + s.Enter(b, bot.EditMessage) + return true + } + case strings.HasPrefix(data, "param_paste:"): + param, index, ok := s.parseParamIndex(data, "param_paste:") + if !ok { + return false + } + if index >= 0 && index < len(s.Channels) { + username := s.Channels[index].Username + s.pasteParamValue(param, username) + s.Enter(b, bot.EditMessage) + return true + } + case strings.HasPrefix(data, "param_clear:"): + param, index, ok := s.parseParamIndex(data, "param_clear:") + if !ok { + return false + } + if index >= 0 && index < len(s.Channels) { + username := s.Channels[index].Username + s.clearParamValue(param, username) + s.Enter(b, bot.EditMessage) + return true + } + case strings.HasPrefix(data, "param_apply_all:"): + param := strings.TrimPrefix(data, "param_apply_all:") + s.applyParamToAll(param) + s.Enter(b, bot.EditMessage) + return true + case data == "param_prev": + if s.ParamPage > 0 { + s.ParamPage-- + } + s.Enter(b, bot.EditMessage) + return true + case data == "param_next": + s.ParamPage++ + s.Enter(b, bot.EditMessage) + return true + case strings.HasPrefix(data, "param_back:"): + param := strings.TrimPrefix(data, "param_back:") + s.InputMode = param + "_scope" + s.Enter(b, bot.EditMessage) + return true + } + return false +} + +func (s *PurchaseOptionalDetails) parseParamIndex(data, prefix string) (string, int, bool) { + rest := strings.TrimPrefix(data, prefix) + parts := strings.Split(rest, ":") + if len(parts) != 2 { + return "", 0, false + } + param := parts[0] + index := parseInt(parts[1]) + return param, index, true +} + +func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) { + switch param { + case "placement": + s.InputMode = "" + s.ReturnMode = "" + b.SetState(NewDateTimePicker(DateTimePickerConfig{ + Title: "Дата и время размещения", + Key: "placement_datetime", + IncludeTime: true, + Selected: s.PlacementDateTime, + BackState: s, + }), bot.EditMessage) + case "cost": + s.InputMode = "cost_select" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "cost_before": + s.InputMode = "cost_before_value" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "format": + s.InputMode = "format_select" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + default: + s.InputMode = "" + s.Enter(b, bot.EditMessage) + } +} + +func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, username string) { + switch param { + case "placement": + s.InputMode = "placement_channels" + b.SetState(NewDateTimePicker(DateTimePickerConfig{ + Title: "Дата и время размещения", + Key: "placement_datetime:" + username, + IncludeTime: true, + Selected: s.PlacementByChannel[username], + BackState: s, + }), bot.EditMessage) + case "cost": + s.InputMode = "cost_select" + s.ReturnMode = "cost_channels" + s.CurrentChannel = username + s.Enter(b, bot.EditMessage) + case "cost_before": + s.InputMode = "cost_before_value" + s.ReturnMode = "cost_before_channels" + s.CurrentChannel = username + s.Enter(b, bot.EditMessage) + case "format": + s.InputMode = "format_select" + s.ReturnMode = "format_channels" + s.CurrentChannel = username + s.Enter(b, bot.EditMessage) + default: + s.InputMode = "" + s.Enter(b, bot.EditMessage) + } +} + +func (s *PurchaseOptionalDetails) copyParamValue(param, username string) { + switch param { + case "placement": + s.PlacementCopy = s.PlacementByChannel[username] + case "cost": + entry := s.CostByChannel[username] + copied := entry + if entry.Value == nil { + copied.Value = nil + } else { + value := *entry.Value + copied.Value = &value + } + s.CostCopy = &copied + case "cost_before": + s.CostBeforeCopy = s.CostBeforeByChannel[username] + case "format": + if value, ok := s.FormatByChannel[username]; ok { + copied := value + s.FormatCopy = &copied + } else { + s.FormatCopy = nil + } + } +} + +func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) { + switch param { + case "placement": + if s.PlacementCopy == nil { + s.PlacementByChannel[username] = nil + return + } + value := s.PlacementCopy.In(mskLocation) + s.PlacementByChannel[username] = &value + case "cost": + if s.CostCopy == nil { + delete(s.CostByChannel, username) + return + } + copied := *s.CostCopy + if copied.Value != nil { + value := *copied.Value + copied.Value = &value + } + s.CostByChannel[username] = copied + case "cost_before": + if s.CostBeforeCopy == nil { + s.CostBeforeByChannel[username] = nil + return + } + value := *s.CostBeforeCopy + s.CostBeforeByChannel[username] = &value + case "format": + if s.FormatCopy == nil { + delete(s.FormatByChannel, username) + return + } + s.FormatByChannel[username] = *s.FormatCopy + } +} + +func (s *PurchaseOptionalDetails) applyParamToAll(param string) { + switch param { + case "placement": + for _, ch := range s.Channels { + s.pasteParamValue(param, ch.Username) + } + case "cost": + for _, ch := range s.Channels { + s.pasteParamValue(param, ch.Username) + } + case "cost_before": + for _, ch := range s.Channels { + s.pasteParamValue(param, ch.Username) + } + case "format": + for _, ch := range s.Channels { + s.pasteParamValue(param, ch.Username) + } + } +} + +func (s *PurchaseOptionalDetails) clearParamValue(param, username string) { + switch param { + case "placement": + s.PlacementByChannel[username] = nil + case "cost": + delete(s.CostByChannel, username) + case "cost_before": + s.CostBeforeByChannel[username] = nil + case "format": + delete(s.FormatByChannel, username) + } +} + +func (s *PurchaseOptionalDetails) paramTitle(param string) string { + switch param { + case "placement": + return "Дата и время размещения" + case "cost": + return "Стоимость" + case "cost_before": + return "Стоимость до торга" + case "format": + return "Формат" + default: + return "" + } +} + +func (s *PurchaseOptionalDetails) paramMode(param string) string { + switch param { + case "placement": + if s.PlacementMode == "" { + return "common" + } + return s.PlacementMode + case "cost": + if s.CostMode == "" { + return "common" + } + return s.CostMode + case "cost_before": + if s.CostBeforeMode == "" { + return "common" + } + return s.CostBeforeMode + case "format": + if s.FormatMode == "" { + return "common" + } + return s.FormatMode + default: + return "common" + } +} + +func (s *PurchaseOptionalDetails) setParamMode(param, mode string) { + switch param { + case "placement": + s.PlacementMode = mode + case "cost": + s.CostMode = mode + case "cost_before": + s.CostBeforeMode = mode + case "format": + s.FormatMode = mode + } +} + +func (s *PurchaseOptionalDetails) ensureDefaults() { + if s.PlacementMode == "" { + s.PlacementMode = "common" + } + if s.CostMode == "" { + s.CostMode = "common" + } + if s.CostBeforeMode == "" { + s.CostBeforeMode = "common" + } + if s.FormatMode == "" { + s.FormatMode = "common" + } + if s.PlacementByChannel == nil { + s.PlacementByChannel = make(map[string]*time.Time) + } + if s.CostByChannel == nil { + s.CostByChannel = make(map[string]CostEntry) + } + if s.CostBeforeByChannel == nil { + s.CostBeforeByChannel = make(map[string]*float64) + } + if s.FormatByChannel == nil { + s.FormatByChannel = make(map[string]string) + } + s.syncChannelMaps() +} + +func (s *PurchaseOptionalDetails) syncChannelMaps() { + valid := make(map[string]struct{}, len(s.Channels)) + for _, ch := range s.Channels { + valid[ch.Username] = struct{}{} + } + for key := range s.PlacementByChannel { + if _, ok := valid[key]; !ok { + delete(s.PlacementByChannel, key) + } + } + for key := range s.CostByChannel { + if _, ok := valid[key]; !ok { + delete(s.CostByChannel, key) + } + } + for key := range s.CostBeforeByChannel { + if _, ok := valid[key]; !ok { + delete(s.CostBeforeByChannel, key) + } + } + for key := range s.FormatByChannel { + if _, ok := valid[key]; !ok { + delete(s.FormatByChannel, key) + } + } +} + +func (s *PurchaseOptionalDetails) formatPlacementSummary() string { + if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { + return "для каждого" + } + return s.formatDateTime(s.PlacementDateTime) +} + +func (s *PurchaseOptionalDetails) formatPlacementDetails() string { + if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return "\n" + s.renderParamChannelSummary("placement") +} + +func (s *PurchaseOptionalDetails) formatCostSummary() string { + if s.CostMode == "per_channel" && len(s.Channels) > 1 { + return "для каждого" + } + return s.formatCostValue() +} + +func (s *PurchaseOptionalDetails) formatCostDetails() string { + if s.CostMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return "\n" + s.renderParamChannelSummary("cost") +} + +func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string { + if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { + return "для каждого" + } + return s.formatCostBefore() +} + +func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string { + if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return "\n" + s.renderParamChannelSummary("cost_before") +} + +func (s *PurchaseOptionalDetails) formatFormatSummary() string { + if s.FormatMode == "per_channel" && len(s.Channels) > 1 { + return "для каждого" + } + return s.formatText(s.Format) +} + +func (s *PurchaseOptionalDetails) formatFormatDetails() string { + if s.FormatMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return "\n" + s.renderParamChannelSummary("format") +} + +func (s *PurchaseOptionalDetails) formatParamValue(param, username string) string { + switch param { + case "placement": + value := s.PlacementByChannel[username] + return s.formatDateTime(value) + case "cost": + return s.formatCostForChannel(username) + case "cost_before": + return s.formatCostBeforeForChannel(username) + case "format": + if value, ok := s.FormatByChannel[username]; ok && value != "" { + return value + } + return "—" + default: + return "—" + } +} + +func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string { + entry, ok := s.CostByChannel[username] + if !ok || entry.Value == nil { + return "—" + } + label := s.costTypeLabelForEntry(entry) + return fmt.Sprintf("%s %.0f₽", label, *entry.Value) +} + +func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(username string) string { + value, ok := s.CostBeforeByChannel[username] + if !ok || value == nil { + return "—" + } + return fmt.Sprintf("%.0f₽", *value) +} + +func (s *PurchaseOptionalDetails) hasPlacementValue() bool { + if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.PlacementByChannel { + if value != nil { + return true + } + } + return false + } + return s.PlacementDateTime != nil +} + +func (s *PurchaseOptionalDetails) hasCostValue() bool { + if s.CostMode == "per_channel" && len(s.Channels) > 1 { + for _, entry := range s.CostByChannel { + if entry.Value != nil { + return true + } + } + return false + } + return s.CostValue != nil +} + +func (s *PurchaseOptionalDetails) hasCostBeforeValue() bool { + if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.CostBeforeByChannel { + if value != nil { + return true + } + } + return false + } + return s.CostBeforeBargain != nil +} + +func (s *PurchaseOptionalDetails) hasFormatValue() bool { + if s.FormatMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.FormatByChannel { + if value != "" { + return true + } + } + return false + } + return s.Format != "" +} + +func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string { + if entry.Type == "cpm" { + return "СРМ" + } + return "Фикс" +} + +func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string { + if s.CurrentChannel == "" { + return s.costTypeLabel() + } + entry, ok := s.CostByChannel[s.CurrentChannel] + if !ok || entry.Type == "" { + return s.costTypeLabel() + } + return s.costTypeLabelForEntry(entry) +} + +func (s *PurchaseOptionalDetails) toggleCostType() { + if s.CurrentChannel == "" { + if s.costTypeLabel() == "СРМ" { + s.CostType = "fixed" + } else { + s.CostType = "cpm" + } + return + } + entry := s.CostByChannel[s.CurrentChannel] + if entry.Type == "" { + entry.Type = s.CostType + } + if s.costTypeLabelForEntry(entry) == "СРМ" { + entry.Type = "fixed" + } else { + entry.Type = "cpm" + } + s.CostByChannel[s.CurrentChannel] = entry +} + +func (s *PurchaseOptionalDetails) setCostValue(value float64) { + if s.CurrentChannel == "" { + s.CostValue = &value + return + } + entry := s.CostByChannel[s.CurrentChannel] + entry.Value = &value + if entry.Type == "" { + entry.Type = s.CostType + } + s.CostByChannel[s.CurrentChannel] = entry +} + +func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) { + if s.CurrentChannel == "" { + s.CostBeforeBargain = &value + return + } + s.CostBeforeByChannel[s.CurrentChannel] = &value +} + +func (s *PurchaseOptionalDetails) setFormatValue(value string) { + if s.CurrentChannel == "" { + s.Format = value + return + } + s.FormatByChannel[s.CurrentChannel] = value +} + +func (s *PurchaseOptionalDetails) backAction() string { + if s.ReturnMode != "" { + return "back_to_return" + } + return "back_to_optional" +} diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go index a490224..93bb92d 100644 --- a/telegram_bot/screens/select_channels_for_purchase.go +++ b/telegram_bot/screens/select_channels_for_purchase.go @@ -13,14 +13,9 @@ import ( ) type PurchaseChannelInput struct { - Username string - PlannedCost *float64 - Comment *string - PlacementDateTime *time.Time - CostType string - CostValue *float64 - CostBeforeBargain *float64 - Format string + Username string + PlannedCost *float64 + Comment *string } type SelectChannelsForPurchase struct {