Update monitoring PF competitor tracking
This commit is contained in:
@@ -15,8 +15,6 @@ from app.models import Project
|
|||||||
from app.services.monitor import (
|
from app.services.monitor import (
|
||||||
BAYUT_ENABLED,
|
BAYUT_ENABLED,
|
||||||
add_competitor_url,
|
add_competitor_url,
|
||||||
_format_listing_added,
|
|
||||||
notify_project_changes,
|
|
||||||
run_check_all,
|
run_check_all,
|
||||||
run_check_for_project,
|
run_check_for_project,
|
||||||
sync_permit_competitors,
|
sync_permit_competitors,
|
||||||
@@ -66,7 +64,6 @@ def cmd_add_listing(payload: dict[str, Any]) -> None:
|
|||||||
listing, err = add_competitor_url(db, project, url)
|
listing, err = add_competitor_url(db, project, url)
|
||||||
if err:
|
if err:
|
||||||
_fail(err)
|
_fail(err)
|
||||||
notify_project_changes(project, [_format_listing_added(project, listing, auto=False)])
|
|
||||||
_write({"listing_id": listing.id})
|
_write({"listing_id": listing.id})
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -83,7 +80,6 @@ def cmd_add_listings(payload: dict[str, Any]) -> None:
|
|||||||
added = 0
|
added = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
notifications: list[str] = []
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for raw in urls:
|
for raw in urls:
|
||||||
url = str(raw or "").strip()
|
url = str(raw or "").strip()
|
||||||
@@ -97,9 +93,6 @@ def cmd_add_listings(payload: dict[str, Any]) -> None:
|
|||||||
errors.append(err)
|
errors.append(err)
|
||||||
else:
|
else:
|
||||||
added += 1
|
added += 1
|
||||||
notifications.append(_format_listing_added(project, listing, auto=False))
|
|
||||||
if notifications:
|
|
||||||
notify_project_changes(project, notifications)
|
|
||||||
_write({"added": added, "skipped": skipped, "errors": errors})
|
_write({"added": added, "skipped": skipped, "errors": errors})
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -76,6 +76,22 @@ type PricePoint struct {
|
|||||||
RecordedAt *string `json:"recorded_at"`
|
RecordedAt *string `json:"recorded_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TeamOverviewRow struct {
|
||||||
|
EmployeeID int64 `json:"employee_id"`
|
||||||
|
EmployeeName string `json:"employee_name"`
|
||||||
|
PortalUserID *string `json:"portal_user_id"`
|
||||||
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
|
ProjectID *int64 `json:"project_id"`
|
||||||
|
ProjectTitle *string `json:"project_title"`
|
||||||
|
DealType *string `json:"deal_type"`
|
||||||
|
DLDPermit *string `json:"dld_permit"`
|
||||||
|
LastCheckedAt *string `json:"last_checked_at"`
|
||||||
|
ListingsTotal int64 `json:"listings_total"`
|
||||||
|
ListingsActive int64 `json:"listings_active"`
|
||||||
|
ListingsRemoved int64 `json:"listings_removed"`
|
||||||
|
MinCompetitorPrice *float64 `json:"min_competitor_price"`
|
||||||
|
}
|
||||||
|
|
||||||
func OpenApp(ctx context.Context, cfg Config) (*App, error) {
|
func OpenApp(ctx context.Context, cfg Config) (*App, error) {
|
||||||
db, err := sql.Open("sqlite", sqliteDSN(cfg.DatabaseURL))
|
db, err := sql.Open("sqlite", sqliteDSN(cfg.DatabaseURL))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.accessMe(w, r)
|
s.accessMe(w, r)
|
||||||
case path == "/api/v1/summary" && r.Method == http.MethodGet:
|
case path == "/api/v1/summary" && r.Method == http.MethodGet:
|
||||||
s.summary(w, r)
|
s.summary(w, r)
|
||||||
|
case path == "/api/v1/team/overview" && r.Method == http.MethodGet:
|
||||||
|
s.teamOverview(w, r)
|
||||||
case path == "/api/v1/employees":
|
case path == "/api/v1/employees":
|
||||||
s.employees(w, r)
|
s.employees(w, r)
|
||||||
case strings.HasPrefix(path, "/api/v1/employees/"):
|
case strings.HasPrefix(path, "/api/v1/employees/"):
|
||||||
@@ -86,6 +88,7 @@ func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
"is_admin": isAdmin(r),
|
"is_admin": isAdmin(r),
|
||||||
"portal_user_id": nullablePlain(portalID),
|
"portal_user_id": nullablePlain(portalID),
|
||||||
"telegram_linked": emp != nil && emp.TGChatID != nil && *emp.TGChatID != "",
|
"telegram_linked": emp != nil && emp.TGChatID != nil && *emp.TGChatID != "",
|
||||||
|
"can_view_team": canViewTeam(r),
|
||||||
"employee": emp,
|
"employee": emp,
|
||||||
"telegram_bot_username": nullablePlain(botUsername),
|
"telegram_bot_username": nullablePlain(botUsername),
|
||||||
"telegram_start_command": command,
|
"telegram_start_command": command,
|
||||||
@@ -110,6 +113,19 @@ func (s Server) summary(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Server) teamOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !canViewTeam(r) {
|
||||||
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := s.App.TeamOverview(r.Context(), subordinatePortalIDs(r), isAdmin(r))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
func (s Server) employees(w http.ResponseWriter, r *http.Request) {
|
func (s Server) employees(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@@ -352,45 +368,13 @@ func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string)
|
|||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deleted, err := s.App.DeleteListing(r.Context(), emp.ID, id)
|
if err := s.App.DeleteListing(r.Context(), emp.ID, id); err != nil {
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "listing not found")
|
writeError(w, http.StatusNotFound, "listing not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if deleted.OwnerChatID != nil && *deleted.OwnerChatID != "" {
|
|
||||||
_ = s.App.TG.SendMessage(r.Context(), *deleted.OwnerChatID, formatDeletedListingMessage(deleted))
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employee, bool) {
|
||||||
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), true)
|
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), true)
|
||||||
if errors.Is(err, ErrTelegramRequired) {
|
if errors.Is(err, ErrTelegramRequired) {
|
||||||
@@ -412,6 +396,26 @@ func isAdmin(r *http.Request) bool {
|
|||||||
return r.Header.Get("X-User-Is-Admin") == "1"
|
return r.Header.Get("X-User-Is-Admin") == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canViewTeam(r *http.Request) bool {
|
||||||
|
return isAdmin(r) || r.Header.Get("X-User-Is-Department-Head") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func subordinatePortalIDs(r *http.Request) []string {
|
||||||
|
raw := strings.TrimSpace(r.Header.Get("X-User-Subordinates"))
|
||||||
|
if raw == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
id := strings.TrimSpace(part)
|
||||||
|
if id != "" {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func nullablePlain(value string) *string {
|
func nullablePlain(value string) *string {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("not found")
|
var ErrNotFound = errors.New("not found")
|
||||||
@@ -386,53 +387,34 @@ func (a *App) DeleteProject(ctx context.Context, ownerID, projectID int64) error
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeletedListing struct {
|
func (a *App) DeleteListing(ctx context.Context, ownerID, listingID int64) error {
|
||||||
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 nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
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)
|
tx, err := a.DB.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM price_history WHERE listing_id = ?`, listing.ID); err != nil {
|
if _, err := tx.ExecContext(ctx, `
|
||||||
return nil, err
|
DELETE FROM price_history
|
||||||
|
WHERE listing_id IN (
|
||||||
|
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); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM competitor_listings WHERE id = ?`, listing.ID); err != nil {
|
res, err := tx.ExecContext(ctx, `
|
||||||
return nil, err
|
DELETE FROM competitor_listings
|
||||||
|
WHERE id = ?
|
||||||
|
AND project_id IN (SELECT id FROM projects WHERE owner_id = ?)`, listingID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
affected, _ := res.RowsAffected()
|
||||||
return nil, err
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
return deleted, nil
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ListingByID(ctx context.Context, id int64, withHistory bool) (*Listing, error) {
|
func (a *App) ListingByID(ctx context.Context, id int64, withHistory bool) (*Listing, error) {
|
||||||
@@ -542,6 +524,87 @@ func (a *App) ListingsForProject(ctx context.Context, projectID int64, withHisto
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) TeamOverview(ctx context.Context, portalUserIDs []string, all bool) ([]TeamOverviewRow, error) {
|
||||||
|
args := []any{}
|
||||||
|
where := ""
|
||||||
|
if !all {
|
||||||
|
if len(portalUserIDs) == 0 {
|
||||||
|
return []TeamOverviewRow{}, nil
|
||||||
|
}
|
||||||
|
placeholders := make([]string, 0, len(portalUserIDs))
|
||||||
|
for _, id := range portalUserIDs {
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
where = "WHERE e.portal_user_id IN (" + strings.Join(placeholders, ",") + ")"
|
||||||
|
}
|
||||||
|
rows, err := a.DB.QueryContext(ctx, `
|
||||||
|
SELECT e.id,
|
||||||
|
e.name,
|
||||||
|
e.portal_user_id,
|
||||||
|
e.tg_chat_id,
|
||||||
|
p.id,
|
||||||
|
p.title,
|
||||||
|
p.deal_type,
|
||||||
|
p.dld_permit,
|
||||||
|
p.last_checked_at,
|
||||||
|
count(l.id),
|
||||||
|
sum(CASE WHEN l.status IN ('ACTIVE','active') THEN 1 ELSE 0 END),
|
||||||
|
sum(CASE WHEN l.status IN ('REMOVED','removed') THEN 1 ELSE 0 END),
|
||||||
|
min(CASE WHEN l.status IN ('ACTIVE','active') THEN l.current_price ELSE NULL END)
|
||||||
|
FROM employees e
|
||||||
|
LEFT JOIN projects p ON p.owner_id = e.id
|
||||||
|
LEFT JOIN competitor_listings l ON l.project_id = p.id
|
||||||
|
`+where+`
|
||||||
|
GROUP BY e.id, e.name, e.portal_user_id, e.tg_chat_id,
|
||||||
|
p.id, p.title, p.deal_type, p.dld_permit, p.last_checked_at, p.created_at
|
||||||
|
ORDER BY e.name COLLATE NOCASE, p.created_at DESC`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []TeamOverviewRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item TeamOverviewRow
|
||||||
|
var portalID, chatID, title, deal, permit, checked sql.NullString
|
||||||
|
var projectID, total, active, removed sql.NullInt64
|
||||||
|
var minPrice sql.NullFloat64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.EmployeeID,
|
||||||
|
&item.EmployeeName,
|
||||||
|
&portalID,
|
||||||
|
&chatID,
|
||||||
|
&projectID,
|
||||||
|
&title,
|
||||||
|
&deal,
|
||||||
|
&permit,
|
||||||
|
&checked,
|
||||||
|
&total,
|
||||||
|
&active,
|
||||||
|
&removed,
|
||||||
|
&minPrice,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.PortalUserID = nullableString(portalID)
|
||||||
|
item.TelegramLinked = chatID.Valid && chatID.String != ""
|
||||||
|
item.ProjectID = nullableInt(projectID)
|
||||||
|
item.ProjectTitle = nullableString(title)
|
||||||
|
if deal.Valid {
|
||||||
|
value := enumDealOut(deal.String)
|
||||||
|
item.DealType = &value
|
||||||
|
}
|
||||||
|
item.DLDPermit = nullableString(permit)
|
||||||
|
item.LastCheckedAt = timeOut(checked)
|
||||||
|
item.ListingsTotal = nullIntValue(total)
|
||||||
|
item.ListingsActive = nullIntValue(active)
|
||||||
|
item.ListingsRemoved = nullIntValue(removed)
|
||||||
|
item.MinCompetitorPrice = nullableFloat(minPrice)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) PriceHistory(ctx context.Context, listingID int64) ([]PricePoint, error) {
|
func (a *App) PriceHistory(ctx context.Context, listingID int64) ([]PricePoint, error) {
|
||||||
rows, err := a.DB.QueryContext(ctx, `
|
rows, err := a.DB.QueryContext(ctx, `
|
||||||
SELECT id, price, recorded_at
|
SELECT id, price, recorded_at
|
||||||
|
|||||||
Reference in New Issue
Block a user