feat: 任务模型物理合并,发布+回收并为单任务双阶段

- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete
- 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份)
- 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止
- 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段
- admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度
- 测试 309 全绿 + Playwright E2E 20/20
This commit is contained in:
ABAPPLO
2026-09-24 15:52:45 +08:00
parent ab78b187c0
commit e2a93e22cf
29 changed files with 2219 additions and 2195 deletions

View File

@@ -27,17 +27,17 @@ type DueSoonRow = {
wecom_external_user_id: string | null;
task_id: string;
task_name: string;
task_type: string;
due_at: string;
pending_phase: "publish" | "recovery";
pending_due_at: string;
content_title: string;
};
function pendingActionLabel(taskType: string) {
return taskType === "screenshot_collect" ? "截图待提交" : "待发布";
function pendingActionLabel(pendingPhase: string) {
return pendingPhase === "recovery" ? "截图待提交" : "待发布";
}
function pendingSummary(rows: DueSoonRow[]) {
const shot = rows.filter((row) => row.task_type === "screenshot_collect").length;
const shot = rows.filter((row) => row.pending_phase === "recovery").length;
const publish = rows.length - shot;
if (shot === 0) return `${rows.length} 条笔记待发布`;
if (publish === 0) return `${rows.length} 份截图待提交`;
@@ -66,6 +66,7 @@ export async function runDueSoonWecomNotifications(
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const taskFilter = String(taskId ?? "").trim();
// 双阶段催办:发布阶段(未回填链接)看 due_at;回收阶段(已发布未收截图)看 recovery_due_at
const dueSoonBase = `SELECT
d.id AS distribution_id,
d.partner_id,
@@ -73,27 +74,29 @@ export async function runDueSoonWecomNotifications(
p.wecom_external_user_id,
t.id AS task_id,
t.name AS task_name,
t.task_type,
t.due_at,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish' ELSE 'recovery' END AS pending_phase,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at ELSE t.recovery_due_at END AS pending_due_at,
c.title AS content_title
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
JOIN contents c ON c.id = d.content_id
WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (d.publish_url IS NULL OR d.publish_url = ''))
)
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at <= ?`;
((d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at <= ?)
OR (t.recovery_enabled = 1
AND d.publish_url IS NOT NULL AND d.publish_url != ''
AND d.result_submitted_at IS NULL
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
AND t.recovery_due_at <= ?)
)`;
const statement = db
.prepare(
taskFilter
? `${dueSoonBase} AND t.id = ? ORDER BY t.due_at ASC, p.name ASC`
: `${dueSoonBase} ORDER BY t.due_at ASC, p.name ASC`,
? `${dueSoonBase} AND t.id = ? ORDER BY pending_due_at ASC, p.name ASC`
: `${dueSoonBase} ORDER BY pending_due_at ASC, p.name ASC`,
)
.bind(...(taskFilter ? [cutoff, taskFilter] : [cutoff]));
.bind(...(taskFilter ? [cutoff, cutoff, taskFilter] : [cutoff, cutoff]));
const result = await statement.all<DueSoonRow>();
const grouped = new Map<
@@ -129,7 +132,7 @@ export async function runDueSoonWecomNotifications(
? [entry.externalUserId]
: [];
const lines = entry.rows.slice(0, 5).map((row) => {
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(${pendingActionLabel(row.task_type)},截止 ${row.due_at})`;
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(${pendingActionLabel(row.pending_phase)},截止 ${row.pending_due_at})`;
});
const overflow =
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
@@ -155,7 +158,7 @@ export async function runDueSoonWecomNotifications(
} else {
dueSoonSent += 1;
}
const earliestDue = entry.rows[0]?.due_at ?? "";
const earliestDue = entry.rows[0]?.pending_due_at ?? "";
digestTasks.push(
`· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`,
);
@@ -168,7 +171,7 @@ export async function runDueSoonWecomNotifications(
for (const row of result.results) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.due_at,
dueAt: row.pending_due_at,
rows: [],
};
entry.rows.push(row);
@@ -224,7 +227,8 @@ type OverdueRow = {
id: string;
task_id: string;
task_name: string;
due_at: string;
pending_phase: "publish" | "recovery";
pending_due_at: string;
project_id: string;
partner_id: string;
partner_name: string;
@@ -241,24 +245,33 @@ export type OverdueEscalationSummary = {
async function queryOverdueUnpublished(
db: DatabaseClient,
cutoff: string,
options: { phases: "publish" | "all" } = { phases: "all" },
): Promise<OverdueRow[]> {
const publishBranch = `(d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at < ?`;
const recoveryBranch = `t.recovery_enabled = 1
AND d.publish_url IS NOT NULL AND d.publish_url != ''
AND d.result_submitted_at IS NULL
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
AND t.recovery_due_at < ?`;
const where =
options.phases === "publish"
? publishBranch
: `(${publishBranch}) OR (${recoveryBranch})`;
const result = await db
.prepare(
`SELECT d.id, t.id AS task_id, t.name AS task_name, t.due_at,
`SELECT d.id, t.id AS task_id, t.name AS task_name,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish' ELSE 'recovery' END AS pending_phase,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at ELSE t.recovery_due_at END AS pending_due_at,
t.project_id, p.id AS partner_id, p.name AS partner_name
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (d.publish_url IS NULL OR d.publish_url = ''))
)
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at < ?
ORDER BY t.due_at ASC, t.name ASC`,
WHERE ${where}
ORDER BY pending_due_at ASC, t.name ASC`,
)
.bind(cutoff)
.bind(...(options.phases === "publish" ? [cutoff] : [cutoff, cutoff]))
.all<OverdueRow>();
return result.results;
}
@@ -274,19 +287,28 @@ export async function runOverdueEscalation(
const byTask = new Map<
string,
{ taskName: string; dueAt: string; projectId: string; count: number; partners: Set<string> }
{
taskName: string;
dueAt: string;
phase: "publish" | "recovery";
projectId: string;
count: number;
partners: Set<string>;
}
>();
for (const row of rows) {
const entry = byTask.get(row.task_id) ?? {
const key = `${row.task_id}:${row.pending_phase}`;
const entry = byTask.get(key) ?? {
taskName: row.task_name,
dueAt: row.due_at,
dueAt: row.pending_due_at,
phase: row.pending_phase,
projectId: bindingValue(row.project_id),
count: 0,
partners: new Set<string>(),
};
entry.count += 1;
entry.partners.add(row.partner_name || row.partner_id);
byTask.set(row.task_id, entry);
byTask.set(key, entry);
}
const summary: OverdueEscalationSummary = {
@@ -303,7 +325,8 @@ export async function runOverdueEscalation(
const lines = [...byTask.values()].map((entry) => {
const partners = [...entry.partners].slice(0, 5).join("、");
const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : "";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条超期未发布 — ${partners}${overflow}`;
const label = entry.phase === "recovery" ? "截图超期未提交" : "超期未发布";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条${label} — ${partners}${overflow}`;
});
try {
await sendWecomRobotMessage(
@@ -321,11 +344,16 @@ export async function runOverdueEscalation(
const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [taskId, entry] of byTask) {
for (const [key, entry] of byTask) {
const taskId = key.split(":")[0];
const overdueText =
entry.phase === "recovery"
? `${entry.count} 份截图超期未提交`
: `${entry.count} 条内容超期未发布`;
await sendTaskChatMessage(
db,
taskId,
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${entry.count} 条内容超期未发布,请跟进催办或释放。`,
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${overdueText},请跟进催办或释放。`,
config,
fetchImpl,
);
@@ -343,7 +371,7 @@ export async function runOverdueEscalation(
touser: members,
taskId: `task:${taskId}:due`,
title: `超期催办:${truncate(entry.taskName, 20)}`,
description: `${entry.count} 条内容超期未发布(截止 ${entry.dueAt}),请跟进催办或释放`,
description: `${entry.phase === "recovery" ? `${entry.count} 份截图超期未提交` : `${entry.count} 条内容超期未发布`}(截止 ${entry.dueAt}),请跟进催办或释放`,
url: cardUrl,
buttons: [
{ key: "view_task", name: "查看任务", url: cardUrl },
@@ -398,7 +426,10 @@ export async function runOverdueAutoRelease(
const today = shanghaiDateFromTimestamp(now);
summary.cutoff = computeDueCutoff(today, -days);
const rows = await queryOverdueUnpublished(db, summary.cutoff);
// 自动释放只处理发布阶段超期:回收超期的笔记已发布,释放重派无意义
const rows = await queryOverdueUnpublished(db, summary.cutoff, {
phases: "publish",
});
for (const row of rows) {
try {