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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user