правки

This commit is contained in:
Artem Tsyrulnikov
2026-02-01 18:13:46 +03:00
parent 4e35c73a66
commit 52cd2ec1a0
8 changed files with 503 additions and 115 deletions

View File

@@ -397,6 +397,17 @@ class Postgres(DatabaseBase):
'project', 'project__channel', 'channel', 'creative'
)
@staticmethod
async def count_placements_by_project_and_channel(
project_id: uuid.UUID,
channel_id: uuid.UUID,
) -> int:
"""Count placements for a project in a specific channel."""
return await domain.Placement.filter(
project_id=project_id,
channel_id=channel_id,
).count()
@staticmethod
async def update_placement(placement: domain.Placement) -> None:
await placement.save()

View File

@@ -9,6 +9,12 @@ from src.domain.placement_post import PlacementPostStatus
from .channel import ChannelOutput
class ProjectOutput(pydantic.BaseModel):
id: uuid.UUID
status: str
channel: ChannelOutput
class CostInfo(pydantic.BaseModel):
type: CostType
value: float
@@ -30,11 +36,14 @@ class PlacementOutput(pydantic.BaseModel):
id: uuid.UUID
status: PlacementStatus
creative_id: uuid.UUID | None = None
creative_name: str | None = None
comment: str | None = None
invite_link: str | None
invite_link_created_at: datetime.datetime | None = None
invite_link_type: InviteLinkType
channel: ChannelOutput
project: ProjectOutput | None = None
placement_number: int
details: PlacementDetails | None = None

View File

@@ -31,16 +31,41 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
return None
def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput:
def _build_placement_output(
placement: domain.Placement,
project: domain.Project | None = None,
placement_number: int = 0,
creative_name: str | None = None,
) -> dto.PlacementOutput:
channel = placement.channel
if channel is None:
log.error('Placement %s has no channel prefetched', placement.id)
raise ValueError(f'Placement {placement.id} has no channel')
# Build project output if project is provided
project_output: dto.ProjectOutput | None = None
if project is not None:
project_channel = project.channel
if project_channel is None:
log.error('Project %s has no channel', project.id)
raise ValueError(f'Project {project.id} has no channel')
project_output = dto.ProjectOutput(
id=project.id,
status=project.status.value,
channel=dto.ChannelOutput(
id=project_channel.id,
telegram_id=project_channel.telegram_id,
title=project_channel.title,
username=project_channel.username,
),
)
return dto.PlacementOutput(
id=placement.id,
status=placement.status,
creative_id=placement.creative_id,
creative_name=creative_name,
comment=placement.comment,
invite_link=placement.invite_link,
invite_link_type=placement.invite_link_type,
@@ -50,6 +75,8 @@ def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput:
title=channel.title,
username=channel.username,
),
project=project_output,
placement_number=placement_number,
details=_build_placement_details(placement),
)
@@ -73,7 +100,6 @@ async def create_placements(
if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id)
invite_link_type = project.purchase_invite_type_default
details = input.details
placements: list[domain.Placement] = []
creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
@@ -106,7 +132,9 @@ async def create_placements(
creative_id=creative.id if creative else None,
channel_id=channel.id,
invite_link=None,
invite_link_type=invite_link_type,
invite_link_type=(channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None)
or (details.invite_link_type if details and details.invite_link_type else None)
or project.purchase_invite_type_default,
status=channel_input.status or domain.PlacementStatus.NO_STATUS,
comment=channel_input.comment,
# Приоритет: channel details > общие details
@@ -138,7 +166,7 @@ async def create_placements(
placement_outputs = []
for placement in placements:
placement_output = _build_placement_output(placement)
placement_output = _build_placement_output(placement, project=project)
placement_outputs.append(
dto.PlacementWithPostsOutput(
**placement_output.model_dump(),

View File

@@ -77,6 +77,30 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
channel = await self.database.get_channel(channel_id=placement.channel_id)
placement.channel = channel
# Prefetch project channel for output building
if project.channel is None:
project_channel = await self.database.get_channel(channel_id=project.channel_id)
project.channel = project_channel
# Get creative name if exists
creative_name = None
if placement.creative_id:
creative = await self.database.get_creative(input.workspace_id, placement.creative_id)
if creative:
creative_name = creative.name
# Calculate placement number (ordinal number of this placement)
placement_number = await self.database.count_placements_by_project_and_channel(
project.id, placement.channel_id
)
# Count placements created before this one
placements_before = await domain.Placement.filter(
project_id=project.id,
channel_id=placement.channel_id,
created_at__lt=placement.created_at,
).count()
placement_number = placements_before + 1
placement_posts = await self.database.get_workspace_placement_posts(
input.workspace_id,
placement_id=placement.id,
@@ -112,7 +136,12 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
if placement_post_output is not None:
break
placement_output = _build_placement_output(placement)
placement_output = _build_placement_output(
placement,
project=project,
placement_number=placement_number,
creative_name=creative_name,
)
return dto.PlacementWithPostsOutput(
**placement_output.model_dump(),
placement_post=placement_post_output,

View File

@@ -82,7 +82,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
placement_outputs = []
for placement in placements:
placement_output = _build_placement_output(placement)
placement_output = _build_placement_output(placement, project=project)
placement_post_output = None
placement_posts_for_placement = placement_posts_by_placement_id.get(placement.id, [])
if placement_posts_for_placement:

View File

@@ -550,6 +550,7 @@ type Channel struct {
TelegramID *int64 `json:"telegram_id"`
Title *string `json:"title"`
Username *string `json:"username"`
InviteLink *string `json:"invite_link"`
}
type CreateChannelInput struct {
@@ -568,6 +569,12 @@ type CreateChannelResult struct {
Error *string `json:"error,omitempty"`
}
type ProjectOutput struct {
ID string `json:"id"`
Status string `json:"status"`
Channel Channel `json:"channel"`
}
type CreateChannelsOutput struct {
Results []CreateChannelResult `json:"results"`
}
@@ -586,24 +593,30 @@ type PlacementDetails struct {
Format *string `json:"format,omitempty"`
Comment *string `json:"comment,omitempty"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
InviteLinkType *string `json:"invite_link_type,omitempty"`
}
type PlacementOutput struct {
ID string `json:"id"`
Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"`
Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
ID string `json:"id"`
Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
Project *ProjectOutput `json:"project"`
PlacementNumber int `json:"placement_number"`
Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
}
type PlacementPostOutput struct {
SubscriptionsCount int `json:"subscriptions_count"`
ViewsCount *int `json:"views_count,omitempty"`
CreatedAt string `json:"created_at"`
TimeOnTop *int `json:"time_on_top,omitempty"`
Post PostOutput `json:"post"`
}

View File

@@ -32,62 +32,164 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Детали размещения</b>\n\n"
channelName := formatChannelName(placement.Channel)
text += fmt.Sprintf(" <b>Канал:</b> %s\n", channelName)
text += fmt.Sprintf("• <b>Статус:</b> %s\n", formatPlacementStatus(placement.Status))
text += fmt.Sprintf("• <b>Ссылка:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
// Размещение в канале
text += "📺 <b>Размещение в канале:</b>\n"
text += formatChannelWithName(placement.Channel)
text += "\n"
// Рекламируемый проект
if placement.Project != nil {
text += "🎯 <b>Рекламируемый проект:</b>\n"
text += formatProject(*placement.Project)
text += "\n"
}
// Ссылка на пост
if placementPost != nil && placementPost.Post.URL != nil && *placementPost.Post.URL != "" {
text += fmt.Sprintf("🔗 <b>Ссылка на пост:</b> %s\n", *placementPost.Post.URL)
}
// Пригласительная ссылка
if placement.InviteLink != nil && *placement.InviteLink != "" {
text += fmt.Sprintf("📩 <b>Пригласительная ссылка:</b> %s\n", *placement.InviteLink)
}
// Тип ссылки
text += fmt.Sprintf("🔓 <b>Тип ссылки:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
// Порядковый номер размещения
if placement.PlacementNumber > 0 {
text += fmt.Sprintf("🔢 <b>Размещение №%d</b> в этом канале\n", placement.PlacementNumber)
}
// Креатив
if placement.Details != nil && placement.Details.CreativeName != nil && *placement.Details.CreativeName != "" {
text += fmt.Sprintf("✨ <b>Креатив:</b> %s\n", *placement.Details.CreativeName)
} else if placement.CreativeName != nil && *placement.CreativeName != "" {
text += fmt.Sprintf("✨ <b>Креатив:</b> %s\n", *placement.CreativeName)
}
text += "\n"
// Финансовая информация
if placement.Details != nil {
if placement.Details.PlacementType != nil {
text += fmt.Sprintf("• <b>Тип:</b> %s\n", formatPlacementType(*placement.Details.PlacementType))
}
if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
text += fmt.Sprintf("• <b>Дата размещения:</b> %s\n", formatDateTime(*placement.Details.PlacementAt))
}
if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" {
text += fmt.Sprintf("• <b>Дата оплаты:</b> %s\n", formatDateTime(*placement.Details.PaymentAt))
}
// Стоимость
if placement.Details.Cost != nil {
text += fmt.Sprintf("• <b>Стоимость:</b> %s %.0f₽\n",
formatCostType(placement.Details.Cost.Type),
placement.Details.Cost.Value,
)
costType := formatCostType(placement.Details.Cost.Type)
text += fmt.Sprintf("💰 <b>Стоимость:</b> %s %.0f₽\n", costType, placement.Details.Cost.Value)
}
// Стоимость до торга
if placement.Details.CostBeforeBargain != nil {
text += fmt.Sprintf("• <b>До торга:</b> %s %.0f₽\n",
formatCostType(placement.Details.CostBeforeBargain.Type),
placement.Details.CostBeforeBargain.Value,
)
costType := formatCostType(placement.Details.CostBeforeBargain.Type)
text += fmt.Sprintf("💸 <b>До торга:</b> %s %.0f₽\n", costType, placement.Details.CostBeforeBargain.Value)
}
// Дата размещения
if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
text += fmt.Sprintf("📅 <b>Дата размещения:</b> %s\n", formatDateTime(*placement.Details.PlacementAt))
}
// Дата оплаты
if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" {
text += fmt.Sprintf("💳 <b>Дата оплаты:</b> %s\n", formatDateTime(*placement.Details.PaymentAt))
}
// Формат
if placement.Details.Format != nil && *placement.Details.Format != "" {
text += fmt.Sprintf(" <b>Формат:</b> %s\n", *placement.Details.Format)
text += fmt.Sprintf("📐 <b>Формат:</b> %s\n", *placement.Details.Format)
}
if placement.Details.Comment != nil && *placement.Details.Comment != "" {
text += fmt.Sprintf("• <b>Комментарий:</b> %s\n", *placement.Details.Comment)
// Тип закупа
if placement.Details.PlacementType != nil {
text += fmt.Sprintf("🔄 <b>Тип закупа:</b> %s\n", formatPlacementType(*placement.Details.PlacementType))
}
}
text += "\n"
// Статистика
if placementPost != nil {
text += "\n<b>Пост</b>\n"
if placementPost.Post.URL != nil && *placementPost.Post.URL != "" {
text += fmt.Sprintf(" <b>Ссылка на пост:</b> %s\n", *placementPost.Post.URL)
} else {
text += "• <b>Ссылка на пост:</b> не найдена\n"
// Кол-во подписчиков
if placementPost.SubscriptionsCount > 0 {
text += fmt.Sprintf("👥 <b>Подписчики:</b> %d\n", placementPost.SubscriptionsCount)
}
// Просмотры поста
if placementPost.ViewsCount != nil {
text += fmt.Sprintf("👁️ <b>Просмотры:</b> %d\n", *placementPost.ViewsCount)
}
// CPF (стоимость подписчика)
if placementPost.SubscriptionsCount > 0 && placement.Details != nil && placement.Details.Cost != nil {
cpf := calculateCPF(placement.Details.Cost.Value, placementPost.SubscriptionsCount)
text += fmt.Sprintf("💵 <b>CPF:</b> %.2f₽\n", cpf)
}
// Конверсия
if placementPost.ViewsCount != nil && *placementPost.ViewsCount > 0 && placementPost.SubscriptionsCount > 0 {
conversion := calculateConversion(placementPost.SubscriptionsCount, *placementPost.ViewsCount)
text += fmt.Sprintf("📊 <b>Конверсия:</b> %.1f%%\n", conversion)
}
}
// Комментарий
if placement.Details != nil && placement.Details.Comment != nil && *placement.Details.Comment != "" {
text += fmt.Sprintf("\n📝 <b>Комментарий:</b> %s\n", *placement.Details.Comment)
}
keyboard := Keyboard(Row(Button("← Назад", "back")))
b.Render(text, keyboard, mode)
}
func formatChannelName(channel backend.Channel) string {
func formatChannelWithName(channel backend.Channel) string {
var name string
if channel.Title != nil && *channel.Title != "" {
return *channel.Title
name = *channel.Title
} else if channel.Username != nil && *channel.Username != "" {
name = "@" + *channel.Username
} else {
name = "Без названия"
}
// Добавляем ссылку если есть username или invite_link
if channel.Username != nil && *channel.Username != "" {
return "@" + *channel.Username
return fmt.Sprintf(" %s (@%s)\n", name, *channel.Username)
} else if channel.InviteLink != nil && *channel.InviteLink != "" {
return fmt.Sprintf(" %s\n", name)
}
return "Без названия"
return fmt.Sprintf(" %s\n", name)
}
func formatProject(project backend.ProjectOutput) string {
var name string
if project.Channel.Title != nil && *project.Channel.Title != "" {
name = *project.Channel.Title
} else if project.Channel.Username != nil && *project.Channel.Username != "" {
name = "@" + *project.Channel.Username
} else {
name = "Без названия"
}
// Добавляем ссылку если есть username
if project.Channel.Username != nil && *project.Channel.Username != "" {
return fmt.Sprintf(" %s (@%s)\n", name, *project.Channel.Username)
}
return fmt.Sprintf(" %s\n", name)
}
func calculateCPF(cost float64, subscriptions int) float64 {
if subscriptions == 0 {
return 0
}
return cost / float64(subscriptions)
}
func calculateConversion(subscriptions int, views int) float64 {
if views == 0 {
return 0
}
return float64(subscriptions) / float64(views) * 100
}
func formatInviteLinkType(value string) string {

View File

@@ -15,48 +15,52 @@ import (
)
type PurchaseOptionalDetails struct {
ProjectID string
ProjectTitle string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
PlacementDateTime *time.Time
PaymentDate *time.Time
CostType string
CostValue *float64
CostBeforeType string
CostBeforeBargain *CostEntry
PurchaseType string
Format string
Comment string
InputMode string
CurrentParam string
CurrentChannel string
ParamPage int
ReturnMode string
PlacementMode string
PaymentDateMode string
CostMode string
CostBeforeMode string
PurchaseTypeMode string
CommentMode string
FormatMode string
PlacementByChannel map[string]*time.Time
PaymentDateByChannel map[string]*time.Time
CostByChannel map[string]CostEntry
CostBeforeByChannel map[string]CostEntry
PurchaseTypeByChannel map[string]string
CommentByChannel map[string]string
FormatByChannel map[string]string
PlacementCopy *time.Time
PaymentDateCopy *time.Time
CostCopy *CostEntry
CostBeforeCopy *CostEntry
PurchaseTypeCopy *string
CommentCopy *string
FormatCopy *string
ChannelEditMode string // "edit" или "copy"
BackState bot.State
ProjectID string
ProjectTitle string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
PlacementDateTime *time.Time
PaymentDate *time.Time
CostType string
CostValue *float64
CostBeforeType string
CostBeforeBargain *CostEntry
PurchaseType string
Format string
Comment string
InviteLinkType string
InputMode string
CurrentParam string
CurrentChannel string
ParamPage int
ReturnMode string
PlacementMode string
PaymentDateMode string
CostMode string
CostBeforeMode string
PurchaseTypeMode string
CommentMode string
FormatMode string
InviteLinkTypeMode string
PlacementByChannel map[string]*time.Time
PaymentDateByChannel map[string]*time.Time
CostByChannel map[string]CostEntry
CostBeforeByChannel map[string]CostEntry
PurchaseTypeByChannel map[string]string
CommentByChannel map[string]string
FormatByChannel map[string]string
InviteLinkTypeByChannel map[string]string
PlacementCopy *time.Time
PaymentDateCopy *time.Time
CostCopy *CostEntry
CostBeforeCopy *CostEntry
PurchaseTypeCopy *string
CommentCopy *string
FormatCopy *string
InviteLinkTypeCopy *string
ChannelEditMode string // "edit" или "copy"
BackState bot.State
}
type CostEntry struct {
@@ -104,6 +108,7 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
))
rows = append(rows, Row(
s.formatButton(),
s.inviteLinkTypeButton(),
))
if row := s.costBeforeDeleteRow(); row != nil {
@@ -182,6 +187,16 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
s.openCommonEditor(b, "format")
}
case "opt_invite_link_type":
// Открываем редактор в текущем режиме
if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "invite_link_type_channels"
s.Enter(b, bot.EditMessage)
} else {
s.InputMode = "invite_link_type"
s.Enter(b, bot.EditMessage)
}
case "opt_comment":
// Открываем редактор в текущем режиме
if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
@@ -332,6 +347,19 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "invite_link_type:") {
value := strings.TrimPrefix(u.CallbackQuery.Data, "invite_link_type:")
s.setInviteLinkTypeValue(value)
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
return
}
s.Enter(b, bot.EditMessage)
}
}
@@ -433,6 +461,9 @@ func (s *PurchaseOptionalDetails) formatOptionalSummary() string {
if s.hasFormatValue() {
lines = append(lines, fmt.Sprintf("<i>Формат:</i> <b>%s</b>%s", s.formatFormatSummary(), s.formatFormatDetails()))
}
if s.hasInviteLinkTypeValue() {
lines = append(lines, fmt.Sprintf("<i>Тип ссылки:</i> <b>%s</b>%s", s.formatInviteLinkTypeSummary(), s.formatInviteLinkTypeDetails()))
}
if s.hasCommentValue() {
lines = append(lines, fmt.Sprintf("<i>Комментарий:</i> <b>%s</b>%s", s.formatCommentSummary(), s.formatCommentDetails()))
}
@@ -449,8 +480,7 @@ func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string {
return "—"
}
local := value.In(ui2.MskLocation)
text := fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04"))
return fmt.Sprintf("<b>%s</b>", text)
return fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04"))
}
func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string {
@@ -458,23 +488,21 @@ func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string {
return "—"
}
local := value.In(ui2.MskLocation)
text := fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()))
return fmt.Sprintf("<b>%s</b>", text)
return fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()))
}
func (s *PurchaseOptionalDetails) formatText(value string) string {
if value == "" {
return "—"
}
return fmt.Sprintf("<b>%s</b>", value)
return value
}
func (s *PurchaseOptionalDetails) formatCostValue() string {
if s.CostValue == nil {
return "—"
}
text := fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue)
return fmt.Sprintf("<b>%s</b>", text)
return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue)
}
func (s *PurchaseOptionalDetails) formatCostBefore() string {
@@ -482,8 +510,7 @@ func (s *PurchaseOptionalDetails) formatCostBefore() string {
return "—"
}
label := s.costBeforeTypeLabelForEntry(*s.CostBeforeBargain)
text := fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value)
return fmt.Sprintf("<b>%s</b>", text)
return fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value)
}
func (s *PurchaseOptionalDetails) costTypeLabel() string {
@@ -576,6 +603,22 @@ func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton
return Button(fmt.Sprintf("%s Комментарий%s", icon, modeIcon), "opt_comment")
}
func (s *PurchaseOptionalDetails) inviteLinkTypeButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasInviteLinkTypeValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.InviteLinkTypeMode == "per_channel" {
modeIcon = " 👥"
}
label := "Тип ссылки"
if s.InviteLinkType != "" {
label += ": " + s.inviteLinkTypeLabel(s.InviteLinkType)
}
return Button(fmt.Sprintf("%s %s%s", icon, label, modeIcon), "opt_invite_link_type")
}
func (s *PurchaseOptionalDetails) globalModeButton() echotron.InlineKeyboardButton {
return Button("⚙️ Режимы параметров", "toggle_global_mode")
}
@@ -686,6 +729,25 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
return
case "format_custom":
text += "<b>Введите формат</b>\n\nНапример: <code>пост</code>"
case "invite_link_type":
text = "<i>Страница выбора типа ссылки</i>\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
}
text += "Выберите тип ссылки\n\n"
keyboard := Keyboard(
Row(
Button("Открытая", "invite_link_type:public"),
Button("С заявками", "invite_link_type:approval"),
),
Row(Button("← Назад", s.backAction())),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "comment":
text += "<b>Введите комментарий</b>"
default:
@@ -761,6 +823,15 @@ func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMo
}
rows = append(rows, Row(Button(formatLabel, "toggle_param_mode:format")))
// Тип ссылки
inviteLinkTypeLabel := "Тип ссылки: "
if s.InviteLinkTypeMode == "per_channel" {
inviteLinkTypeLabel += "👥"
} else {
inviteLinkTypeLabel += "общий"
}
rows = append(rows, Row(Button(inviteLinkTypeLabel, "toggle_param_mode:invite_link_type")))
rows = append(rows, Row(Button("← Назад", "back_to_optional")))
b.Render(text, Keyboard(rows...), mode)
@@ -789,6 +860,9 @@ func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.Render
case "format_channels":
s.renderParamChannels(b, mode, "format")
return true
case "invite_link_type_channels":
s.renderParamChannels(b, mode, "invite_link_type")
return true
default:
return false
}
@@ -928,7 +1002,7 @@ func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string
// Паддинг вычисляем так, чтобы все значения начинались с одной позиции
const (
marker = "· " // Маркер пункта
valueIndent = 2 // Отступ после самого длинного названия
valueIndent = 6 // Отступ после самого длинного названия (увеличен для лучшей читаемости)
)
var result []string
@@ -936,7 +1010,7 @@ func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string
labelLen := len([]rune(line.label))
// Паддинг = (макс.длина - тек.длина) + отступ после названия
paddingLen := maxLabelLen - labelLen + valueIndent
padding := strings.Repeat(" ", paddingLen)
padding := strings.Repeat("\u00A0", paddingLen)
formattedLine := fmt.Sprintf("%s%s%s%s", marker, line.label, padding, line.value)
result = append(result, formattedLine)
@@ -953,6 +1027,9 @@ func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string
finalResult := strings.Join(result, "\n")
// Оборачиваем в <pre> для моноширинного шрифта (выравнивание работает только в monospace)
finalResult = fmt.Sprintf("<pre>%s</pre>", finalResult)
// Логируем итоговый результат
log.Info().
Str("param", param).
@@ -1100,6 +1177,11 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "invite_link_type":
s.InputMode = "invite_link_type"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
default:
s.InputMode = ""
s.Enter(b, bot.EditMessage)
@@ -1151,6 +1233,11 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe
s.ReturnMode = "format_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "invite_link_type":
s.InputMode = "invite_link_type"
s.ReturnMode = "invite_link_type_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
default:
s.InputMode = ""
s.Enter(b, bot.EditMessage)
@@ -1204,6 +1291,13 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
} else {
s.FormatCopy = nil
}
case "invite_link_type":
if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok {
copied := value
s.InviteLinkTypeCopy = &copied
} else {
s.InviteLinkTypeCopy = nil
}
}
}
@@ -1275,6 +1369,14 @@ func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) {
key := channelKey(ch)
s.FormatByChannel[key] = s.Format
}
case "invite_link_type":
if s.InviteLinkType == "" {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
s.InviteLinkTypeByChannel[key] = s.InviteLinkType
}
}
}
@@ -1334,6 +1436,12 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
return
}
s.FormatByChannel[channelKey] = *s.FormatCopy
case "invite_link_type":
if s.InviteLinkTypeCopy == nil {
delete(s.InviteLinkTypeByChannel, channelKey)
return
}
s.InviteLinkTypeByChannel[channelKey] = *s.InviteLinkTypeCopy
}
}
@@ -1367,6 +1475,10 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "invite_link_type":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
}
}
@@ -1386,6 +1498,8 @@ func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) {
delete(s.CommentByChannel, channelKey)
case "format":
delete(s.FormatByChannel, channelKey)
case "invite_link_type":
delete(s.InviteLinkTypeByChannel, channelKey)
}
}
@@ -1405,6 +1519,8 @@ func (s *PurchaseOptionalDetails) paramTitle(param string) string {
return "Комментарий"
case "format":
return "Формат"
case "invite_link_type":
return "Тип ссылки"
default:
return ""
}
@@ -1447,6 +1563,11 @@ func (s *PurchaseOptionalDetails) paramMode(param string) string {
return "common"
}
return s.FormatMode
case "invite_link_type":
if s.InviteLinkTypeMode == "" {
return "common"
}
return s.InviteLinkTypeMode
default:
return "common"
}
@@ -1468,6 +1589,8 @@ func (s *PurchaseOptionalDetails) setParamMode(param, mode string) {
s.CommentMode = mode
case "format":
s.FormatMode = mode
case "invite_link_type":
s.InviteLinkTypeMode = mode
}
}
@@ -1493,6 +1616,9 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
if s.FormatMode == "" {
s.FormatMode = "common"
}
if s.InviteLinkTypeMode == "" {
s.InviteLinkTypeMode = "common"
}
if s.PlacementByChannel == nil {
s.PlacementByChannel = make(map[string]*time.Time)
}
@@ -1514,6 +1640,9 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
if s.FormatByChannel == nil {
s.FormatByChannel = make(map[string]string)
}
if s.InviteLinkTypeByChannel == nil {
s.InviteLinkTypeByChannel = make(map[string]string)
}
s.syncChannelMaps()
}
@@ -1560,6 +1689,11 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() {
delete(s.FormatByChannel, key)
}
}
for key := range s.InviteLinkTypeByChannel {
if _, ok := valid[key]; !ok {
delete(s.InviteLinkTypeByChannel, key)
}
}
}
func (s *PurchaseOptionalDetails) formatPlacementSummary() string {
@@ -1573,7 +1707,7 @@ func (s *PurchaseOptionalDetails) formatPlacementDetails() string {
if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("placement")
return s.renderParamChannelSummary("placement")
}
func (s *PurchaseOptionalDetails) formatPaymentDateSummary() string {
@@ -1587,7 +1721,7 @@ func (s *PurchaseOptionalDetails) formatPaymentDateDetails() string {
if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("payment_date")
return s.renderParamChannelSummary("payment_date")
}
func (s *PurchaseOptionalDetails) formatCostSummary() string {
@@ -1601,7 +1735,7 @@ func (s *PurchaseOptionalDetails) formatCostDetails() string {
if s.CostMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("cost")
return s.renderParamChannelSummary("cost")
}
func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string {
@@ -1615,7 +1749,7 @@ func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string {
if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("cost_before")
return s.renderParamChannelSummary("cost_before")
}
func (s *PurchaseOptionalDetails) formatPurchaseTypeSummary() string {
@@ -1629,7 +1763,7 @@ func (s *PurchaseOptionalDetails) formatPurchaseTypeDetails() string {
if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("purchase_type")
return s.renderParamChannelSummary("purchase_type")
}
func (s *PurchaseOptionalDetails) formatCommentSummary() string {
@@ -1643,7 +1777,7 @@ func (s *PurchaseOptionalDetails) formatCommentDetails() string {
if s.CommentMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("comment")
return s.renderParamChannelSummary("comment")
}
func (s *PurchaseOptionalDetails) formatFormatSummary() string {
@@ -1657,7 +1791,21 @@ func (s *PurchaseOptionalDetails) formatFormatDetails() string {
if s.FormatMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("format")
return s.renderParamChannelSummary("format")
}
func (s *PurchaseOptionalDetails) formatInviteLinkTypeSummary() string {
if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.inviteLinkTypeLabel(s.InviteLinkType)
}
func (s *PurchaseOptionalDetails) formatInviteLinkTypeDetails() string {
if s.InviteLinkTypeMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("invite_link_type")
}
func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) string {
@@ -1674,17 +1822,22 @@ func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) str
return s.formatCostBeforeForChannel(channelKey)
case "purchase_type":
if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
return fmt.Sprintf("<b>%s</b>", value)
return value
}
return "—"
case "comment":
if value, ok := s.CommentByChannel[channelKey]; ok && value != "" {
return fmt.Sprintf("<b>%s</b>", value)
return value
}
return "—"
case "format":
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
return fmt.Sprintf("<b>%s</b>", value)
return value
}
return "—"
case "invite_link_type":
if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" {
return s.inviteLinkTypeLabel(value)
}
return "—"
default:
@@ -1698,8 +1851,7 @@ func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string
return "—"
}
label := s.costTypeLabelForEntry(entry)
text := fmt.Sprintf("%s %.0f₽", label, *entry.Value)
return fmt.Sprintf("<b>%s</b>", text)
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
}
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string {
@@ -1708,8 +1860,7 @@ func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string)
return "—"
}
label := s.costBeforeTypeLabelForEntry(entry)
text := fmt.Sprintf("%s %.0f₽", label, *entry.Value)
return fmt.Sprintf("<b>%s</b>", text)
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
}
func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
@@ -1728,6 +1879,8 @@ func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
return s.CommentCopy != nil
case "format":
return s.FormatCopy != nil
case "invite_link_type":
return s.InviteLinkTypeCopy != nil
default:
return false
}
@@ -1756,6 +1909,9 @@ func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool
case "format":
value, ok := s.FormatByChannel[channelKey]
return ok && value != ""
case "invite_link_type":
value, ok := s.InviteLinkTypeByChannel[channelKey]
return ok && value != ""
default:
return false
}
@@ -1845,6 +2001,28 @@ func (s *PurchaseOptionalDetails) hasCommentValue() bool {
return s.Comment != ""
}
func (s *PurchaseOptionalDetails) hasInviteLinkTypeValue() bool {
if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.InviteLinkTypeByChannel {
if value != "" {
return true
}
}
return false
}
return s.InviteLinkType != ""
}
func (s *PurchaseOptionalDetails) inviteLinkTypeLabel(linkType string) string {
if linkType == "public" {
return "Открытая"
}
if linkType == "approval" {
return "С заявками"
}
return "—"
}
func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string {
if entry.Type == "cpm" {
return "СРМ"
@@ -1966,6 +2144,14 @@ func (s *PurchaseOptionalDetails) setFormatValue(value string) {
s.FormatByChannel[s.CurrentChannel] = value
}
func (s *PurchaseOptionalDetails) setInviteLinkTypeValue(value string) {
if s.CurrentChannel == "" {
s.InviteLinkType = value
return
}
s.InviteLinkTypeByChannel[s.CurrentChannel] = value
}
func (s *PurchaseOptionalDetails) backAction() string {
if s.ReturnMode != "" {
return "back_to_return"
@@ -2081,6 +2267,11 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *
details.Format = &s.Format
}
}
if s.InviteLinkTypeMode != "per_channel" || len(s.Channels) <= 1 {
if s.InviteLinkType != "" {
details.InviteLinkType = &s.InviteLinkType
}
}
if placementType != nil && (s.PurchaseTypeMode == "per_channel" || len(s.Channels) <= 1) {
details.PlacementType = placementType
}
@@ -2130,6 +2321,11 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string) *backen
details.Format = &value
}
}
if s.InviteLinkTypeMode == "per_channel" {
if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" {
details.InviteLinkType = &value
}
}
return details
}