Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""«Выбранные» — проектный канбан (п.4.8 ТЗ).
|
|
|
|
Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'),
|
|
в архив/корзину проектные карточки не попадают. Есть история движения,
|
|
вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания
|
|
для стадии «Отложено».
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
from .. import constants as C
|
|
from ..db import store
|
|
from ..sse import broker
|
|
|
|
log = logging.getLogger("leadradar.projects")
|
|
|
|
STAGES = {s["id"] for s in C.PIPELINE_STAGES}
|
|
|
|
|
|
def _now() -> int:
|
|
return time.time_ns() // 1_000_000
|
|
|
|
|
|
def _json(value: list, default: str = "[]") -> str:
|
|
return json.dumps(value or [], ensure_ascii=False) if value is not None else default
|
|
|
|
|
|
def _row_to_card(row: dict) -> dict:
|
|
history = json.loads(row["history"] or "[]")
|
|
return {
|
|
"id": row["id"],
|
|
"stage": row["stage"],
|
|
"local": bool(row["local"]),
|
|
"leadId": row["lead_id"],
|
|
"title": row["title"],
|
|
"summary": row["summary"],
|
|
"stack": json.loads(row["stack"] or "[]"),
|
|
"budget": (
|
|
{"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]}
|
|
if row["budget_cur"]
|
|
else None
|
|
),
|
|
"contact": row["contact"],
|
|
"comments": json.loads(row["comments"] or "[]"),
|
|
"links": json.loads(row["links"] or "[]"),
|
|
"files": json.loads(row["files"] or "[]"),
|
|
"tzText": row["tz_text"],
|
|
"history": history,
|
|
"reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None,
|
|
"createdAt": row["created_at"],
|
|
"updatedAt": row["updated_at"],
|
|
}
|
|
|
|
|
|
def list_cards(stage: str | None = None) -> list[dict]:
|
|
if stage:
|
|
rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage])
|
|
else:
|
|
rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC")
|
|
return [_row_to_card(r) for r in rows]
|
|
|
|
|
|
def get_card(card_id: str) -> dict | None:
|
|
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
|
|
return _row_to_card(row) if row else None
|
|
|
|
|
|
def _insert(row_fields: dict) -> dict:
|
|
now = _now()
|
|
card_id = row_fields["id"]
|
|
store.execute(
|
|
"INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, "
|
|
"budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, "
|
|
"history, reminder_at, reminder_fired, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)",
|
|
[
|
|
card_id,
|
|
row_fields.get("stage", "planned"),
|
|
bool(row_fields.get("local")),
|
|
row_fields.get("lead_id"),
|
|
row_fields.get("title", ""),
|
|
row_fields.get("summary", ""),
|
|
_json(row_fields.get("stack")),
|
|
row_fields.get("budget_from"),
|
|
row_fields.get("budget_to"),
|
|
row_fields.get("budget_cur", ""),
|
|
row_fields.get("contact", ""),
|
|
_json(row_fields.get("comments")),
|
|
_json(row_fields.get("links")),
|
|
_json(row_fields.get("files")),
|
|
row_fields.get("tz_text", ""),
|
|
_json(row_fields.get("history")),
|
|
now,
|
|
now,
|
|
],
|
|
)
|
|
return get_card(card_id)
|
|
|
|
|
|
def create_local_card(data: dict) -> dict:
|
|
"""Ручное создание карточки — «локальная» (без лида)."""
|
|
budget = data.get("budget") if isinstance(data.get("budget"), dict) else None
|
|
return _insert(
|
|
{
|
|
"id": store.uid("pr_"),
|
|
"stage": data.get("stage") if data.get("stage") in STAGES else "planned",
|
|
"local": True,
|
|
"title": str(data.get("title", "")).strip(),
|
|
"summary": str(data.get("summary", "")),
|
|
"stack": data.get("stack") or [],
|
|
"budget_from": budget.get("from") if budget else None,
|
|
"budget_to": budget.get("to") if budget else None,
|
|
"budget_cur": budget.get("cur", "") if budget else "",
|
|
"contact": str(data.get("contact", "")),
|
|
"comments": data.get("comments") or [],
|
|
"links": data.get("links") or [],
|
|
"files": data.get("files") or [],
|
|
"tz_text": str(data.get("tzText", "")),
|
|
"history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}],
|
|
}
|
|
)
|
|
|
|
|
|
def take_lead_to_projects(lead_id: str) -> dict:
|
|
"""«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка."""
|
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
|
if not lead:
|
|
raise KeyError(lead_id)
|
|
existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id])
|
|
if existing:
|
|
return get_card(existing["id"])
|
|
budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None
|
|
card = _insert(
|
|
{
|
|
"id": store.uid("pr_"),
|
|
"stage": "planned",
|
|
"local": False,
|
|
"lead_id": lead_id,
|
|
"title": lead["title"],
|
|
"summary": lead["summary"],
|
|
"stack": json.loads(lead["stack"] or "[]"),
|
|
"budget_from": budget["from"] if budget else None,
|
|
"budget_to": budget["to"] if budget else None,
|
|
"budget_cur": budget["cur"] if budget else "",
|
|
"contact": lead["contact"],
|
|
"comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}],
|
|
"tz_text": "",
|
|
"history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}],
|
|
}
|
|
)
|
|
# эксклюзивность: на дашборде лида больше нет, возврата нет
|
|
store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id])
|
|
return card
|
|
|
|
|
|
def patch_card(card_id: str, patch: dict) -> dict:
|
|
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
|
|
if not row:
|
|
raise KeyError(card_id)
|
|
simple = {
|
|
"title": "title",
|
|
"summary": "summary",
|
|
"contact": "contact",
|
|
"tz_text": "tzText",
|
|
}
|
|
for col, key in simple.items():
|
|
if key in patch:
|
|
store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id])
|
|
if "stack" in patch:
|
|
store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id])
|
|
if "budget" in patch:
|
|
b = patch["budget"] if isinstance(patch["budget"], dict) else None
|
|
store.execute(
|
|
"UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?",
|
|
[b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id],
|
|
)
|
|
if "comments" in patch:
|
|
store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id])
|
|
if "links" in patch:
|
|
store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id])
|
|
if "files" in patch:
|
|
store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id])
|
|
_bump(card_id)
|
|
return get_card(card_id)
|
|
|
|
|
|
def _bump(card_id: str) -> None:
|
|
store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id])
|
|
|
|
|
|
def add_comment(card_id: str, text: str) -> list[dict]:
|
|
row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id])
|
|
comments = json.loads(row["comments"] or "[]")
|
|
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
|
|
patch_card(card_id, {"comments": comments})
|
|
return comments
|
|
|
|
|
|
def move_stage(card_id: str, stage: str) -> dict:
|
|
if stage not in STAGES:
|
|
raise ValueError("Неизвестная стадия")
|
|
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
|
|
if not row:
|
|
raise KeyError(card_id)
|
|
history = json.loads(row["history"] or "[]")
|
|
now = _now()
|
|
store.execute(
|
|
"UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
|
|
[stage, now, card_id],
|
|
)
|
|
history.append({"id": store.uid("h_"), "at": now, "stage": stage})
|
|
store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id])
|
|
return get_card(card_id)
|
|
|
|
|
|
def remove_card(card_id: str) -> None:
|
|
store.execute("DELETE FROM projects WHERE id = ?", [card_id])
|
|
|
|
|
|
def clear_stage(stage: str) -> int:
|
|
"""Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются)."""
|
|
if stage not in ("rejected",):
|
|
raise ValueError("Очистка разрешена только для стадии «Отклонено»")
|
|
rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage])
|
|
if not rows:
|
|
return 0
|
|
store.execute("DELETE FROM projects WHERE stage = ?", [stage])
|
|
return len(rows)
|
|
|
|
|
|
# ─── Напоминания стадии «Отложено» ────────────────────────────────────────
|
|
|
|
def set_reminder(card_id: str, at: int) -> dict:
|
|
if not store.get_setting("remindersEnabled"):
|
|
raise PermissionError("Напоминания об отложенных выключены в настройках")
|
|
store.execute(
|
|
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
|
|
[at, _now(), card_id],
|
|
)
|
|
return get_card(card_id)
|
|
|
|
|
|
def clear_reminder(card_id: str) -> None:
|
|
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id])
|
|
|
|
|
|
def active_reminders() -> list[dict]:
|
|
rows = store.query(
|
|
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at"
|
|
)
|
|
return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows]
|
|
|
|
|
|
def snooze(card_id: str, ms: int = C.DAY_MS) -> None:
|
|
store.execute(
|
|
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?",
|
|
[_now() + ms, card_id],
|
|
)
|
|
|
|
|
|
async def check_reminders() -> list[dict]:
|
|
"""Наступившие напоминания -> события. Если выключено — чистим протухшие."""
|
|
if not store.get_setting("remindersEnabled"):
|
|
# протухшие напоминания не храним: при включении старые не «выстрелят»
|
|
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()])
|
|
return []
|
|
now = _now()
|
|
rows = store.query(
|
|
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL "
|
|
"AND reminder_fired = FALSE AND reminder_at <= ?",
|
|
[now],
|
|
)
|
|
due = []
|
|
for r in rows:
|
|
store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]])
|
|
due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]})
|
|
for item in due:
|
|
await broker.publish("reminder_due", item)
|
|
return due
|