diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8f03be43..15329545 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -75,12 +75,12 @@ ApiRequestLog = SysApiRequestLog from app.models.project_management import ( Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion, - ProjectTeamHospital, ProjectAssignmentAudit, + ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory, ) __all__ = [ "Project", "ProjectTeam", "ProjectTeamMember", "ProjectTeamRegion", - "ProjectTeamHospital", "ProjectAssignmentAudit", + "ProjectTeamHospital", "ProjectAssignmentAudit", "ProjectTeamHistory", "BaseModel", "SoftDeleteModel", "SysUser", diff --git a/backend/app/models/project_management.py b/backend/app/models/project_management.py index 1c3446c5..e6c3f2d2 100644 --- a/backend/app/models/project_management.py +++ b/backend/app/models/project_management.py @@ -1,6 +1,6 @@ """租户项目及归属审计;区域医院关联只记录组织资料。""" from datetime import datetime -from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, UniqueConstraint +from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, Text, UniqueConstraint from app.models.base import Base @@ -65,3 +65,16 @@ class ProjectAssignmentAudit(ProjectFields, Base): new_project_name = Column(String(200)) reason = Column(String(512), nullable=False) operator_name = Column(String(100)) + + +class ProjectTeamHistory(ProjectFields, Base): + """项目组版本历史;每次成功写入记完整快照。""" + __tablename__ = 'biz_project_team_history' + __table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),) + team_id = Column(String(64), nullable=False, index=True) + version_no = Column(Integer, nullable=False) + action = Column(String(16), nullable=False) + snapshot_json = Column(Text, nullable=False) + operator_user_id = Column(String(64)) + operator_user_name = Column(String(100)) + operate_time = Column(DateTime, nullable=False, default=datetime.now) diff --git a/backend/app/services/project_management.py b/backend/app/services/project_management.py index a1747d3a..e7db34b4 100644 --- a/backend/app/services/project_management.py +++ b/backend/app/services/project_management.py @@ -1,4 +1,5 @@ """项目资料、权限内统计和关联归属事务。""" +import json from datetime import date, datetime, timedelta from fastapi import HTTPException from sqlalchemy import or_, and_, func @@ -6,7 +7,7 @@ from app.core.data_scope import apply_data_scope from app.core.utils import generate_uuid from app.crud.child_row_sync import sync_child_rows from app.models.project_management import (Project, ProjectTeam, ProjectTeamMember, - ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit) + ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory) from app.models.models import (SysUser, BizCollectionRegion, BizCollectionHospital, BizPresignOrder, BizCollectionProtocol, BizPilotApplication) from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert @@ -250,6 +251,44 @@ CHILDREN = ( ) +def _next_team_history_version(db, tenant, team_id): + current = (db.query(ProjectTeamHistory.version_no) + .filter(ProjectTeamHistory.tenant_no == tenant, + ProjectTeamHistory.team_id == team_id, + ProjectTeamHistory.del_flag == 0) + .order_by(ProjectTeamHistory.version_no.desc()) + .with_for_update() + .first()) + return (current[0] if current else 0) + 1 + + +def record_team_history(db, tenant, user, row, action): + """Append-only team snapshot; caller owns the surrounding transaction.""" + if not isinstance(row, ProjectTeam): + return + snapshot = serialize_entity(db, tenant, row) + operator_id = user.get('user_id') + operator = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None + operator_name = (operator.nick_name if operator else None) or (operator_id or '') + now = datetime.now() + db.add(ProjectTeamHistory( + id=generate_uuid(), + tenant_no=tenant, + team_id=row.id, + version_no=_next_team_history_version(db, tenant, row.id), + action=action, + snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')), + operator_user_id=operator_id, + operator_user_name=operator_name, + operate_time=now, + create_by=operator_id, + create_dept_id=user.get('dept_id') or '', + update_by=operator_id, + create_time=now, + update_time=now, + )) + + def serialize_entity(db, tenant, row): leader = scoped(db, SysUser, tenant).filter(SysUser.id == row.leader_user_id).first() result = dict(id=row.id, leaderUserId=row.leader_user_id, leaderName=leader.nick_name if leader else '', remark=row.remark, createTime=row.create_time) @@ -322,6 +361,7 @@ def save_entity(db, tenant, user, model, body, row_id=None): natural_key=lambda item, p=public: item[p], row_natural_key=lambda child, a=attr: getattr(child, a)) except ValueError as exc: raise HTTPException(400, str(exc)) + record_team_history(db, tenant, user, row, 'update' if row_id else 'create') else: team = get_row(db, ProjectTeam, tenant, body.get('teamId'), lock=True) if row_id and row.team_id != team.id and has_business(db, tenant, row.id): @@ -362,6 +402,8 @@ def delete_entity(db, tenant, user, model, row_id): raise HTTPException(400, '项目已被业务引用,请停用') if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first(): raise HTTPException(400, '项目组已被项目引用,请停用') + if model is ProjectTeam: + record_team_history(db, tenant, user, row, 'delete') row.del_flag, row.update_by = 1, user.get('user_id') if model is ProjectTeam: for _, child_model, *_ in CHILDREN: diff --git a/backend/tests/test_project_management.py b/backend/tests/test_project_management.py index 8add57bd..3ca100f0 100644 --- a/backend/tests/test_project_management.py +++ b/backend/tests/test_project_management.py @@ -10,9 +10,10 @@ class ProjectManagementTest(unittest.TestCase): def setUp(self): self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing') from app.services import project_management as svc - from app.models.project_management import Project, ProjectTeam, ProjectTeamMember + from app.models.project_management import Project, ProjectTeam, ProjectTeamMember, ProjectTeamHistory from app.models.base import Base self.svc, self.Project, self.Team = svc, Project, ProjectTeam + self.History = ProjectTeamHistory self.engine = create_engine('sqlite:///:memory:') Base.metadata.create_all(self.engine) self.db = Session(self.engine) @@ -235,6 +236,58 @@ class ProjectManagementTest(unittest.TestCase): self.assertIn("parent.perms='project:project:list'", menus) self.assertNotIn('INSERT INTO sys_role_menu', menus) + def test_team_history_versions_on_create_update_delete(self): + import json + from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion + from app.models.project_management import ProjectTeamHistory + self.db.add_all([ + SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'), + SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'), + BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'), + BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'), + ]) + self.db.commit() + created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict( + teamName='Alpha', leaderUserId='u', enabled=True, + members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}], + remark='v1', + )) + team_id = created['id'] + rows = (self.db.query(ProjectTeamHistory) + .filter_by(tenant_no='t', team_id=team_id, del_flag=0) + .order_by(ProjectTeamHistory.version_no).all()) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].action, 'create') + self.assertEqual(rows[0].version_no, 1) + snap = json.loads(rows[0].snapshot_json) + self.assertEqual(snap['teamName'], 'Alpha') + self.assertEqual(snap['leaderName'], 'User') + self.assertEqual([m['userId'] for m in snap['members']], ['m']) + self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1']) + + self.svc.save_entity(self.db, 't', self.user, self.Team, dict( + teamName='Alpha2', leaderUserId='u', enabled=True, + members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}], + hospitals=[{'hospitalId': 'h1'}], remark='v2', + ), team_id) + rows = (self.db.query(ProjectTeamHistory) + .filter_by(tenant_no='t', team_id=team_id, del_flag=0) + .order_by(ProjectTeamHistory.version_no).all()) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[1].action, 'update') + self.assertEqual(rows[1].version_no, 2) + self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2') + self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2) + + self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id) + rows = (self.db.query(ProjectTeamHistory) + .filter_by(tenant_no='t', team_id=team_id, del_flag=0) + .order_by(ProjectTeamHistory.version_no).all()) + self.assertEqual(len(rows), 3) + self.assertEqual(rows[2].action, 'delete') + self.assertEqual(rows[2].version_no, 3) + self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2') + if __name__ == '__main__': unittest.main() diff --git a/docs/sql/migrations/2026-11-05-project_team_history.sql b/docs/sql/migrations/2026-11-05-project_team_history.sql new file mode 100644 index 00000000..7503179e --- /dev/null +++ b/docs/sql/migrations/2026-11-05-project_team_history.sql @@ -0,0 +1,25 @@ +-- Issue #50: 项目组版本历史(append-only JSON 快照)。MySQL 5.7. +-- DDL 隐式提交;该脚本不作为自动应用启动步骤。上线前仍须备份并在目标副本验证。 + +CREATE TABLE IF NOT EXISTS biz_project_team_history ( + id VARCHAR(64) NOT NULL, + team_id VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + action VARCHAR(16) NOT NULL, + snapshot_json TEXT NOT NULL, + operator_user_id VARCHAR(64) NULL, + operator_user_name VARCHAR(100) NULL, + operate_time DATETIME NOT NULL, + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_team_version (tenant_no, team_id, version_no), + KEY ix_team_history (tenant_no, team_id, version_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/docs/superpowers/plans/2026-11-05-project-team-history.md b/docs/superpowers/plans/2026-11-05-project-team-history.md new file mode 100644 index 00000000..2c673167 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-project-team-history.md @@ -0,0 +1,309 @@ +# 项目组版本历史(仅落库)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 项目组每次创建/编辑/软删除成功后,向 `biz_project_team_history` 追加一条带递增 `version_no` 的完整 JSON 快照,无需 UI。 + +**Architecture:** 在 `backend/app/services/project_management.py` 的 `save_entity` / `delete_entity` 中,对 `ProjectTeam` 在 `commit` 前调用历史写入助手;快照复用 `serialize_entity`;表结构与协议操作日志同类,但按版本号语义建模。 + +**Tech Stack:** FastAPI / SQLAlchemy / MySQL 5.7 DDL / unittest + SQLite in-memory(沿用 `test_project_management.py`) + +**Spec:** `docs/superpowers/specs/2026-11-05-project-team-history-design.md` + +**修改端:** 仅 `backend`(不改 `web-admin`) + +--- + +## File map + +| 文件 | 职责 | +| --- | --- | +| `docs/sql/migrations/2026-11-05-project_team_history.sql` | MySQL 建表 | +| `backend/app/models/project_management.py` | `ProjectTeamHistory` ORM | +| `backend/app/models/__init__.py` | 导出模型 | +| `backend/app/services/project_management.py` | `record_team_history` + 挂钩 save/delete | +| `backend/tests/test_project_management.py` | 创建/多次更新/删除历史断言 | + +--- + +### Task 1: 失败测试(TDD 红) + +**Files:** +- Modify: `backend/tests/test_project_management.py` +- (实现尚未存在时导入 `ProjectTeamHistory` 会失败,或断言历史行数为 0→期望 ≥1) + +- [ ] **Step 1: 在 `ProjectManagementTest` 增加测试方法** + +在 `backend/tests/test_project_management.py` 末尾(同类测试旁)加入: + +```python + def test_team_history_versions_on_create_update_delete(self): + import json + from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion + from app.models.project_management import ProjectTeamHistory + self.db.add_all([ + SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'), + SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'), + BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'), + BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'), + ]) + self.db.commit() + created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict( + teamName='Alpha', leaderUserId='u', enabled=True, + members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}], + remark='v1', + )) + team_id = created['id'] + rows = (self.db.query(ProjectTeamHistory) + .filter_by(tenant_no='t', team_id=team_id, del_flag=0) + .order_by(ProjectTeamHistory.version_no).all()) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].action, 'create') + self.assertEqual(rows[0].version_no, 1) + snap = json.loads(rows[0].snapshot_json) + self.assertEqual(snap['teamName'], 'Alpha') + self.assertEqual(snap['leaderName'], 'User') + self.assertEqual([m['userId'] for m in snap['members']], ['m']) + self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1']) + + self.svc.save_entity(self.db, 't', self.user, self.Team, dict( + teamName='Alpha2', leaderUserId='u', enabled=True, + members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}], + hospitals=[{'hospitalId': 'h1'}], remark='v2', + ), team_id) + rows = (self.db.query(ProjectTeamHistory) + .filter_by(tenant_no='t', team_id=team_id, del_flag=0) + .order_by(ProjectTeamHistory.version_no).all()) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[1].action, 'update') + self.assertEqual(rows[1].version_no, 2) + self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2') + self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2) + + # 解除「已被项目引用」阻挡:setUp 里的项目挂在 'g',新建组可删 + self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id) + rows = (self.db.query(ProjectTeamHistory) + .filter_by(tenant_no='t', team_id=team_id, del_flag=0) + .order_by(ProjectTeamHistory.version_no).all()) + self.assertEqual(len(rows), 3) + self.assertEqual(rows[2].action, 'delete') + self.assertEqual(rows[2].version_no, 3) + self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2') +``` + +注意:若 `BizCollectionRegion` / `BizCollectionHospital` 构造字段与模型不一致,按 `backend/app/models/models.py` 实际必填列补齐(与 `references()` 所用表一致)。`SysUser.status` 须为可被 `save_entity` 接受的启用态(现有 `'1'`)。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v +``` + +Expected: FAIL(`ProjectTeamHistory` 不存在,或历史行数为 0) + +- [ ] **Step 3: Commit(仅测试,待用户要求提交时执行;实现会话可与后续任务合并提交)** + +--- + +### Task 2: DDL 迁移 + +**Files:** +- Create: `docs/sql/migrations/2026-11-05-project_team_history.sql` + +- [ ] **Step 1: 写入迁移** + +```sql +-- Project team version history (append-only JSON snapshots). MySQL 5.7. +-- DDL 隐式提交;不作为应用启动自动步骤。上线前备份并在目标副本验证。 + +CREATE TABLE IF NOT EXISTS biz_project_team_history ( + id VARCHAR(64) NOT NULL, + team_id VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + action VARCHAR(16) NOT NULL, + snapshot_json TEXT NOT NULL, + operator_user_id VARCHAR(64) NULL, + operator_user_name VARCHAR(100) NULL, + operate_time DATETIME NOT NULL, + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_team_version (tenant_no, team_id, version_no), + KEY ix_team_history (tenant_no, team_id, version_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +- [ ] **Step 2: 确认无应用启动自动执行该 SQL 的挂钩**(仅文档/运维手工应用,与 `2026-11-02-project_management.sql` 一致) + +--- + +### Task 3: ORM 模型 + +**Files:** +- Modify: `backend/app/models/project_management.py` +- Modify: `backend/app/models/__init__.py` + +- [ ] **Step 1: 在 `project_management.py` 增加模型**(需 `from sqlalchemy import Text` 若尚未导入) + +```python +class ProjectTeamHistory(ProjectFields, Base): + """项目组版本历史;每次成功写入记完整快照。""" + __tablename__ = 'biz_project_team_history' + __table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),) + team_id = Column(String(64), nullable=False, index=True) + version_no = Column(Integer, nullable=False) + action = Column(String(16), nullable=False) + snapshot_json = Column(Text, nullable=False) + operator_user_id = Column(String(64)) + operator_user_name = Column(String(100)) + operate_time = Column(DateTime, nullable=False, default=datetime.now) +``` + +- [ ] **Step 2: 更新 `__init__.py` 导入与 `__all__`** + +```python +from app.models.project_management import ( + Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion, + ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory, +) +# __all__ 中追加 "ProjectTeamHistory" +``` + +--- + +### Task 4: 服务层写入挂钩 + +**Files:** +- Modify: `backend/app/services/project_management.py` + +- [ ] **Step 1: 导入** + +```python +import json +from app.models.project_management import ( + Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion, + ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory, +) +``` + +- [ ] **Step 2: 增加助手函数**(放在 `CHILDREN` / `serialize_entity` 附近) + +```python +def _next_team_history_version(db, tenant, team_id): + current = (db.query(ProjectTeamHistory.version_no) + .filter(ProjectTeamHistory.tenant_no == tenant, + ProjectTeamHistory.team_id == team_id, + ProjectTeamHistory.del_flag == 0) + .order_by(ProjectTeamHistory.version_no.desc()) + .with_for_update() + .first()) + return (current[0] if current else 0) + 1 + + +def record_team_history(db, tenant, user, row, action): + """Append-only team snapshot; caller owns the surrounding transaction.""" + if not isinstance(row, ProjectTeam): + return + snapshot = serialize_entity(db, tenant, row) + operator_id = user.get('user_id') + leader = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None + # Prefer nick_name when operator is a tenant user; fall back to user_id. + operator_name = (leader.nick_name if leader else None) or (operator_id or '') + now = datetime.now() + db.add(ProjectTeamHistory( + id=generate_uuid(), + tenant_no=tenant, + team_id=row.id, + version_no=_next_team_history_version(db, tenant, row.id), + action=action, + snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')), + operator_user_id=operator_id, + operator_user_name=operator_name, + operate_time=now, + create_by=operator_id, + create_dept_id=user.get('dept_id') or '', + update_by=operator_id, + create_time=now, + update_time=now, + )) +``` + +修正:上面用 `leader` 变量名易混淆,实现时用 `operator = scoped(...).filter(SysUser.id == operator_id).first()`。 + +- [ ] **Step 3: 挂钩 `save_entity`** + +在 `ProjectTeam` 分支完成子行 `sync_child_rows` 之后、最终 `db.add(row)` / `db.commit()` 之前(确保 `row.id` 已有),调用: + +```python + record_team_history(db, tenant, user, row, 'update' if row_id else 'create') +``` + +注意:`save_entity` 对 Project 与 ProjectTeam 共用末尾 `db.commit()`。仅当 `model is ProjectTeam` 时记录。将调用放在 Team 分支末尾(子行同步成功后),Project 分支不调用。 + +- [ ] **Step 4: 挂钩 `delete_entity`** + +```python +def delete_entity(db, tenant, user, model, row_id): + row = get_row(db, model, tenant, row_id, lock=True) + if model is Project and has_business(db, tenant, row.id): + raise HTTPException(400, '项目已被业务引用,请停用') + if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first(): + raise HTTPException(400, '项目组已被项目引用,请停用') + if model is ProjectTeam: + record_team_history(db, tenant, user, row, 'delete') # 先快照 + row.del_flag, row.update_by = 1, user.get('user_id') + if model is ProjectTeam: + for _, child_model, *_ in CHILDREN: + for child in scoped(db, child_model, tenant).filter(child_model.team_id == row.id).all(): + child.del_flag, child.update_by = 1, user.get('user_id') + db.commit() + return {'id': row.id} +``` + +- [ ] **Step 5: 跑目标测试** + +```bash +cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v +``` + +Expected: PASS + +- [ ] **Step 6: 回归相关测试** + +```bash +cd backend && python -m pytest tests/test_project_management.py tests/test_project_assignment_integration.py -v +``` + +Expected: PASS(或仅记录与本改动无关的既有失败) + +--- + +### Task 5: 收尾核对 + +- [ ] **Step 1:** 确认未改 `web-admin`、未加 History API、未引入 Figma。 +- [ ] **Step 2:** 规格验收条目可映射到测试与代码路径。 +- [ ] **Step 3:** 按 personal-dev 交付:工单回写、推送/PR/合并仅在授权下执行;当前工程 `write_issue`/`push_task_branch`/`create_pr`/`merge` 默认 false,缺权时保留本地完成态并报告缺口。 + +--- + +## Spec coverage checklist + +| 规格要求 | Task | +| --- | --- | +| `biz_project_team_history` + 唯一版本约束 | 2, 3 | +| create/update/delete 记版 | 4 | +| 完整 serialize 快照 | 4 + 测试 1 | +| 删除先快照再软删 | 4 Step 4 | +| 无 UI / 无查询 API | Task 5 | +| 自动化测试 | 1, 4 | + +## 执行说明 + +实现前按 personal-dev:创建/领取 Gitea 工单 → worktree(基线 `V2.0`)→ 在 worktree 内执行本计划。提交仅在用户要求或交付流程授权时进行。 diff --git a/docs/superpowers/specs/2026-11-05-project-team-history-design.md b/docs/superpowers/specs/2026-11-05-project-team-history-design.md new file mode 100644 index 00000000..c7fc55b3 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-project-team-history-design.md @@ -0,0 +1,107 @@ +# 项目组版本历史(仅落库)设计 + +日期:2026-11-05 +状态:待用户确认规格后进入实现计划 +范围:`backend`(Web 项目组管理经现有 API 写入;本期不改 `web-admin` UI,不画 Figma) + +## 背景与目标 + +项目组管理(`/project/management/teams`)每次新增、编辑或软删除后,需要可追溯的版本历史,使 N 次修改后仍能还原当时完整信息:名称、负责人、成员、负责地区、负责医院、启用状态、备注及操作元数据。 + +本期只保证**数据正确落库**;不做历史列表/详情界面。 + +## 已确认决策 + +| 项 | 结论 | +| --- | --- | +| 交互 | 无界面;不生成 Figma | +| 存储 | 方案 A:独立历史表 + 完整 JSON 快照 | +| 业务范围 | 仅项目组(`ProjectTeam`);不含项目(`Project`) | +| 修改端 | `backend`;Web 继续走现有保存/删除 API | +| 查询 API | 本期不暴露只读接口;用自动化测试验证落库 | +| 与现有审计关系 | 不复用 `biz_project_assignment_audit`(仅业务归属变更) | + +## 架构 + +在 `save_entity` / `delete_entity` 中,当模型为 `ProjectTeam` 且事务即将成功提交前: + +1. 用现有 `serialize_entity` 生成当前(删除场景为软删后)完整字典; +2. 计算该 `team_id` 在租户内下一 `version_no`; +3. 追加写入 `biz_project_team_history` 一行(append-only,软删除标记仅遵循表结构惯例,业务不改历史行)。 + +对齐协议操作日志(`biz_protocol_operation_log`)的「动作 + 快照 JSON」思路,但本表按项目组版本号递增,强调「第 N 版完整态」而非前后 diff。 + +``` +Web 保存/删除 + → API project_management + → save_entity / delete_entity + → 同步子行 / 软删 + → 写入 ProjectTeamHistory(version_no, action, snapshot_json, operator_*) + → commit +``` + +失败时与主写操作同一事务回滚,避免「主数据已改、历史未记」。 + +## 数据模型 + +表名:`biz_project_team_history` +迁移:`docs/sql/migrations/` 下新增脚本(命名对齐现有 `2026-11-02-project_management.sql` 风格)。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID PK | +| `team_id` | 项目组 ID(软删后仍可关联) | +| `version_no` | 租户内同一 `team_id` 从 1 递增 | +| `action` | `create` / `update` / `delete` | +| `snapshot_json` | Text,UTF-8 JSON;结构等于当时 `serialize_entity` 输出(含 `teamName`、`leaderUserId`、`leaderName`、`members`、`regions`、`hospitals`、`enabled`、`remark` 等) | +| `operator_user_id` / `operator_user_name` | 操作人 ID 与姓名快照 | +| `operate_time` | 业务操作时间 | +| `tenant_no` 及标准审计字段 | 与工程表字段顺序约定一致;`del_flag` 默认 0 | + +唯一约束:`(tenant_no, team_id, version_no)`。 +索引:`(tenant_no, team_id, version_no DESC)` 便于日后按组查历史。 + +ORM:在 `backend/app/models/project_management.py` 增加 `ProjectTeamHistory`,并在 `models/__init__.py` 导出。 + +## 写入规则 + +| 场景 | `action` | 快照时机 | +| --- | --- | --- | +| 新增项目组 | `create` | 子行同步完成后 | +| 编辑项目组 | `update` | 子行同步完成后(每次成功保存记一版,不要求字段 diff) | +| 软删除项目组 | `delete` | **先**用 `serialize_entity` 打删除前快照,**再**对主表与子行置 `del_flag=1`,最后写入历史行并 `commit`(`scoped` 默认排除已删行,禁止先软删再序列化) | + +`version_no`:同事务内对 `(tenant_no, team_id)` 取 `MAX(version_no)+1`,无历史则从 1 开始;配合行锁/唯一约束防止并发撞号。 + +不在「无字段变化的重复保存」上做跳过优化(YAGNI);每次成功保存都记一版,满足「N 次修改 N 条历史」的验收语义。 + +## 非目标(本期不做) + +- Web/小程序/移动 OA 任何历史入口或展示 +- 历史恢复(rollback)到某版本 +- 字段级 diff 展示 +- 项目(非项目组)版本历史 +- 对外只读 History API(可后续单独开需求) + +## 测试 + +在现有项目组相关测试附近增加用例(隔离库): + +1. 创建项目组 → 存在 `version_no=1`、`action=create`,`snapshot_json` 含负责人与成员/医院等; +2. 连续更新 N 次 → 存在版本 1…N+1,最新快照与当前 `serialize_entity` 一致; +3. 软删除 → 新增 `action=delete` 版本,且主流程仍成功。 + +不要求启动 `web-admin` 或改前端。 + +## 验收条件 + +1. 对任一项目组完成创建与若干次编辑后,库中按 `version_no` 可还原每一次的完整快照(负责人、成员、地区、医院等)。 +2. 软删除同样产生历史行。 +3. Web 项目组管理界面行为与现网一致(无新增按钮/弹窗)。 +4. 相关自动化测试通过;字段/表结构有 `docs/sql` 迁移可查。 + +## 风险与备注 + +- `snapshot_json` 含展示名,人员改名后历史仍保留操作当时姓名(刻意为之)。 +- 工程 `permissions.write_issue` 当前为 false:实现阶段创建/回写 Gitea 工单需用户授权或代建;本地代码与测试仍可推进。 +- 集成分支:`V2.0`。