Reuse resolved reminder rows instead of inserting duplicates, skip failed keys in batch send, reject invalid scan settings with 400, and bind 去处理 via event delegation after async render. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
758 lines
25 KiB
Python
758 lines
25 KiB
Python
"""Read-only reminder rule engine and delivery helpers.
|
||
|
||
Scans existing tables to surface pending items; delivery writes append-only
|
||
``reminders`` / ``reminder_events`` rows without touching bank or business data.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timedelta, timezone
|
||
import json
|
||
import sqlite3
|
||
from typing import Any
|
||
import uuid
|
||
|
||
from bank_importer import auth
|
||
from bank_importer.db import utc_now
|
||
|
||
RULE_UNSUBMITTED = "unsubmitted"
|
||
RULE_GAP = "gap"
|
||
RULE_PENDING = "pending_review"
|
||
RULE_MANUAL = "manual"
|
||
|
||
SETTING_MONTHLY_START_DAY = "monthly_start_day"
|
||
SETTING_GAP_DAYS = "gap_days"
|
||
SETTING_SCAN_TIME = "scan_time"
|
||
|
||
DEFAULT_SETTINGS: dict[str, str] = {
|
||
SETTING_MONTHLY_START_DAY: "5",
|
||
SETTING_GAP_DAYS: "5",
|
||
SETTING_SCAN_TIME: "08:00",
|
||
}
|
||
|
||
RULE_LABELS = {
|
||
RULE_UNSUBMITTED: "流水未提交",
|
||
RULE_GAP: "流水断档",
|
||
RULE_PENDING: "待确认/待审核",
|
||
}
|
||
|
||
ACTION_LINKS = {
|
||
RULE_UNSUBMITTED: "upload",
|
||
RULE_GAP: "flows",
|
||
RULE_PENDING: "reconcile",
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScanFinding:
|
||
company_id: int
|
||
company_name: str
|
||
rule_key: str
|
||
dedupe_key: str
|
||
rule_params: dict[str, Any]
|
||
title: str
|
||
content: str
|
||
reason: str
|
||
days_open: int
|
||
deadline: str | None
|
||
action_link: str
|
||
existing_reminder_id: int | None
|
||
send_count: int
|
||
|
||
|
||
def _today() -> date:
|
||
return datetime.now(timezone.utc).date()
|
||
|
||
|
||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||
rows = connection.execute("SELECT key, value FROM reminder_settings").fetchall()
|
||
settings = dict(DEFAULT_SETTINGS)
|
||
for row in rows:
|
||
settings[row["key"]] = row["value"]
|
||
return settings
|
||
|
||
|
||
def _valid_scan_time(value: str) -> bool:
|
||
parts = value.strip().split(":")
|
||
if len(parts) != 2 or not parts[0].isdigit() or not parts[1].isdigit():
|
||
return False
|
||
if len(parts[1]) != 2:
|
||
return False
|
||
hour, minute = int(parts[0]), int(parts[1])
|
||
return 0 <= hour <= 23 and 0 <= minute <= 59
|
||
|
||
|
||
def validate_setting(key: str, value: str) -> str:
|
||
raw = str(value).strip()
|
||
if key == SETTING_MONTHLY_START_DAY:
|
||
try:
|
||
day = int(raw)
|
||
except ValueError as exc:
|
||
raise ValueError("每月起始日须为 1 到 28 的整数。") from exc
|
||
if day < 1 or day > 28:
|
||
raise ValueError("每月起始日须为 1 到 28 的整数。")
|
||
return str(day)
|
||
if key == SETTING_GAP_DAYS:
|
||
try:
|
||
days = int(raw)
|
||
except ValueError as exc:
|
||
raise ValueError("断档天数须为大于等于 1 的整数。") from exc
|
||
if days < 1:
|
||
raise ValueError("断档天数须为大于等于 1 的整数。")
|
||
return str(days)
|
||
if key == SETTING_SCAN_TIME:
|
||
if not _valid_scan_time(raw):
|
||
raise ValueError("扫描时间须为合法的 HH:MM。")
|
||
hour, minute = raw.split(":")
|
||
return f"{int(hour):02d}:{minute}"
|
||
raise ValueError(f"unknown setting: {key}")
|
||
|
||
|
||
def update_settings(connection: sqlite3.Connection, updates: dict[str, str]) -> dict[str, str]:
|
||
allowed = set(DEFAULT_SETTINGS)
|
||
cleaned: dict[str, str] = {}
|
||
for key, value in updates.items():
|
||
if key not in allowed:
|
||
raise ValueError(f"unknown setting: {key}")
|
||
cleaned[key] = validate_setting(key, value)
|
||
now = utc_now()
|
||
with connection:
|
||
for key, value in cleaned.items():
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO reminder_settings (key, value, updated_at)
|
||
VALUES (?, ?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||
""",
|
||
(key, value, now),
|
||
)
|
||
return get_settings(connection)
|
||
|
||
|
||
def _setting_int(settings: dict[str, str], key: str) -> int:
|
||
try:
|
||
return int(validate_setting(key, settings.get(key, DEFAULT_SETTINGS[key])))
|
||
except (TypeError, ValueError):
|
||
return int(DEFAULT_SETTINGS[key])
|
||
|
||
|
||
def _month_period(day: date | None = None) -> str:
|
||
ref = day or _today()
|
||
return f"{ref.year:04d}-{ref.month:02d}"
|
||
|
||
|
||
def _parse_date(value: str | None) -> date | None:
|
||
if not value:
|
||
return None
|
||
try:
|
||
return date.fromisoformat(value[:10])
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _days_between(start: date, end: date) -> int:
|
||
return max(0, (end - start).days)
|
||
|
||
|
||
def _duration_pill_class(days: int) -> str:
|
||
if days >= 7:
|
||
return "pill-danger"
|
||
if days >= 3:
|
||
return "pill-warn"
|
||
return "pill-muted"
|
||
|
||
|
||
def _existing_reminder(connection: sqlite3.Connection, dedupe_key: str) -> sqlite3.Row | None:
|
||
return connection.execute(
|
||
"""
|
||
SELECT id, send_count, status, last_sent_at
|
||
FROM reminders
|
||
WHERE dedupe_key = ? AND status != 'resolved'
|
||
""",
|
||
(dedupe_key,),
|
||
).fetchone()
|
||
|
||
|
||
def _company_has_month_batch(connection: sqlite3.Connection, company_id: int, period: str) -> bool:
|
||
year, month = period.split("-")
|
||
prefix = f"{year}-{month}"
|
||
row = connection.execute(
|
||
"""
|
||
SELECT 1
|
||
FROM import_batches b
|
||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||
WHERE b.company_id = ?
|
||
AND (
|
||
substr(s.period_start, 1, 7) = ?
|
||
OR substr(s.period_end, 1, 7) = ?
|
||
OR (s.period_start <= ? || '-31' AND s.period_end >= ? || '-01')
|
||
)
|
||
LIMIT 1
|
||
""",
|
||
(company_id, prefix, prefix, prefix, prefix),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
def _latest_batch_end(connection: sqlite3.Connection, company_id: int) -> date | None:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT MAX(s.period_end) AS latest_end
|
||
FROM import_batches b
|
||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||
WHERE b.company_id = ?
|
||
""",
|
||
(company_id,),
|
||
).fetchone()
|
||
return _parse_date(row["latest_end"] if row else None)
|
||
|
||
|
||
def _pending_review_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||
match_count = connection.execute(
|
||
"""
|
||
SELECT COUNT(DISTINCT e.id)
|
||
FROM canonical_transfer_events e
|
||
JOIN current_transfer_decisions c ON c.event_id = e.id
|
||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
|
||
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
|
||
WHERE e.lifecycle = 'active'
|
||
AND d.classification IN ('unresolved', 'needs_review')
|
||
AND (payer.company_id = ? OR payee.company_id = ?)
|
||
""",
|
||
(company_id, company_id),
|
||
).fetchone()[0]
|
||
sheet_count = connection.execute(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM sheet_reviews r
|
||
JOIN import_batches b ON b.id = r.import_batch_id
|
||
WHERE b.company_id = ?
|
||
AND r.outcome = 'parsed'
|
||
AND r.review_status = 'pending'
|
||
""",
|
||
(company_id,),
|
||
).fetchone()[0]
|
||
account_count = connection.execute(
|
||
"""
|
||
SELECT COUNT(*) FROM bank_accounts
|
||
WHERE company_id = ? AND status = 'pending'
|
||
""",
|
||
(company_id,),
|
||
).fetchone()[0]
|
||
return int(match_count) + int(sheet_count) + int(account_count)
|
||
|
||
|
||
def _active_companies(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||
return connection.execute(
|
||
"""
|
||
SELECT id, name FROM companies
|
||
WHERE COALESCE(status, 'active') != 'disabled'
|
||
ORDER BY name
|
||
"""
|
||
).fetchall()
|
||
|
||
|
||
def scan_findings(connection: sqlite3.Connection) -> list[ScanFinding]:
|
||
settings = get_settings(connection)
|
||
today = _today()
|
||
period = _month_period(today)
|
||
monthly_start = _setting_int(settings, SETTING_MONTHLY_START_DAY)
|
||
gap_days = _setting_int(settings, SETTING_GAP_DAYS)
|
||
findings: list[ScanFinding] = []
|
||
|
||
for company in _active_companies(connection):
|
||
company_id = int(company["id"])
|
||
company_name = company["name"]
|
||
|
||
if today.day >= monthly_start and not _company_has_month_batch(connection, company_id, period):
|
||
dedupe_key = f"{company_id}:{RULE_UNSUBMITTED}:{period}"
|
||
days_open = _days_between(date(today.year, today.month, monthly_start), today)
|
||
existing = _existing_reminder(connection, dedupe_key)
|
||
findings.append(
|
||
ScanFinding(
|
||
company_id=company_id,
|
||
company_name=company_name,
|
||
rule_key=RULE_UNSUBMITTED,
|
||
dedupe_key=dedupe_key,
|
||
rule_params={"period": period},
|
||
title=f"{company_name} · {today.month} 月流水未提交",
|
||
content=(
|
||
f"贵公司 {period} 银行流水尚未提交。"
|
||
f"请于截止日前完成全部账户流水上传。"
|
||
),
|
||
reason=f"应交 {today.month:02d}-{monthly_start:02d} · 已逾期 {days_open} 天",
|
||
days_open=days_open,
|
||
deadline=None,
|
||
action_link=ACTION_LINKS[RULE_UNSUBMITTED],
|
||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||
send_count=int(existing["send_count"]) if existing else 0,
|
||
)
|
||
)
|
||
|
||
latest_end = _latest_batch_end(connection, company_id)
|
||
if latest_end is not None:
|
||
gap = _days_between(latest_end, today)
|
||
if gap > gap_days:
|
||
dedupe_key = f"{company_id}:{RULE_GAP}:{latest_end.isoformat()}"
|
||
existing = _existing_reminder(connection, dedupe_key)
|
||
findings.append(
|
||
ScanFinding(
|
||
company_id=company_id,
|
||
company_name=company_name,
|
||
rule_key=RULE_GAP,
|
||
dedupe_key=dedupe_key,
|
||
rule_params={"latest_end": latest_end.isoformat(), "gap_days": gap},
|
||
title=f"{company_name} · 流水断档 {gap} 天",
|
||
content=(
|
||
f"最近流水截止日为 {latest_end.isoformat()},"
|
||
f"已连续 {gap} 天无新数据,请补传断档期间银行流水。"
|
||
),
|
||
reason=f"最近截止 {latest_end.strftime('%m-%d')} · 断档 {gap} 天",
|
||
days_open=gap,
|
||
deadline=None,
|
||
action_link=ACTION_LINKS[RULE_GAP],
|
||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||
send_count=int(existing["send_count"]) if existing else 0,
|
||
)
|
||
)
|
||
|
||
pending_count = _pending_review_count(connection, company_id)
|
||
if pending_count > 0:
|
||
dedupe_key = f"{company_id}:{RULE_PENDING}:active"
|
||
existing = _existing_reminder(connection, dedupe_key)
|
||
first_seen = _parse_date(existing["last_sent_at"][:10] if existing and existing["last_sent_at"] else None)
|
||
days_open = _days_between(first_seen, today) if first_seen else 0
|
||
findings.append(
|
||
ScanFinding(
|
||
company_id=company_id,
|
||
company_name=company_name,
|
||
rule_key=RULE_PENDING,
|
||
dedupe_key=dedupe_key,
|
||
rule_params={"pending_count": pending_count},
|
||
title=f"{company_name} · {pending_count} 项待确认/待审核",
|
||
content=(
|
||
f"贵公司当前有 {pending_count} 项往来确认、流水审核或账户登记待处理,"
|
||
f"请尽快完成确认以免影响结账。"
|
||
),
|
||
reason=f"待处理 {pending_count} 项",
|
||
days_open=days_open,
|
||
deadline=None,
|
||
action_link=ACTION_LINKS[RULE_PENDING],
|
||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||
send_count=int(existing["send_count"]) if existing else 0,
|
||
)
|
||
)
|
||
|
||
findings.sort(key=lambda item: item.days_open, reverse=True)
|
||
return findings
|
||
|
||
|
||
def run_scan(
|
||
connection: sqlite3.Connection,
|
||
*,
|
||
actor: sqlite3.Row | None = None,
|
||
ip: str | None = None,
|
||
) -> dict[str, Any]:
|
||
findings = scan_findings(connection)
|
||
counts = {
|
||
RULE_UNSUBMITTED: sum(1 for f in findings if f.rule_key == RULE_UNSUBMITTED),
|
||
RULE_GAP: sum(1 for f in findings if f.rule_key == RULE_GAP),
|
||
RULE_PENDING: sum(1 for f in findings if f.rule_key == RULE_PENDING),
|
||
}
|
||
auth.audit(
|
||
connection,
|
||
"reminder_scan",
|
||
actor=actor,
|
||
detail=json.dumps({"total": len(findings), "by_rule": counts}, ensure_ascii=False),
|
||
ip=ip,
|
||
)
|
||
return {"findings": findings, "counts": counts}
|
||
|
||
|
||
def finding_to_dict(finding: ScanFinding) -> dict[str, Any]:
|
||
return {
|
||
"company_id": finding.company_id,
|
||
"company_name": finding.company_name,
|
||
"rule_key": finding.rule_key,
|
||
"rule_label": RULE_LABELS.get(finding.rule_key, finding.rule_key),
|
||
"dedupe_key": finding.dedupe_key,
|
||
"rule_params": finding.rule_params,
|
||
"title": finding.title,
|
||
"content": finding.content,
|
||
"reason": finding.reason,
|
||
"days_open": finding.days_open,
|
||
"duration_pill": _duration_pill_class(finding.days_open),
|
||
"deadline": finding.deadline,
|
||
"action_link": finding.action_link,
|
||
"existing_reminder_id": finding.existing_reminder_id,
|
||
"send_count": finding.send_count,
|
||
}
|
||
|
||
|
||
def _append_event(
|
||
connection: sqlite3.Connection,
|
||
reminder_id: int,
|
||
event_type: str,
|
||
actor: str,
|
||
detail: str | None = None,
|
||
) -> None:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO reminder_events (reminder_id, event_type, actor, detail, created_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(reminder_id, event_type, actor, detail, utc_now()),
|
||
)
|
||
|
||
|
||
def _actor_label(actor: sqlite3.Row | None) -> str:
|
||
if actor is None:
|
||
return "system"
|
||
return str(actor["id"])
|
||
|
||
|
||
def deliver_finding(
|
||
connection: sqlite3.Connection,
|
||
dedupe_key: str,
|
||
*,
|
||
actor: sqlite3.Row | None = None,
|
||
ip: str | None = None,
|
||
) -> int | None:
|
||
findings = {item.dedupe_key: item for item in scan_findings(connection)}
|
||
finding = findings.get(dedupe_key)
|
||
if finding is None:
|
||
return None
|
||
return _deliver(connection, finding, actor=actor, ip=ip)
|
||
|
||
|
||
def _deliver(
|
||
connection: sqlite3.Connection,
|
||
finding: ScanFinding,
|
||
*,
|
||
actor: sqlite3.Row | None = None,
|
||
ip: str | None = None,
|
||
) -> int:
|
||
now = utc_now()
|
||
actor_ref = _actor_label(actor)
|
||
existing = connection.execute(
|
||
"SELECT id, send_count, status FROM reminders WHERE dedupe_key = ?",
|
||
(finding.dedupe_key,),
|
||
).fetchone()
|
||
|
||
with connection:
|
||
if existing is not None:
|
||
reminder_id = int(existing["id"])
|
||
send_count = int(existing["send_count"]) + 1
|
||
connection.execute(
|
||
"""
|
||
UPDATE reminders
|
||
SET send_count = ?, last_sent_at = ?, status = 'open',
|
||
title = ?, content = ?, rule_params = ?, action_link = ?
|
||
WHERE id = ?
|
||
""",
|
||
(
|
||
send_count,
|
||
now,
|
||
finding.title,
|
||
finding.content,
|
||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||
finding.action_link,
|
||
reminder_id,
|
||
),
|
||
)
|
||
event_type = "sent" if existing["status"] == "resolved" else (
|
||
"escalated" if send_count > 1 else "sent"
|
||
)
|
||
_append_event(
|
||
connection,
|
||
reminder_id,
|
||
event_type,
|
||
actor_ref,
|
||
f"第 {send_count} 次催办",
|
||
)
|
||
else:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO reminders (
|
||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||
created_by, created_at, action_link
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'auto', 'open', 1, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
finding.company_id,
|
||
finding.rule_key,
|
||
finding.dedupe_key,
|
||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||
finding.title,
|
||
finding.content,
|
||
finding.deadline,
|
||
now,
|
||
now,
|
||
actor_ref,
|
||
now,
|
||
finding.action_link,
|
||
),
|
||
)
|
||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
_append_event(connection, reminder_id, "sent", actor_ref, finding.reason)
|
||
|
||
auth.audit(
|
||
connection,
|
||
"reminder_send",
|
||
actor=actor,
|
||
target=f"reminder:{reminder_id}",
|
||
detail=finding.dedupe_key,
|
||
ip=ip,
|
||
)
|
||
return reminder_id
|
||
|
||
|
||
def deliver_many(
|
||
connection: sqlite3.Connection,
|
||
dedupe_keys: list[str],
|
||
*,
|
||
actor: sqlite3.Row | None = None,
|
||
ip: str | None = None,
|
||
) -> list[int]:
|
||
sent: list[int] = []
|
||
for key in dedupe_keys:
|
||
try:
|
||
reminder_id = deliver_finding(connection, key, actor=actor, ip=ip)
|
||
except Exception:
|
||
continue
|
||
if reminder_id is not None:
|
||
sent.append(reminder_id)
|
||
return sent
|
||
|
||
|
||
def send_manual(
|
||
connection: sqlite3.Connection,
|
||
*,
|
||
company_id: int,
|
||
display_type: str,
|
||
content: str,
|
||
deadline: str | None,
|
||
actor: sqlite3.Row,
|
||
ip: str | None = None,
|
||
) -> int:
|
||
company = connection.execute(
|
||
"SELECT name FROM companies WHERE id = ?", (company_id,)
|
||
).fetchone()
|
||
if company is None:
|
||
raise ValueError("company not found")
|
||
now = utc_now()
|
||
dedupe_key = f"{company_id}:{RULE_MANUAL}:{uuid.uuid4().hex}"
|
||
title = f"{company['name']} · {display_type}"
|
||
with connection:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO reminders (
|
||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||
created_by, created_at, action_link
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'manual', 'open', 1, ?, ?, ?, ?, NULL)
|
||
""",
|
||
(
|
||
company_id,
|
||
RULE_MANUAL,
|
||
dedupe_key,
|
||
json.dumps({"display_type": display_type}, ensure_ascii=False),
|
||
title,
|
||
content,
|
||
deadline,
|
||
now,
|
||
now,
|
||
str(actor["id"]),
|
||
now,
|
||
),
|
||
)
|
||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
_append_event(connection, reminder_id, "sent", str(actor["id"]), display_type)
|
||
auth.audit(
|
||
connection,
|
||
"reminder_send_manual",
|
||
actor=actor,
|
||
target=f"reminder:{reminder_id}",
|
||
detail=display_type,
|
||
ip=ip,
|
||
)
|
||
return reminder_id
|
||
|
||
|
||
def resend_reminder(
|
||
connection: sqlite3.Connection,
|
||
reminder_id: int,
|
||
*,
|
||
actor: sqlite3.Row,
|
||
ip: str | None = None,
|
||
) -> None:
|
||
row = connection.execute(
|
||
"SELECT id, send_count, status FROM reminders WHERE id = ?", (reminder_id,)
|
||
).fetchone()
|
||
if row is None or row["status"] == "resolved":
|
||
raise ValueError("reminder not available")
|
||
now = utc_now()
|
||
send_count = int(row["send_count"]) + 1
|
||
with connection:
|
||
connection.execute(
|
||
"""
|
||
UPDATE reminders
|
||
SET send_count = ?, last_sent_at = ?, status = 'open'
|
||
WHERE id = ?
|
||
""",
|
||
(send_count, now, reminder_id),
|
||
)
|
||
_append_event(
|
||
connection,
|
||
reminder_id,
|
||
"escalated",
|
||
str(actor["id"]),
|
||
f"第 {send_count} 次催办",
|
||
)
|
||
auth.audit(
|
||
connection,
|
||
"reminder_resend",
|
||
actor=actor,
|
||
target=f"reminder:{reminder_id}",
|
||
ip=ip,
|
||
)
|
||
|
||
|
||
def _reminder_row_to_dict(row: sqlite3.Row, *, company_name: str | None = None) -> dict[str, Any]:
|
||
params = json.loads(row["rule_params"]) if row["rule_params"] else {}
|
||
rule_key = row["rule_key"]
|
||
display_type = params.get("display_type") if rule_key == RULE_MANUAL else RULE_LABELS.get(rule_key, rule_key)
|
||
status = row["status"]
|
||
status_ui = {"open": "unread", "acknowledged": "doing", "resolved": "done"}[status]
|
||
return {
|
||
"id": row["id"],
|
||
"company_id": row["company_id"],
|
||
"company_name": company_name or row["company_name"],
|
||
"rule_key": rule_key,
|
||
"display_type": display_type,
|
||
"title": row["title"],
|
||
"content": row["content"],
|
||
"deadline": row["deadline"],
|
||
"source": row["source"],
|
||
"status": status,
|
||
"status_ui": status_ui,
|
||
"send_count": row["send_count"],
|
||
"first_sent_at": row["first_sent_at"],
|
||
"last_sent_at": row["last_sent_at"],
|
||
"created_by": row["created_by"],
|
||
"created_at": row["created_at"],
|
||
"action_link": row["action_link"],
|
||
"rule_params": params,
|
||
}
|
||
|
||
|
||
def list_admin_reminders(
|
||
connection: sqlite3.Connection,
|
||
*,
|
||
source: str | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
conditions = ["r.send_count > 0"]
|
||
params: list[Any] = []
|
||
if source in {"auto", "manual"}:
|
||
conditions.append("r.source = ?")
|
||
params.append(source)
|
||
where = " AND ".join(conditions)
|
||
rows = connection.execute(
|
||
f"""
|
||
SELECT r.*, c.name AS company_name
|
||
FROM reminders r
|
||
JOIN companies c ON c.id = r.company_id
|
||
WHERE {where}
|
||
ORDER BY r.last_sent_at DESC, r.id DESC
|
||
""",
|
||
params,
|
||
).fetchall()
|
||
return [_reminder_row_to_dict(row) for row in rows]
|
||
|
||
|
||
def list_company_reminders(connection: sqlite3.Connection, company_id: int) -> list[dict[str, Any]]:
|
||
rows = connection.execute(
|
||
"""
|
||
SELECT r.*, c.name AS company_name
|
||
FROM reminders r
|
||
JOIN companies c ON c.id = r.company_id
|
||
WHERE r.company_id = ? AND r.send_count > 0
|
||
ORDER BY
|
||
CASE r.status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
|
||
r.last_sent_at DESC
|
||
""",
|
||
(company_id,),
|
||
).fetchall()
|
||
return [_reminder_row_to_dict(row) for row in rows]
|
||
|
||
|
||
def get_reminder_detail(connection: sqlite3.Connection, reminder_id: int) -> dict[str, Any] | None:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT r.*, c.name AS company_name
|
||
FROM reminders r
|
||
JOIN companies c ON c.id = r.company_id
|
||
WHERE r.id = ?
|
||
""",
|
||
(reminder_id,),
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
events = connection.execute(
|
||
"""
|
||
SELECT event_type, actor, detail, created_at
|
||
FROM reminder_events
|
||
WHERE reminder_id = ?
|
||
ORDER BY id
|
||
""",
|
||
(reminder_id,),
|
||
).fetchall()
|
||
payload = _reminder_row_to_dict(row)
|
||
payload["events"] = [dict(item) for item in events]
|
||
return payload
|
||
|
||
|
||
def update_reminder_status(
|
||
connection: sqlite3.Connection,
|
||
reminder_id: int,
|
||
new_status: str,
|
||
*,
|
||
company_id: int | None = None,
|
||
actor: sqlite3.Row | None = None,
|
||
) -> bool:
|
||
if new_status not in {"acknowledged", "resolved"}:
|
||
raise ValueError("invalid status")
|
||
row = connection.execute(
|
||
"SELECT id, company_id, status FROM reminders WHERE id = ?",
|
||
(reminder_id,),
|
||
).fetchone()
|
||
if row is None:
|
||
return False
|
||
if company_id is not None and int(row["company_id"]) != company_id:
|
||
return False
|
||
if row["status"] == "resolved":
|
||
return False
|
||
event_type = "acknowledged" if new_status == "acknowledged" else "resolved"
|
||
actor_ref = _actor_label(actor)
|
||
with connection:
|
||
connection.execute(
|
||
"UPDATE reminders SET status = ? WHERE id = ?",
|
||
(new_status, reminder_id),
|
||
)
|
||
_append_event(connection, reminder_id, event_type, actor_ref, None)
|
||
return True
|
||
|
||
|
||
def company_unread_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT COUNT(*) AS n FROM reminders
|
||
WHERE company_id = ? AND status = 'open' AND send_count > 0
|
||
""",
|
||
(company_id,),
|
||
).fetchone()
|
||
return int(row["n"])
|