FR-150 · 設計文件 · 2026-10-18(D1~D11 定案)

PM 勾幾張急的任務,按一次就寄出提醒

專案總覽一顆鈕開彈窗,勾未完成任務、留一句話、寄出

到期提醒目前只有排程自動寄兩次(剩 3 天、到期當天)。PM 遇到急件想不等排程直接催人,本案在專案總覽動作列加「寄送任務提醒」,開一個彈窗列出本輪未完成任務,PM 勾要催的、可附一段留言,按一次寄出。信寄給每張卡目前該處理的人,每張卡留一則系統留言記錄被催過,寄送紀錄進既有的通知紀錄頁並標出是誰寄的。

D1~D11 全數定案 3 張實作卡,BE 先出 一支 migration(紀錄表加一欄) 不動套件 陷阱:通知紀錄頁的標籤 key 用 stage 拼字串

狀態:設計定案(D1~D11),已開卡待派|日期:2026-10-18|母卡 CM-2894、子卡 CM-2895 / CM-2896 / CM-2897 需求與決策紀錄:requirement.md|雛形:prototype/index.html 開發 branch:三個 repo 目前都在 feature/FR-139(開工前 runner 自己 git branch --show-current 確認,不對就停下問)

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


1. 需求背景

1.1 一句話

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

1.2 要解的問題

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

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

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

1.3 端到端流程

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 秒版)

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

3. 決策定案(D1~D11)

# 決策 定案 理由 被排除的選項
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. 現況接入點盤點

項目 位置 本案怎麼接
自動到期提醒 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. 詳細設計

5.1 資料

migration:scripts/sql/2026-10-18-fr150-task-alert-triggered-by.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:「

    管理者留言:
    %(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)只加:

<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 張實作卡)

卡 範圍 依賴 驗收
① 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. 端到端驗收

  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. 這份文件的定位

你想知道 看這份
PM 原話與每題怎麼裁 requirement.md
畫面長怎樣 雛形
為什麼這樣決定、要做哪些 本頁