项目里程碑初步优化

This commit is contained in:
Cheng Zhou
2026-02-27 09:06:06 +08:00
parent 8f3f717e48
commit fd7e3fc948
47 changed files with 2029 additions and 783 deletions
+177 -120
View File
@@ -1,11 +1,10 @@
import uuid
import re
from io import BytesIO
from datetime import date
from collections import Counter
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import (
@@ -22,7 +21,6 @@ from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import study_setup_config as study_setup_config_crud
from app.crud import user as user_crud
from app.services.setup_config_excel import export_setup_config_excel, import_setup_config_excel
from app.schemas.common import PaginatedResponse
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
from app.schemas.member import StudyMemberCreate
@@ -81,6 +79,7 @@ def _validate_setup_data(
*,
project_plan_start: date | None = None,
project_plan_end: date | None = None,
strict_required: bool = True,
) -> None:
errors: list[dict[str, str]] = []
allowed_milestone_status = {"未开始", "进行中", "已完成", "延期"}
@@ -96,13 +95,24 @@ def _validate_setup_data(
for index, row in enumerate(payload.projectMilestones):
row_prefix = f"projectMilestones[{index}]"
start_text = (row.startDate or row.planDate or "").strip()
end_text = (row.endDate or row.planDate or "").strip()
if not row.id:
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
if _is_empty_row([row.name, row.owner, row.remark, row.planDate]):
if _is_empty_row([row.name, row.owner, row.remark, start_text, end_text]):
continue
if not row.name.strip():
errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"})
_parse_date_str(row.planDate, f"{row_prefix}.planDate", errors)
start = _parse_date_str(start_text, f"{row_prefix}.startDate", errors)
end = _parse_date_str(end_text, f"{row_prefix}.endDate", errors)
if not start_text:
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写开始日期"})
if not end_text:
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写结束日期"})
if start and end and start > end:
errors.append({"field": f"{row_prefix}.endDate", "message": "结束日期不能早于开始日期"})
if row.durationDays < 1:
errors.append({"field": f"{row_prefix}.durationDays", "message": "耗时天数不能小于1"})
if row.status not in allowed_milestone_status:
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
@@ -111,9 +121,9 @@ def _validate_setup_data(
errors.append({"field": "enrollmentPlan.totalTarget", "message": "计划总入组例数不能小于0"})
enrollment_plan_start = _parse_date_str(plan.startDate, "enrollmentPlan.startDate", errors)
enrollment_plan_end = _parse_date_str(plan.endDate, "enrollmentPlan.endDate", errors)
if not plan.startDate:
if strict_required and not plan.startDate:
errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"})
if not plan.endDate:
if strict_required and not plan.endDate:
errors.append({"field": "enrollmentPlan.endDate", "message": "请填写计划结束日期"})
if enrollment_plan_start and enrollment_plan_end and enrollment_plan_start > enrollment_plan_end:
errors.append({"field": "enrollmentPlan.endDate", "message": "结束日期不能早于开始日期"})
@@ -164,9 +174,9 @@ def _validate_setup_data(
total_site_target += max(row.target, 0)
start = _parse_date_str(row.startDate, f"{row_prefix}.startDate", errors)
end = _parse_date_str(row.endDate, f"{row_prefix}.endDate", errors)
if not row.startDate:
if strict_required and not row.startDate:
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"})
if not row.endDate:
if strict_required and not row.endDate:
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写完成日期"})
if start and end and start > end:
errors.append({"field": f"{row_prefix}.endDate", "message": "完成日期不能早于启动日期"})
@@ -279,17 +289,18 @@ def _build_projection_audit_detail(
warnings: list[str],
skipped_items: list[dict[str, str]],
) -> str:
warning_part = " | ".join(warnings[:5]) if warnings else "-"
warning_part = "".join(warnings[:5]) if warnings else ""
reason_counter = Counter(item.get("reason") for item in skipped_items if item.get("reason"))
reason_summary = ", ".join(f"{reason}:{count}" for reason, count in sorted(reason_counter.items())) if reason_counter else "-"
reason_summary = "".join(f"{reason} {count}" for reason, count in sorted(reason_counter.items())) if reason_counter else ""
projection_status_label = "成功" if projection_status == "success" else "失败" if projection_status == "failed" else projection_status
return (
"立项配置已发布"
f"; projection_status={projection_status}"
f"; study_updated={study_updated}"
f"; site_updated={site_updated_count}"
f"; site_skipped={site_skipped_count}"
f"; warnings={warning_part}"
f"; skipped_reason_counts={reason_summary}"
f"; 发布同步结果:{projection_status_label}"
f"; 已同步项目主信息:{'' if study_updated else ''}"
f"; 已同步中心数:{site_updated_count}"
f"; 未同步中心数:{site_skipped_count}"
f"; 同步告警:{warning_part}"
f"; 未同步原因统计:{reason_summary}"
)
@@ -326,14 +337,145 @@ def _to_setup_config_version_read(record, *, published_by_name: str | None = Non
)
_SETUP_MODULE_KEYS = (
"projectMilestones",
"enrollmentPlan",
"siteMilestones",
"siteEnrollmentPlans",
"monitoringStrategies",
"centerConfirm",
)
_SETUP_MODULE_LABELS = {
"projectMilestones": "项目里程碑",
"enrollmentPlan": "项目入组计划",
"siteMilestones": "中心里程碑",
"siteEnrollmentPlans": "中心入组计划",
"monitoringStrategies": "监查策略",
"centerConfirm": "中心确认",
}
_SETUP_FIELD_LABELS = {
"id": "ID",
"name": "名称",
"milestone": "里程碑",
"planDate": "计划日期",
"startDate": "开始日期",
"endDate": "结束日期",
"durationDays": "耗时(天)",
"owner": "负责人",
"remark": "备注",
"status": "状态",
"totalTarget": "计划总入组例数",
"startDate": "开始日期",
"endDate": "结束日期",
"monthlyGoalNote": "月度目标说明",
"stageBreakdown": "分阶段计划",
"siteId": "中心ID",
"siteName": "中心名称",
"target": "计划例数",
"note": "备注",
"strategyType": "监查类型",
"detail": "策略详情",
"frequency": "监查频次",
"updatedAt": "更新时间",
"enabled": "启用状态",
"confirmer": "确认人",
"confirmStatus": "确认状态",
"confirmDate": "确认日期",
}
def _setup_value_text(value: Any) -> str:
if value is None:
return "未填写"
if isinstance(value, bool):
return "" if value else ""
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
text = value.strip()
if not text:
return "未填写"
if len(text) > 80:
return f"{text[:80]}..."
return text
if isinstance(value, list):
return f"已配置列表({len(value)}项)"
if isinstance(value, dict):
return "已配置内容"
return str(value)
def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
if not isinstance(row, dict):
return f"{index + 1}"
if module_key == "projectMilestones":
return str(row.get("name") or "").strip() or f"{index + 1}"
if module_key == "siteMilestones":
return str(row.get("milestone") or "").strip() or f"{index + 1}"
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"{index + 1}"
if module_key == "monitoringStrategies":
return str(row.get("strategyType") or "").strip() or f"{index + 1}"
return f"{index + 1}"
def _setup_collect_diff_lines(
module_key: str,
old_value: Any,
new_value: Any,
path: list[str],
lines: list[str],
) -> None:
if isinstance(old_value, list) or isinstance(new_value, list):
old_list = old_value if isinstance(old_value, list) else []
new_list = new_value if isinstance(new_value, list) else []
for index in range(max(len(old_list), len(new_list))):
old_item = old_list[index] if index < len(old_list) else None
new_item = new_list[index] if index < len(new_list) else None
identity = _setup_row_identity(module_key, new_item if new_item is not None else old_item, index)
_setup_collect_diff_lines(module_key, old_item, new_item, [*path, identity], lines)
return
if isinstance(old_value, dict) or isinstance(new_value, dict):
old_dict = old_value if isinstance(old_value, dict) else {}
new_dict = new_value if isinstance(new_value, dict) else {}
keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
for key in keys:
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
return
if old_value == new_value:
return
module_label = _SETUP_MODULE_LABELS.get(module_key, module_key)
readable_path = " / ".join(_SETUP_FIELD_LABELS.get(part, part) for part in path if part)
field_text = f"{module_label} / {readable_path}" if readable_path else module_label
lines.append(f"{field_text}{_setup_value_text(old_value)} -> {_setup_value_text(new_value)}")
def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
old = old or {}
new = new or {}
changed = []
for key in ("projectMilestones", "enrollmentPlan", "siteMilestones", "siteEnrollmentPlans", "monitoringStrategies", "centerConfirm"):
if old.get(key) != new.get(key):
changed.append(key)
return "变更模块:" + (", ".join(changed) if changed else "")
changed_modules: list[str] = []
detail_lines: list[str] = []
for module_key in _SETUP_MODULE_KEYS:
if old.get(module_key) == new.get(module_key):
continue
changed_modules.append(_SETUP_MODULE_LABELS.get(module_key, module_key))
_setup_collect_diff_lines(module_key, old.get(module_key), new.get(module_key), [], detail_lines)
module_summary = "变更模块:" + ("".join(changed_modules) if changed_modules else "")
if not detail_lines:
return module_summary
max_lines = 20
visible_lines = detail_lines[:max_lines]
if len(detail_lines) > max_lines:
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
return f"{module_summary}; 变更明细:" + "".join(visible_lines)
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
@@ -634,16 +776,18 @@ async def upsert_study_setup_config(
if not record:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置保存失败")
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=record.id,
action="UPDATE_SETUP_CONFIG",
detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id,
operator_role=current_user.role,
)
has_setup_diff = old_config != (record.config or {})
if has_setup_diff:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=record.id,
action="UPDATE_SETUP_CONFIG",
detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id,
operator_role=current_user.role,
)
saved_by_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
@@ -861,90 +1005,3 @@ async def delete_study_setup_config_version(
operator_id=current_user.id,
operator_role=current_user.role,
)
@router.get(
"/{study_id}/setup-config/export-excel",
dependencies=[Depends(require_study_member())],
)
async def export_study_setup_config_excel(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StreamingResponse:
config = await get_study_setup_config(study_id=study_id, db=db, current_user=current_user)
study = await study_crud.get(db, study_id)
file_bytes = export_setup_config_excel(config.data)
filename = f"setup-config-{study.code if study else study_id}.xlsx"
return StreamingResponse(
BytesIO(file_bytes),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post(
"/{study_id}/setup-config/import-excel",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def import_study_setup_config_excel(
study_id: uuid.UUID,
file: UploadFile = File(...),
expected_version: int | None = Form(default=None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudySetupConfigRead:
if not (file.filename or "").lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式文件")
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
try:
payload_data = import_setup_config_excel(await file.read())
except Exception:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel解析失败,请检查模板格式")
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
site_lookup = {str(site.id): site.name or "" for site in sites}
_validate_setup_data(
payload_data,
site_lookup,
project_plan_start=getattr(study, "plan_start_date", None),
project_plan_end=getattr(study, "plan_end_date", None),
)
existing = await study_setup_config_crud.get_by_study(db, study_id)
old_config = dict(existing.config or {}) if existing else {}
record, conflict = await study_setup_config_crud.upsert(
db,
study_id,
expected_version=expected_version,
data=payload_data,
saved_by=current_user.id,
)
if conflict:
_raise_conflict_error()
if not record:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置导入失败")
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=record.id,
action="IMPORT_SETUP_CONFIG_EXCEL",
detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id,
operator_role=current_user.role,
)
saved_by_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
if record.published_by:
user = await user_crud.get_by_id(db, record.published_by)
if user:
published_by_name = user.full_name or user.username or user.email
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)
@@ -13,6 +13,9 @@ class ProjectMilestoneItem(BaseModel):
id: str
name: str = ""
planDate: str = ""
startDate: str = ""
endDate: str = ""
durationDays: int = 1
owner: str = ""
remark: str = ""
status: MilestoneStatus = "未开始"
-246
View File
@@ -1,246 +0,0 @@
from __future__ import annotations
from datetime import date, datetime
from io import BytesIO
import random
from typing import Any
from openpyxl import Workbook, load_workbook
from app.schemas.study_setup_config import StudySetupConfigData
def _make_row_id() -> str:
return f"{int(datetime.now().timestamp() * 1000)}_{random.randint(1000, 9999)}"
def _is_empty(*values: Any) -> bool:
return all(v is None or str(v).strip() == "" for v in values)
def _sheet_headers(sheet, headers: list[str]) -> None:
sheet.append(headers)
for idx, title in enumerate(headers, start=1):
cell = sheet.cell(row=1, column=idx)
cell.value = title
def export_setup_config_excel(data: StudySetupConfigData) -> bytes:
wb = Workbook()
ws_guide = wb.active
ws_guide.title = "说明"
ws_guide.append(["立项配置Excel模板说明"])
ws_guide.append(["1. 各业务Sheet首行是表头,不要修改列名。"])
ws_guide.append(["2. 可在现有行基础上新增/删除行。"])
ws_guide.append(["3. 日期格式建议:YYYY-MM-DD。"])
ws_guide.append(["4. 导入后系统会自动校验并回填到立项配置草稿。"])
ws_pm = wb.create_sheet("项目里程碑")
_sheet_headers(ws_pm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
for row in data.projectMilestones:
ws_pm.append([row.id, row.name, row.planDate, row.owner, row.status, row.remark])
ws_plan = wb.create_sheet("项目入组计划")
_sheet_headers(ws_plan, ["计划总入组例数", "计划开始日期", "计划结束日期", "月度目标说明", "分阶段计划"])
p = data.enrollmentPlan
ws_plan.append([p.totalTarget, p.startDate, p.endDate, p.monthlyGoalNote, p.stageBreakdown])
ws_sm = wb.create_sheet("中心里程碑")
_sheet_headers(ws_sm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
for row in data.siteMilestones:
ws_sm.append([row.id, row.milestone, row.planDate, row.owner, row.status, row.remark])
ws_sep = wb.create_sheet("中心入组计划")
_sheet_headers(ws_sep, ["ID", "中心ID", "中心名称", "计划例数", "启动日期", "完成日期", "备注", "分阶段计划"])
for row in data.siteEnrollmentPlans:
ws_sep.append([row.id, row.siteId, row.siteName, row.target, row.startDate, row.endDate, row.note, row.stageBreakdown])
ws_ms = wb.create_sheet("监查计划策略")
_sheet_headers(ws_ms, ["ID", "监查类型", "策略详情", "监查次数", "更新时间", "是否启用"])
for row in data.monitoringStrategies:
ws_ms.append([row.id, row.strategyType, row.detail, row.frequency, row.updatedAt, "" if row.enabled else ""])
ws_cc = wb.create_sheet("中心确认")
_sheet_headers(ws_cc, ["ID", "中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"])
for row in data.centerConfirm:
ws_cc.append([row.id, row.siteId, row.siteName, row.confirmer, row.confirmStatus, row.confirmDate, row.note])
bio = BytesIO()
wb.save(bio)
return bio.getvalue()
def _to_int(value: Any, default: int = 0) -> int:
if value is None or str(value).strip() == "":
return default
try:
return int(float(str(value)))
except (TypeError, ValueError):
return default
def _to_date_str(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
return str(value).strip()
def _to_datetime_str(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value).strip()
def _to_bool(value: Any, default: bool = True) -> bool:
if value is None:
return default
v = str(value).strip().lower()
if v in {"1", "true", "yes", "y", "", "启用"}:
return True
if v in {"0", "false", "no", "n", "", "禁用"}:
return False
return default
def import_setup_config_excel(file_bytes: bytes) -> StudySetupConfigData:
wb = load_workbook(filename=BytesIO(file_bytes), data_only=True)
project_milestones = []
ws = wb["项目里程碑"] if "项目里程碑" in wb.sheetnames else None
if ws:
for row in ws.iter_rows(min_row=2, values_only=True):
rid, name, plan_date, owner, status, remark = row[:6]
if _is_empty(rid, name, plan_date, owner, status, remark):
continue
project_milestones.append(
{
"id": str(rid).strip() if rid else _make_row_id(),
"name": str(name or "").strip(),
"planDate": _to_date_str(plan_date),
"owner": str(owner or "").strip(),
"status": str(status or "未开始").strip() or "未开始",
"remark": str(remark or "").strip(),
}
)
enrollment_plan = {
"totalTarget": 0,
"startDate": "",
"endDate": "",
"monthlyGoalNote": "",
"stageBreakdown": "",
}
ws = wb["项目入组计划"] if "项目入组计划" in wb.sheetnames else None
if ws:
first = next(ws.iter_rows(min_row=2, values_only=True), None)
if first:
enrollment_plan = {
"totalTarget": _to_int(first[0], 0),
"startDate": _to_date_str(first[1]),
"endDate": _to_date_str(first[2]),
"monthlyGoalNote": str(first[3] or "").strip(),
"stageBreakdown": str(first[4] or "").strip(),
}
site_milestones = []
ws = wb["中心里程碑"] if "中心里程碑" in wb.sheetnames else None
if ws:
for row in ws.iter_rows(min_row=2, values_only=True):
raw_status = None
if len(row) >= 8:
rid, site_id, site_name, milestone, plan_date, owner, raw_status, remark = row[:8]
_ = (site_id, site_name)
elif len(row) == 7:
# Backward compatibility: old template has no status column and includes center columns.
rid, site_id, site_name, milestone, plan_date, owner, remark = row[:7]
_ = (site_id, site_name)
else:
rid, milestone, plan_date, owner, raw_status, remark = row[:6]
if _is_empty(rid, milestone, plan_date, owner, raw_status, remark):
continue
status = str(raw_status or "未开始").strip() or "未开始"
site_milestones.append(
{
"id": str(rid).strip() if rid else _make_row_id(),
"milestone": str(milestone or "").strip(),
"planDate": _to_date_str(plan_date),
"owner": str(owner or "").strip(),
"status": status,
"remark": str(remark or "").strip(),
}
)
site_enrollment_plans = []
ws = wb["中心入组计划"] if "中心入组计划" in wb.sheetnames else None
if ws:
for row in ws.iter_rows(min_row=2, values_only=True):
rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown = (list(row[:8]) + [None] * 8)[:8]
if _is_empty(rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown):
continue
site_enrollment_plans.append(
{
"id": str(rid).strip() if rid else _make_row_id(),
"siteId": str(site_id or "").strip(),
"siteName": str(site_name or "").strip(),
"target": _to_int(target, 0),
"startDate": _to_date_str(start_date),
"endDate": _to_date_str(end_date),
"note": str(note or "").strip(),
"stageBreakdown": str(stage_breakdown or "").strip(),
}
)
monitoring_strategies = []
ws = wb["监查计划策略"] if "监查计划策略" in wb.sheetnames else None
if ws:
for row in ws.iter_rows(min_row=2, values_only=True):
rid, strategy_type, detail, frequency, updated_at, enabled = row[:6]
if _is_empty(rid, strategy_type, detail, frequency, updated_at, enabled):
continue
monitoring_strategies.append(
{
"id": str(rid).strip() if rid else _make_row_id(),
"strategyType": str(strategy_type or "").strip(),
"detail": str(detail or "").strip(),
"frequency": str(frequency or "").strip(),
"updatedAt": _to_datetime_str(updated_at) or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"enabled": _to_bool(enabled, True),
}
)
center_confirm = []
ws = wb["中心确认"] if "中心确认" in wb.sheetnames else None
if ws:
for row in ws.iter_rows(min_row=2, values_only=True):
rid, site_id, site_name, confirmer, confirm_status, confirm_date, note = row[:7]
if _is_empty(rid, site_id, site_name, confirmer, confirm_status, confirm_date, note):
continue
center_confirm.append(
{
"id": str(rid).strip() if rid else _make_row_id(),
"siteId": str(site_id or "").strip(),
"siteName": str(site_name or "").strip(),
"confirmer": str(confirmer or "").strip(),
"confirmStatus": str(confirm_status or "待确认").strip() or "待确认",
"confirmDate": _to_date_str(confirm_date),
"note": str(note or "").strip(),
}
)
return StudySetupConfigData.model_validate(
{
"projectMilestones": project_milestones,
"enrollmentPlan": enrollment_plan,
"siteMilestones": site_milestones,
"siteEnrollmentPlans": site_enrollment_plans,
"monitoringStrategies": monitoring_strategies,
"centerConfirm": center_confirm,
}
)
@@ -121,14 +121,15 @@ async def _replace_project_milestones(
)
)
for row in setup_data.projectMilestones:
if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), (row.planDate or "").strip()]):
start_text = (row.startDate or row.planDate or "").strip()
if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), start_text]):
continue
name = (row.name or "").strip()
if not name:
result.warnings.append(f"project_milestone_name_empty:{row.id}")
continue
planned_date = _parse_date(row.planDate)
if row.planDate and not planned_date:
planned_date = _parse_date(start_text)
if start_text and not planned_date:
result.warnings.append(f"project_milestone_plan_date_invalid:{row.id}")
raw_status = (row.status or "").strip()
mapped_status = MILESTONE_STATUS_MAP.get(raw_status, "NOT_STARTED")
+2 -84
View File
@@ -2,10 +2,8 @@ import asyncio
import json
import os
import sys
import tempfile
import urllib.error
import urllib.request
import uuid
import asyncpg
@@ -40,68 +38,6 @@ def request_json(path: str, *, method: str = "GET", token: str | None = None, pa
return exc.code, body_json
def request_bytes(path: str, *, method: str = "GET", token: str | None = None) -> tuple[int, bytes]:
url = f"{BASE}{path}"
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.getcode(), resp.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
def request_multipart(
path: str,
*,
token: str,
file_field: str,
file_name: str,
file_content: bytes,
expected_version: int,
) -> tuple[int, dict]:
boundary = f"----ctms-boundary-{uuid.uuid4().hex}"
chunks: list[bytes] = []
chunks.append(
(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="expected_version"\r\n\r\n'
f"{expected_version}\r\n"
).encode("utf-8")
)
chunks.append(
(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n'
f"Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n"
).encode("utf-8")
)
chunks.append(file_content)
chunks.append(b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
body = b"".join(chunks)
url = f"{BASE}{path}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
req = urllib.request.Request(url, method="POST", headers=headers, data=body)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
text = resp.read().decode() or "{}"
return resp.getcode(), json.loads(text)
except urllib.error.HTTPError as exc:
text = exc.read().decode() or "{}"
try:
body_json = json.loads(text)
except Exception:
body_json = {"raw": text}
return exc.code, body_json
def assert_or_exit(condition: bool, message: str) -> None:
if not condition:
print(f"[FAIL] {message}")
@@ -440,24 +376,6 @@ def main() -> int:
)
print("[9/12] rollback_ok")
status, exported_bytes = request_bytes(f"/api/v1/studies/{study_id}/setup-config/export-excel", token=token)
assert_or_exit(status == 200 and len(exported_bytes) > 0, f"导出Excel失败 status={status} bytes={len(exported_bytes)}")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp.write(exported_bytes)
tmp_path = tmp.name
print(f"[10/12] export_excel_ok file={tmp_path}")
status, imported = request_multipart(
f"/api/v1/studies/{study_id}/setup-config/import-excel",
token=token,
file_field="file",
file_name="setup-config-smoke.xlsx",
file_content=exported_bytes,
expected_version=rolled["version"],
)
assert_or_exit(status == 200 and isinstance(imported.get("version"), int), f"导入失败 status={status} body={imported}")
print(f"[11/12] import_excel_ok version={imported['version']}")
status, _ = request_json(f"/api/v1/studies/{study_id}/lock", token=token, method="PATCH", payload={})
assert_or_exit(status == 200, f"锁定失败 status={status}")
try:
@@ -465,10 +383,10 @@ def main() -> int:
f"/api/v1/studies/{study_id}/setup-config",
token=token,
method="PUT",
payload={"expected_version": imported["version"], "data": imported["data"]},
payload={"expected_version": rolled["version"], "data": rolled["data"]},
)
assert_or_exit(status == 403, f"锁定后写入应403,实际 status={status} body={locked_result}")
print("[12/12] lock_403_check_ok")
print("[10/10] lock_403_check_ok")
finally:
unlock_status, unlock_body = request_json(f"/api/v1/studies/{study_id}/unlock", token=token, method="PATCH", payload={})
assert_or_exit(unlock_status == 200, f"解锁失败 status={unlock_status} body={unlock_body}")