правки

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' '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 @staticmethod
async def update_placement(placement: domain.Placement) -> None: async def update_placement(placement: domain.Placement) -> None:
await placement.save() await placement.save()

View File

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

View File

@@ -31,16 +31,41 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
return None 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 channel = placement.channel
if channel is None: if channel is None:
log.error('Placement %s has no channel prefetched', placement.id) log.error('Placement %s has no channel prefetched', placement.id)
raise ValueError(f'Placement {placement.id} has no channel') 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( return dto.PlacementOutput(
id=placement.id, id=placement.id,
status=placement.status, status=placement.status,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=creative_name,
comment=placement.comment, comment=placement.comment,
invite_link=placement.invite_link, invite_link=placement.invite_link,
invite_link_type=placement.invite_link_type, invite_link_type=placement.invite_link_type,
@@ -50,6 +75,8 @@ def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput:
title=channel.title, title=channel.title,
username=channel.username, username=channel.username,
), ),
project=project_output,
placement_number=placement_number,
details=_build_placement_details(placement), details=_build_placement_details(placement),
) )
@@ -73,7 +100,6 @@ async def create_placements(
if project.channel.telegram_id is None: if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id) raise domain.ChannelNotFound(project.channel.id)
invite_link_type = project.purchase_invite_type_default
details = input.details details = input.details
placements: list[domain.Placement] = [] placements: list[domain.Placement] = []
creatives_by_id: dict[uuid.UUID, domain.Creative] = {} creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
@@ -106,7 +132,9 @@ async def create_placements(
creative_id=creative.id if creative else None, creative_id=creative.id if creative else None,
channel_id=channel.id, channel_id=channel.id,
invite_link=None, 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, status=channel_input.status or domain.PlacementStatus.NO_STATUS,
comment=channel_input.comment, comment=channel_input.comment,
# Приоритет: channel details > общие details # Приоритет: channel details > общие details
@@ -138,7 +166,7 @@ async def create_placements(
placement_outputs = [] placement_outputs = []
for placement in placements: for placement in placements:
placement_output = _build_placement_output(placement) placement_output = _build_placement_output(placement, project=project)
placement_outputs.append( placement_outputs.append(
dto.PlacementWithPostsOutput( dto.PlacementWithPostsOutput(
**placement_output.model_dump(), **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) channel = await self.database.get_channel(channel_id=placement.channel_id)
placement.channel = channel 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( placement_posts = await self.database.get_workspace_placement_posts(
input.workspace_id, input.workspace_id,
placement_id=placement.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: if placement_post_output is not None:
break 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( return dto.PlacementWithPostsOutput(
**placement_output.model_dump(), **placement_output.model_dump(),
placement_post=placement_post_output, placement_post=placement_post_output,

View File

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

View File

@@ -550,6 +550,7 @@ type Channel struct {
TelegramID *int64 `json:"telegram_id"` TelegramID *int64 `json:"telegram_id"`
Title *string `json:"title"` Title *string `json:"title"`
Username *string `json:"username"` Username *string `json:"username"`
InviteLink *string `json:"invite_link"`
} }
type CreateChannelInput struct { type CreateChannelInput struct {
@@ -568,6 +569,12 @@ type CreateChannelResult struct {
Error *string `json:"error,omitempty"` Error *string `json:"error,omitempty"`
} }
type ProjectOutput struct {
ID string `json:"id"`
Status string `json:"status"`
Channel Channel `json:"channel"`
}
type CreateChannelsOutput struct { type CreateChannelsOutput struct {
Results []CreateChannelResult `json:"results"` Results []CreateChannelResult `json:"results"`
} }
@@ -586,16 +593,21 @@ type PlacementDetails struct {
Format *string `json:"format,omitempty"` Format *string `json:"format,omitempty"`
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
CreativeID *string `json:"creative_id,omitempty"` CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
InviteLinkType *string `json:"invite_link_type,omitempty"`
} }
type PlacementOutput struct { type PlacementOutput struct {
ID string `json:"id"` ID string `json:"id"`
Status string `json:"status"` Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"` CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"` InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"` InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"` Channel Channel `json:"channel"`
Project *ProjectOutput `json:"project"`
PlacementNumber int `json:"placement_number"`
Details *PlacementDetails `json:"details,omitempty"` Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
} }
@@ -604,6 +616,7 @@ type PlacementPostOutput struct {
SubscriptionsCount int `json:"subscriptions_count"` SubscriptionsCount int `json:"subscriptions_count"`
ViewsCount *int `json:"views_count,omitempty"` ViewsCount *int `json:"views_count,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
TimeOnTop *int `json:"time_on_top,omitempty"`
Post PostOutput `json:"post"` 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" text := "<b>Детали размещения</b>\n\n"
channelName := formatChannelName(placement.Channel) // Размещение в канале
text += fmt.Sprintf(" <b>Канал:</b> %s\n", channelName) text += "📺 <b>Размещение в канале:</b>\n"
text += fmt.Sprintf("• <b>Статус:</b> %s\n", formatPlacementStatus(placement.Status)) text += formatChannelWithName(placement.Channel)
text += fmt.Sprintf("• <b>Ссылка:</b> %s\n", formatInviteLinkType(placement.InviteLinkType)) 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 != 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 { if placement.Details.Cost != nil {
text += fmt.Sprintf("• <b>Стоимость:</b> %s %.0f₽\n", costType := formatCostType(placement.Details.Cost.Type)
formatCostType(placement.Details.Cost.Type), text += fmt.Sprintf("💰 <b>Стоимость:</b> %s %.0f₽\n", costType, placement.Details.Cost.Value)
placement.Details.Cost.Value,
)
} }
// Стоимость до торга
if placement.Details.CostBeforeBargain != nil { if placement.Details.CostBeforeBargain != nil {
text += fmt.Sprintf("• <b>До торга:</b> %s %.0f₽\n", costType := formatCostType(placement.Details.CostBeforeBargain.Type)
formatCostType(placement.Details.CostBeforeBargain.Type), text += fmt.Sprintf("💸 <b>До торга:</b> %s %.0f₽\n", costType, placement.Details.CostBeforeBargain.Value)
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 != "" { 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 { if placementPost != nil {
text += "\n<b>Пост</b>\n" // Кол-во подписчиков
if placementPost.Post.URL != nil && *placementPost.Post.URL != "" { if placementPost.SubscriptionsCount > 0 {
text += fmt.Sprintf(" <b>Ссылка на пост:</b> %s\n", *placementPost.Post.URL) text += fmt.Sprintf("👥 <b>Подписчики:</b> %d\n", placementPost.SubscriptionsCount)
} else {
text += "• <b>Ссылка на пост:</b> не найдена\n"
} }
// Просмотры поста
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"))) keyboard := Keyboard(Row(Button("← Назад", "back")))
b.Render(text, keyboard, mode) 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 != "" { 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 != "" { 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 { func formatInviteLinkType(value string) string {

View File

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