---
title: "PM 手動寄任務提醒 — 設計文件 (FR-150)"
brand: "Guidant AI · **FR-150** PM 手動寄任務提醒"
eyebrow: "FR-150 · 設計文件 · 2026-10-18（D1～D11 定案）"
h1: "PM 勾幾張急的任務，按一次就寄出提醒"
subtitle: "專案總覽一顆鈕開彈窗，勾未完成任務、留一句話、寄出"
lede: "到期提醒目前只有排程自動寄兩次（剩 3 天、到期當天）。PM 遇到急件想不等排程直接催人，本案在**專案總覽動作列**加「寄送任務提醒」，開一個彈窗列出本輪未完成任務，PM 勾要催的、可附一段留言，按一次寄出。信寄給每張卡**目前該處理的人**，每張卡留一則系統留言記錄被催過，寄送紀錄進既有的通知紀錄頁並標出是誰寄的。"
chips: [
  {text: "D1～D11 全數定案", kind: ok},
  {text: "3 張實作卡，BE 先出", kind: accent},
  {text: "一支 migration（紀錄表加一欄）", kind: warn},
  {text: "不動套件", kind: ok},
  {text: "陷阱：通知紀錄頁的標籤 key 用 stage 拼字串", kind: crit}
]
footer: "FR-150 · 設計文件 · 2026-10-18 · 需求與決策紀錄：[requirement.md](requirement.md) · 雛形：[prototype/index.html](prototype/index.html) · 沿革見 LOG 與 git log"
---

> 狀態：**設計定案（D1～D11），已開卡待派**｜日期：2026-10-18｜母卡 [CM-2894](https://app.notion.com/p/FR-150-PM-3fd346da4cd0817a8397d1507a6209c9)、子卡 CM-2895 / CM-2896 / CM-2897
> 需求與決策紀錄：[`requirement.md`](./requirement.md)｜雛形：[`prototype/index.html`](./prototype/index.html)
> 開發 branch：三個 repo 目前都在 `feature/FR-139`（開工前 runner 自己 `git branch --show-current` 確認，不對就停下問）

> 🔴 **這份是設計決策，不是現況。** 內文的行號停在 2026-10-18 定稿當時，實作改動的結果不回頭改寫這裡。

---

## 1. 需求背景 {#background nav="背景"}

### 1.1 一句話

PM 在專案總覽按「寄送任務提醒」，彈窗勾幾張未完成任務、可附留言，按一次寄出提醒信給每張卡目前該處理的人。

### 1.2 要解的問題

到期提醒（FR-140）只在**剩 3 天**與**到期當天**各自動寄一次，同一階段不重寄。PM 遇到急件時有三個缺口：

- 等不到時點：明天就要的卡，今天排程不會寄。
- 催不了第二次：同一階段已寄過，再急也不會再寄。
- 講不了原因：信是固定模板，PM 沒辦法說「客戶明天要」。

逐張開抽屜按太煩，一鍵全發會催到不急的人，所以做「一個彈窗、勾、寄」。

### 1.3 端到端流程

```mermaid
sequenceDiagram
    actor PM as 專案 manager
    participant FE as 專案總覽
    participant BE as 後端
    actor AS as 任務處理人
    PM->>FE: 按「寄送任務提醒」
    FE->>BE: GET 候選任務（本輪未完成）
    BE-->>FE: 任務清單（含處理人、期限、上次手動提醒時間）
    PM->>FE: 勾任務、填留言、按寄出
    FE->>FE: 勾到 10 分鐘內寄過的 → 二次確認
    FE->>BE: POST job_uids＋message
    BE->>BE: 驗 manager、驗每張卡屬於本專案本輪且未完成
    loop 每張卡
        BE->>AS: 寄一封提醒信（每位處理人一封）
        BE->>BE: 寫通知紀錄（triggered_by＝PM）
        BE->>BE: 卡上留一則系統留言
    end
    BE-->>FE: 寄了幾封、失敗幾封、略過幾張
    FE-->>PM: toast 結果
```

---

## 2. 分工概述（30 秒版） {#overview nav="概述"}

| 誰 | 看到／做什麼 |
|---|---|
| 專案 manager | 總覽動作列多一顆「寄送任務提醒」；彈窗勾任務、留言、寄出；toast 看結果 |
| 任務處理人 | 收到一封「專案管理者提醒」信，附 PM 留言與卡片連結；開卡看到一則系統留言 |
| 其他專案參與者 | 通知紀錄頁多出這類列，標「管理者提醒 · <PM 暱稱>」；任務卡上看得到系統留言 |
| 沒設定任何東西的客戶 | 什麼都不變，自動到期提醒照舊 |

---

## 3. 決策定案（D1～D11） {#decisions nav="決策定案"}

| # | 決策 | 定案 | 理由 | 被排除的選項 |
|---|---|---|---|---|
| D1 | 入口與互動 | 專案總覽動作列一顆鈕，開彈窗勾選後一次寄 | PM 催人的時機是看整體進度時；勾選兼顧「他選」與「一次多張」 | 逐卡抽屜按鈕（決策者：一個一個點會崩潰）；一鍵全發（催到不急的人、無法說原因） |
| D2 | 誰能按 | 專案 manager；本輪已結束時入口不顯示、端點擋 | 與改期限同一條守門（`assert_project_manager`＋輪次未結束） | — |
| D3 | 列哪些卡 | **本輪**狀態 TODO／PROCESSING 的卡；沒輪次的舊專案退回全專案 | 與「開始執行任務」同一套輪次解析（round_no 最大），避免催到上一輪殘卡 | 跨輪全列（覆核輪會撈到母輪殘卡） |
| D4 | 沒期限的卡 | 列出、可勾 | 手動版的本質是 PM 判斷急，不綁期限 | 只列有期限的卡 |
| D5 | 沒人可寄的卡 | 列出但灰掉不能勾；後端收到也略過並回報張數 | 寄了也沒人收；讓 PM 看見「這張還沒派人」本身也有用 | 不列（PM 會以為漏了） |
| D6 | 寄給誰 | 每張卡的處理人（見 D10）；發信的 PM 與其他 manager **都不寄** | PM 知道自己寄了；manager 互收催信是噪音；要知道有人催過看卡上系統留言 | 照自動版一起寄 manager |
| D7 | 一人多卡 | 同一收件人合併成一封清單信（決策者 2026-10-22 確認） | 同一人被催多張卡時只收一封，收件匣不被洗版；信內逐張列出卡片 | 一卡一封 |
| D8 | 留言 | 選填、上限 500 字、整批共用一段；進信件與系統留言 | PM 要能說為什麼急；逐卡留言會讓彈窗變表單 | 逐卡留言 |
| D9 | 重複寄 | 可以；勾到 10 分鐘內寄過的卡，寄出前二次確認；後端不擋 | 急件催兩次是正常需求，二次確認擋手滑就夠 | 後端硬擋冷卻期；每日上限 |
| D10 | 處理人怎麼算（**FR-148 任務關卡後**） | 卡有關卡且已過第 1 關 → 寄**當下那關的人**（`job_review_step_assignees`）；零關或在第 1 關 → 寄 `task_assignees` | 「催」要催現在卡在誰手上；卡在主管審的時候催執行人沒用 | 一律寄原指派人；指派人＋當下關卡都寄 |
| D11 | 卡上系統留言 | 每張被寄的卡留一則，作者「系統」，內容「管理者 <暱稱> 已寄出提醒」＋留言 | 處理人開卡就看到被催過；補上 D6 不寄 manager 的資訊缺口 | 不留 |

> D10 決策者原話：「就是通知當下在執行任務的人」。

---

## 4. 現況接入點盤點 {#inventory nav="現況盤點"}

| 項目 | 位置 | 本案怎麼接 |
|---|---|---|
| 自動到期提醒 | `app/task_alert/service/job_due_reminder_service.py` | 不改它；把「寄一封＋寫紀錄」那段抽成共用 helper，自動版與手動版都呼叫 |
| 收件人查詢 | `infra/readmodel/tasks/job_due_reminder_query.py::recipients` | 不改；手動版另寫收件人查詢（D6 不含 manager、D10 依關卡） |
| 通知紀錄表 | `compliance.task_alert_notifications`；唯一索引 `(object_type, COALESCE(scan_uid, object_uid), COALESCE(stage,''), recipient_email)` | 加 `triggered_by`；手動列 `object_type=job_due`、`stage=manual_<epoch>` 天然繞過唯一鍵 |
| 通知紀錄 API | `app/task_alert/service/task_alert_notification_app_service.py::_to_view`、`api/task_alert/serializers/task_alert_notification.py` | 回傳加 `triggered_by`、`triggered_by_name` |
| 通知紀錄頁 | FE `src/views/project/ProjectAlertNotificationsView.vue:203`，標籤 `t(\`lang.alert_notifications.job_due_${data.stage}\`)` | **陷阱**：手動列 stage 是 `manual_<epoch>`，照拼會找不到 key。判 `stage.startsWith('manual_')` 走另一個 key |
| 守門 | `app/flow_control/service/job_service.py::_require_manager`（內部 `assert_project_manager`） | 手動版 service 用同一個 `common.authz.project.assert_project_manager` |
| 輪次解析 | `infra/flow_control/repository/task_execution_query.py::_current_round_id`（round_no 最大） | 候選清單用同一個邏輯；**不要另寫一套**，抽出或直接呼叫 |
| 系統留言 | `app/flow_control/service/not_met_job_redo_service.py:80`：`job_comment_domain_service.create(job_uid, author_id=None, author_nickname=<系統>, content)` | 照抄這個寫法 |
| 關卡 | `compliance.job_executions.review_step_no`（NULL＝零關）、`compliance.job_review_step_assignees`（第 2 關以後的人） | D10 收件人依此判斷 |
| 總覽動作列 | FE `src/views/project/ProjectAuditorOverview.vue:1113-1160` | **該檔 2,134 行已超標**，彈窗一律新元件，總覽頁只加一顆鈕＋一行掛元件 |
| 待辦清單 view | `public.vw_user_job_queue`（一列一指派人） | **不能拿來當候選清單**：沒指派人的卡不會出現，D5 要列灰掉的卡。另寫以任務為主體的查詢 |

---

## 5. 詳細設計 {#design nav="詳細設計"}

### 5.1 資料

**migration**：`scripts/sql/2026-10-18-fr150-task-alert-triggered-by.sql`

```sql
ALTER TABLE compliance.task_alert_notifications
    ADD COLUMN IF NOT EXISTS triggered_by varchar(64); -- 2026-10-18
COMMENT ON COLUMN compliance.task_alert_notifications.triggered_by
    IS '手動觸發者 login_name；排程自動寄的為 NULL'; -- 2026-10-18
```

- 主線 `phase=active`、`envs=*`，**只套 DEV**；回報「出貨基線待重產」。
- ORM `infra/task_alert/models/` 對應 model 加欄（帶 `comment=`）；entity 加 `triggered_by: Optional[str] = None`。
- 手動列的 `stage`：`manual_<epoch 秒>`，**同一批共用同一個值**，之後查「這一批寄了哪些」對得起來。`stage` 欄已是 varchar(32)，`manual_` 加 10 位數 17 字元，夠用。

### 5.2 端點

兩支都在 `api/task_alert/`，route 不碰 session。

| 方法 | 路徑 | 用途 |
|---|---|---|
| GET | `/project/<project_uid>/job-due-reminder/candidates` | 彈窗清單 |
| POST | `/project/<project_uid>/job-due-reminder` | 寄出 |

**GET 回傳**（每張卡一筆）：

| 欄位 | 說明 |
|---|---|
| `job_uid`、`job_name` | — |
| `control_no`、`control_title` | 控制項代碼與名稱，沒有就 null |
| `status` | TODO／PROCESSING |
| `due_at`、`due_at_manual` | 期限 |
| `recipients` | `[{user_id, login_name, nickname}]`，依 D10 算出的處理人；空陣列＝灰掉 |
| `review_step_label` | 有關卡時「第 N 關 · <關名>」，零關為 null |
| `last_manual_reminded_at` | 該卡最近一筆 `stage LIKE 'manual_%'` 的 `created_at`，沒有就 null |

排序在 BE 做：逾期 → 3 天內 → 其他有期限 → 沒期限；同級依期限近到遠，再依任務名。

**POST body**：`{ "job_uids": [...], "message": "..." }`，`job_uids` 1～200 筆，`message` 可空、≤500 字。

**POST 回傳**：`{ jobs, sent, failed, skipped_no_recipient }`。

**錯誤**：

| 情況 | 例外 | error code |
|---|---|---|
| 不是 manager | `ForbiddenError` | 沿用 `GRC_NOT_MANAGER` |
| 本輪已結束 | `PreconditionFailedError` | 新增一個 `GRC_412xxx`（本輪已結束，不能寄提醒） |
| `job_uids` 有不屬於本專案本輪、或已完成／取消的卡 | `BadRequestError`，**整批退回不部分寄** | 新增一個 `GRC_400xxx` |

task_alert 模組既有的錯誤碼就用 `common/code/flow_control_error_code.py`，新碼也加在那裡，**實作時查當下各段最大序號往下接**（FR-148 平行開發中，序號會變）；同步 FE `error-code.json`。

### 5.3 service

新檔 `app/task_alert/service/manual_job_due_reminder_service.py`，兩個 public method 都 `@transaction`：

- `list_candidates(project_uid, user_id)`：守門 → 查詢 → 組回傳。
- `send(project_uid, job_uids, message, user_ctx)`：守門 → 驗卡 → 逐卡寄信＋寫紀錄＋留言 → 回統計。

**共用 helper**：把自動版 `_notify_job` 內「claim 紀錄 → 寄 → mark」那段抽成 `app/task_alert/service/job_due_mail_sender.py`，兩邊共用；自動版行為不變。

**信件**（`force_locale("zh_Hant_TW")`，與自動版一致）：

- 主旨 key `job_due_manual_mail_subject`：`[%(system)s] 專案管理者提醒：%(job)s`
- 內文沿用 `job_due_mail_content`，`when` 用 `job_due_manual_mail_when`：「專案管理者 %(pm)s 請您儘速處理」
- 有留言時內文後接 `job_due_manual_mail_message`：「<br><br>管理者留言：<br>%(message)s」；留言 `html.escape` 後把換行轉 `<br>`
- 沒期限時期限那列印「未設定」（自動版不會遇到，`_due_text` 回空字串會留下一行「完成期限：」，要處理）

**系統留言** key `job_due_manual_comment`：「管理者 %(pm)s 已寄出提醒」，有留言時接「：%(message)s」。作者用 `not_met_redo_comment.author_nickname()` 同一個系統名稱。留言寫入失敗只記 warning，不影響寄信結果。

**寄送**：同步。一封失敗不影響其他封；SMTP 未啟用時 `send_mail_notification` 回 False，照自動版記 failed。

### 5.4 查詢（readmodel）

新檔 `infra/readmodel/tasks/manual_job_due_reminder_query.py`，出口 `list[dict]`。

- `list_candidates(project_id)`：以 `job_executions` 為主體，用 `workflow_execution_control_mapping.round_id = <本輪>` 圈範圍（沒輪次退回 `task_assignees.project_id`），JOIN 控制項名稱、`review_step_no`；處理人與上次手動提醒另兩支批次查詢補上，不在主 SQL 裡 GROUP。
- `recipients_for(job_ids)`：D10 規則，回 `{job_id: [(user_id, email, nickname, login_name)]}`，排除 email 為空者。
- `last_manual_reminded(job_uids)`：`MAX(created_at) WHERE object_type='job_due' AND stage LIKE 'manual\_%'`。
- `validate_jobs(project_id, job_uids)`：回不合格的 uid 清單。

### 5.5 FE

**總覽頁**（`ProjectAuditorOverview.vue`）只加：

```vue
<Button v-if="isProjectManager && selectedApUid && !selectedAp?.is_finished"
        outlined size="small" icon="pi pi-send"
        :label="t('lang.job_due_reminder.btn_entry')"
        @click="reminderDialogVisible = true" />
<JobDueReminderDialog v-model:visible="reminderDialogVisible" :project-uid="projectId" />
```

位置放在「通知紀錄」鈕前面，兩顆相鄰。

**新元件** `src/components/grc/job-due-reminder/JobDueReminderDialog.vue`，資料與送出邏輯放 composable `src/composables/useJobDueReminder.js`：

- PrimeVue Dialog，寬 `min(960px, 95vw)`。
- 工具列（照「清單頁工具列照待辦任務」慣例：搜尋左、分段右）：搜尋（任務名／控制項／處理人）＋分段「全部／逾期／3 天內」。
- DataTable：勾選欄／任務（名稱，下一行小字控制項）／處理人（多人頓號串；有關卡時下一行小字「第 N 關 · 關名」）／期限（重用 `JobDueDateText`）／上次提醒（「X 分鐘前」「X 天前」「—」）。
- `recipients` 為空的列：勾選框 disabled、整列降透明度、處理人欄寫「尚未指派」。
- 表頭全選只作用於目前篩選後、且可勾的列。
- 底部：留言 Textarea（`maxlength=500`，右下字數）；一行摘要「將寄給 N 張任務、共 M 位處理人」（M 為去重人數）；「取消」「寄出」。
- 寄出：勾到 `last_manual_reminded_at` 在 10 分鐘內的卡時，先 `confirm.require` 列出張數再確認。
- 回來 toast：成功「已寄出 M 封提醒」；有失敗加「K 封寄送失敗，請管理員檢查郵件設定」；有略過加「S 張沒有處理人已略過」。成功後關窗。

**通知紀錄頁**（`ProjectAlertNotificationsView.vue`）：`job_due` 列判 `stage.startsWith('manual_')` 時，標籤改 `lang.alert_notifications.job_due_manual`（「管理者提醒」），後接 `triggered_by_name`。

**i18n**：新檔 `job-due-reminder.json`（zh-tw／en 兩份），namespace `job_due_reminder`；**FE 的 i18n 是手動 import**，要在 `src/config/locales/index.js` 補兩行（比照 `alert-notifications.json` 第 60、160 行），漏了畫面會顯示 key 原字串。通知紀錄補一個 key。

**設計守門**：照 `guidant-design-system` skill；不准左側粗色邊框；字級字型走全站。

### 5.6 spec

- `project-management/project-overview.md` §1.1 動作列加一列。
- `project-management/project-alert-notifications.md` §1 來源加第三種、§11 加手動寄送一列、§6 欄位加 `triggered_by`。
- `audit-execution/my-tasks.md` §11.5 到期提醒段補一句「PM 也可在專案總覽手動寄」。

---

## 6. 拆分（3 張實作卡） {#breakdown nav="拆分"}

| 卡 | 範圍 | 依賴 | 驗收 |
|---|---|---|---|
| ① BE 全部（CM-2895） | migration、ORM／entity、共用寄信 helper（自動版改呼叫它）、readmodel、service、兩支端點、error code、信件與留言 i18n、通知紀錄 API 加欄 | — | 用 curl 打 GET 看到本輪未完成卡（含未指派卡、空 recipients）；POST 兩張卡，信箱各收到一封、紀錄表多兩列 `stage=manual_*`、`triggered_by` 有值、卡上各多一則系統留言；自動版排程照舊寄（把一張卡期限設 3 天後跑一次 tick） |
| ② FE 彈窗＋通知紀錄標示（CM-2896） | 總覽頁入口、`JobDueReminderDialog`、composable、通知紀錄頁手動列標籤、i18n、`error-code.json` | ① | 對照雛形；10 分鐘內再寄跳二次確認；通知紀錄頁手動列顯示「管理者提醒 · PM 名」 |
| ③ spec（CM-2897） | §5.6 三頁 | ①② | 決策者收尾令下才做 |

---

## 7. 端到端驗收 {#acceptance nav="端到端驗收"}

1. 用專案 manager 進一個本輪進行中的專案總覽，動作列看到「寄送任務提醒」；用 auditor 帳號進同一頁看不到。
2. 開彈窗：本輪 TODO／PROCESSING 的卡都在、已完成的不在、上一輪的不在；逾期排最前；沒派人的卡灰掉不能勾。
3. 切「逾期」分段後按全選，只勾到逾期且可勾的卡。
4. 勾兩張（一張有期限、一張沒期限），填留言「客戶明天要」，寄出 → toast「已寄出 N 封」。
5. 收件匣：每位處理人每張卡一封，主旨「專案管理者提醒」，內文有留言；沒期限那封寫「未設定」。PM 本人與其他 manager 沒收到。
6. 兩張卡的抽屜各多一則系統留言。
7. 通知紀錄頁多出這幾列，標「管理者提醒 · <PM 暱稱>」，點列開回那張卡。
8. 5 分鐘內再開彈窗勾同一張卡寄出 → 跳二次確認，確認後照寄。
9. 一張卡走到 FR-148 第 2 關後再寄 → 收件人是第 2 關的人，不是執行人。
10. 郵件伺服器關掉時寄出 → toast 顯示失敗封數，通知紀錄頁那幾列標寄送失敗。

**回歸**：自動到期提醒（剩 3 天、當天）、通知紀錄頁其他三種列的標籤、總覽頁其他按鈕。

---

## 8. 這份文件的定位 {#positioning nav="定位"}

| 你想知道 | 看這份 |
|---|---|
| PM 原話與每題怎麼裁 | [requirement.md](requirement.md) |
| 畫面長怎樣 | [雛形](prototype/index.html) |
| 為什麼這樣決定、要做哪些 | 本頁 |
