"""«Выбранные» — проектный канбан (п.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