Compare commits

...

2 Commits

Author SHA1 Message Date
Codex
5434d1b283 feat: improve report submission form 2026-05-07 22:24:04 +08:00
Codex
3c7b7096dd feat: remind only missing reporters with follow-up 2026-05-07 21:38:50 +08:00
11 changed files with 428 additions and 83 deletions

View File

@ -85,6 +85,7 @@ http://你的可访问地址:8787/auth/feishu/callback
本项目提供 Windows 任务计划脚本。任务每天触发,但发送前会读取 `data/workday-calendar.json`,只在国家法定工作日发送: 本项目提供 Windows 任务计划脚本。任务每天触发,但发送前会读取 `data/workday-calendar.json`,只在国家法定工作日发送:
- 工作日 18:00日报填写提醒 - 工作日 18:00日报填写提醒
- 工作日 18:50只提醒仍未提交的人
- 工作日 19:00日报提交汇总 - 工作日 19:00日报提交汇总
这意味着: 这意味着:

View File

@ -41,6 +41,7 @@ class Database:
feishu_user_id TEXT NOT NULL, feishu_user_id TEXT NOT NULL,
employee_name TEXT NOT NULL, employee_name TEXT NOT NULL,
report_date TEXT NOT NULL, report_date TEXT NOT NULL,
report_status TEXT NOT NULL DEFAULT 'normal',
today_done TEXT NOT NULL, today_done TEXT NOT NULL,
tomorrow_plan TEXT NOT NULL, tomorrow_plan TEXT NOT NULL,
blockers TEXT NOT NULL DEFAULT '', blockers TEXT NOT NULL DEFAULT '',
@ -56,6 +57,13 @@ class Database:
except sqlite3.OperationalError as error: except sqlite3.OperationalError as error:
if "duplicate column name" not in str(error).lower(): if "duplicate column name" not in str(error).lower():
raise raise
try:
self.connection.execute(
"ALTER TABLE daily_reports ADD COLUMN report_status TEXT NOT NULL DEFAULT 'normal'"
)
except sqlite3.OperationalError as error:
if "duplicate column name" not in str(error).lower():
raise
self.connection.commit() self.connection.commit()
def load_employees(self, employee_seed_path: Path) -> None: def load_employees(self, employee_seed_path: Path) -> None:
@ -129,15 +137,16 @@ class Database:
self.connection.execute( self.connection.execute(
""" """
INSERT INTO daily_reports ( INSERT INTO daily_reports (
feishu_user_id, employee_name, report_date, today_done, tomorrow_plan, feishu_user_id, employee_name, report_date, report_status, today_done, tomorrow_plan,
blockers, help_needed, submitted_at, updated_at blockers, help_needed, submitted_at, updated_at
) )
VALUES ( VALUES (
:feishu_user_id, :employee_name, :report_date, :today_done, :tomorrow_plan, :feishu_user_id, :employee_name, :report_date, :report_status, :today_done, :tomorrow_plan,
:blockers, :help_needed, :submitted_at, :updated_at :blockers, :help_needed, :submitted_at, :updated_at
) )
ON CONFLICT(feishu_user_id, report_date) DO UPDATE SET ON CONFLICT(feishu_user_id, report_date) DO UPDATE SET
employee_name = excluded.employee_name, employee_name = excluded.employee_name,
report_status = excluded.report_status,
today_done = excluded.today_done, today_done = excluded.today_done,
tomorrow_plan = excluded.tomorrow_plan, tomorrow_plan = excluded.tomorrow_plan,
blockers = excluded.blockers, blockers = excluded.blockers,
@ -151,7 +160,7 @@ class Database:
with self._lock: with self._lock:
rows = self.connection.execute( rows = self.connection.execute(
""" """
SELECT id, feishu_user_id, employee_name, report_date, today_done, SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
tomorrow_plan, blockers, help_needed, submitted_at, updated_at tomorrow_plan, blockers, help_needed, submitted_at, updated_at
FROM daily_reports FROM daily_reports
WHERE report_date = ? WHERE report_date = ?
@ -165,7 +174,7 @@ class Database:
with self._lock: with self._lock:
rows = self.connection.execute( rows = self.connection.execute(
""" """
SELECT id, feishu_user_id, employee_name, report_date, today_done, SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
tomorrow_plan, blockers, help_needed, submitted_at, updated_at tomorrow_plan, blockers, help_needed, submitted_at, updated_at
FROM daily_reports FROM daily_reports
WHERE feishu_user_id = ? WHERE feishu_user_id = ?
@ -175,3 +184,18 @@ class Database:
(feishu_user_id, limit), (feishu_user_id, limit),
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def find_previous_report(self, feishu_user_id: str, before_date: str) -> dict[str, Any] | None:
with self._lock:
row = self.connection.execute(
"""
SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
FROM daily_reports
WHERE feishu_user_id = ? AND report_date < ?
ORDER BY report_date DESC, updated_at DESC
LIMIT 1
""",
(feishu_user_id, before_date),
).fetchone()
return dict(row) if row else None

View File

@ -22,6 +22,14 @@ def _optional_text(value: Any) -> str:
return str(value or "").strip() return str(value or "").strip()
def _report_status(value: Any) -> str:
status = str(value or "normal").strip()
allowed = {"normal", "risk", "need_help"}
if status not in allowed:
raise ValueError("report_status is invalid")
return status
class ReportService: class ReportService:
def __init__(self, database: Database, clock: Clock | None = None): def __init__(self, database: Database, clock: Clock | None = None):
self.database = database self.database = database
@ -42,6 +50,7 @@ class ReportService:
"feishu_user_id": feishu_user_id, "feishu_user_id": feishu_user_id,
"employee_name": employee["name"], "employee_name": employee["name"],
"report_date": report_date, "report_date": report_date,
"report_status": _report_status(data.get("report_status")),
"today_done": today_done, "today_done": today_done,
"tomorrow_plan": tomorrow_plan, "tomorrow_plan": tomorrow_plan,
"blockers": _optional_text(data.get("blockers")), "blockers": _optional_text(data.get("blockers")),
@ -81,6 +90,7 @@ class ReportService:
"tomorrow_plan", "tomorrow_plan",
"blockers", "blockers",
"help_needed", "help_needed",
"report_status",
"submitted_at", "submitted_at",
] ]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore") writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
@ -99,6 +109,17 @@ class ReportService:
"reports": self.database.list_reports_for_employee(user_id, limit), "reports": self.database.list_reports_for_employee(user_id, limit),
} }
def previous_report_reference(self, feishu_user_id: str, before_date: str) -> dict[str, Any]:
user_id = _required_text(feishu_user_id, "feishu_user_id")
date = _required_text(before_date, "report_date")
employee = self.database.find_employee(user_id)
if not employee or employee.get("active") != 1:
raise ValueError("employee is not active")
return {
"employee": employee,
"report": self.database.find_previous_report(user_id, date),
}
def is_admin(self, feishu_user_id: str) -> bool: def is_admin(self, feishu_user_id: str) -> bool:
user_id = _required_text(feishu_user_id, "feishu_user_id") user_id = _required_text(feishu_user_id, "feishu_user_id")
employee = self.database.find_employee(user_id) employee = self.database.find_employee(user_id)

View File

@ -24,7 +24,8 @@ def send_reminder() -> dict:
payload = create_reminder_payload(f"{config.base_url}/submit") payload = create_reminder_payload(f"{config.base_url}/submit")
database = Database(config.database_path, config.employee_seed_path) database = Database(config.database_path, config.employee_seed_path)
try: try:
employees = database.list_active_employees() service = ReportService(database)
employees = service.list_reports_for_date(date.today().isoformat())["missing"]
if config.feishu_app_id and config.feishu_app_secret: if config.feishu_app_id and config.feishu_app_secret:
token = get_tenant_access_token(config.feishu_app_id, config.feishu_app_secret) token = get_tenant_access_token(config.feishu_app_id, config.feishu_app_secret)
sent = [] sent = []

View File

@ -1,9 +1,25 @@
const state = { data: null }; const state = { data: null };
const statusLabels = {
normal: "正常",
risk: "有风险",
need_help: "需要支持"
};
function text(value) { function text(value) {
return String(value || ""); return String(value || "");
} }
function escapeHtml(value) {
return text(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[char]));
}
function render() { function render() {
const employeeQuery = document.querySelector("#employee-filter").value.trim().toLowerCase(); const employeeQuery = document.querySelector("#employee-filter").value.trim().toLowerCase();
const keywordQuery = document.querySelector("#keyword-filter").value.trim().toLowerCase(); const keywordQuery = document.querySelector("#keyword-filter").value.trim().toLowerCase();
@ -18,22 +34,33 @@ function render() {
const reports = data.reports.filter((report) => { const reports = data.reports.filter((report) => {
const employeeMatch = report.employee_name.toLowerCase().includes(employeeQuery); const employeeMatch = report.employee_name.toLowerCase().includes(employeeQuery);
const searchable = [report.today_done, report.tomorrow_plan, report.blockers, report.help_needed].join(" ").toLowerCase(); const searchable = [
report.today_done,
report.tomorrow_plan,
report.blockers,
report.help_needed,
statusLabels[report.report_status]
].join(" ").toLowerCase();
const keywordMatch = searchable.includes(keywordQuery); const keywordMatch = searchable.includes(keywordQuery);
const blockerMatch = !onlyBlockers || report.blockers || report.help_needed; const blockerMatch = !onlyBlockers || report.blockers || report.help_needed || report.report_status === "need_help";
return employeeMatch && keywordMatch && blockerMatch; return employeeMatch && keywordMatch && blockerMatch;
}); });
document.querySelector("#reports").innerHTML = reports.length ? reports.map((report) => ` document.querySelector("#reports").innerHTML = reports.length ? reports.map((report) => `
<article class="report-card"> <article class="report-card">
<h3>${report.employee_name}</h3> <div class="report-head">
<div class="report-grid"> <h3>${escapeHtml(report.employee_name)}</h3>
<div><div class="field-title">今日完成</div><div>${text(report.today_done)}</div></div> <span class="status-badge status-${escapeHtml(report.report_status || "normal")}">
<div><div class="field-title">明日计划</div><div>${text(report.tomorrow_plan)}</div></div> ${statusLabels[report.report_status] || "正常"}
<div><div class="field-title">遇到的问题</div><div>${text(report.blockers) || ""}</div></div> </span>
<div><div class="field-title">需要协助</div><div>${text(report.help_needed) || ""}</div></div>
</div> </div>
<p>提交时间${report.updated_at}</p> <div class="report-grid">
<div><div class="field-title">今日完成</div><pre>${escapeHtml(report.today_done)}</pre></div>
<div><div class="field-title">明日计划</div><pre>${escapeHtml(report.tomorrow_plan)}</pre></div>
<div><div class="field-title">遇到的问题</div><pre>${escapeHtml(report.blockers || "")}</pre></div>
<div><div class="field-title">需要协助</div><pre>${escapeHtml(report.help_needed || "")}</pre></div>
</div>
<p>提交时间${escapeHtml(report.updated_at)}</p>
</article> </article>
`).join("") : "<p>当前筛选下没有日报。</p>"; `).join("") : "<p>当前筛选下没有日报。</p>";

View File

@ -32,6 +32,66 @@ button {
border: 0; border: 0;
cursor: pointer; cursor: pointer;
} }
.identity-box {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid #dbeafe;
border-radius: 6px;
background: #eff6ff;
}
.status-options {
display: flex;
flex-wrap: wrap;
gap: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
}
.status-options legend { font-weight: 700; }
.status-options label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 400;
}
.item-group {
display: grid;
gap: 8px;
}
.item-group h2 { margin: 0; }
.item-group [data-list] {
display: grid;
gap: 8px;
}
.previous-plan {
display: grid;
gap: 10px;
margin-top: 18px;
background: #f8fafc;
}
.previous-plan[hidden] { display: none; }
.previous-plan h2, .previous-plan p { margin: 0; }
.previous-plan button { justify-self: start; }
.report-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.status-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
}
.status-normal { background: #dcfce7; color: #166534; }
.status-risk { background: #fef3c7; color: #92400e; }
.status-need_help { background: #fee2e2; color: #991b1b; }
.filters { .filters {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
@ -68,6 +128,6 @@ pre {
.message.success { color: #047857; } .message.success { color: #047857; }
.message.error { color: #b91c1c; } .message.error { color: #b91c1c; }
@media (max-width: 760px) { @media (max-width: 760px) {
.topbar, .stats, .history-head { display: grid; } .topbar, .stats, .history-head, .report-head { display: grid; }
.filters, .report-grid { grid-template-columns: 1fr; } .filters, .report-grid { grid-template-columns: 1fr; }
} }

View File

@ -43,7 +43,6 @@ def page(title: str, body: str, scripts: str = "") -> bytes:
def submit_page(current_date: str, session: dict[str, str] | None = None) -> bytes: def submit_page(current_date: str, session: dict[str, str] | None = None) -> bytes:
numbered_template = "1. \n2. \n3. \n4. "
if session: if session:
identity_html = f""" identity_html = f"""
<div class="identity-box"> <div class="identity-box">
@ -52,38 +51,102 @@ def submit_page(current_date: str, session: dict[str, str] | None = None) -> byt
</div> </div>
<input id="feishu-user-id" type="hidden" name="feishu_user_id" value="{escape(session["feishu_user_id"])}"> <input id="feishu-user-id" type="hidden" name="feishu_user_id" value="{escape(session["feishu_user_id"])}">
""" """
history_hint = "系统已自动识别你的飞书身份,可以查看自己最近提交的记录" history_hint = "系统已自动识别你的飞书身份,这里会显示你最近提交过的日报"
else: else:
identity_html = '<label>员工 ID<input id="feishu-user-id" name="feishu_user_id" required placeholder="例如 u_alice"></label>' identity_html = '<label>员工 ID<input id="feishu-user-id" name="feishu_user_id" required placeholder="例如 u_alice"></label>'
history_hint = "输入员工 ID 后可以查看自己最近提交的记录" history_hint = "输入员工 ID 后,可以查看自己最近提交过的日报"
return page( return page(
"每日报告", "每日报告",
f""" f"""
<main class="shell narrow"> <main class="shell narrow">
<h1>每日</h1> <h1>每日工作汇</h1>
<form id="report-form" class="panel"> <form id="report-form" class="panel">
{identity_html} {identity_html}
<label>日期<input name="report_date" type="date" required value="{escape(current_date)}"></label> <label>日期<input id="report-date" name="report_date" type="date" required value="{escape(current_date)}"></label>
<label>今日完成<textarea name="today_done" required rows="5">{numbered_template}</textarea></label> <fieldset class="status-options">
<label>明日计划<textarea name="tomorrow_plan" required rows="4">{numbered_template}</textarea></label> <legend>今日状态</legend>
<label>遇到的问题<textarea name="blockers" rows="3"></textarea></label> <label><input type="radio" name="report_status" value="normal" checked> 正常</label>
<label>需要协助<textarea name="help_needed" rows="3"></textarea></label> <label><input type="radio" name="report_status" value="risk"> 有风险</label>
<label><input type="radio" name="report_status" value="need_help"> 需要支持</label>
</fieldset>
<section class="item-group">
<h2>今日完成</h2>
<div data-list="today_done">
<input placeholder="1.">
<input placeholder="2.">
<input placeholder="3.">
<input placeholder="4.">
</div>
</section>
<section class="item-group">
<h2>明日计划</h2>
<div data-list="tomorrow_plan">
<input placeholder="1.">
<input placeholder="2.">
<input placeholder="3.">
<input placeholder="4.">
</div>
</section>
<label>遇到的问题<textarea name="blockers" rows="3" placeholder="没有可以不填"></textarea></label>
<label>需要协助<textarea name="help_needed" rows="3" placeholder="没有可以不填"></textarea></label>
<button type="submit">提交日报</button> <button type="submit">提交日报</button>
<p id="form-message" class="message"></p> <p id="form-message" class="message"></p>
</form> </form>
<section id="previous-plan" class="panel previous-plan" hidden>
<div>
<h2>上次明日计划参考</h2>
<p id="previous-plan-date"></p>
</div>
<pre id="previous-plan-content"></pre>
<button id="use-previous-plan" type="button">填入今日完成</button>
</section>
<section class="panel history-panel"> <section class="panel history-panel">
<div class="history-head"> <div class="history-head">
<div> <div>
<h2>我的历史日报</h2> <h2>我的历史日报</h2>
<p>{history_hint}</p> <p>{history_hint}</p>
</div> </div>
<button id="load-history" type="button">查看历史</button> <button id="load-history" type="button">刷新历史</button>
</div> </div>
<div id="history-list" class="history-list">暂无历史记录</div> <div id="history-list" class="history-list">暂无历史记录</div>
</section> </section>
</main> </main>
<script> <script>
const statusLabels = {{
normal: "正常",
risk: "有风险",
need_help: "需要支持"
}};
function escapeHtml(value) {{
return String(value || "").replace(/[&<>"']/g, (char) => ({{
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}}[char]));
}}
function collectItems(name) {{
const values = Array.from(document.querySelectorAll(`[data-list="${{name}}"] input`))
.map((input) => input.value.trim())
.filter(Boolean);
return values.map((value, index) => `${{index + 1}}. ${{value}}`).join("\\n");
}}
function fillItemInputs(name, text) {{
const inputs = Array.from(document.querySelectorAll(`[data-list="${{name}}"] input`));
const lines = String(text || "")
.split(/\\n+/)
.map((line) => line.replace(/^\\s*\\d+[\\.)]\\s*/, "").trim())
.filter(Boolean);
inputs.forEach((input, index) => {{
input.value = lines[index] || "";
}});
}}
function renderHistory(data) {{ function renderHistory(data) {{
const container = document.querySelector("#history-list"); const container = document.querySelector("#history-list");
if (!data.reports.length) {{ if (!data.reports.length) {{
@ -92,16 +155,16 @@ function renderHistory(data) {{
}} }}
container.innerHTML = data.reports.map((report) => ` container.innerHTML = data.reports.map((report) => `
<article class="history-card"> <article class="history-card">
<h3>${{report.report_date}}</h3> <h3>${{escapeHtml(report.report_date)}} · ${{statusLabels[report.report_status] || "正常"}}</h3>
<div><strong>今日完成</strong><pre>${{report.today_done}}</pre></div> <div><strong>今日完成</strong><pre>${{escapeHtml(report.today_done)}}</pre></div>
<div><strong>明日计划</strong><pre>${{report.tomorrow_plan}}</pre></div> <div><strong>明日计划</strong><pre>${{escapeHtml(report.tomorrow_plan)}}</pre></div>
<div><strong>问题</strong><pre>${{report.blockers || ""}}</pre></div> <div><strong>问题</strong><pre>${{escapeHtml(report.blockers || "")}}</pre></div>
<div><strong>协助</strong><pre>${{report.help_needed || ""}}</pre></div> <div><strong>协助</strong><pre>${{escapeHtml(report.help_needed || "")}}</pre></div>
</article> </article>
`).join(""); `).join("");
}} }}
document.querySelector("#load-history").addEventListener("click", async () => {{ async function loadHistory() {{
const userId = document.querySelector("#feishu-user-id").value.trim(); const userId = document.querySelector("#feishu-user-id").value.trim();
const container = document.querySelector("#history-list"); const container = document.querySelector("#history-list");
if (!userId) {{ if (!userId) {{
@ -115,11 +178,42 @@ document.querySelector("#load-history").addEventListener("click", async () => {{
const result = await response.json(); const result = await response.json();
container.textContent = result.error || "历史记录加载失败。"; container.textContent = result.error || "历史记录加载失败。";
}} }}
}}
async function loadPreviousPlan() {{
const userId = document.querySelector("#feishu-user-id").value.trim();
const reportDate = document.querySelector("#report-date").value;
const panel = document.querySelector("#previous-plan");
if (!userId || !reportDate) {{
panel.hidden = true;
return;
}}
const response = await fetch(`/api/reports/previous?feishu_user_id=${{encodeURIComponent(userId)}}&date=${{encodeURIComponent(reportDate)}}`);
if (!response.ok) {{
panel.hidden = true;
return;
}}
const result = await response.json();
if (!result.report) {{
panel.hidden = true;
return;
}}
document.querySelector("#previous-plan-date").textContent = `${{result.report.report_date}} 提交`;
document.querySelector("#previous-plan-content").textContent = result.report.tomorrow_plan;
panel.hidden = false;
}}
document.querySelector("#load-history").addEventListener("click", loadHistory);
document.querySelector("#report-date").addEventListener("change", loadPreviousPlan);
document.querySelector("#use-previous-plan").addEventListener("click", () => {{
fillItemInputs("today_done", document.querySelector("#previous-plan-content").textContent);
}}); }});
document.querySelector("#report-form").addEventListener("submit", async (event) => {{ document.querySelector("#report-form").addEventListener("submit", async (event) => {{
event.preventDefault(); event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries()); const data = Object.fromEntries(new FormData(event.currentTarget).entries());
data.today_done = collectItems("today_done");
data.tomorrow_plan = collectItems("tomorrow_plan");
const response = await fetch("/api/reports", {{ const response = await fetch("/api/reports", {{
method: "POST", method: "POST",
headers: {{ "content-type": "application/json" }}, headers: {{ "content-type": "application/json" }},
@ -129,13 +223,17 @@ document.querySelector("#report-form").addEventListener("submit", async (event)
if (response.ok) {{ if (response.ok) {{
message.textContent = "提交成功。"; message.textContent = "提交成功。";
message.className = "message success"; message.className = "message success";
document.querySelector("#load-history").click(); loadHistory();
loadPreviousPlan();
}} else {{ }} else {{
const result = await response.json(); const result = await response.json();
message.textContent = result.error || "提交失败。"; message.textContent = result.error || "提交失败。";
message.className = "message error"; message.className = "message error";
}} }}
}}); }});
loadHistory();
loadPreviousPlan();
</script>""", </script>""",
) )
@ -177,7 +275,7 @@ def forbidden_page() -> bytes:
<main class="shell narrow"> <main class="shell narrow">
<section class="panel"> <section class="panel">
<h1>无权限</h1> <h1>无权限</h1>
<p>你没有查看日报汇总的权限请联系管理员开通</p> <p>你没有查看日报汇总的权限请联系管理员开通</p>
<p><a href="/submit">返回日报填写页</a></p> <p><a href="/submit">返回日报填写页</a></p>
</section> </section>
</main>""", </main>""",
@ -300,6 +398,14 @@ class DailyReportHandler(BaseHTTPRequestHandler):
except ValueError as error: except ValueError as error:
self._json(400, {"error": str(error)}) self._json(400, {"error": str(error)})
return return
if parsed.path == "/api/reports/previous":
user_id = query.get("feishu_user_id", [""])[0]
report_date = query.get("date", [today_string()])[0]
try:
self._json(200, self.report_service.previous_report_reference(user_id, report_date))
except ValueError as error:
self._json(400, {"error": str(error)})
return
if parsed.path == "/api/reports/export": if parsed.path == "/api/reports/export":
if not self._require_admin(): if not self._require_admin():
return return

View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="/root/kaikai_test"
cat > /etc/systemd/system/kaikai-daily-reminder.service <<'EOF'
[Unit]
Description=Kaikai Daily Report Reminder
[Service]
WorkingDirectory=/root/kaikai_test
ExecStart=/usr/bin/python3 -m daily_report.scheduled reminder
Type=oneshot
EOF
cat > /etc/systemd/system/kaikai-daily-reminder.timer <<'EOF'
[Unit]
Description=Run Kaikai Daily Report Reminder at 18:00
[Timer]
OnCalendar=*-*-* 18:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
cat > /etc/systemd/system/kaikai-daily-reminder-followup.service <<'EOF'
[Unit]
Description=Kaikai Daily Report Follow-up Reminder
[Service]
WorkingDirectory=/root/kaikai_test
ExecStart=/usr/bin/python3 -m daily_report.scheduled reminder
Type=oneshot
EOF
cat > /etc/systemd/system/kaikai-daily-reminder-followup.timer <<'EOF'
[Unit]
Description=Run Kaikai Daily Report Follow-up Reminder at 18:50
[Timer]
OnCalendar=*-*-* 18:50:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
cat > /etc/systemd/system/kaikai-daily-summary.service <<'EOF'
[Unit]
Description=Kaikai Daily Report Summary
[Service]
WorkingDirectory=/root/kaikai_test
ExecStart=/usr/bin/python3 -m daily_report.scheduled summary
Type=oneshot
EOF
cat > /etc/systemd/system/kaikai-daily-summary.timer <<'EOF'
[Unit]
Description=Run Kaikai Daily Report Summary at 19:00
[Timer]
OnCalendar=*-*-* 19:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now kaikai-daily-reminder.timer
systemctl enable --now kaikai-daily-reminder-followup.timer
systemctl enable --now kaikai-daily-summary.timer
systemctl list-timers --all | grep kaikai || true

View File

@ -42,7 +42,7 @@ class ReportServiceTest(unittest.TestCase):
"feishu_user_id": "u_1", "feishu_user_id": "u_1",
"report_date": "2026-05-07", "report_date": "2026-05-07",
"today_done": "", "today_done": "",
"tomorrow_plan": "管理页", "tomorrow_plan": "管理页",
} }
) )
finally: finally:
@ -56,29 +56,32 @@ class ReportServiceTest(unittest.TestCase):
{ {
"feishu_user_id": "u_1", "feishu_user_id": "u_1",
"report_date": "2026-05-07", "report_date": "2026-05-07",
"today_done": "完成原型初稿", "report_status": "risk",
"tomorrow_plan": "和主管评审", "today_done": "1. 完成原型初稿",
"tomorrow_plan": "1. 和主管评审",
} }
) )
service.upsert_report( service.upsert_report(
{ {
"feishu_user_id": "u_1", "feishu_user_id": "u_1",
"report_date": "2026-05-07", "report_date": "2026-05-07",
"today_done": "完成原型初稿并调整文案", "report_status": "need_help",
"tomorrow_plan": "和主管评审", "today_done": "1. 完成原型初稿\n2. 调整文案",
"tomorrow_plan": "1. 和主管评审",
"blockers": "需要确认 logo", "blockers": "需要确认 logo",
"help_needed": "确认视觉方向", "help_needed": "确认视觉方向",
} }
) )
result = service.list_reports_for_date("2026-05-07") result = service.list_reports_for_date("2026-05-07")
self.assertEqual(len(result["reports"]), 1) self.assertEqual(len(result["reports"]), 1)
self.assertEqual(result["reports"][0]["today_done"], "完成原型初稿并调整文案") self.assertEqual(result["reports"][0]["report_status"], "need_help")
self.assertEqual(result["reports"][0]["today_done"], "1. 完成原型初稿\n2. 调整文案")
self.assertEqual(result["reports"][0]["blockers"], "需要确认 logo") self.assertEqual(result["reports"][0]["blockers"], "需要确认 logo")
self.assertEqual(len(result["missing"]), 0) self.assertEqual(len(result["missing"]), 0)
finally: finally:
db.close() db.close()
def test_returns_missing_active_employees_and_csv_export(self) -> None: def test_returns_missing_active_employees_csv_and_previous_reference(self) -> None:
db = make_database( db = make_database(
[ [
{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}, {"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True},
@ -87,12 +90,20 @@ class ReportServiceTest(unittest.TestCase):
) )
service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc)) service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc))
try: try:
service.upsert_report(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-06",
"today_done": "1. 完成上线检查清单",
"tomorrow_plan": "1. 准备发布",
}
)
service.upsert_report( service.upsert_report(
{ {
"feishu_user_id": "u_1", "feishu_user_id": "u_1",
"report_date": "2026-05-07", "report_date": "2026-05-07",
"today_done": "完成上线检查清单", "today_done": "1. 准备发布",
"tomorrow_plan": "准备发布", "tomorrow_plan": "1. 复盘问题",
} }
) )
result = service.list_reports_for_date("2026-05-07") result = service.list_reports_for_date("2026-05-07")
@ -100,8 +111,12 @@ class ReportServiceTest(unittest.TestCase):
self.assertEqual(result["missing"][0]["name"], "Chen") self.assertEqual(result["missing"][0]["name"], "Chen")
csv_text = service.export_reports_csv("2026-05-07") csv_text = service.export_reports_csv("2026-05-07")
self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,submitted_at", csv_text) self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,report_status,submitted_at", csv_text)
self.assertIn("Lin,2026-05-07,完成上线检查清单,准备发布", csv_text) self.assertIn("Lin,2026-05-07,1. 准备发布,1. 复盘问题,,,normal,", csv_text)
reference = service.previous_report_reference("u_1", "2026-05-07")
self.assertEqual(reference["report"]["report_date"], "2026-05-06")
self.assertEqual(reference["report"]["tomorrow_plan"], "1. 准备发布")
finally: finally:
db.close() db.close()

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import tempfile import tempfile
import unittest import unittest
from datetime import date
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@ -55,7 +56,7 @@ class ScheduledTest(unittest.TestCase):
self.assertIn("0/2", text) self.assertIn("0/2", text)
self.assertIn("Chen、Lin", text) self.assertIn("Chen、Lin", text)
def test_send_reminder_private_messages_all_active_employees(self) -> None: def test_send_reminder_private_messages_missing_employees_only(self) -> None:
with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp: with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp:
root = Path(temp) root = Path(temp)
seed_path = root / "employees.json" seed_path = root / "employees.json"
@ -87,7 +88,8 @@ class ScheduledTest(unittest.TestCase):
with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch( with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch(
"daily_report.scheduled.get_tenant_access_token", lambda app_id, app_secret: "tenant-token" "daily_report.scheduled.get_tenant_access_token", lambda app_id, app_secret: "tenant-token"
), patch("daily_report.scheduled.send_bot_interactive_message", fake_send): ), patch("daily_report.scheduled.send_bot_interactive_message", fake_send), patch("daily_report.scheduled.date") as fake_date:
fake_date.today.return_value = date(2026, 5, 7)
result = scheduled.send_reminder() result = scheduled.send_reminder()
self.assertEqual(result["mode"], "bot_private") self.assertEqual(result["mode"], "bot_private")

View File

@ -4,6 +4,7 @@ import json
import tempfile import tempfile
import threading import threading
import unittest import unittest
import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@ -52,19 +53,23 @@ def get_with_cookie(url: str, cookie: str) -> tuple[int, str]:
return response.status, response.read().decode("utf-8") return response.status, response.read().decode("utf-8")
def admin_cookie(user_id: str = "u_1", name: str = "Lin") -> str:
return "daily_report_session=" + create_session_cookie(
{"feishu_user_id": user_id, "name": name}, "session-secret"
)
class WebTest(unittest.TestCase): class WebTest(unittest.TestCase):
def test_serves_submit_and_manager_pages(self) -> None: def test_serves_submit_and_manager_pages(self) -> None:
server, db, base_url = make_server() server, db, base_url = make_server()
try: try:
status, submit = get(f"{base_url}/submit") status, submit = get(f"{base_url}/submit")
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn("每日报告", submit) self.assertIn("每日工作汇报", submit)
self.assertIn("1. \n2. \n3. \n4.", submit) self.assertIn("今日状态", submit)
self.assertIn('data-list="today_done"', submit)
self.assertIn("我的历史日报", submit) self.assertIn("我的历史日报", submit)
admin_cookie = "daily_report_session=" + create_session_cookie(
{"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"
)
db.upsert_employee( db.upsert_employee(
{ {
"feishu_user_id": "u_1", "feishu_user_id": "u_1",
@ -75,7 +80,7 @@ class WebTest(unittest.TestCase):
"role": "admin", "role": "admin",
} }
) )
status, manager = get_with_cookie(f"{base_url}/manager", admin_cookie) status, manager = get_with_cookie(f"{base_url}/manager", admin_cookie())
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn("日报浏览", manager) self.assertIn("日报浏览", manager)
finally: finally:
@ -83,24 +88,42 @@ class WebTest(unittest.TestCase):
server.server_close() server.server_close()
db.close() db.close()
def test_accepts_report_submission_and_returns_summary(self) -> None: def test_accepts_report_submission_and_returns_summary_and_previous_reference(self) -> None:
server, db, base_url = make_server() server, db, base_url = make_server()
try: try:
request = urllib.request.Request( first_request = urllib.request.Request(
f"{base_url}/api/reports", f"{base_url}/api/reports",
data=json.dumps( data=json.dumps(
{ {
"feishu_user_id": "u_1", "feishu_user_id": "u_1",
"report_date": "2026-05-07", "report_date": "2026-05-06",
"today_done": "完成 API", "today_done": "1. 完成 API",
"tomorrow_plan": "完善界面", "tomorrow_plan": "1. 完善界面",
}, },
ensure_ascii=False, ensure_ascii=False,
).encode("utf-8"), ).encode("utf-8"),
headers={"content-type": "application/json"}, headers={"content-type": "application/json"},
method="POST", method="POST",
) )
with urllib.request.urlopen(request, timeout=5) as response: with urllib.request.urlopen(first_request, timeout=5) as response:
self.assertEqual(response.status, 200)
second_request = urllib.request.Request(
f"{base_url}/api/reports",
data=json.dumps(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-07",
"report_status": "need_help",
"today_done": "1. 完善界面",
"tomorrow_plan": "1. 上线测试",
},
ensure_ascii=False,
).encode("utf-8"),
headers={"content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(second_request, timeout=5) as response:
self.assertEqual(response.status, 200) self.assertEqual(response.status, 200)
db.upsert_employee( db.upsert_employee(
@ -113,20 +136,24 @@ class WebTest(unittest.TestCase):
"role": "admin", "role": "admin",
} }
) )
admin_cookie = "daily_report_session=" + create_session_cookie( status, body = get_with_cookie(f"{base_url}/api/reports?date=2026-05-07", admin_cookie())
{"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"
)
status, body = get_with_cookie(f"{base_url}/api/reports?date=2026-05-07", admin_cookie)
summary = json.loads(body) summary = json.loads(body)
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(summary["submittedCount"], 1) self.assertEqual(summary["submittedCount"], 1)
self.assertEqual(summary["reports"][0]["today_done"], "完成 API") self.assertEqual(summary["reports"][0]["today_done"], "1. 完善界面")
self.assertEqual(summary["reports"][0]["report_status"], "need_help")
status, history_body = get(f"{base_url}/api/reports/history?feishu_user_id=u_1") status, history_body = get(f"{base_url}/api/reports/history?feishu_user_id=u_1")
history = json.loads(history_body) history = json.loads(history_body)
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(history["employee"]["name"], "Lin") self.assertEqual(history["employee"]["name"], "Lin")
self.assertEqual(history["reports"][0]["tomorrow_plan"], "完善界面") self.assertEqual(history["reports"][0]["tomorrow_plan"], "1. 上线测试")
status, reference_body = get(f"{base_url}/api/reports/previous?feishu_user_id=u_1&date=2026-05-07")
reference = json.loads(reference_body)
self.assertEqual(status, 200)
self.assertEqual(reference["report"]["report_date"], "2026-05-06")
self.assertEqual(reference["report"]["tomorrow_plan"], "1. 完善界面")
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
@ -135,18 +162,8 @@ class WebTest(unittest.TestCase):
def test_submit_page_uses_logged_in_feishu_identity(self) -> None: def test_submit_page_uses_logged_in_feishu_identity(self) -> None:
server, db, base_url = make_server() server, db, base_url = make_server()
try: try:
opener = urllib.request.build_opener() status, body = get_with_cookie(f"{base_url}/submit", admin_cookie())
opener.addheaders = [ self.assertEqual(status, 200)
(
"Cookie",
"daily_report_session="
+ create_session_cookie({"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"),
)
]
with opener.open(f"{base_url}/submit", timeout=5) as response:
body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn("Lin", body) self.assertIn("Lin", body)
self.assertIn('type="hidden" name="feishu_user_id" value="u_1"', body) self.assertIn('type="hidden" name="feishu_user_id" value="u_1"', body)
self.assertNotIn("员工 ID<input", body) self.assertNotIn("员工 ID<input", body)
@ -163,17 +180,12 @@ class WebTest(unittest.TestCase):
] ]
) )
try: try:
staff_cookie = "daily_report_session=" + create_session_cookie( staff_cookie = admin_cookie("u_staff", "Staff")
{"feishu_user_id": "u_staff", "name": "Staff"}, "session-secret"
)
with self.assertRaises(urllib.error.HTTPError) as error: with self.assertRaises(urllib.error.HTTPError) as error:
get_with_cookie(f"{base_url}/manager", staff_cookie) get_with_cookie(f"{base_url}/manager", staff_cookie)
self.assertEqual(error.exception.code, 403) self.assertEqual(error.exception.code, 403)
admin_cookie = "daily_report_session=" + create_session_cookie( status, body = get_with_cookie(f"{base_url}/manager", admin_cookie("u_admin", "Admin"))
{"feishu_user_id": "u_admin", "name": "Admin"}, "session-secret"
)
status, body = get_with_cookie(f"{base_url}/manager", admin_cookie)
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn("日报浏览", body) self.assertIn("日报浏览", body)
finally: finally: