实现制度签收轻量回执(05-28-oa-policy-acknowledgement-v1)
这个提交包含在:
@@ -22,3 +22,73 @@ QA script patterns over new abstractions.
|
||||
- `cd server && node --check src/routes/workflow.js`
|
||||
- `cd server && ./scripts/regression-workflow-actions.sh`
|
||||
- `cd server && ./scripts/regression-all.sh`
|
||||
|
||||
## Scenario: Portal Announcement Acknowledgement
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: extending `announcements_v1` from read-only announcements into formal employee acknowledgement.
|
||||
- Applies to `server/prisma/schema.prisma`, `server/src/routes/announcements.js`, `client/src/api/announcements.ts`, employee `/announcements`, and admin `/system/announcements`.
|
||||
- Use this only for lightweight policy acknowledgement; do not treat it as electronic signature, CA signing, contract signing, or full policy CMS.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- DB: `PortalAnnouncement` may carry `category`, `requires_acknowledgement`, `acknowledgement_statement`, `acknowledgement_due_at`.
|
||||
- DB: `PortalAnnouncementAcknowledgement` stores one receipt per `(announcement_id, employee_id)` with statement/title/summary snapshots and `actor_id`.
|
||||
- API: `GET /api/announcements?manage=0` returns visible announcements and the current employee's acknowledgement status.
|
||||
- API: `GET /api/announcements?manage=1` requires `system.settings` and returns acknowledgement summary plus minimal pending employees.
|
||||
- API: `POST /api/announcements/:id/acknowledge` accepts `{ "confirmed": true }`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Required acknowledgement status values: `not_required`, `pending`, `acknowledged`, `overdue`.
|
||||
- Employee response must not include other employees' acknowledgement rows or pending lists.
|
||||
- Admin pending list may include only `id`, `employeeNo`, `name`, and `status`; do not include phone, email, WeCom userid, account identifiers, or sensitive profile fields.
|
||||
- Acknowledgement is explicit only: opening an announcement or clicking its link must never create a receipt.
|
||||
- Policy content changes after any acknowledgement require a new announcement version; do not mutate title, summary, category, required flag, or statement in place.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- `announcements_v1` disabled -> `409 VALID_001`.
|
||||
- policy acknowledgement without statement -> `400 VALID_001`.
|
||||
- `manage=1` without `system.settings` -> `403 PERM_001`.
|
||||
- acknowledge invisible/missing announcement -> `404 ANN_404`.
|
||||
- acknowledge normal announcement -> `409 VALID_001`.
|
||||
- acknowledge without `{ confirmed: true }` -> `400 VALID_001`.
|
||||
- edit protected policy fields after acknowledgement exists -> `409 VALID_001`.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: admin creates a policy announcement, employee checks the confirmation box, server creates one receipt and one audit log.
|
||||
- Base: normal announcement remains compatible and returns `not_required`.
|
||||
- Bad: frontend displays another employee's receipt status on `/announcements`.
|
||||
- Bad: admin updates an acknowledged policy title instead of creating a new version.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Prisma validate after schema/migration changes.
|
||||
- Route focused tests for required statement, permission, idempotency, normal/invisible announcement rejection, admin summary, and protected edit rejection.
|
||||
- Frontend typecheck and build.
|
||||
- Playwright flow for admin create -> employee acknowledge -> admin summary update when UI changes.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
await prisma.portalAnnouncement.update({
|
||||
where: { id },
|
||||
data: { title: "制度 V2" },
|
||||
});
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const acknowledgementCount = await prisma.portalAnnouncementAcknowledgement.count({
|
||||
where: { announcement_id: id },
|
||||
});
|
||||
if (acknowledgementCount > 0) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409 });
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Verify Trellis task contract and migration approval boundaries."}
|
||||
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Verify Prisma, route and backend test requirements."}
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Verify frontend page changes follow existing OA design conventions."}
|
||||
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Verify acknowledgement APIs and UI do not leak sensitive employee or policy data."}
|
||||
@@ -0,0 +1,176 @@
|
||||
# 制度签收轻量回执设计
|
||||
|
||||
## Summary
|
||||
|
||||
采用“公告中心扩展 + 独立回执表”的方案。制度签收公告仍是 `PortalAnnouncement`,新增必签元数据;员工签收写入新回执表。这样复用既有公告入口、权限、feature flag 和 Portal 展示,同时让正式签收回执独立留痕,不把公告阅读误认为签收。
|
||||
|
||||
## Data Model
|
||||
|
||||
建议扩展 `PortalAnnouncement`:
|
||||
|
||||
- `category`: `String`,默认 `general`,V1 值为 `general` / `policy`
|
||||
- `requires_acknowledgement`: `Boolean`,默认 `false`
|
||||
- `acknowledgement_statement`: `String?`,必签时必填
|
||||
- `acknowledgement_due_at`: `DateTime?`
|
||||
|
||||
新增 `PortalAnnouncementAcknowledgement`:
|
||||
|
||||
- `id`: uuid
|
||||
- `announcement_id`: uuid,关联 `PortalAnnouncement`
|
||||
- `employee_id`: uuid,关联 `Employee`
|
||||
- `acknowledged_at`: DateTime
|
||||
- `statement_snapshot`: String
|
||||
- `title_snapshot`: String
|
||||
- `summary_snapshot`: String
|
||||
- `actor_id`: uuid,可与 employee_id 相同,V1 不支持代签
|
||||
- `created_at`: DateTime
|
||||
|
||||
索引/约束:
|
||||
|
||||
- `@@unique([announcement_id, employee_id])`
|
||||
- `@@index([employee_id, acknowledged_at])`
|
||||
- `@@index([announcement_id, acknowledged_at])`
|
||||
|
||||
## API Contract
|
||||
|
||||
### `GET /api/announcements?manage=0`
|
||||
|
||||
普通员工返回可见公告,并附带本人签收状态:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"title": "费用报销制度签收",
|
||||
"summary": "请阅读并确认小团队 V1 报销规则。",
|
||||
"category": "policy",
|
||||
"requiresAcknowledgement": true,
|
||||
"acknowledgementStatement": "我已阅读并理解该制度。",
|
||||
"acknowledgementDueAt": "2026-06-01T00:00:00.000Z",
|
||||
"acknowledgement": {
|
||||
"status": "pending",
|
||||
"acknowledgedAt": null,
|
||||
"overdue": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
普通员工只看到自己的签收状态,不看到他人列表。
|
||||
|
||||
### `GET /api/announcements?manage=1`
|
||||
|
||||
仅 `system.settings` 可用。返回管理视图,包含统计:
|
||||
|
||||
```json
|
||||
{
|
||||
"acknowledgementSummary": {
|
||||
"required": true,
|
||||
"totalActiveEmployees": 6,
|
||||
"acknowledgedCount": 4,
|
||||
"pendingCount": 2,
|
||||
"overdueCount": 1
|
||||
},
|
||||
"pendingEmployees": [
|
||||
{ "id": "...", "employeeNo": "HYD***", "name": "员工A" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
未签员工列表只返回员工号、姓名和状态,不返回手机号、邮箱、身份证、企业微信 userid 或账号凭据。
|
||||
|
||||
### `POST /api/announcements/:id/acknowledge`
|
||||
|
||||
普通员工签收本人回执。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirmed": true
|
||||
}
|
||||
```
|
||||
|
||||
校验:
|
||||
|
||||
- `announcements_v1` 必须启用。
|
||||
- 公告必须 active 且在可见时间窗口内。
|
||||
- 公告 `requires_acknowledgement=true`。
|
||||
- `confirmed=true`。
|
||||
- 员工必须 active。
|
||||
- 重复签收返回已有回执,不新建第二条。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"item": {
|
||||
"announcementId": "...",
|
||||
"status": "acknowledged",
|
||||
"acknowledgedAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
审计:
|
||||
|
||||
- `PORTAL_ANNOUNCEMENT_ACKNOWLEDGED`
|
||||
|
||||
## Frontend Design
|
||||
|
||||
使用 `frontend-design` 裁决后的方向:**安静、正式、清晰的 OA 合规面板**。
|
||||
|
||||
员工公告页:
|
||||
|
||||
- 保持公告列表,不做大营销布局。
|
||||
- 必签公告使用更明确的状态列/徽标:待签收、已签收、已逾期。
|
||||
- 卡片内展示签收声明、截止日期和主按钮。
|
||||
- 签收按钮前必须有 checkbox:“我已阅读并理解该制度内容和资料留存边界”。
|
||||
- 已签后展示签收时间,不再显示可重复操作按钮。
|
||||
|
||||
管理员公告页:
|
||||
|
||||
- 左侧新建公告表单增加:
|
||||
- 公告类型:普通 / 制度签收
|
||||
- 签收声明
|
||||
- 截止日期
|
||||
- 右侧列表显示签收统计,不把未签名单做成大表格;V1 用紧凑列表即可。
|
||||
- 页面继续使用现有 `card-std` / PC 操作页模式,不新建独立视觉系统。
|
||||
|
||||
## Permissions
|
||||
|
||||
- 创建/编辑/归档/查看统计:`system.settings`
|
||||
- 查看公告:authenticated user
|
||||
- 签收:authenticated active employee,仅本人
|
||||
- 不允许管理员代签,不允许普通员工查看他人签收。
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- 新增字段全部带默认或 nullable,避免影响既有公告数据。
|
||||
- 新增回执表只记录未来签收,不导入历史纸质/电子签收。
|
||||
- 生产 migration 只在用户明确批准部署时应用;本任务本地验证不代表公网已启用。
|
||||
|
||||
## Tests
|
||||
|
||||
后端 focused:
|
||||
|
||||
- 创建普通公告仍兼容。
|
||||
- 创建必签公告缺声明 -> 400。
|
||||
- 员工获取公告时看到本人 pending 状态。
|
||||
- 员工签收必签公告 -> 201/200,审计写入。
|
||||
- 重复签收 -> 幂等。
|
||||
- 普通公告签收 -> 409。
|
||||
- 管理员 manage 查询看到统计和未签列表。
|
||||
|
||||
前端 E2E:
|
||||
|
||||
- 管理员创建必签公告。
|
||||
- 员工进入公告页,勾选确认,点击签收。
|
||||
- 员工刷新后状态为已签收。
|
||||
- 管理员看到已签/待签统计变化。
|
||||
|
||||
## Reviewer Need
|
||||
|
||||
本任务涉及 Prisma migration、权限和审计。实施后需要做只读 review,重点看:
|
||||
|
||||
- 普通用户是否可能看到他人签收。
|
||||
- 管理接口是否泄露手机号、邮箱、账号、企业微信 userid。
|
||||
- 已签回执是否会被公告编辑破坏语义。
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task, approvals, commit and migration governance for a non-trivial feature."}
|
||||
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Backend route, Prisma migration and validation requirements."}
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Frontend design authority and PC operation page conventions."}
|
||||
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Sensitive data exclusions for policy materials and employee acknowledgement reports."}
|
||||
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "Evidence and report boundaries for task artifacts."}
|
||||
@@ -0,0 +1,95 @@
|
||||
# 制度签收轻量回执
|
||||
|
||||
## Goal
|
||||
|
||||
在不引入完整电子签章、复杂制度库或合同级法务系统的前提下,为 OA 公告中心增加“正式制度签收回执”能力。管理员可发布需要员工确认的制度公告,员工必须通过明确勾选和点击完成签收,系统保存签收时间、签收人、声明快照和审计记录。目标是补齐 OALAUNCH1 数据治理门禁中的“公告阅读不等于正式签收”缺口。
|
||||
|
||||
## Scope
|
||||
|
||||
- 基于现有 `announcements_v1` 和公告中心扩展,不新增独立制度 CMS。
|
||||
- 支持管理员创建/编辑公告时标记“需要签收”,填写签收声明和签收截止日期。
|
||||
- 支持员工在公告页查看待签收状态,并通过明确确认动作完成本人签收。
|
||||
- 支持管理员在公告管理页查看签收总数、已签、未签、逾期统计和未签员工脱敏/最小必要列表。
|
||||
- 后端保存签收回执、签收声明快照和审计日志。
|
||||
- 覆盖后端 focused 测试、前端 typecheck/build 和至少一个公告签收 E2E/组件级流程验证。
|
||||
|
||||
## What I Already Know
|
||||
|
||||
- 当前 `PortalAnnouncement` 只有公告标题、摘要、链接、启停、优先级和时间窗口,没有签收模型。
|
||||
- `EmployeeChecklistTask.task_type = policy_ack` 已存在,但它只服务入职 checklist,不适合作为公司级制度签收台账。
|
||||
- `docs/管理员手册.md` 和 `docs/员工快速上手.md` 已明确:公告阅读不等于正式签收,制度签收是正式启用前的数据治理缺口。
|
||||
- 公告模块已有 feature flag `announcements_v1`、后台路由 `/api/announcements`、员工页 `/announcements` 和管理页 `/system/announcements`。
|
||||
- 前端视觉裁决以 `frontend-design` 为准;本任务应保持 OA 操作界面简洁、密集、可扫读,不做营销页或重视觉重构。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 管理员创建公告时可选择:
|
||||
- 普通公告:无需签收,行为保持兼容。
|
||||
- 制度签收公告:需要签收,必须有签收声明,建议可填截止日期。
|
||||
- 员工看到制度签收公告时必须有明确状态:
|
||||
- 待签收
|
||||
- 已签收
|
||||
- 已逾期未签
|
||||
- 员工签收必须是显式动作:勾选确认声明后点击签收按钮;不能仅因打开页面或点击链接视为签收。
|
||||
- 同一员工对同一公告只能形成一条有效回执;重复点击应幂等返回已签收状态。
|
||||
- 签收回执至少记录:公告、员工、签收时间、签收声明快照、公告标题/摘要快照、操作者、创建时间。
|
||||
- 管理员可以查看签收概览和未签员工列表;普通员工不能查看他人签收状态。
|
||||
- 公告正文和链接仍按现有敏感资料边界处理:不上传涉密合同正文、银行资料、完整路径、账号凭据或敏感报价。
|
||||
- 更新已签收的制度公告不自动重置签收;如制度内容变化,应新建一条新版本公告,避免历史回执语义混乱。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 后端模型和迁移支持公告签收回执,Prisma validate 通过。
|
||||
- [x] `GET /api/announcements` 对普通员工返回本人签收状态,不泄露他人信息。
|
||||
- [x] `POST /api/announcements/:id/acknowledge` 支持本人签收,校验 feature flag、公告可见、需要签收和确认声明。
|
||||
- [x] 管理员管理接口能看到签收统计和未签员工最小必要信息。
|
||||
- [x] 前端员工公告页能完成待签收 -> 已签收流程,按钮状态、防重复和逾期提示清楚。
|
||||
- [x] 前端公告管理页能创建必签公告并查看签收概览。
|
||||
- [x] 后端 focused 测试覆盖签收权限、幂等、不可签普通公告、不可签不可见公告。
|
||||
- [x] 前端 typecheck/build 通过,签收流程 E2E 或等价自动化通过。
|
||||
- [x] 不部署、不改公网配置、不批量写真实签收数据;公网启用需另行批准。
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 不做电子签章、CA、手写签名或法律级电子合同。
|
||||
- 不做复杂制度库、制度版本全文管理、在线红线审阅或知识库问答。
|
||||
- 不做按部门/角色定向签收;V1 默认面向所有 active 员工。
|
||||
- 不做代签、补签证明上传、批量导入历史纸质签收。
|
||||
- 不通过企业微信原生审批承载制度签收;企业微信只可作为后续提醒入口。
|
||||
- 不把公司制度原文、合同正文、银行资料、工资资料、完整本地路径或敏感附件导入仓库或 Trellis。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Backend:
|
||||
- `cd server && npm run prisma:validate`
|
||||
- `cd server && node --check src/routes/announcements.js`
|
||||
- 增加并运行公告签收 focused 单测或现有路由测试。
|
||||
- Frontend:
|
||||
- `cd client && npm run typecheck`
|
||||
- `cd client && npm run build`
|
||||
- 增加/运行公告签收 E2E:管理员创建必签公告,员工签收,管理员看到统计。
|
||||
- Governance:
|
||||
- `node scripts/qa/check-trellis-governance.mjs`
|
||||
- `git diff --check`
|
||||
|
||||
## Rollout / Rollback
|
||||
|
||||
- V1 受现有 `announcements_v1` 控制;不新增部署 env。
|
||||
- 本地/测试通过后再决定是否部署公网。
|
||||
- 回滚方式:关闭 `announcements_v1` 或隐藏公告入口;已保存回执作为审计记录保留,不自动删除。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 建议 V1 默认“所有 active 员工都需要签收”,暂不做定向范围。是否批准按这个范围实现?
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- 参考文件:
|
||||
- `server/prisma/schema.prisma`
|
||||
- `server/src/routes/announcements.js`
|
||||
- `client/src/api/announcements.ts`
|
||||
- `client/src/pages/AnnouncementsPage.tsx`
|
||||
- `client/src/pages/PortalAnnouncementsPage.tsx`
|
||||
- `.trellis/spec/hyb-oa/frontend-react-vite-design.md`
|
||||
- `.trellis/spec/hyb-oa/backend-fastify-prisma.md`
|
||||
- `.trellis/spec/hyb-oa/security-privacy.md`
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "oa-policy-acknowledgement-v1",
|
||||
"name": "oa-policy-acknowledgement-v1",
|
||||
"title": "制度签收轻量回执",
|
||||
"description": "在现有公告中心上增加轻量制度签收回执,支持管理员发布必签制度公告、员工显式签收、管理员查看签收统计和审计留痕。",
|
||||
"status": "in_progress",
|
||||
"dev_type": "feature",
|
||||
"scope": "server-client-prisma-announcements-policy-acknowledgement",
|
||||
"package": "hyb-oa",
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-05-28",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"server/prisma/schema.prisma",
|
||||
"server/src/routes/announcements.js",
|
||||
"client/src/api/announcements.ts",
|
||||
"client/src/pages/AnnouncementsPage.tsx",
|
||||
"client/src/pages/PortalAnnouncementsPage.tsx",
|
||||
"docs/管理员手册.md",
|
||||
"docs/员工快速上手.md"
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
+55
-16
@@ -1,5 +1,29 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type AnnouncementCategory = "general" | "policy";
|
||||
export type AnnouncementAcknowledgementStatus = "not_required" | "pending" | "acknowledged" | "overdue";
|
||||
|
||||
export type PortalAnnouncementAcknowledgement = {
|
||||
status: AnnouncementAcknowledgementStatus;
|
||||
acknowledgedAt: string | null;
|
||||
overdue: boolean;
|
||||
};
|
||||
|
||||
export type PortalAnnouncementAcknowledgementSummary = {
|
||||
required: boolean;
|
||||
totalActiveEmployees: number;
|
||||
acknowledgedCount: number;
|
||||
pendingCount: number;
|
||||
overdueCount: number;
|
||||
};
|
||||
|
||||
export type PortalAnnouncementPendingEmployee = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
status: "pending" | "overdue";
|
||||
};
|
||||
|
||||
export type PortalAnnouncement = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -9,16 +33,18 @@ export type PortalAnnouncement = {
|
||||
endsAt: string | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
category: AnnouncementCategory;
|
||||
requiresAcknowledgement: boolean;
|
||||
acknowledgementStatement: string | null;
|
||||
acknowledgementDueAt: string | null;
|
||||
acknowledgement?: PortalAnnouncementAcknowledgement;
|
||||
acknowledgementSummary?: PortalAnnouncementAcknowledgementSummary;
|
||||
pendingEmployees?: PortalAnnouncementPendingEmployee[];
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAnnouncements(manage = false) {
|
||||
const suffix = manage ? "?manage=1" : "";
|
||||
return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`);
|
||||
}
|
||||
|
||||
export async function createAnnouncement(payload: {
|
||||
export type AnnouncementWritePayload = {
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl?: string | null;
|
||||
@@ -26,22 +52,25 @@ export async function createAnnouncement(payload: {
|
||||
endsAt?: string | null;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
}) {
|
||||
category?: AnnouncementCategory;
|
||||
requiresAcknowledgement?: boolean;
|
||||
acknowledgementStatement?: string | null;
|
||||
acknowledgementDueAt?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAnnouncements(manage = false) {
|
||||
const suffix = manage ? "?manage=1" : "";
|
||||
return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`);
|
||||
}
|
||||
|
||||
export async function createAnnouncement(payload: AnnouncementWritePayload) {
|
||||
return apiRequestData<{ item: PortalAnnouncement }>("/api/announcements", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAnnouncement(id: string, payload: Partial<{
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl: string | null;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
}>) {
|
||||
export async function updateAnnouncement(id: string, payload: Partial<AnnouncementWritePayload>) {
|
||||
return apiRequestData<{ item: PortalAnnouncement }>(`/api/announcements/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
@@ -54,3 +83,13 @@ export async function archiveAnnouncement(id: string) {
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function acknowledgeAnnouncement(id: string) {
|
||||
return apiRequestData<{ item: { id: string | null; announcementId: string; status: "acknowledged"; acknowledgedAt: string | null } }>(
|
||||
`/api/announcements/${id}/acknowledge`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirmed: true }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,135 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAnnouncements } from "../api/announcements";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { acknowledgeAnnouncement, fetchAnnouncements, type AnnouncementAcknowledgementStatus, type PortalAnnouncement } from "../api/announcements";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
|
||||
|
||||
function acknowledgementLabel(status: AnnouncementAcknowledgementStatus) {
|
||||
if (status === "acknowledged") return "已签收";
|
||||
if (status === "overdue") return "已逾期";
|
||||
if (status === "pending") return "待签收";
|
||||
return "普通公告";
|
||||
}
|
||||
|
||||
function acknowledgementBadgeClass(status: AnnouncementAcknowledgementStatus) {
|
||||
if (status === "acknowledged") return "badge-success";
|
||||
if (status === "overdue") return "badge-error";
|
||||
if (status === "pending") return "badge-warning";
|
||||
return "badge-neutral";
|
||||
}
|
||||
|
||||
function AnnouncementCard({
|
||||
item,
|
||||
checked,
|
||||
submitting,
|
||||
onCheckedChange,
|
||||
onAcknowledge,
|
||||
}: {
|
||||
item: PortalAnnouncement;
|
||||
checked: boolean;
|
||||
submitting: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
onAcknowledge: () => void;
|
||||
}) {
|
||||
const acknowledgement = item.acknowledgement ?? { status: "not_required", acknowledgedAt: null, overdue: false };
|
||||
const requiresAcknowledgement = item.requiresAcknowledgement;
|
||||
const isAcknowledged = acknowledgement.status === "acknowledged";
|
||||
const canSubmit = requiresAcknowledgement && !isAcknowledged && checked && !submitting;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`card-std p-5 md:p-6 ${requiresAcknowledgement ? "border-l-4 border-l-[var(--color-accent-primary)]" : ""}`}
|
||||
data-testid={`announcement-card-${item.id}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold leading-7 text-zinc-900">{item.title}</h2>
|
||||
<span className={acknowledgementBadgeClass(acknowledgement.status)}>
|
||||
{acknowledgementLabel(acknowledgement.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</p>
|
||||
</div>
|
||||
{item.acknowledgementDueAt ? (
|
||||
<div className="shrink-0 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-zinc-600">
|
||||
截止:{formatDisplayDate(item.acknowledgementDueAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{item.linkUrl ? (
|
||||
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.linkUrl} target="_blank" rel="noreferrer">
|
||||
查看链接
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{requiresAcknowledgement ? (
|
||||
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50/70 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-500">签收声明</div>
|
||||
<div className="mt-1 text-sm leading-6 text-zinc-800">{item.acknowledgementStatement}</div>
|
||||
{isAcknowledged ? (
|
||||
<div className="mt-3 text-sm text-emerald-700">
|
||||
已于 {formatDisplayDateTime(acknowledgement.acknowledgedAt)} 完成本人签收。
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<label className="flex min-w-0 items-start gap-2 text-sm leading-6 text-zinc-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||
checked={checked}
|
||||
onChange={(event) => onCheckedChange(event.target.checked)}
|
||||
/>
|
||||
<span>我已阅读并理解该制度内容,确认按公司制度执行。</span>
|
||||
</label>
|
||||
<Button type="button" className="w-full md:w-auto" disabled={!canSubmit} onClick={onAcknowledge}>
|
||||
{submitting ? "提交中..." : acknowledgement.status === "overdue" ? "逾期补签" : "确认签收"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnnouncementsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [checkedById, setCheckedById] = useState<Record<string, boolean>>({});
|
||||
const announcementsQuery = useQuery({ queryKey: ["announcements", "public"], queryFn: () => fetchAnnouncements(false) });
|
||||
const acknowledgeMutation = useMutation({
|
||||
mutationFn: acknowledgeAnnouncement,
|
||||
onSuccess: async (_data, id) => {
|
||||
setCheckedById((current) => ({ ...current, [id]: false }));
|
||||
await queryClient.invalidateQueries({ queryKey: ["announcements"] });
|
||||
toast.success("签收已记录");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("签收失败,请刷新后重试");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="公告" subtitle="全员查看公司通知、制度链接和 FAQ 入口。" />
|
||||
<PageHeader title="公告" subtitle="查看公司通知、制度链接和需要本人确认的制度签收。" />
|
||||
<div className="space-y-4">
|
||||
{announcementsQuery.isLoading ? <div className="card-std p-6 text-sm text-zinc-500">公告加载中...</div> : null}
|
||||
{(announcementsQuery.data?.items ?? []).map((item) => (
|
||||
<div key={item.id} className="card-std p-6">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{item.summary}</div>
|
||||
{item.linkUrl ? <a className="mt-3 inline-block text-sm text-primary" href={item.linkUrl} target="_blank" rel="noreferrer">查看链接</a> : null}
|
||||
</div>
|
||||
<AnnouncementCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
checked={Boolean(checkedById[item.id])}
|
||||
submitting={acknowledgeMutation.isPending && acknowledgeMutation.variables === item.id}
|
||||
onCheckedChange={(checked) => setCheckedById((current) => ({ ...current, [item.id]: checked }))}
|
||||
onAcknowledge={() => acknowledgeMutation.mutate(item.id)}
|
||||
/>
|
||||
))}
|
||||
{!announcementsQuery.isLoading && !(announcementsQuery.data?.items ?? []).length ? (
|
||||
<div className="card-std p-6 text-sm text-zinc-500">暂无公告</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement } from "../api/announcements";
|
||||
import { toast } from "sonner";
|
||||
import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement, type AnnouncementCategory, type PortalAnnouncement } from "../api/announcements";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
|
||||
|
||||
function toChinaEndOfDayIso(value: string) {
|
||||
if (!value) return null;
|
||||
return new Date(`${value}T23:59:59+08:00`).toISOString();
|
||||
}
|
||||
|
||||
function acknowledgementModeLabel(category: AnnouncementCategory, requiresAcknowledgement: boolean) {
|
||||
return category === "policy" || requiresAcknowledgement ? "制度签收" : "普通公告";
|
||||
}
|
||||
|
||||
function formatSummary(item: PortalAnnouncement) {
|
||||
const summary = item.acknowledgementSummary;
|
||||
if (!summary?.required) return "无需签收";
|
||||
return `已签 ${summary.acknowledgedCount}/${summary.totalActiveEmployees} · 待签 ${summary.pendingCount}`;
|
||||
}
|
||||
|
||||
export function PortalAnnouncementsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -13,20 +31,39 @@ export function PortalAnnouncementsPage() {
|
||||
const [summary, setSummary] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [priority, setPriority] = useState("100");
|
||||
const [mode, setMode] = useState<AnnouncementCategory>("general");
|
||||
const [acknowledgementStatement, setAcknowledgementStatement] = useState("我已阅读并理解该制度,确认按公司制度执行。");
|
||||
const [acknowledgementDueDate, setAcknowledgementDueDate] = useState("");
|
||||
|
||||
const listQuery = useQuery({ queryKey: ["announcements", "manage"], queryFn: () => fetchAnnouncements(true) });
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["announcements"] });
|
||||
const isPolicy = mode === "policy";
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createAnnouncement({ title, summary, linkUrl: linkUrl || null, priority: Number(priority || 100) }),
|
||||
mutationFn: () => createAnnouncement({
|
||||
title,
|
||||
summary,
|
||||
linkUrl: linkUrl || null,
|
||||
priority: Number(priority || 100),
|
||||
category: mode,
|
||||
requiresAcknowledgement: isPolicy,
|
||||
acknowledgementStatement: isPolicy ? acknowledgementStatement.trim() : null,
|
||||
acknowledgementDueAt: isPolicy ? toChinaEndOfDayIso(acknowledgementDueDate) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setTitle("");
|
||||
setSummary("");
|
||||
setLinkUrl("");
|
||||
setPriority("100");
|
||||
setMode("general");
|
||||
setAcknowledgementStatement("我已阅读并理解该制度,确认按公司制度执行。");
|
||||
setAcknowledgementDueDate("");
|
||||
await refresh();
|
||||
toast.success("公告已创建");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("公告创建失败,请检查填写内容");
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
@@ -45,39 +82,149 @@ export function PortalAnnouncementsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim() || !summary.trim()) {
|
||||
toast.error("请填写标题和摘要");
|
||||
return;
|
||||
}
|
||||
if (isPolicy && !acknowledgementStatement.trim()) {
|
||||
toast.error("制度签收公告必须填写签收声明");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="公告管理" subtitle="轻量发布公告、制度链接和 FAQ 入口。" />
|
||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建公告</h3>
|
||||
<div className="space-y-2"><Label>标题</Label><Input value={title} onChange={(event) => setTitle(event.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>摘要</Label><textarea className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" value={summary} onChange={(event) => setSummary(event.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>链接</Label><Input value={linkUrl} onChange={(event) => setLinkUrl(event.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>优先级</Label><Input value={priority} onChange={(event) => setPriority(event.target.value)} /></div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>{createMutation.isPending ? "创建中..." : "创建公告"}</Button>
|
||||
</div>
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">公告列表</h3>
|
||||
<div className="space-y-3">
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">priority={item.priority} · {item.enabled ? "enabled" : "disabled"}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => toggleMutation.mutate({ id: item.id, enabled: !item.enabled })}>{item.enabled ? "停用" : "启用"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => archiveMutation.mutate(item.id)}>归档</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="公告管理" subtitle="发布普通公告,或创建需要全员显式确认的制度签收公告。" />
|
||||
<div className="admin-workspace-grid xl:grid-cols-[360px,minmax(0,1fr)]">
|
||||
<section className="admin-command-panel space-y-4 p-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">新建公告</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-500">制度内容变化时请新建公告版本,不直接覆盖已签收内容。</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>公告类型</Label>
|
||||
<Tabs value={mode} onValueChange={(value) => setMode(value as AnnouncementCategory)}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2">
|
||||
<TabsTrigger value="general">普通公告</TabsTrigger>
|
||||
<TabsTrigger value="policy">制度签收</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>标题</Label>
|
||||
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例如:费用报销制度签收" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>摘要</Label>
|
||||
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="写清楚适用范围、入口和处理要求。" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>链接</Label>
|
||||
<Input value={linkUrl} onChange={(event) => setLinkUrl(event.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
{isPolicy ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3.5">
|
||||
<div className="space-y-2">
|
||||
<Label>签收声明</Label>
|
||||
<Textarea
|
||||
className="min-h-[88px]"
|
||||
value={acknowledgementStatement}
|
||||
onChange={(event) => setAcknowledgementStatement(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label>签收截止日</Label>
|
||||
<Input type="date" value={acknowledgementDueDate} onChange={(event) => setAcknowledgementDueDate(event.target.value)} />
|
||||
<p className="text-xs leading-5 text-zinc-500">按国内习惯,截止日当天 23:59 前均视为未逾期。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>优先级</Label>
|
||||
<Input value={priority} onChange={(event) => setPriority(event.target.value)} inputMode="numeric" />
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={handleCreate}>
|
||||
{createMutation.isPending ? "创建中..." : isPolicy ? "创建制度签收公告" : "创建公告"}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="admin-command-panel space-y-4 p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">公告列表</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-500">管理员可查看制度签收统计;未签名单仅展示员工号和姓名。</p>
|
||||
</div>
|
||||
{listQuery.isFetching ? <span className="badge-neutral">刷新中</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(listQuery.data?.items ?? []).map((item) => {
|
||||
const summaryStats = item.acknowledgementSummary;
|
||||
const pendingEmployees = item.pendingEmployees ?? [];
|
||||
const pendingPreview = pendingEmployees.slice(0, 6);
|
||||
return (
|
||||
<article key={item.id} className="admin-tile-card space-y-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold text-zinc-900">{item.title}</h3>
|
||||
<span className={item.requiresAcknowledgement ? "badge-warning" : "badge-neutral"}>
|
||||
{acknowledgementModeLabel(item.category, item.requiresAcknowledgement)}
|
||||
</span>
|
||||
<span className={item.enabled ? "badge-success" : "badge-neutral"}>{item.enabled ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||
<span>优先级 {item.priority}</span>
|
||||
<span>更新 {formatDisplayDateTime(item.updatedAt)}</span>
|
||||
{item.acknowledgementDueAt ? <span>截止 {formatDisplayDate(item.acknowledgementDueAt)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => toggleMutation.mutate({ id: item.id, enabled: !item.enabled })}>
|
||||
{item.enabled ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => archiveMutation.mutate(item.id)}>归档</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.requiresAcknowledgement && summaryStats ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3.5">
|
||||
<div className="grid gap-2 text-sm md:grid-cols-4">
|
||||
<div><span className="text-zinc-500">范围</span><div className="font-semibold text-zinc-900">{summaryStats.totalActiveEmployees} 人</div></div>
|
||||
<div><span className="text-zinc-500">已签</span><div className="font-semibold text-emerald-700">{summaryStats.acknowledgedCount} 人</div></div>
|
||||
<div><span className="text-zinc-500">待签</span><div className="font-semibold text-amber-700">{summaryStats.pendingCount} 人</div></div>
|
||||
<div><span className="text-zinc-500">逾期</span><div className="font-semibold text-red-700">{summaryStats.overdueCount} 人</div></div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs font-semibold text-zinc-500">{formatSummary(item)}</div>
|
||||
{pendingPreview.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{pendingPreview.map((employee) => (
|
||||
<span key={employee.id} className={employee.status === "overdue" ? "badge-error" : "badge-warning"}>
|
||||
{employee.employeeNo} · {employee.name}
|
||||
</span>
|
||||
))}
|
||||
{pendingEmployees.length > pendingPreview.length ? (
|
||||
<span className="badge-neutral">另 {pendingEmployees.length - pendingPreview.length} 人</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm text-emerald-700">当前 active 员工均已签收。</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无公告</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
import { ADMIN, EMPLOYEE, login } from "./helpers/auth";
|
||||
|
||||
type MockUserKey = "admin" | "employee";
|
||||
|
||||
type MockAnnouncement = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl: string | null;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
category: "general" | "policy";
|
||||
requiresAcknowledgement: boolean;
|
||||
acknowledgementStatement: string | null;
|
||||
acknowledgementDueAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
acknowledgedEmployeeIds: Set<string>;
|
||||
};
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-policy-ack", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function buildMe(user: MockUserKey) {
|
||||
if (user === "admin") {
|
||||
return {
|
||||
id: "admin-policy-ack",
|
||||
employeeNo: "A001",
|
||||
name: "Admin",
|
||||
phone: "mock-phone-admin",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-admin",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["admin"],
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 60 },
|
||||
wecomBinding: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "employee-policy-ack",
|
||||
employeeNo: "E001",
|
||||
name: "Employee",
|
||||
phone: "mock-phone-employee",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-employee",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["employee"],
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 60 },
|
||||
wecomBinding: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAnnouncementForUser(item: MockAnnouncement, currentUser: MockUserKey) {
|
||||
const me = buildMe(currentUser);
|
||||
const acknowledged = item.acknowledgedEmployeeIds.has(me.id);
|
||||
const overdue = Boolean(item.acknowledgementDueAt && new Date(item.acknowledgementDueAt).getTime() < Date.now());
|
||||
return {
|
||||
...item,
|
||||
acknowledgedEmployeeIds: undefined,
|
||||
acknowledgement: {
|
||||
status: !item.requiresAcknowledgement ? "not_required" : acknowledged ? "acknowledged" : overdue ? "overdue" : "pending",
|
||||
acknowledgedAt: acknowledged ? "2026-05-28T03:00:00.000Z" : null,
|
||||
overdue: !acknowledged && overdue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapAnnouncementForAdmin(item: MockAnnouncement) {
|
||||
const employees = [
|
||||
{ id: "employee-policy-ack", employeeNo: "E001", name: "Employee", status: "pending" },
|
||||
{ id: "employee-policy-ack-2", employeeNo: "E002", name: "员工二", status: "pending" },
|
||||
];
|
||||
const pendingEmployees = item.requiresAcknowledgement
|
||||
? employees.filter((employee) => !item.acknowledgedEmployeeIds.has(employee.id))
|
||||
: [];
|
||||
return {
|
||||
...item,
|
||||
acknowledgedEmployeeIds: undefined,
|
||||
acknowledgementSummary: {
|
||||
required: item.requiresAcknowledgement,
|
||||
totalActiveEmployees: item.requiresAcknowledgement ? employees.length : 0,
|
||||
acknowledgedCount: item.requiresAcknowledgement ? employees.length - pendingEmployees.length : 0,
|
||||
pendingCount: pendingEmployees.length,
|
||||
overdueCount: 0,
|
||||
},
|
||||
pendingEmployees,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockPolicyAckApis(page: Page) {
|
||||
let currentUser: MockUserKey = "employee";
|
||||
const announcements: MockAnnouncement[] = [];
|
||||
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/auth/capabilities") {
|
||||
await fulfillJson(route, {
|
||||
emailDelivery: { ready: true, statusReasons: [] },
|
||||
emailLoginCode: { enabled: true },
|
||||
passwordReset: { enabled: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, status: "disabled", statusReasons: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login") {
|
||||
const payload = request.postDataJSON() as { identifier?: string };
|
||||
currentUser = payload.identifier === ADMIN.identifier ? "admin" : "employee";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({ accessToken: `mock-policy-${currentUser}`, expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/refresh") {
|
||||
await fulfillJson(route, { accessToken: `mock-policy-${currentUser}`, expiresIn: 900, mustChangePassword: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/me") {
|
||||
await fulfillJson(route, buildMe(currentUser));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-policy-ack",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "main", permissionCodes: [], featureFlagKey: null }],
|
||||
admin: currentUser === "admin"
|
||||
? [{ key: "announcementsAdmin", href: "/system/announcements", label: "公告管理", icon: "campaign", placement: "admin", permissionCodes: ["system.settings"], featureFlagKey: null }]
|
||||
: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
workspace: { id: "workspace-policy-ack", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: buildMe(currentUser).name },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
todayPresence: null,
|
||||
teamPresence: null,
|
||||
weeklyReport: null,
|
||||
weeklySummary: null,
|
||||
todoSummary: null,
|
||||
projectSummary: null,
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/notifications") {
|
||||
await fulfillJson(route, { items: [], unreadCount: 0, page: 1, pageSize: 20, total: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/announcements" && request.method() === "GET") {
|
||||
const manage = url.searchParams.get("manage") === "1";
|
||||
await fulfillJson(route, {
|
||||
items: announcements.map((item) => manage ? mapAnnouncementForAdmin(item) : mapAnnouncementForUser(item, currentUser)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/announcements" && request.method() === "POST") {
|
||||
const payload = request.postDataJSON() as {
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl?: string | null;
|
||||
priority?: number;
|
||||
category?: "general" | "policy";
|
||||
requiresAcknowledgement?: boolean;
|
||||
acknowledgementStatement?: string | null;
|
||||
acknowledgementDueAt?: string | null;
|
||||
};
|
||||
const created: MockAnnouncement = {
|
||||
id: `policy-ann-${announcements.length + 1}`,
|
||||
title: payload.title,
|
||||
summary: payload.summary,
|
||||
linkUrl: payload.linkUrl ?? null,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
enabled: true,
|
||||
priority: payload.priority ?? 100,
|
||||
category: payload.category ?? "general",
|
||||
requiresAcknowledgement: Boolean(payload.requiresAcknowledgement),
|
||||
acknowledgementStatement: payload.acknowledgementStatement ?? null,
|
||||
acknowledgementDueAt: payload.acknowledgementDueAt ?? null,
|
||||
createdAt: "2026-05-28T02:00:00.000Z",
|
||||
updatedAt: "2026-05-28T02:00:00.000Z",
|
||||
acknowledgedEmployeeIds: new Set(),
|
||||
};
|
||||
announcements.push(created);
|
||||
await fulfillJson(route, { item: mapAnnouncementForAdmin(created) }, 201);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/acknowledge") && request.method() === "POST") {
|
||||
const segments = path.split("/");
|
||||
const id = segments[segments.length - 2];
|
||||
const announcement = announcements.find((item) => item.id === id);
|
||||
if (announcement) {
|
||||
announcement.acknowledgedEmployeeIds.add(buildMe(currentUser).id);
|
||||
}
|
||||
await fulfillJson(route, { item: { id: "ack-1", announcementId: id, status: "acknowledged", acknowledgedAt: "2026-05-28T03:00:00.000Z" } }, 201);
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
}
|
||||
|
||||
async function clearBrowserSession(page: Page) {
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
}
|
||||
|
||||
test("制度签收公告:管理员创建、员工签收、管理员看到统计变化", async ({ page }) => {
|
||||
await mockPolicyAckApis(page);
|
||||
const title = `自动化制度签收 ${Date.now()}`;
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/system/announcements");
|
||||
await page.getByRole("tab", { name: "制度签收" }).click();
|
||||
await page.getByPlaceholder("例如:费用报销制度签收").fill(title);
|
||||
await page.getByPlaceholder("写清楚适用范围、入口和处理要求。").fill("请全员阅读并确认制度签收流程。");
|
||||
await page.locator('input[type="date"]').fill("2026-06-03");
|
||||
await page.getByRole("button", { name: "创建制度签收公告" }).click();
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
await expect(page.getByText("已签 0/2 · 待签 2")).toBeVisible();
|
||||
|
||||
await clearBrowserSession(page);
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/announcements");
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
await expect(page.getByText("待签收")).toBeVisible();
|
||||
await page.getByLabel("我已阅读并理解该制度内容,确认按公司制度执行。").check();
|
||||
await page.getByRole("button", { name: "确认签收" }).click();
|
||||
await expect(page.getByText("已签收")).toBeVisible();
|
||||
await expect(page.getByText(/完成本人签收/)).toBeVisible();
|
||||
|
||||
await clearBrowserSession(page);
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/system/announcements");
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
await expect(page.getByText("已签 1/2 · 待签 1")).toBeVisible();
|
||||
});
|
||||
+2
-1
@@ -104,7 +104,8 @@
|
||||
- 如果管理员要求你补交付记录、合同归档位置或交接说明,请优先在海音达工作台里补齐,不要只在群里回复“已处理”。
|
||||
|
||||
## 15. 制度签收和资料留存
|
||||
- 管理员发布或更新涉及员工利益、费用、保密、资产、权限或档案的制度时,你需要按要求完成纸面或电子制度签收;只看到公告不等于已经完成正式签收。
|
||||
- 管理员发布或更新涉及员工利益、费用、保密、资产、权限或档案的制度时,你需要按要求完成纸面或电子制度签收;如果 OA 公告页显示“待签收”或“已逾期”,需要勾选签收声明并点击“确认签收”。
|
||||
- 只看到普通公告、打开公告链接或阅读制度材料,不等于已经完成正式签收;完成后公告页会显示“已签收”和签收时间。
|
||||
- 资料留存先按“小团队 V1”执行:OA 中写清编号、摘要、负责人、项目或合同关联、归档位置摘要和必要证明;涉密正文、敏感报价、成本细节、银行流水原件、账号凭据、Secret、完整配置和完整本地路径不要上传。
|
||||
- 用印相关事项先在合同台账或管理员指定台账中留痕,至少说明事项、文件版本、份数、审批状态、日期、责任人和归档位置。
|
||||
- 遇到审批异常、权限异常、资料误传、付款/AP 卡住或无法导出时,按“阻断 / 非阻断 / 建议”告诉管理员,并提供页面、步骤和影响,不要发送密码、完整票面、银行回单或敏感报价截图。
|
||||
|
||||
+5
-3
@@ -451,7 +451,7 @@ ASSET1 后,资产模块统一承载固定资产和电子资产索引,不再
|
||||
|
||||
账号资产 `EA-ACC` V1 暂缓。离职交接时检查“此人是否持有关键系统权限”,不在 OA 维护每个普通账号资产卡,也不保存任何密码或密钥。
|
||||
|
||||
历史资料导入只允许从 `/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引` 读取脱敏索引,默认 dry-run。公网写入需要 `ASSET_INDEX_IMPORT_WRITE=1` 和单独确认;报告不得输出 S3/S4 原文、完整路径、Secret、token、银行流水原件或未脱敏票面。
|
||||
历史资料导入只允许从管理员指定的公司资料脱敏索引目录读取,默认 dry-run。公网写入需要 `ASSET_INDEX_IMPORT_WRITE=1` 和单独确认;报告不得输出 S3/S4 原文、完整路径、Secret、token、银行流水原件或未脱敏票面。
|
||||
|
||||
阻断判定:
|
||||
|
||||
@@ -516,7 +516,7 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
|
||||
| 资料留存 | OA 只放可控摘要、hash、归档位置摘要、非涉密证明和审计线索;涉密正文、敏感报价、成本细节、银行流水原件和凭据继续线下或专用工具保管 | 资料不清楚时不上传,只登记待归档 |
|
||||
| 用印台账 | V1 先通过合同台账的摘要、归档位置、离线档案号和附件审计承载用印记录;至少记录事项、文件版本、份数、审批状态、日期、责任人和归档位置 | 现有字段无法说明时,先用人工台账补充,后续再立项做专用字段 |
|
||||
| 审批异常 SOP | 无审批人、自审、越权可见、付款/AP 卡住、合同或资料敏感信息误上传均视为阻断 | 当天建修复合同、人工绕行留风险,或暂停对应流程 |
|
||||
| 制度签收 | 员工利益相关制度先保留纸面或电子签收证据;OA 公告可发布制度通知,但不把公告阅读等同于正式签收 | 缺签收时不进入最终 Go/No-Go |
|
||||
| 制度签收 | OA 公告管理可创建“制度签收”公告,员工必须勾选声明并点击签收;系统保存本人签收时间、声明快照和审计记录 | 缺签收或缺外部签收证据时不进入最终 Go/No-Go |
|
||||
| P2 冻结 | 复杂预算、复杂招采、电子签章、完整档案系统、ERP、银行流水导入、真实支付直连和 AI 写规则继续冻结 | 真实使用证明必要后另建 Trellis task |
|
||||
|
||||
制度参数人工问答建议:
|
||||
@@ -525,7 +525,7 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
|
||||
- B:暂不填固定金额阈值,所有预算外、合同、用印和高风险事项逐单管理层审批。
|
||||
- C:暂停财务、合同或采购真实启用,先把制度参数定稿。
|
||||
|
||||
当前 OA 对齐判断:合同台账已有敏感级别、归档位置和离线档案号,资产台账已有 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP` 元数据能力,公告模块可承载制度通知。因此 V1 不立即做 Prisma schema 迁移;如果真实使用证明“用印台账专用字段”或“制度签收回执”是阻断,再另建功能合同并进入 reviewer。
|
||||
当前 OA 对齐判断:合同台账已有敏感级别、归档位置和离线档案号,资产台账已有 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP` 元数据能力,公告模块已支持轻量制度签收回执。用印台账专用字段仍不作为正式启用前置;如果真实使用证明该字段是阻断,再另建功能合同并进入 reviewer。
|
||||
|
||||
## 4. 人员生命周期管理
|
||||
### 4.1 入职
|
||||
@@ -564,6 +564,8 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
|
||||
|
||||
## 7. 公告与 Portal 使用建议
|
||||
- 公告只发公司级通知、制度更新、假期提醒,不做复杂 CMS。
|
||||
- 涉及员工利益、费用、保密、资产、权限或档案的制度,优先在 `系统设置 -> 公告管理` 创建“制度签收”公告,填写签收声明和截止日;已有人签收后如制度内容变化,应新建公告版本,不直接改旧公告标题、摘要或声明。
|
||||
- 管理员可在公告管理页查看全员 active 员工的已签、待签、逾期统计和最小必要未签名单;不得把手机号、邮箱、企业微信 userid 或敏感资料导出到公告签收证据里。
|
||||
- `Portal` 首屏建议重点看:
|
||||
- 今日团队状态
|
||||
- 未提交周报人数
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
ALTER TABLE "portal_announcements"
|
||||
ADD COLUMN "category" TEXT NOT NULL DEFAULT 'general',
|
||||
ADD COLUMN "requires_acknowledgement" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "acknowledgement_statement" TEXT,
|
||||
ADD COLUMN "acknowledgement_due_at" TIMESTAMP(3);
|
||||
|
||||
CREATE TABLE "portal_announcement_acknowledgements" (
|
||||
"id" UUID NOT NULL,
|
||||
"announcement_id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"acknowledged_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"statement_snapshot" TEXT NOT NULL,
|
||||
"title_snapshot" TEXT NOT NULL,
|
||||
"summary_snapshot" TEXT NOT NULL,
|
||||
"actor_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "portal_announcement_acknowledgements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "portal_announcement_acknowledgements_announcement_id_employee_id_key"
|
||||
ON "portal_announcement_acknowledgements"("announcement_id", "employee_id");
|
||||
|
||||
CREATE INDEX "portal_announcement_acknowledgements_employee_id_acknowledged_at_idx"
|
||||
ON "portal_announcement_acknowledgements"("employee_id", "acknowledged_at");
|
||||
|
||||
CREATE INDEX "portal_announcement_acknowledgements_announcement_id_acknowledged_at_idx"
|
||||
ON "portal_announcement_acknowledgements"("announcement_id", "acknowledged_at");
|
||||
|
||||
ALTER TABLE "portal_announcement_acknowledgements"
|
||||
ADD CONSTRAINT "portal_announcement_acknowledgements_announcement_id_fkey"
|
||||
FOREIGN KEY ("announcement_id") REFERENCES "portal_announcements"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "portal_announcement_acknowledgements"
|
||||
ADD CONSTRAINT "portal_announcement_acknowledgements_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "portal_announcement_acknowledgements"
|
||||
ADD CONSTRAINT "portal_announcement_acknowledgements_actor_id_fkey"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "employees"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+37
-8
@@ -683,6 +683,8 @@ model Employee {
|
||||
email_login_codes EmailLoginCode[]
|
||||
lifecycle_events EmployeeLifecycleEvent[]
|
||||
checklist_tasks EmployeeChecklistTask[]
|
||||
portal_announcement_acknowledgements PortalAnnouncementAcknowledgement[] @relation("PortalAnnouncementAcknowledgementEmployee")
|
||||
portal_announcement_acknowledgement_actions PortalAnnouncementAcknowledgement[] @relation("PortalAnnouncementAcknowledgementActor")
|
||||
presence_records EmployeePresence[]
|
||||
attendance_records AttendanceRecord[]
|
||||
weekly_reports WeeklyReport[]
|
||||
@@ -1876,24 +1878,51 @@ model PaymentBatchReceiptAttachment {
|
||||
}
|
||||
|
||||
model PortalAnnouncement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String
|
||||
summary String
|
||||
link_url String?
|
||||
starts_at DateTime?
|
||||
ends_at DateTime?
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(100)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String
|
||||
summary String
|
||||
link_url String?
|
||||
starts_at DateTime?
|
||||
ends_at DateTime?
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(100)
|
||||
category String @default("general")
|
||||
requires_acknowledgement Boolean @default(false)
|
||||
acknowledgement_statement String?
|
||||
acknowledgement_due_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
acknowledgements PortalAnnouncementAcknowledgement[]
|
||||
|
||||
@@index([enabled, priority, starts_at, ends_at], map: "idx_portal_announcements_enabled_priority")
|
||||
@@map("portal_announcements")
|
||||
}
|
||||
|
||||
model PortalAnnouncementAcknowledgement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
announcement_id String @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
acknowledged_at DateTime @default(now())
|
||||
statement_snapshot String
|
||||
title_snapshot String
|
||||
summary_snapshot String
|
||||
actor_id String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
|
||||
announcement PortalAnnouncement @relation(fields: [announcement_id], references: [id], onDelete: Cascade)
|
||||
employee Employee @relation("PortalAnnouncementAcknowledgementEmployee", fields: [employee_id], references: [id], onDelete: Restrict)
|
||||
actor Employee @relation("PortalAnnouncementAcknowledgementActor", fields: [actor_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([announcement_id, employee_id])
|
||||
@@index([employee_id, acknowledged_at])
|
||||
@@index([announcement_id, acknowledged_at])
|
||||
@@map("portal_announcement_acknowledgements")
|
||||
}
|
||||
|
||||
model WorkspaceSetting {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
profile WorkspaceProfile @default(small_team_internal)
|
||||
|
||||
+253
-11
@@ -16,12 +16,21 @@ const WRITE_SCHEMA = z.object({
|
||||
endsAt: DATE_TIME_SCHEMA.nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
priority: z.coerce.number().int().min(1).max(9999).optional(),
|
||||
category: z.enum(["general", "policy"]).optional(),
|
||||
requiresAcknowledgement: z.boolean().optional(),
|
||||
acknowledgementStatement: z.string().trim().min(1).max(300).nullable().optional(),
|
||||
acknowledgementDueAt: DATE_TIME_SCHEMA.nullable().optional(),
|
||||
}).strict();
|
||||
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
const ACKNOWLEDGE_SCHEMA = z.object({ confirmed: z.literal(true) }).strict();
|
||||
const ACKNOWLEDGEMENT_PROTECTED_FIELDS = new Set(["title", "summary", "category", "requiresAcknowledgement", "acknowledgementStatement"]);
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
function buildNotFoundError(details = {}) {
|
||||
return new AppError({ code: "ANN_404", message: "公告不存在或不可见", statusCode: 404, details });
|
||||
}
|
||||
function parseId(value) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" });
|
||||
@@ -38,7 +47,97 @@ async function ensureFeatureEnabled(fastify) {
|
||||
throw buildValidationError({ featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
function mapItem(item) {
|
||||
function normalizeAnnouncementPolicyFields(body, current = null) {
|
||||
let requiresAcknowledgement = current?.requires_acknowledgement ?? false;
|
||||
if (body.category === "policy") {
|
||||
requiresAcknowledgement = true;
|
||||
} else if (body.category === "general") {
|
||||
requiresAcknowledgement = false;
|
||||
}
|
||||
if (body.requiresAcknowledgement !== undefined) {
|
||||
requiresAcknowledgement = body.requiresAcknowledgement;
|
||||
}
|
||||
|
||||
const acknowledgementStatement = body.acknowledgementStatement !== undefined
|
||||
? body.acknowledgementStatement?.trim() || null
|
||||
: current?.acknowledgement_statement ?? null;
|
||||
const acknowledgementDueAt = body.acknowledgementDueAt !== undefined
|
||||
? (body.acknowledgementDueAt ? new Date(body.acknowledgementDueAt) : null)
|
||||
: current?.acknowledgement_due_at ?? null;
|
||||
|
||||
if (requiresAcknowledgement && !acknowledgementStatement) {
|
||||
throw buildValidationError({ acknowledgementStatement: "required_for_policy_acknowledgement" });
|
||||
}
|
||||
|
||||
return {
|
||||
category: requiresAcknowledgement ? "policy" : "general",
|
||||
requires_acknowledgement: requiresAcknowledgement,
|
||||
acknowledgement_statement: requiresAcknowledgement ? acknowledgementStatement : null,
|
||||
acknowledgement_due_at: requiresAcknowledgement ? acknowledgementDueAt : null,
|
||||
};
|
||||
}
|
||||
function hasAcknowledgementProtectedPatch(body) {
|
||||
return Object.keys(body).some((key) => ACKNOWLEDGEMENT_PROTECTED_FIELDS.has(key));
|
||||
}
|
||||
function isVisibleAnnouncement(item, now = new Date()) {
|
||||
return Boolean(
|
||||
item?.enabled &&
|
||||
(!item.starts_at || item.starts_at <= now) &&
|
||||
(!item.ends_at || item.ends_at >= now),
|
||||
);
|
||||
}
|
||||
function buildUserAcknowledgement(item, now) {
|
||||
if (!item.requires_acknowledgement) {
|
||||
return { status: "not_required", acknowledgedAt: null, overdue: false };
|
||||
}
|
||||
const acknowledgement = item.acknowledgements?.[0] ?? null;
|
||||
if (acknowledgement) {
|
||||
return {
|
||||
status: "acknowledged",
|
||||
acknowledgedAt: acknowledgement.acknowledged_at?.toISOString?.() ?? null,
|
||||
overdue: false,
|
||||
};
|
||||
}
|
||||
const overdue = Boolean(item.acknowledgement_due_at && item.acknowledgement_due_at < now);
|
||||
return { status: overdue ? "overdue" : "pending", acknowledgedAt: null, overdue };
|
||||
}
|
||||
function buildManageAcknowledgement(item, activeEmployees, now) {
|
||||
if (!item.requires_acknowledgement) {
|
||||
return {
|
||||
summary: {
|
||||
required: false,
|
||||
totalActiveEmployees: activeEmployees.length,
|
||||
acknowledgedCount: 0,
|
||||
pendingCount: 0,
|
||||
overdueCount: 0,
|
||||
},
|
||||
pendingEmployees: [],
|
||||
};
|
||||
}
|
||||
|
||||
const acknowledgedEmployeeIds = new Set((item.acknowledgements ?? []).map((acknowledgement) => acknowledgement.employee_id));
|
||||
const pendingEmployees = activeEmployees
|
||||
.filter((employee) => !acknowledgedEmployeeIds.has(employee.id))
|
||||
.map((employee) => ({
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
status: item.acknowledgement_due_at && item.acknowledgement_due_at < now ? "overdue" : "pending",
|
||||
}));
|
||||
const overdueCount = item.acknowledgement_due_at && item.acknowledgement_due_at < now ? pendingEmployees.length : 0;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
required: true,
|
||||
totalActiveEmployees: activeEmployees.length,
|
||||
acknowledgedCount: activeEmployees.length - pendingEmployees.length,
|
||||
pendingCount: pendingEmployees.length,
|
||||
overdueCount,
|
||||
},
|
||||
pendingEmployees,
|
||||
};
|
||||
}
|
||||
function mapItem(item, { activeEmployees = [], isManage = false, now = new Date() } = {}) {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
@@ -48,6 +147,19 @@ function mapItem(item) {
|
||||
endsAt: item.ends_at?.toISOString?.() ?? null,
|
||||
enabled: item.enabled,
|
||||
priority: item.priority,
|
||||
category: item.category ?? "general",
|
||||
requiresAcknowledgement: Boolean(item.requires_acknowledgement),
|
||||
acknowledgementStatement: item.acknowledgement_statement ?? null,
|
||||
acknowledgementDueAt: item.acknowledgement_due_at?.toISOString?.() ?? null,
|
||||
...(isManage
|
||||
? (() => {
|
||||
const result = buildManageAcknowledgement(item, activeEmployees, now);
|
||||
return {
|
||||
acknowledgementSummary: result.summary,
|
||||
pendingEmployees: result.pendingEmployees,
|
||||
};
|
||||
})()
|
||||
: { acknowledgement: buildUserAcknowledgement(item, now) }),
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
@@ -59,24 +171,42 @@ async function announcementsRoutes(fastify) {
|
||||
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
|
||||
const query = queryParsed.data;
|
||||
const isManage = query.manage === "1" || query.manage === "true";
|
||||
if (isManage) {
|
||||
fastify.assertPermission(request.employee, "system.settings");
|
||||
}
|
||||
const now = new Date();
|
||||
const where = isManage && fastify.hasPermission(request.employee, "system.settings")
|
||||
const where = isManage
|
||||
? {}
|
||||
: {
|
||||
enabled: true,
|
||||
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
|
||||
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
|
||||
};
|
||||
const items = await fastify.prisma.portalAnnouncement.findMany({
|
||||
where,
|
||||
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
|
||||
});
|
||||
return sendData(reply, request, { items: items.map(mapItem) });
|
||||
const [items, activeEmployees] = await Promise.all([
|
||||
fastify.prisma.portalAnnouncement.findMany({
|
||||
where,
|
||||
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
|
||||
include: {
|
||||
acknowledgements: isManage
|
||||
? { select: { employee_id: true, acknowledged_at: true } }
|
||||
: { where: { employee_id: request.employee.id }, select: { employee_id: true, acknowledged_at: true } },
|
||||
},
|
||||
}),
|
||||
isManage
|
||||
? fastify.prisma.employee.findMany({
|
||||
where: { status: "active" },
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
orderBy: [{ employee_no: "asc" }, { name: "asc" }],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return sendData(reply, request, { items: items.map((item) => mapItem(item, { activeEmployees, isManage, now })) });
|
||||
});
|
||||
|
||||
fastify.post("/announcements", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||
const policyFields = normalizeAnnouncementPolicyFields(body);
|
||||
const created = await fastify.prisma.portalAnnouncement.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
@@ -86,18 +216,40 @@ async function announcementsRoutes(fastify) {
|
||||
ends_at: body.endsAt ? new Date(body.endsAt) : null,
|
||||
enabled: body.enabled ?? true,
|
||||
priority: body.priority ?? 100,
|
||||
...policyFields,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PORTAL_ANNOUNCEMENT_CREATED", targetType: "portal_announcement", targetId: created.id }) });
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapItem(created) } });
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PORTAL_ANNOUNCEMENT_CREATED",
|
||||
targetType: "portal_announcement",
|
||||
targetId: created.id,
|
||||
extra: { requiresAcknowledgement: created.requires_acknowledgement },
|
||||
}),
|
||||
});
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapItem(created, { isManage: true }) } });
|
||||
});
|
||||
|
||||
fastify.patch("/announcements/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const announcementId = parseId(request.params?.id);
|
||||
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||
const current = await fastify.prisma.portalAnnouncement.findUnique({ where: { id: announcementId } });
|
||||
if (!current) throw buildNotFoundError({ id: "not_found" });
|
||||
|
||||
if (hasAcknowledgementProtectedPatch(body)) {
|
||||
const acknowledgementCount = await fastify.prisma.portalAnnouncementAcknowledgement.count({
|
||||
where: { announcement_id: announcementId },
|
||||
});
|
||||
if (acknowledgementCount > 0) {
|
||||
throw buildValidationError({ acknowledgement: "create_new_version_after_acknowledgement" }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const policyFields = normalizeAnnouncementPolicyFields(body, current);
|
||||
const updated = await fastify.prisma.portalAnnouncement.update({
|
||||
where: { id: announcementId },
|
||||
data: {
|
||||
@@ -108,10 +260,11 @@ async function announcementsRoutes(fastify) {
|
||||
...(body.endsAt !== undefined ? { ends_at: body.endsAt ? new Date(body.endsAt) : null } : {}),
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
...policyFields,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapItem(updated) });
|
||||
return sendData(reply, request, { item: mapItem(updated, { isManage: true }) });
|
||||
});
|
||||
|
||||
fastify.post("/announcements/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
@@ -121,7 +274,96 @@ async function announcementsRoutes(fastify) {
|
||||
where: { id: announcementId },
|
||||
data: { enabled: false, ends_at: new Date(), updated_by: request.employee.id },
|
||||
});
|
||||
return sendData(reply, request, { item: mapItem(updated) });
|
||||
return sendData(reply, request, { item: mapItem(updated, { isManage: true }) });
|
||||
});
|
||||
|
||||
fastify.post("/announcements/:id/acknowledge", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const announcementId = parseId(request.params?.id);
|
||||
parseBody(ACKNOWLEDGE_SCHEMA, request.body);
|
||||
|
||||
const now = new Date();
|
||||
const announcement = await fastify.prisma.portalAnnouncement.findUnique({
|
||||
where: { id: announcementId },
|
||||
include: {
|
||||
acknowledgements: {
|
||||
where: { employee_id: request.employee.id },
|
||||
select: { id: true, acknowledged_at: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!announcement || !isVisibleAnnouncement(announcement, now)) {
|
||||
throw buildNotFoundError({ id: "not_found_or_not_visible" });
|
||||
}
|
||||
if (!announcement.requires_acknowledgement) {
|
||||
throw buildValidationError({ acknowledgement: "not_required" }, 409);
|
||||
}
|
||||
if (!announcement.acknowledgement_statement) {
|
||||
throw buildValidationError({ acknowledgementStatement: "missing_for_policy_acknowledgement" }, 409);
|
||||
}
|
||||
|
||||
const existing = announcement.acknowledgements?.[0] ?? null;
|
||||
if (existing) {
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
id: existing.id,
|
||||
announcementId: announcement.id,
|
||||
status: "acknowledged",
|
||||
acknowledgedAt: existing.acknowledged_at?.toISOString?.() ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const acknowledgement = await fastify.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.portalAnnouncementAcknowledgement.create({
|
||||
data: {
|
||||
announcement_id: announcement.id,
|
||||
employee_id: request.employee.id,
|
||||
statement_snapshot: announcement.acknowledgement_statement,
|
||||
title_snapshot: announcement.title,
|
||||
summary_snapshot: announcement.summary,
|
||||
actor_id: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PORTAL_ANNOUNCEMENT_ACKNOWLEDGED",
|
||||
targetType: "portal_announcement_acknowledgement",
|
||||
targetId: created.id,
|
||||
extra: { announcementId: announcement.id },
|
||||
}),
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
return reply.code(201).send({
|
||||
requestId: request.id,
|
||||
data: {
|
||||
item: {
|
||||
id: acknowledgement.id,
|
||||
announcementId: announcement.id,
|
||||
status: "acknowledged",
|
||||
acknowledgedAt: acknowledgement.acknowledged_at?.toISOString?.() ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code !== "P2002") throw error;
|
||||
const acknowledgement = await fastify.prisma.portalAnnouncementAcknowledgement.findUnique({
|
||||
where: { announcement_id_employee_id: { announcement_id: announcement.id, employee_id: request.employee.id } },
|
||||
select: { id: true, acknowledged_at: true },
|
||||
});
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
id: acknowledgement?.id ?? null,
|
||||
announcementId: announcement.id,
|
||||
status: "acknowledged",
|
||||
acknowledgedAt: acknowledgement?.acknowledged_at?.toISOString?.() ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
module.exports = announcementsRoutes;
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const featureFlagState = vi.hoisted(() => ({ enabled: true }));
|
||||
|
||||
vi.mock("../../../src/services/featureFlags", () => ({
|
||||
FEATURE_FLAG_KEYS: { announcements: "announcements_v1" },
|
||||
evaluateFeatureFlag: vi.fn(async () => ({
|
||||
enabled: featureFlagState.enabled,
|
||||
reason: featureFlagState.enabled ? "mock_on" : "mock_off",
|
||||
})),
|
||||
}));
|
||||
|
||||
const announcementsRoutes = require("../../../src/routes/announcements");
|
||||
|
||||
const ADMIN_ID = "00000000-0000-4000-8000-000000000001";
|
||||
const EMPLOYEE_ID = "00000000-0000-4000-8000-000000000002";
|
||||
const EMPLOYEE_2_ID = "00000000-0000-4000-8000-000000000003";
|
||||
const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004";
|
||||
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
|
||||
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
|
||||
const HIDDEN_ID = "10000000-0000-4000-8000-000000000003";
|
||||
|
||||
function buildEmployee(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "员工一",
|
||||
status: "active",
|
||||
permissionCodes: ["expense.view_own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnnouncement(overrides = {}) {
|
||||
const now = new Date("2026-05-28T02:00:00.000Z");
|
||||
return {
|
||||
id: POLICY_ID,
|
||||
title: "费用制度签收",
|
||||
summary: "请确认费用制度 V1。",
|
||||
link_url: null,
|
||||
starts_at: null,
|
||||
ends_at: null,
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
category: "policy",
|
||||
requires_acknowledgement: true,
|
||||
acknowledgement_statement: "我已阅读并理解该制度。",
|
||||
acknowledgement_due_at: new Date("2026-06-01T15:59:59.000Z"),
|
||||
created_at: now,
|
||||
created_by: ADMIN_ID,
|
||||
updated_at: now,
|
||||
updated_by: ADMIN_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneWithInclude(item, include, acknowledgements) {
|
||||
const row = { ...item };
|
||||
if (include?.acknowledgements) {
|
||||
let rows = acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === item.id);
|
||||
if (include.acknowledgements.where?.employee_id) {
|
||||
rows = rows.filter((acknowledgement) => acknowledgement.employee_id === include.acknowledgements.where.employee_id);
|
||||
}
|
||||
row.acknowledgements = rows.map((acknowledgement) => ({
|
||||
id: acknowledgement.id,
|
||||
employee_id: acknowledgement.employee_id,
|
||||
acknowledged_at: acknowledgement.acknowledged_at,
|
||||
}));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function matchAnnouncementWhere(item, where = {}) {
|
||||
if (where.enabled !== undefined && item.enabled !== where.enabled) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date("2026-05-28T02:00:00.000Z");
|
||||
if (where.OR && item.starts_at && item.starts_at > now) {
|
||||
return false;
|
||||
}
|
||||
if (where.AND && item.ends_at && item.ends_at < now) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function createPrismaState(options = {}) {
|
||||
const employees = options.employees ?? [
|
||||
buildEmployee({ id: EMPLOYEE_ID, employee_no: "E001", name: "员工一" }),
|
||||
buildEmployee({ id: EMPLOYEE_2_ID, employee_no: "E002", name: "员工二" }),
|
||||
buildEmployee({ id: INACTIVE_EMPLOYEE_ID, employee_no: "E003", name: "离职员工", status: "archived" }),
|
||||
];
|
||||
const announcements = options.announcements ?? [
|
||||
buildAnnouncement(),
|
||||
buildAnnouncement({
|
||||
id: GENERAL_ID,
|
||||
title: "普通公告",
|
||||
summary: "无需签收。",
|
||||
category: "general",
|
||||
requires_acknowledgement: false,
|
||||
acknowledgement_statement: null,
|
||||
acknowledgement_due_at: null,
|
||||
priority: 20,
|
||||
}),
|
||||
buildAnnouncement({
|
||||
id: HIDDEN_ID,
|
||||
title: "未来公告",
|
||||
starts_at: new Date("2026-06-01T00:00:00.000Z"),
|
||||
}),
|
||||
];
|
||||
const acknowledgements = options.acknowledgements ?? [];
|
||||
const auditLogs = [];
|
||||
|
||||
const portalAnnouncement = {
|
||||
findMany: vi.fn(async ({ where, include }) =>
|
||||
announcements
|
||||
.filter((item) => matchAnnouncementWhere(item, where))
|
||||
.sort((left, right) => left.priority - right.priority || right.created_at.getTime() - left.created_at.getTime())
|
||||
.map((item) => cloneWithInclude(item, include, acknowledgements)),
|
||||
),
|
||||
findUnique: vi.fn(async ({ where, include }) => {
|
||||
const item = announcements.find((announcement) => announcement.id === where.id);
|
||||
return item ? cloneWithInclude(item, include, acknowledgements) : null;
|
||||
}),
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const created = buildAnnouncement({
|
||||
...data,
|
||||
id: `10000000-0000-4000-8000-${String(announcements.length + 10).padStart(12, "0")}`,
|
||||
link_url: data.link_url ?? null,
|
||||
starts_at: data.starts_at ?? null,
|
||||
ends_at: data.ends_at ?? null,
|
||||
created_at: new Date("2026-05-28T02:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-28T02:00:00.000Z"),
|
||||
});
|
||||
announcements.push(created);
|
||||
return created;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const index = announcements.findIndex((announcement) => announcement.id === where.id);
|
||||
if (index < 0) {
|
||||
throw new Error("not_found");
|
||||
}
|
||||
announcements[index] = { ...announcements[index], ...data, updated_at: new Date("2026-05-28T03:00:00.000Z") };
|
||||
return announcements[index];
|
||||
}),
|
||||
};
|
||||
|
||||
const portalAnnouncementAcknowledgement = {
|
||||
count: vi.fn(async ({ where }) =>
|
||||
acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === where.announcement_id).length,
|
||||
),
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const duplicated = acknowledgements.find(
|
||||
(acknowledgement) =>
|
||||
acknowledgement.announcement_id === data.announcement_id &&
|
||||
acknowledgement.employee_id === data.employee_id,
|
||||
);
|
||||
if (duplicated) {
|
||||
const error = new Error("duplicate_acknowledgement");
|
||||
error.code = "P2002";
|
||||
throw error;
|
||||
}
|
||||
const created = {
|
||||
id: `20000000-0000-4000-8000-${String(acknowledgements.length + 1).padStart(12, "0")}`,
|
||||
acknowledged_at: new Date("2026-05-28T02:30:00.000Z"),
|
||||
created_at: new Date("2026-05-28T02:30:00.000Z"),
|
||||
...data,
|
||||
};
|
||||
acknowledgements.push(created);
|
||||
return created;
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
const compound = where.announcement_id_employee_id;
|
||||
if (!compound) return null;
|
||||
return acknowledgements.find(
|
||||
(acknowledgement) =>
|
||||
acknowledgement.announcement_id === compound.announcement_id &&
|
||||
acknowledgement.employee_id === compound.employee_id,
|
||||
) ?? null;
|
||||
}),
|
||||
};
|
||||
|
||||
const auditLog = {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
auditLogs.push(data);
|
||||
return { id: `audit-${auditLogs.length}`, ...data };
|
||||
}),
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
portalAnnouncement,
|
||||
portalAnnouncementAcknowledgement,
|
||||
employee: {
|
||||
findMany: vi.fn(async ({ where }) => employees.filter((employee) => employee.status === where.status)),
|
||||
},
|
||||
auditLog,
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: "flag-announcements",
|
||||
flag_key: "announcements_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-05-28T00:00:00.000Z"),
|
||||
created_by: null,
|
||||
updated_at: new Date("2026-05-28T00:00:00.000Z"),
|
||||
updated_by: null,
|
||||
})),
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback({ portalAnnouncementAcknowledgement, auditLog })),
|
||||
};
|
||||
|
||||
return { prisma, employees, announcements, acknowledgements, auditLogs };
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const state = createPrismaState(options);
|
||||
const actor = options.actor ?? buildEmployee({ id: EMPLOYEE_ID, permissionCodes: ["expense.view_own"] });
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
app.decorate("config", { announcementsEnabled: true });
|
||||
app.decorate("prisma", state.prisma);
|
||||
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode));
|
||||
app.decorate("assertPermission", (employee, permissionCode) => {
|
||||
if (!app.hasPermission(employee, permissionCode)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: permissionCode },
|
||||
});
|
||||
}
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
await app.authenticate(request);
|
||||
app.assertPermission(request.employee, permissionCode);
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code, message: error.message, details: error.details },
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: error.message, details: {} } });
|
||||
});
|
||||
await app.register(announcementsRoutes, { prefix: "/api" });
|
||||
return { app, state };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
featureFlagState.enabled = true;
|
||||
});
|
||||
|
||||
describe("announcements policy acknowledgement routes", () => {
|
||||
it("rejects policy acknowledgement announcement without statement", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/announcements",
|
||||
payload: {
|
||||
title: "制度公告",
|
||||
summary: "缺少签收声明。",
|
||||
category: "policy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.acknowledgementStatement).toBe("required_for_policy_acknowledgement");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns only current employee acknowledgement status for normal list", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/announcements" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
|
||||
expect(item.acknowledgement).toMatchObject({ status: "pending", acknowledgedAt: null, overdue: false });
|
||||
expect(item.acknowledgementSummary).toBeUndefined();
|
||||
expect(item.pendingEmployees).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates acknowledgement once and writes audit log idempotently", async () => {
|
||||
const { app, state } = await buildApp();
|
||||
|
||||
const first = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${POLICY_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${POLICY_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
|
||||
expect(first.statusCode).toBe(201);
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(state.acknowledgements).toHaveLength(1);
|
||||
expect(state.acknowledgements[0]).toMatchObject({
|
||||
announcement_id: POLICY_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
statement_snapshot: "我已阅读并理解该制度。",
|
||||
title_snapshot: "费用制度签收",
|
||||
});
|
||||
expect(state.auditLogs).toHaveLength(1);
|
||||
expect(state.auditLogs[0]).toMatchObject({
|
||||
action: "PORTAL_ANNOUNCEMENT_ACKNOWLEDGED",
|
||||
target_type: "portal_announcement_acknowledgement",
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects acknowledgement for general or invisible announcements", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const general = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${GENERAL_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
const hidden = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${HIDDEN_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
|
||||
expect(general.statusCode).toBe(409);
|
||||
expect(general.json().error.details.acknowledgement).toBe("not_required");
|
||||
expect(hidden.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns admin acknowledgement summary and minimal pending employees only", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
||||
acknowledgements: [
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000099",
|
||||
announcement_id: POLICY_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
actor_id: EMPLOYEE_ID,
|
||||
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
statement_snapshot: "我已阅读并理解该制度。",
|
||||
title_snapshot: "费用制度签收",
|
||||
summary_snapshot: "请确认费用制度 V1。",
|
||||
created_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
|
||||
expect(item.acknowledgementSummary).toMatchObject({
|
||||
required: true,
|
||||
totalActiveEmployees: 2,
|
||||
acknowledgedCount: 1,
|
||||
pendingCount: 1,
|
||||
overdueCount: 0,
|
||||
});
|
||||
expect(item.pendingEmployees).toEqual([
|
||||
{ id: EMPLOYEE_2_ID, employeeNo: "E002", name: "员工二", status: "pending" },
|
||||
]);
|
||||
expect(JSON.stringify(item.pendingEmployees)).not.toMatch(/phone|email|wecom/i);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("denies manage query for employee without system settings permission", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects protected policy content edits after acknowledgement exists", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
||||
acknowledgements: [
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000099",
|
||||
announcement_id: POLICY_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
actor_id: EMPLOYEE_ID,
|
||||
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
statement_snapshot: "我已阅读并理解该制度。",
|
||||
title_snapshot: "费用制度签收",
|
||||
summary_snapshot: "请确认费用制度 V1。",
|
||||
created_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/announcements/${POLICY_ID}`,
|
||||
payload: { title: "费用制度签收 V2" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.acknowledgement).toBe("create_new_version_after_acknowledgement");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户