Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fefef41524 | ||
|
|
e97f47b06a | ||
|
|
a4defb2c94 | ||
|
|
8717702b58 | ||
|
|
d30dbad788 | ||
|
|
e92112e9fd | ||
|
|
d6dc768efb | ||
|
|
96518c08fb | ||
|
|
54a34b24ba | ||
|
|
5dc695a138 | ||
|
|
406ed0ee6d | ||
|
|
6cba55f5c8 | ||
|
|
352a4b7402 | ||
|
|
e895daf8fc | ||
|
|
3bdbefb56e | ||
|
|
ecfdf65d4f | ||
|
|
1fd1100b8f | ||
|
|
2b2ddc8dd5 | ||
|
|
9af53e81f4 | ||
|
|
9e3a67a8e4 | ||
|
|
ebaab7d210 |
No files matched your search
@@ -240,6 +240,10 @@ class Tracker:
|
||||
if not value:
|
||||
return records
|
||||
records.extend(value)
|
||||
# Gitea comment pages may ignore page and keep returning the same short
|
||||
# list; a short page is the conventional last-page signal.
|
||||
if len(value) < PAGE_SIZE:
|
||||
return records
|
||||
raise InstallError('issue tracker pagination limit exceeded')
|
||||
|
||||
def issues(self):
|
||||
|
||||
@@ -45,6 +45,7 @@ class TeamBody(Body):
|
||||
|
||||
class ProjectBody(Body):
|
||||
projectName: str = Field(min_length=1, max_length=200)
|
||||
projectNo: str = Field(min_length=1, max_length=64)
|
||||
teamId: str
|
||||
leaderUserId: str
|
||||
bizTypes: List[str]
|
||||
|
||||
@@ -56,12 +56,14 @@ def _validate_batch_payload(batch: Dict[str, Any], index: int) -> None:
|
||||
assignees = batch.get("assigneeUserIds") or batch.get("assignee_user_ids") or []
|
||||
if not batch_name:
|
||||
raise ValueError(f"{label}:请填写批次名称")
|
||||
if not assignees:
|
||||
raise ValueError(f"{label}:请指定负责人")
|
||||
if not lab_ids and not third_ids:
|
||||
raise ValueError(f"{label}:请至少选择一个检测项目")
|
||||
if len(lab_ids) != len(set(lab_ids)):
|
||||
raise ValueError(f"{label}:实验室项目不能重复")
|
||||
if len(third_ids) != len(set(third_ids)):
|
||||
raise ValueError(f"{label}:第三方项目不能重复")
|
||||
if not assignees:
|
||||
raise ValueError(f"{label}:请指定负责人")
|
||||
|
||||
|
||||
def _order_source_for_wo(wo: BizQcWorkOrder) -> str:
|
||||
@@ -834,8 +836,13 @@ def configure_work_order_batches(
|
||||
}
|
||||
keep_batch_ids = {((b.get("id") or "").strip()) for b in batches if (b.get("id") or "").strip()}
|
||||
|
||||
# 提交空 batches = 显式清空:强制软删批次及关联任务(含进行中/已完成)
|
||||
# 提交空 batches = 显式清空;提交批次与库中无交集 = 前端「清空全部后再添加」全量重建
|
||||
# 两种情况都强制软删旧批次及关联任务(含进行中/已完成)
|
||||
force_clear_all = not batches
|
||||
full_replace = bool(batches) and bool(existing_batches) and keep_batch_ids.isdisjoint(
|
||||
existing_batches.keys()
|
||||
)
|
||||
force_remove = is_voided or force_clear_all or full_replace
|
||||
for bid, batch_row in existing_batches.items():
|
||||
if bid not in keep_batch_ids:
|
||||
_soft_delete_batch(
|
||||
@@ -843,7 +850,7 @@ def configure_work_order_batches(
|
||||
batch_row,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
force=is_voided or force_clear_all,
|
||||
force=force_remove,
|
||||
)
|
||||
|
||||
fee_total = Decimal("0")
|
||||
|
||||
@@ -3050,6 +3050,17 @@ def configure_work_order(
|
||||
|
||||
|
||||
def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) -> None:
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
active = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
pending = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
@@ -3060,28 +3071,31 @@ def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) ->
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if pending:
|
||||
return
|
||||
wo = get_work_order(db, work_order_id, tenant_no=tenant_no)
|
||||
if wo and wo.work_order_status == "ypz":
|
||||
wo.work_order_status = "ywc"
|
||||
task_conclusions = [
|
||||
c
|
||||
for (c,) in db.query(BizQcTask.conclusion)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
]
|
||||
derived = build_qc_conclusion_fields(task_conclusions)["qcConclusion"]
|
||||
wo.conclusion = derived or None
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
|
||||
if not wo or not can_auto_complete_work_order(
|
||||
status=wo.work_order_status or "",
|
||||
active_task_count=active,
|
||||
pending_task_count=pending,
|
||||
):
|
||||
return
|
||||
wo.work_order_status = "ywc"
|
||||
task_conclusions = [
|
||||
c
|
||||
for (c,) in db.query(BizQcTask.conclusion)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
]
|
||||
derived = build_qc_conclusion_fields(task_conclusions)["qcConclusion"]
|
||||
wo.conclusion = derived or None
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
|
||||
|
||||
sync_schedule_from_qc_work_order(db, wo)
|
||||
sync_schedule_from_qc_work_order(db, wo)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -3418,6 +3432,18 @@ def configure_temp_qc_work_order(
|
||||
def try_complete_temp_qc_work_order(
|
||||
db: Session, work_order_id: str, tenant_no: str
|
||||
) -> None:
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
active = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.order_source == "temporary",
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
pending = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
@@ -3429,14 +3455,17 @@ def try_complete_temp_qc_work_order(
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if pending:
|
||||
return
|
||||
wo = get_temp_qc_work_order(db, work_order_id, tenant_no)
|
||||
if wo and wo.work_order_status == "ypz":
|
||||
wo.work_order_status = "ywc"
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
db.commit()
|
||||
if not wo or not can_auto_complete_work_order(
|
||||
status=wo.work_order_status or "",
|
||||
active_task_count=active,
|
||||
pending_task_count=pending,
|
||||
):
|
||||
return
|
||||
wo.work_order_status = "ywc"
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_temp_qc_work_order(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -984,11 +984,14 @@ def list_protocols(
|
||||
for row in cell_rows
|
||||
)
|
||||
if source in (None, SOURCE_DONATION):
|
||||
if status or collected_from or collected_until:
|
||||
donation_enabled = False
|
||||
else:
|
||||
donation_enabled = True
|
||||
if not donation_enabled:
|
||||
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
|
||||
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
|
||||
skip_donation = False
|
||||
if source is None and (status or collected_from or collected_until):
|
||||
skip_donation = True
|
||||
elif source == SOURCE_DONATION and (collected_from or collected_until):
|
||||
skip_donation = True
|
||||
if skip_donation:
|
||||
query = None
|
||||
else:
|
||||
query = apply_donation_scope(
|
||||
@@ -1001,6 +1004,8 @@ def list_protocols(
|
||||
if query is None:
|
||||
donation_rows = []
|
||||
else:
|
||||
if status and source == SOURCE_DONATION:
|
||||
query = query.filter(BizResearchDonation.donation_status == status)
|
||||
if created_from:
|
||||
query = query.filter(BizResearchDonation.create_time >= created_from)
|
||||
if created_until:
|
||||
|
||||
@@ -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):
|
||||
@@ -333,8 +373,17 @@ def save_entity(db, tenant, user, model, body, row_id=None):
|
||||
if status not in ('pending', 'active', 'completed', 'disabled'):
|
||||
raise HTTPException(400, '项目状态无效')
|
||||
row.project_name, row.team_id, row.biz_types, row.status = body[name_key].strip(), team.id, ','.join(dict.fromkeys(kinds)), status
|
||||
if not row_id:
|
||||
row.project_no = 'XM' + datetime.now().strftime('%Y%m%d') + generate_uuid().replace('-', '')[:16].upper()
|
||||
project_no = str(body.get('projectNo') or '').strip()
|
||||
if not project_no:
|
||||
raise HTTPException(400, '项目编号不能为空')
|
||||
if len(project_no) > 64:
|
||||
raise HTTPException(400, '项目编号不能超过 64 个字符')
|
||||
conflict = scoped(db, Project, tenant).filter(Project.project_no == project_no)
|
||||
if row_id:
|
||||
conflict = conflict.filter(Project.id != row.id)
|
||||
if conflict.with_for_update().first():
|
||||
raise HTTPException(400, '项目编号已存在')
|
||||
row.project_no = project_no
|
||||
try:
|
||||
row.start_date = date.fromisoformat(body['startDate']) if body.get('startDate') else None
|
||||
row.end_date = date.fromisoformat(body['endDate']) if body.get('endDate') else None
|
||||
@@ -353,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:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""质检工单自动完成判定(无 DB 依赖,供 CRUD 与单测共用)。"""
|
||||
|
||||
|
||||
def can_auto_complete_work_order(
|
||||
*, status: str, active_task_count: int, pending_task_count: int
|
||||
) -> bool:
|
||||
"""仅当工单为已配置、存在活跃任务且全部已完成时,才可标为已完成。
|
||||
|
||||
活跃任务数为 0 时不得完成:调整检测清空后重配若尚未产生任务,
|
||||
否则会把「已配置」误标为「已完成」。
|
||||
"""
|
||||
if (status or "").strip() != "ypz":
|
||||
return False
|
||||
if active_task_count <= 0:
|
||||
return False
|
||||
if pending_task_count > 0:
|
||||
return False
|
||||
return True
|
||||
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
|
||||
assert [item['id'] for item in items] == ['hit']
|
||||
|
||||
|
||||
def test_donation_list_filters_by_protocol_status_alias():
|
||||
from app.services.market_protocol import list_protocols
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
hit = _donation(db, 'don-hit', created=datetime(2026, 10, 8, 9, 0))
|
||||
hit.donation_status = 'sxz'
|
||||
miss = _donation(db, 'don-miss', created=datetime(2026, 10, 8, 9, 0))
|
||||
miss.donation_status = 'dsx'
|
||||
db.commit()
|
||||
|
||||
items, total = list_protocols(
|
||||
db,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
source='research_donation',
|
||||
protocol_status='sxz',
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert [item['id'] for item in items] == ['don-hit']
|
||||
|
||||
|
||||
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -148,8 +149,8 @@ class ProjectManagementTest(unittest.TestCase):
|
||||
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
|
||||
self.db.commit()
|
||||
result = self.svc.save_entity(self.db, 't', self.user, self.Project,
|
||||
dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
|
||||
self.assertTrue(result['projectNo'].startswith('XM'))
|
||||
dict(projectName='New', projectNo='CUSTOM-001', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
|
||||
self.assertEqual(result['projectNo'], 'CUSTOM-001')
|
||||
self.assertIsNone(result['startDate'])
|
||||
|
||||
def test_conversion_table_reverse_graph_and_audit(self):
|
||||
@@ -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()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""质检工单自动完成条件:无活跃任务时不得标为已完成(issue #32)。"""
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestCanAutoCompleteWorkOrder(unittest.TestCase):
|
||||
def test_zero_active_tasks_must_not_complete(self):
|
||||
"""调整检测清空后重配若尚未产生任务,不能把已配置打成已完成。"""
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="ypz", active_task_count=0, pending_task_count=0
|
||||
)
|
||||
)
|
||||
|
||||
def test_all_tasks_completed_can_complete(self):
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertTrue(
|
||||
can_auto_complete_work_order(
|
||||
status="ypz", active_task_count=3, pending_task_count=0
|
||||
)
|
||||
)
|
||||
|
||||
def test_pending_tasks_block_complete(self):
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="ypz", active_task_count=3, pending_task_count=1
|
||||
)
|
||||
)
|
||||
|
||||
def test_non_configured_status_not_completed(self):
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="dpz", active_task_count=2, pending_task_count=0
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="ywc", active_task_count=2, pending_task_count=0
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestValidateBatchRequiresItems(unittest.TestCase):
|
||||
def test_batch_without_items_rejected(self):
|
||||
from app.crud.qc_inspection_batch import _validate_batch_payload
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
_validate_batch_payload(
|
||||
{
|
||||
"batchName": "B1",
|
||||
"assigneeUserIds": ["u1"],
|
||||
"labItemIds": [],
|
||||
"thirdPartyItemIds": [],
|
||||
},
|
||||
0,
|
||||
)
|
||||
self.assertIn("检测项目", str(ctx.exception))
|
||||
|
||||
def test_batch_with_lab_item_ok(self):
|
||||
from app.crud.qc_inspection_batch import _validate_batch_payload
|
||||
|
||||
_validate_batch_payload(
|
||||
{
|
||||
"batchName": "B1",
|
||||
"assigneeUserIds": ["u1"],
|
||||
"labItemIds": ["lab-1"],
|
||||
"thirdPartyItemIds": [],
|
||||
},
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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;
|
||||
@@ -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 内执行本计划。提交仅在用户要求或交付流程授权时进行。
|
||||
@@ -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`。
|
||||
@@ -28,25 +28,27 @@
|
||||
class="market-form-fields"
|
||||
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
|
||||
>
|
||||
<MarketFormField
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:model-value="value?.[field.key]"
|
||||
:options="fieldOptions(field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(field, $event)"
|
||||
@upload="$emit('upload', { section, field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
<template v-for="item in fieldsWithInsert" :key="item.field.key">
|
||||
<slot v-if="item.insertBefore" name="insert" />
|
||||
<MarketFormField
|
||||
:field="item.field"
|
||||
:model-value="value?.[item.field.key]"
|
||||
:options="fieldOptions(item.field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(item.field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(item.field, $event)"
|
||||
@upload="$emit('upload', { section, field: item.field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
</template>
|
||||
<slot v-if="appendInsert" name="insert" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import MarketFormField from './MarketFormField.vue'
|
||||
import { sampleCodeOptionsForRow } from '../reference-options.js'
|
||||
|
||||
@@ -58,9 +60,12 @@ const props = defineProps({
|
||||
mode: { type: String, default: 'create' },
|
||||
uploading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
searchCustomers: { type: Function, default: null }
|
||||
searchCustomers: { type: Function, default: null },
|
||||
/** 在首个匹配可见字段前插入 #insert 插槽(如协议状态下的项目归属) */
|
||||
insertBeforeKeys: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
|
||||
const slots = useSlots()
|
||||
const value = computed(() => props.modelValue || {})
|
||||
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||
const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
@@ -71,6 +76,19 @@ const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
!(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) &&
|
||||
!(field.createOnly && props.mode !== 'create')
|
||||
))
|
||||
const fieldsWithInsert = computed(() => {
|
||||
const keys = props.insertBeforeKeys || []
|
||||
let inserted = false
|
||||
return visibleFields.value.map((field) => {
|
||||
const insertBefore = Boolean(slots.insert) && !inserted && keys.includes(field.key)
|
||||
if (insertBefore) inserted = true
|
||||
return { field, insertBefore }
|
||||
})
|
||||
})
|
||||
const appendInsert = computed(() => {
|
||||
if (!slots.insert || !(props.insertBeforeKeys || []).length) return false
|
||||
return !fieldsWithInsert.value.some((item) => item.insertBefore)
|
||||
})
|
||||
|
||||
function fieldOptions(field, rowIndex = -1) {
|
||||
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
<template>
|
||||
<section class="market-form-section">
|
||||
<h2>{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
||||
<component :is="embedded ? 'div' : 'section'" :class="rootClass">
|
||||
<h2 v-if="!embedded">{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
||||
<label v-else :id="labelId">所属项目<b v-if="required && !editing && !filter">*</b></label>
|
||||
<template v-if="editing">
|
||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
||||
<template v-if="embedded">
|
||||
<div class="market-form-field__control market-form-field__control--text" :aria-labelledby="labelId">
|
||||
{{ projectLabel(record) }}
|
||||
</div>
|
||||
<p class="market-form-field__tip">项目组:{{ record.teamName || '—' }};归属调整请使用 Web 项目归属维护</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<PresignSelectField
|
||||
:model-value="modelValue || ''"
|
||||
:options="selectOptions"
|
||||
:disabled="disabled || loading || Boolean(error)"
|
||||
:loading="loading"
|
||||
searchable
|
||||
title="所属项目"
|
||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<van-cell v-if="loading" title="正在加载项目…" />
|
||||
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
|
||||
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
|
||||
<div :class="embedded ? 'market-form-field__control' : undefined">
|
||||
<PresignSelectField
|
||||
:model-value="modelValue || ''"
|
||||
:options="selectOptions"
|
||||
:disabled="disabled || loading || Boolean(error)"
|
||||
:loading="loading"
|
||||
searchable
|
||||
title="所属项目"
|
||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="embedded">
|
||||
<p v-if="loading" class="market-form-field__tip">正在加载项目…</p>
|
||||
<button v-else-if="error" type="button" class="market-form-field__tip market-form-field__tip--action" @click="load">
|
||||
项目加载失败,点击重试({{ error }})
|
||||
</button>
|
||||
<p v-else-if="!options.length" class="market-form-field__tip">
|
||||
{{ required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目' }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-cell v-if="loading" title="正在加载项目…" />
|
||||
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
|
||||
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -35,6 +57,7 @@ const props = defineProps({
|
||||
bizType: { type: String, required: true },
|
||||
editing: { type: Boolean, default: false },
|
||||
filter: { type: Boolean, default: false },
|
||||
embedded: { type: Boolean, default: false },
|
||||
context: { type: Object, default: null },
|
||||
disabled: { type: Boolean, default: false },
|
||||
record: { type: Object, default: () => ({}) }
|
||||
@@ -44,7 +67,9 @@ const options = ref([])
|
||||
const required = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const labelId = `project-assignment-${Math.random().toString(36).slice(2, 9)}`
|
||||
let controller
|
||||
const rootClass = computed(() => (props.embedded ? 'market-form-field' : 'market-form-section'))
|
||||
const selectOptions = computed(() => [
|
||||
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
|
||||
...options.value
|
||||
|
||||
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
|
||||
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
|
||||
return {
|
||||
source: result.source,
|
||||
protocolStatus: result.protocolStatus
|
||||
protocolStatus: result.protocolStatus,
|
||||
projectId: ''
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
|
||||
|
||||
assert.deepEqual(first, {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(second, {
|
||||
source: 'research_donation',
|
||||
protocolStatus: ''
|
||||
protocolStatus: '',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
|
||||
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
|
||||
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
|
||||
}), { progressStatus: 'yjq' })
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
}), 1)
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'research_donation',
|
||||
protocolStatus: 'sxz',
|
||||
projectId: 'p1'
|
||||
}), 2)
|
||||
assert.equal(activeFilterCount('freight', {
|
||||
transportMode: '', sampleSource: 'research_donation'
|
||||
}), 1)
|
||||
|
||||
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
|
||||
assert.match(protocol, /inlineDropdownFilters/)
|
||||
assert.match(protocol, /全部协议状态/)
|
||||
assert.match(protocol, /source:\s*'research_donation'/)
|
||||
assert.match(protocol, /filterFields:\s*\[\]/)
|
||||
assert.doesNotMatch(protocol, /createTimeRange/)
|
||||
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
|
||||
@@ -320,6 +321,25 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
assert.doesNotMatch(filters, /createTimeRange/)
|
||||
assert.match(genericList, /inlineDropdownFilters/)
|
||||
assert.match(genericList, /market-list__filter-row/)
|
||||
assert.match(genericList, /showProtocolProjectFilter/)
|
||||
assert.match(genericList, /全部项目/)
|
||||
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
|
||||
})
|
||||
|
||||
test('protocol form embeds project assignment inside protocol info before status fields', () => {
|
||||
const form = source('../views/market/MarketModuleForm.vue')
|
||||
const formSection = source('./components/MarketFormSection.vue')
|
||||
const projectField = source('./components/ProjectAssignmentField.vue')
|
||||
|
||||
assert.match(form, /insert-before-keys="\['protocolStatus', 'donationStatus'\]"/)
|
||||
assert.match(form, /#insert/)
|
||||
assert.match(form, /embedded/)
|
||||
assert.match(form, /<ProjectAssignmentField[\s\S]*?embedded/)
|
||||
assert.doesNotMatch(form, /ProjectAssignmentField v-if="context\.module === 'protocol'"/)
|
||||
assert.match(formSection, /insertBeforeKeys/)
|
||||
assert.match(formSection, /name="insert"/)
|
||||
assert.match(projectField, /embedded/)
|
||||
assert.match(projectField, /market-form-field/)
|
||||
})
|
||||
|
||||
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
|
||||
|
||||
@@ -478,6 +478,12 @@ export default {
|
||||
allText: '全部协议状态',
|
||||
source: 'cell_storage',
|
||||
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
},
|
||||
{
|
||||
key: 'protocolStatus',
|
||||
allText: '全部协议状态',
|
||||
source: 'research_donation',
|
||||
options: DONATION_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
}
|
||||
],
|
||||
statusLabel(detail) {
|
||||
|
||||
@@ -1247,6 +1247,25 @@ body.van-toast--unclickable .presign-order-page * {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip,
|
||||
.market-form-field .market-form-field__control--text .market-form-field__tip {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip--action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0a9f6d;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-form-field > .van-field,
|
||||
.market-form-field__control {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -12,25 +12,52 @@
|
||||
</van-empty>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ProjectAssignmentField v-if="context.module === 'protocol'" ref="projectAssignment"
|
||||
v-model="formData.projectId" :biz-type="formData.source" :editing="mode === 'edit'"
|
||||
:record="formData" :disabled="fieldsLocked" @update:model-value="dirty = true" />
|
||||
<MarketFormSection
|
||||
v-for="section in leadingSections"
|
||||
:key="section.key"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<template v-for="section in leadingSections" :key="section.key">
|
||||
<MarketFormSection
|
||||
v-if="!(context.module === 'protocol' && section.key === 'protocol')"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<MarketFormSection
|
||||
v-else
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
:insert-before-keys="['protocolStatus', 'donationStatus']"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
>
|
||||
<template #insert>
|
||||
<ProjectAssignmentField
|
||||
ref="projectAssignment"
|
||||
embedded
|
||||
v-model="formData.projectId"
|
||||
:biz-type="formData.source"
|
||||
:editing="mode === 'edit'"
|
||||
:record="formData"
|
||||
:disabled="fieldsLocked"
|
||||
@update:model-value="dirty = true"
|
||||
/>
|
||||
</template>
|
||||
</MarketFormSection>
|
||||
</template>
|
||||
|
||||
<section
|
||||
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
:show-header="false"
|
||||
>
|
||||
<div class="market-list__filters">
|
||||
<ProjectAssignmentField v-if="context.module === 'protocol'" v-model="listFilters.projectId"
|
||||
:biz-type="listStatus" filter :context="context" @update:model-value="search" />
|
||||
<div
|
||||
v-if="listFilterTabs.length"
|
||||
class="market-source-tabs"
|
||||
@@ -59,15 +57,24 @@
|
||||
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="inlineDropdownFilters.length" class="market-list__filter-row">
|
||||
<div
|
||||
v-if="inlineDropdownFilters.length || showProtocolProjectFilter"
|
||||
class="market-list__filter-row"
|
||||
>
|
||||
<van-dropdown-menu active-color="#0a9f6d">
|
||||
<van-dropdown-item
|
||||
v-for="field in inlineDropdownFilters"
|
||||
:key="field.key"
|
||||
:key="`${field.source || 'all'}:${field.key}`"
|
||||
:model-value="listFilters[field.key]"
|
||||
:options="inlineFilterOptionsByKey[field.key] || []"
|
||||
@change="(value) => onInlineFilterChange(field.key, value)"
|
||||
/>
|
||||
<van-dropdown-item
|
||||
v-if="showProtocolProjectFilter"
|
||||
:model-value="listFilters.projectId || ''"
|
||||
:options="protocolProjectFilterOptions"
|
||||
@change="(value) => onInlineFilterChange('projectId', value)"
|
||||
/>
|
||||
</van-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,11 +161,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
|
||||
import {
|
||||
fetchMarketFormOptions,
|
||||
fetchMarketList,
|
||||
fetchProjectOptions,
|
||||
performMarketAction
|
||||
} from '../../api/market.js'
|
||||
import { intersectAllowedActions } from '../../market/action-policy.js'
|
||||
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
|
||||
import {
|
||||
@@ -169,6 +180,7 @@ import {
|
||||
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
|
||||
import { marketErrorMessage } from '../../market/market-error.js'
|
||||
import { getMarketModule } from '../../market/module-registry.js'
|
||||
import { projectOptions } from '../../market/project-assignment.js'
|
||||
import {
|
||||
getMarketRouteContext,
|
||||
MARKET_MODULE_TITLES,
|
||||
@@ -247,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
|
||||
const source = listFilters.value.source || listStatus.value
|
||||
return rows.filter((field) => !field.source || field.source === source)
|
||||
})
|
||||
const showProtocolProjectFilter = computed(() => context.value.module === 'protocol')
|
||||
const protocolProjectOptions = ref([])
|
||||
const protocolProjectFilterOptions = computed(() => [
|
||||
{ text: '全部项目', value: '' },
|
||||
...protocolProjectOptions.value
|
||||
])
|
||||
const activeFilters = computed(() =>
|
||||
activeFilterCount(context.value.module, listFilters.value)
|
||||
)
|
||||
@@ -299,6 +317,7 @@ const allDeletableSelected = computed(() =>
|
||||
)
|
||||
let requestSequence = 0
|
||||
let abortController = null
|
||||
let projectOptionsController = null
|
||||
let searchTimer = null
|
||||
|
||||
function normalizeList(result) {
|
||||
@@ -360,7 +379,32 @@ function queueSearch() {
|
||||
searchTimer = window.setTimeout(search, 350)
|
||||
}
|
||||
|
||||
async function loadProtocolProjectOptions() {
|
||||
projectOptionsController?.abort()
|
||||
if (!showProtocolProjectFilter.value) {
|
||||
protocolProjectOptions.value = []
|
||||
return
|
||||
}
|
||||
const current = new AbortController()
|
||||
projectOptionsController = current
|
||||
const bizType = listFilters.value.source || listStatus.value || 'cell_storage'
|
||||
try {
|
||||
const data = await fetchProjectOptions(bizType, {
|
||||
signal: current.signal,
|
||||
filter: true,
|
||||
context: context.value
|
||||
})
|
||||
if (projectOptionsController !== current) return
|
||||
protocolProjectOptions.value = projectOptions(data)
|
||||
} catch (error) {
|
||||
if (projectOptionsController === current && error?.name !== 'AbortError') {
|
||||
protocolProjectOptions.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListFilterOptions() {
|
||||
await loadProtocolProjectOptions()
|
||||
const optionKeys = [
|
||||
...sheetFilterFields.value.map((field) => field.optionKey),
|
||||
...inlineDropdownFilters.value.map((field) => field.optionKey)
|
||||
@@ -596,6 +640,7 @@ function switchListStatus(value) {
|
||||
tab: next
|
||||
}
|
||||
router.replace({ query: nextQuery }).catch(() => {})
|
||||
loadProtocolProjectOptions()
|
||||
loadPage(true)
|
||||
}
|
||||
|
||||
@@ -644,6 +689,7 @@ onBeforeUnmount(() => {
|
||||
const requestId = ++requestSequence
|
||||
listState.value = invalidateMarketRequestState(listState.value, requestId)
|
||||
abortController?.abort()
|
||||
projectOptionsController?.abort()
|
||||
window.clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -20,7 +20,13 @@
|
||||
v-for="option in displayOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
:label="team ? option.teamName : `${option.projectName} · ${option.teamName}`"
|
||||
:label="
|
||||
team
|
||||
? option.teamName
|
||||
: teamId
|
||||
? option.projectName
|
||||
: `${option.projectName} · ${option.teamName}`
|
||||
"
|
||||
:disabled="!filter && option.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -43,6 +49,8 @@
|
||||
const props = defineProps<{
|
||||
filter?: boolean
|
||||
team?: boolean
|
||||
/** When selecting projects, keep options under this team. */
|
||||
teamId?: string
|
||||
modelValue?: string
|
||||
bizType?: BizType
|
||||
disabled?: boolean
|
||||
@@ -54,15 +62,18 @@
|
||||
ready: [value: boolean]
|
||||
}>()
|
||||
const options = ref<ProjectOption[]>([])
|
||||
const displayOptions = computed(() =>
|
||||
props.team
|
||||
? [
|
||||
...new Map(
|
||||
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
|
||||
).values()
|
||||
]
|
||||
const displayOptions = computed(() => {
|
||||
if (props.team) {
|
||||
return [
|
||||
...new Map(
|
||||
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
|
||||
).values()
|
||||
]
|
||||
}
|
||||
return props.teamId
|
||||
? options.value.filter((option) => option.teamId === props.teamId)
|
||||
: options.value
|
||||
)
|
||||
})
|
||||
const loading = ref(false)
|
||||
const error = ref(false),
|
||||
retry = ref(0)
|
||||
|
||||
@@ -22,13 +22,14 @@ export function assignmentSnapshot(records: ProjectRecord[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
/** Write bodies intentionally exclude response-only labels, timestamps and generated numbers. */
|
||||
/** Write bodies intentionally exclude response-only labels and timestamps. */
|
||||
export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecord): ProjectRecord {
|
||||
const fields =
|
||||
kind === 'teams'
|
||||
? ['teamName', 'leaderUserId', 'enabled', 'remark']
|
||||
: [
|
||||
'projectName',
|
||||
'projectNo',
|
||||
'teamId',
|
||||
'leaderUserId',
|
||||
'bizTypes',
|
||||
@@ -58,3 +59,11 @@ export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecor
|
||||
export function projectOptionsParams(bizType: BizType, filter = false) {
|
||||
return filter ? { bizType, usage: 'filter' as const } : { bizType }
|
||||
}
|
||||
|
||||
/** List cells join child display names; empty entries are dropped. */
|
||||
export function joinChildNames(items?: ProjectRecord[]) {
|
||||
return (items || [])
|
||||
.map((item) => item?.name)
|
||||
.filter(Boolean)
|
||||
.join('、')
|
||||
}
|
||||
@@ -26,6 +26,8 @@ describe('flattenProtocolAuditRows', () => {
|
||||
id: 'p1',
|
||||
protocolNo: 'ZKZX1',
|
||||
motherName: '郭梦朵',
|
||||
projectName: '测试项目',
|
||||
teamName: '陈星项目组111',
|
||||
sampleCollectTime: '2025-09-15 08:51:00',
|
||||
sampleItems: [
|
||||
{ sampleTypeName: '脐带血', sampleCode: 'A1', labStorageCode: 'B0000004' },
|
||||
@@ -40,6 +42,25 @@ describe('flattenProtocolAuditRows', () => {
|
||||
assert.equal(rows[0].labStorageCode, 'B0000004')
|
||||
assert.equal(rows[1].labStorageCode, 'B0000004A')
|
||||
assert.equal(rows[0].protocolId, 'p1')
|
||||
assert.equal(rows[0].projectName, '测试项目')
|
||||
assert.equal(rows[0].teamName, '陈星项目组111')
|
||||
assert.equal(rows[1].projectName, '测试项目')
|
||||
assert.equal(rows[1].teamName, '陈星项目组111')
|
||||
})
|
||||
|
||||
it('copies protocol project attribution onto each flat row for table props', () => {
|
||||
const rows = flattenProtocolAuditRows([
|
||||
{
|
||||
id: 'p-attr',
|
||||
projectName: '归属项目',
|
||||
teamName: '归属项目组',
|
||||
sampleItems: [{ sampleCode: 'ZKZX34000199', sampleTypeName: '围产期造血干细胞' }]
|
||||
}
|
||||
])
|
||||
assert.equal(rows.length, 1)
|
||||
assert.equal(rows[0].projectName, '归属项目')
|
||||
assert.equal(rows[0].teamName, '归属项目组')
|
||||
assert.equal(rows[0].sampleCode, 'ZKZX34000199')
|
||||
})
|
||||
|
||||
it('zero samples → one placeholder row, not co-stored', () => {
|
||||
|
||||
@@ -20,6 +20,9 @@ export type ProtocolAuditFlatRow = {
|
||||
sampleCount: number
|
||||
sampleIndex: number
|
||||
isCoStored: boolean
|
||||
/** 协议级项目归属,供扁平行表格 prop 绑定 */
|
||||
projectName: string
|
||||
teamName: string
|
||||
collectDate: string
|
||||
sampleCollectTime: string
|
||||
motherName: string
|
||||
@@ -95,6 +98,8 @@ export function flattenProtocolAuditRows(
|
||||
sampleCount,
|
||||
sampleIndex,
|
||||
isCoStored,
|
||||
projectName: str(protocol.projectName),
|
||||
teamName: str(protocol.teamName),
|
||||
collectDate: extractCollectDate(str(protocol.sampleCollectTime)),
|
||||
sampleCollectTime: str(protocol.sampleCollectTime),
|
||||
motherName: str(protocol.motherName),
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
<el-table-column v-if="!isTeam" prop="projectNo" label="项目编号" min-width="160" />
|
||||
<el-table-column v-if="!isTeam" prop="teamName" label="所属项目组" min-width="160" />
|
||||
<el-table-column prop="leaderName" label="负责人" min-width="120" />
|
||||
<el-table-column v-if="isTeam" label="成员" min-width="180" show-overflow-tooltip
|
||||
><template #default="{ row }">{{
|
||||
joinChildNames(row.members)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column v-if="isTeam" label="负责医院" min-width="200" show-overflow-tooltip
|
||||
><template #default="{ row }">{{
|
||||
joinChildNames(row.hospitals)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" min-width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
@@ -139,12 +149,19 @@
|
||||
v-else
|
||||
v-model="form.projectName"
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
v-if="!isTeam"
|
||||
label="项目编号"
|
||||
prop="projectNo"
|
||||
:rules="[{ required: true, message: '请输入项目编号' }]"
|
||||
><el-input v-model="form.projectNo" maxlength="64" show-word-limit
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
v-if="!isTeam"
|
||||
label="所属项目组"
|
||||
prop="teamId"
|
||||
:rules="[{ required: true, message: '请选择项目组' }]"
|
||||
><el-select v-model="form.teamId" filterable
|
||||
><el-select v-model="form.teamId" filterable @change="onTeamChange"
|
||||
><el-option
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
@@ -243,7 +260,7 @@
|
||||
>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { managementPayload } from '@/utils/project-management'
|
||||
import { joinChildNames, managementPayload } from '@/utils/project-management'
|
||||
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
|
||||
import {
|
||||
Plus,
|
||||
@@ -333,6 +350,11 @@
|
||||
})
|
||||
search()
|
||||
}
|
||||
function onTeamChange(teamId: string) {
|
||||
if (readonly.value || isTeam.value) return
|
||||
const team = teams.value.find((item) => item.id === teamId)
|
||||
if (team?.leaderUserId) form.value.leaderUserId = team.leaderUserId
|
||||
}
|
||||
async function edit(row?: ProjectRecord, view = false) {
|
||||
readonly.value = view
|
||||
editingId.value = row?.id || ''
|
||||
@@ -341,6 +363,7 @@
|
||||
: {
|
||||
teamName: '',
|
||||
projectName: '',
|
||||
projectNo: '',
|
||||
leaderUserId: '',
|
||||
enabled: true,
|
||||
status: 'pending',
|
||||
|
||||
@@ -143,57 +143,58 @@
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="项目归属维护"
|
||||
width="720px"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top"
|
||||
><el-form-item label="目标项目" required
|
||||
><ProjectSelect
|
||||
v-model="target"
|
||||
:key="selected.id"
|
||||
<el-form class="form-grid-2" label-position="top">
|
||||
<el-form-item label="原所属项目组">
|
||||
<el-input :model-value="selected.teamName || '未归属'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="原所属项目">
|
||||
<el-input :model-value="selected.projectName || '未归属'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目组" required>
|
||||
<ProjectSelect
|
||||
:key="`team-${selected.id}`"
|
||||
team
|
||||
v-model="targetTeamId"
|
||||
:biz-type="selected.bizType"
|
||||
@update:model-value="previewRows = []" /></el-form-item
|
||||
><el-form-item label="调整原因" required
|
||||
><el-input v-model="reason" type="textarea" :maxlength="500" /></el-form-item
|
||||
></el-form>
|
||||
<el-alert
|
||||
title="请先预览全部关联单据,再确认调整。关联集合或归属发生变化时需要重新预览。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-table :data="previewRows" class="list-table" stripe
|
||||
><el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center" />
|
||||
<el-table-column prop="bizNo" label="关联单据" min-width="200" /><el-table-column
|
||||
prop="projectName"
|
||||
label="原项目"
|
||||
min-width="160"
|
||||
/></el-table>
|
||||
<template #footer
|
||||
><el-button @click="visible = false"
|
||||
@update:model-value="onTargetTeamChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目" required>
|
||||
<ProjectSelect
|
||||
:key="`project-${selected.id}-${targetTeamId}`"
|
||||
v-model="target"
|
||||
:team-id="targetTeamId"
|
||||
:biz-type="selected.bizType"
|
||||
:disabled="!targetTeamId"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整原因" required class="form-col-span-2">
|
||||
<el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false"
|
||||
><el-icon><Close /></el-icon>取消</el-button
|
||||
><el-button :loading="busy" @click="preview"
|
||||
><el-icon><View /></el-icon>预览关联单据</el-button
|
||||
><el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!previewRows.length || !reason.trim()"
|
||||
:disabled="!target || !reason.trim()"
|
||||
:loading="busy"
|
||||
@click="commit"
|
||||
><el-icon><Check /></el-icon>确认调整</el-button
|
||||
></template
|
||||
>
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog
|
||||
v-model="historyVisible"
|
||||
title="归属审计记录"
|
||||
width="800px"
|
||||
width="1100px"
|
||||
append-to-body
|
||||
class="dialog-form-layout"
|
||||
><el-table :data="historyRows" class="list-table" stripe
|
||||
@@ -233,7 +234,7 @@
|
||||
presignOrderStatusTagType,
|
||||
applicationStatusTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { Search, Edit, Clock, Close, View, Check, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { Search, Edit, Clock, Close, Check, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { assignmentTabs, assignmentColumns, assignmentKeywordHints } from './assignment-columns'
|
||||
import ProjectSelect from '@/components/business/ProjectSelect.vue'
|
||||
import { assignmentSnapshot } from '@/utils/project-management'
|
||||
@@ -268,10 +269,10 @@
|
||||
loadError = ref(false),
|
||||
visible = ref(false),
|
||||
busy = ref(false),
|
||||
targetTeamId = ref(''),
|
||||
target = ref(''),
|
||||
reason = ref(''),
|
||||
selected = ref<ProjectRecord>({}),
|
||||
previewRows = ref<ProjectRecord[]>([]),
|
||||
historyVisible = ref(false),
|
||||
historyRows = ref<ProjectRecord[]>([])
|
||||
const visibleColumns = computed(() =>
|
||||
@@ -361,47 +362,54 @@
|
||||
}
|
||||
function open(row: ProjectRecord) {
|
||||
selected.value = row
|
||||
targetTeamId.value = ''
|
||||
target.value = ''
|
||||
reason.value = ''
|
||||
previewRows.value = []
|
||||
visible.value = true
|
||||
}
|
||||
async function preview() {
|
||||
function onTargetTeamChange() {
|
||||
target.value = ''
|
||||
}
|
||||
async function commit() {
|
||||
if (!targetTeamId.value) {
|
||||
ElMessage.warning('请选择新项目组')
|
||||
return
|
||||
}
|
||||
if (!target.value) {
|
||||
ElMessage.warning('请选择目标项目')
|
||||
ElMessage.warning('请选择新项目')
|
||||
return
|
||||
}
|
||||
if (!reason.value.trim()) {
|
||||
ElMessage.warning('请填写调整原因')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
const previewTarget = target.value
|
||||
const previewRecord = selected.value
|
||||
const commitTarget = target.value
|
||||
const commitRecord = selected.value
|
||||
try {
|
||||
const result = await previewAssignment({
|
||||
const preview = await previewAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value
|
||||
})
|
||||
if (selected.value === previewRecord && target.value === previewTarget && visible.value) {
|
||||
previewRows.value = result.records
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function commit() {
|
||||
busy.value = true
|
||||
try {
|
||||
await saveAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value,
|
||||
reason: reason.value.trim(),
|
||||
expectedAssignments: assignmentSnapshot(previewRows.value)
|
||||
expectedAssignments: assignmentSnapshot(preview.records)
|
||||
})
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
visible.value = false
|
||||
ElMessage.success('归属已调整')
|
||||
await load()
|
||||
} catch {
|
||||
previewRows.value = []
|
||||
// API layer already surfaces the conflict / validation message.
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -576,6 +576,10 @@
|
||||
ElMessage.warning(`${label}:请指定负责人`)
|
||||
return false
|
||||
}
|
||||
if (!(b.labItemIds?.length || 0) && !(b.thirdPartyItemIds?.length || 0)) {
|
||||
ElMessage.warning(`${label}:请至少选择一个检测项目`)
|
||||
return false
|
||||
}
|
||||
const labSet = new Set(b.labItemIds)
|
||||
if (labSet.size !== b.labItemIds.length) {
|
||||
ElMessage.warning(`${label}:实验室项目不能重复`)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { buildDrillFilters, assignmentSnapshot } from '../src/utils/project-management'
|
||||
import {
|
||||
buildDrillFilters,
|
||||
assignmentSnapshot,
|
||||
joinChildNames
|
||||
} from '../src/utils/project-management'
|
||||
|
||||
test('drill preserves applied dates and selected project when a team is opened', () => {
|
||||
assert.deepEqual(
|
||||
@@ -65,6 +69,12 @@ test('management payload excludes readonly response fields and preserves child I
|
||||
)
|
||||
})
|
||||
|
||||
test('joinChildNames joins display names and skips blanks', () => {
|
||||
assert.equal(joinChildNames([{ name: '甲' }, { name: '' }, { name: '乙' }, {}]), '甲、乙')
|
||||
assert.equal(joinChildNames([]), '')
|
||||
assert.equal(joinChildNames(undefined), '')
|
||||
})
|
||||
|
||||
test('filter options use visible historical scope; create and reassignment remain strict', async () => {
|
||||
const { projectOptionsParams } = await import('../src/utils/project-management')
|
||||
assert.deepEqual(projectOptionsParams('cell_storage', true), {
|
||||
|
||||
Reference in new issue
Block a user