Auto-sync permit competitors in monitoring PF
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
This commit is contained in:
@@ -52,20 +52,22 @@ type Project struct {
|
||||
}
|
||||
|
||||
type Listing struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Source string `json:"source"`
|
||||
ExternalID string `json:"external_id"`
|
||||
URL string `json:"url"`
|
||||
Title *string `json:"title"`
|
||||
AgentName *string `json:"agent_name"`
|
||||
AgencyName *string `json:"agency_name"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
Currency *string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
FirstSeenAt *string `json:"first_seen_at"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
PriceHistory []PricePoint `json:"price_history,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Source string `json:"source"`
|
||||
ExternalID string `json:"external_id"`
|
||||
URL string `json:"url"`
|
||||
Title *string `json:"title"`
|
||||
AgentName *string `json:"agent_name"`
|
||||
AgencyName *string `json:"agency_name"`
|
||||
PermitNumber *string `json:"permit_number"`
|
||||
AutoDiscovered bool `json:"auto_discovered"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
Currency *string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
FirstSeenAt *string `json:"first_seen_at"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
PriceHistory []PricePoint `json:"price_history,omitempty"`
|
||||
}
|
||||
|
||||
type PricePoint struct {
|
||||
@@ -157,6 +159,8 @@ func (a *App) InitDB(ctx context.Context) error {
|
||||
title VARCHAR(500),
|
||||
agent_name VARCHAR(300),
|
||||
agency_name VARCHAR(300),
|
||||
permit_number VARCHAR(100),
|
||||
auto_discovered BOOLEAN NOT NULL DEFAULT 0,
|
||||
current_price FLOAT,
|
||||
currency VARCHAR(10),
|
||||
status VARCHAR(7) NOT NULL,
|
||||
@@ -178,7 +182,10 @@ func (a *App) InitDB(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return a.migrateEmployees(ctx)
|
||||
if err := a.migrateEmployees(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.migrateCompetitorListings(ctx)
|
||||
}
|
||||
|
||||
func (a *App) migrateEmployees(ctx context.Context) error {
|
||||
@@ -208,6 +215,37 @@ func (a *App) migrateEmployees(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateCompetitorListings(ctx context.Context) error {
|
||||
rows, err := a.DB.QueryContext(ctx, `PRAGMA table_info(competitor_listings)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var defaultValue any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if !columns["permit_number"] {
|
||||
if _, err := a.DB.ExecContext(ctx, `ALTER TABLE competitor_listings ADD COLUMN permit_number VARCHAR(100)`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !columns["auto_discovered"] {
|
||||
if _, err := a.DB.ExecContext(ctx, `ALTER TABLE competitor_listings ADD COLUMN auto_discovered BOOLEAN NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanPtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -352,13 +352,45 @@ func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string)
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
if err := s.App.DeleteListing(r.Context(), emp.ID, id); err != nil {
|
||||
deleted, err := s.App.DeleteListing(r.Context(), emp.ID, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "listing not found")
|
||||
return
|
||||
}
|
||||
if deleted.OwnerChatID != nil && *deleted.OwnerChatID != "" {
|
||||
_ = s.App.TG.SendMessage(r.Context(), *deleted.OwnerChatID, formatDeletedListingMessage(deleted))
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func formatDeletedListingMessage(deleted *DeletedListing) string {
|
||||
listing := deleted.Listing
|
||||
title := "без названия"
|
||||
if listing.Title != nil && *listing.Title != "" {
|
||||
title = *listing.Title
|
||||
}
|
||||
price := "—"
|
||||
if listing.CurrentPrice != nil {
|
||||
currency := "AED"
|
||||
if listing.Currency != nil && *listing.Currency != "" {
|
||||
currency = *listing.Currency
|
||||
}
|
||||
price = strconv.FormatFloat(*listing.CurrentPrice, 'f', 0, 64) + " " + currency
|
||||
}
|
||||
permit := "—"
|
||||
if listing.PermitNumber != nil && *listing.PermitNumber != "" {
|
||||
permit = *listing.PermitNumber
|
||||
}
|
||||
return "🏠 <b>" + deleted.ProjectTitle + "</b>\n" +
|
||||
"Тип: " + deleted.ProjectDeal + " · Изменений: 1\n" +
|
||||
"——————————\n" +
|
||||
"🗑️ <b>Удален конкурент</b> — " + listing.Source + "\n" +
|
||||
title + "\n" +
|
||||
"Последняя цена: " + price + "\n" +
|
||||
"Permit: <code>" + permit + "</code>\n" +
|
||||
listing.URL
|
||||
}
|
||||
|
||||
func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employee, bool) {
|
||||
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), true)
|
||||
if errors.Is(err, ErrTelegramRequired) {
|
||||
|
||||
@@ -386,30 +386,53 @@ func (a *App) DeleteProject(ctx context.Context, ownerID, projectID int64) error
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) DeleteListing(ctx context.Context, ownerID, listingID int64) error {
|
||||
var id int64
|
||||
err := a.DB.QueryRowContext(ctx, `
|
||||
SELECT l.id
|
||||
FROM competitor_listings l JOIN projects p ON p.id = l.project_id
|
||||
WHERE l.id = ? AND p.owner_id = ?`, listingID, ownerID).Scan(&id)
|
||||
type DeletedListing struct {
|
||||
Listing *Listing
|
||||
ProjectTitle string
|
||||
ProjectDeal string
|
||||
OwnerChatID *string
|
||||
}
|
||||
|
||||
func (a *App) DeleteListing(ctx context.Context, ownerID, listingID int64) (*DeletedListing, error) {
|
||||
row := a.DB.QueryRowContext(ctx, listingSelect()+`
|
||||
JOIN projects p ON p.id = l.project_id
|
||||
WHERE l.id = ? AND p.owner_id = ?`, listingID, ownerID)
|
||||
listing, err := scanListing(row, false)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
deleted := &DeletedListing{Listing: listing}
|
||||
var deal string
|
||||
var chat sql.NullString
|
||||
if err := a.DB.QueryRowContext(ctx, `
|
||||
SELECT p.title, p.deal_type, e.tg_chat_id
|
||||
FROM projects p
|
||||
JOIN employees e ON e.id = p.owner_id
|
||||
WHERE p.id = ? AND p.owner_id = ?`, listing.ProjectID, ownerID).
|
||||
Scan(&deleted.ProjectTitle, &deal, &chat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleted.ProjectDeal = enumDealOut(deal)
|
||||
deleted.OwnerChatID = nullableString(chat)
|
||||
|
||||
tx, err := a.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM price_history WHERE listing_id = ?`, id); err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM price_history WHERE listing_id = ?`, listing.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM competitor_listings WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM competitor_listings WHERE id = ?`, listing.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx.Commit()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (a *App) ListingByID(ctx context.Context, id int64, withHistory bool) (*Listing, error) {
|
||||
@@ -547,18 +570,19 @@ func (a *App) PriceHistory(ctx context.Context, listingID int64) ([]PricePoint,
|
||||
func listingSelect() string {
|
||||
return `
|
||||
SELECT l.id, l.project_id, l.source, l.external_id, l.url, l.title, l.agent_name,
|
||||
l.agency_name, l.current_price, l.currency, l.status, l.first_seen_at, l.last_seen_at
|
||||
l.agency_name, l.permit_number, l.auto_discovered, l.current_price, l.currency, l.status, l.first_seen_at, l.last_seen_at
|
||||
FROM competitor_listings l`
|
||||
}
|
||||
|
||||
func scanListing(row rowScanner, _ bool) (*Listing, error) {
|
||||
var l Listing
|
||||
var source, status string
|
||||
var title, agent, agency, currency, firstSeen, lastSeen sql.NullString
|
||||
var title, agent, agency, permit, currency, firstSeen, lastSeen sql.NullString
|
||||
var price sql.NullFloat64
|
||||
var autoDiscovered bool
|
||||
if err := row.Scan(
|
||||
&l.ID, &l.ProjectID, &source, &l.ExternalID, &l.URL, &title, &agent, &agency,
|
||||
&price, ¤cy, &status, &firstSeen, &lastSeen,
|
||||
&permit, &autoDiscovered, &price, ¤cy, &status, &firstSeen, &lastSeen,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -566,6 +590,8 @@ func scanListing(row rowScanner, _ bool) (*Listing, error) {
|
||||
l.Title = nullableString(title)
|
||||
l.AgentName = nullableString(agent)
|
||||
l.AgencyName = nullableString(agency)
|
||||
l.PermitNumber = nullableString(permit)
|
||||
l.AutoDiscovered = autoDiscovered
|
||||
l.CurrentPrice = nullableFloat(price)
|
||||
l.Currency = nullableString(currency)
|
||||
l.Status = enumStatusOut(status)
|
||||
|
||||
Reference in New Issue
Block a user