This commit is contained in:
zlwu committed 2026-08-28 17:38:37 +08:00
1 parent 3d98104455
commit 0d52442902
76 files changed
+21994 -12642

No files matched your search

+13
View File
@@ -903,6 +903,19 @@ STATIC_MENU_TREE = [
"component": "/oa-approval/start/index",
"meta": {"title": "发起审批", "keepAlive": True, "menuCode": "oa:start"},
},
{
"id": "OA000000000000000000000000000203",
"name": "OaApprovalHistory",
"path": "history/:appId",
"component": "/oa-approval/history/index",
"meta": {
"title": "历史记录",
"keepAlive": True,
"isHide": True,
"worktabMulti": True,
"menuCode": "oa:start",
},
},
{
"id": "OA000000000000000000000000000104",
"name": "OaApprovalPending",
+41
View File
@@ -937,6 +937,47 @@ async def list_oa_instance_history(
return error_response(400, str(e))
@router.get("/instances/history/export")
async def export_oa_instance_history(
appId: str = Query(..., description="流程应用 ID"),
instanceId: str = Query(""),
instanceIds: str = Query("", description="逗号分隔的实例 ID,用于导出勾选"),
instanceStatus: str = Query(""),
initiatorName: str = Query(""),
applyTimeFrom: str = Query(""),
applyTimeTo: str = Query(""),
finishTimeFrom: str = Query(""),
finishTimeTo: str = Query(""),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.core.menu_guard import user_has_menu_perm
from app.services.oa_engine import OaEngineError, export_instance_history
try:
tenant_no = _get_tenant_no(db, user)
can_view_all = user_has_menu_perm(db, user, OA_START_HISTORY_ALL_PERM)
id_list = [x.strip() for x in (instanceIds or "").split(",") if x.strip()]
data = export_instance_history(
db,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
can_view_all=can_view_all,
app_id=appId,
instance_id=instanceId or None,
instance_ids=id_list or None,
initiator_name=initiatorName or None,
apply_time_from=applyTimeFrom or None,
apply_time_to=applyTimeTo or None,
finish_time_from=finishTimeFrom or None,
finish_time_to=finishTimeTo or None,
instance_status=instanceStatus or None,
)
return success_response(data)
except OaEngineError as e:
return error_response(400, str(e))
@router.get("/instances/{instance_id}")
async def get_oa_instance(
instance_id: str,
+2
View File
@@ -62,6 +62,7 @@ class ProductionTaskStatusUpdate(BaseModel):
class TaskStepAdd(BaseModel):
model_config = ConfigDict(populate_by_name=True)
process_id: Optional[str] = None
process_code: Optional[str] = None
category_id: Optional[str] = None
@@ -74,6 +75,7 @@ class TaskStepAdd(BaseModel):
class TaskStepUpdate(BaseModel):
model_config = ConfigDict(populate_by_name=True)
process_name: Optional[str] = None
description: Optional[str] = None
equipment_ids: Optional[List[str]] = Field(None, alias="equipmentIds")
+494 -4
View File
@@ -1004,10 +1004,12 @@ def _enrich_list_rows(
ver = ver_by_id.get(inst.app_version_id or "")
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
form_data = _loads(inst.form_data_json, {}) or {}
preview = [
{"label": f.get("label") or "", "value": f.get("value") or ""}
for f in flatten_form_fields_for_summary(schema, form_data)[:limit]
]
preview = []
for f in flatten_form_fields_for_summary(schema, form_data)[:limit]:
item = dict(f)
item["label"] = item.get("label") or ""
item["value"] = item.get("value") or ""
preview.append(item)
merged = dict(row)
merged.update(summary)
merged["previewFields"] = preview
@@ -2317,6 +2319,494 @@ def list_instance_history(
}
_HISTORY_EXPORT_MAX_ROWS = 5000
_EXPORT_RESULT_TEXT = {
"completed": "审批通过",
"approved": "审批通过",
"rejected": "审批拒绝",
"returned": "已打回",
"cancelled": "已撤销",
"running": "",
"pending": "",
}
_EXPORT_STATUS_TEXT = {
"completed": "已结束",
"approved": "已结束",
"rejected": "已结束",
"cancelled": "已结束",
"returned": "已打回",
"running": "进行中",
"pending": "待处理",
}
_EXPORT_ACTION_TEXT = {
"submitted": "提交申请",
"approved": "同意",
"rejected": "拒绝",
"returned": "打回",
"completed": "同意",
"pending": "待处理",
"cancelled": "取消",
"transferred": "转交",
"done": "抄送",
}
_FIXED_HISTORY_EXPORT_COLUMNS: List[Tuple[str, str]] = [
("instanceNo", "审批编号"),
("createTime", "创建时间"),
("initiatorName", "创建人"),
("currentAssignee", "当前负责人"),
("approvalResult", "审批结果"),
("approvalStatus", "审批状态"),
("updateTime", "更新时间"),
("finishTime", "完成时间"),
("initiatorDept", "创建人部门"),
("title", "审批单标题"),
("historyApprovers", "历史审批人"),
("duration", "耗时(时:分:秒)"),
("approvalRecord", "审批记录"),
]
def _format_export_duration(start: Optional[datetime], end: Optional[datetime]) -> str:
if not start or not end:
return ""
delta = end - start
if delta.total_seconds() < 0:
return ""
total = int(delta.total_seconds())
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m}:{s}"
def _export_upload_cell_value(field: Dict[str, Any]) -> str:
files = field.get("files") or []
if not isinstance(files, list):
return str(field.get("value") or "")
urls: List[str] = []
for f in files:
if not isinstance(f, dict):
continue
u = str(f.get("url") or "").strip()
if u:
urls.append(u)
if urls:
return ",".join(urls)
return str(field.get("value") or "")
def _export_progress_action(status: str, *, task_type: str = "") -> str:
st = (status or "").strip()
if (task_type or "").strip() == "cc" or st == "done":
return "抄送"
return _EXPORT_ACTION_TEXT.get(st, st or "")
def _format_export_progress_record(progress: List[Dict[str, Any]]) -> str:
lines: List[str] = []
for item in progress or []:
kind = (item.get("kind") or "").strip()
title = (item.get("title") or "").strip()
ts = (item.get("timestamp") or "").strip()
task_type = (item.get("taskType") or "").strip()
if kind == "submit":
name = (item.get("assigneeUserName") or "").strip()
if not name:
continue
lines.append(f"{name}|提交申请|{ts}|提交申请")
continue
people = item.get("people") or []
if isinstance(people, list) and people:
for p in people:
if not isinstance(p, dict):
continue
name = (p.get("nickName") or p.get("assigneeUserName") or "").strip()
if not name:
continue
pst = (p.get("status") or "").strip()
if pst in ("pending", "running", "cancelled") and task_type != "cc":
continue
node = title or (item.get("nodeKey") or "").strip() or "-"
pts = (p.get("timestamp") or ts or "").strip()
action = _export_progress_action(pst, task_type=task_type)
comment = (p.get("commentText") or "").strip()
seg = f"{name}|{node}|{pts}|{action}"
if comment:
seg += f"|{comment}"
lines.append(seg)
continue
name = (item.get("assigneeUserName") or "").strip()
if not name:
continue
st = (item.get("status") or "").strip()
if st in ("pending", "running", "cancelled") and task_type != "cc":
continue
node = title or (item.get("nodeKey") or "").strip() or "-"
action = _export_progress_action(st, task_type=task_type)
comment = (item.get("commentText") or "").strip()
seg = f"{name}|{node}|{ts}|{action}"
if comment:
seg += f"|{comment}"
lines.append(seg)
return ";\r\n".join(lines)
def _collect_history_approver_names(progress: List[Dict[str, Any]]) -> str:
names: List[str] = []
seen = set()
for item in progress or []:
if (item.get("kind") or "").strip() == "submit":
continue
people = item.get("people") or []
if isinstance(people, list) and people:
for p in people:
if not isinstance(p, dict):
continue
st = (p.get("status") or "").strip()
if st not in ("approved", "completed", "rejected", "returned", "transferred"):
continue
name = (p.get("nickName") or p.get("assigneeUserName") or "").strip()
if name and name not in seen:
seen.add(name)
names.append(name)
continue
st = (item.get("status") or "").strip()
if st not in ("approved", "completed", "rejected", "returned", "transferred"):
continue
name = (item.get("assigneeUserName") or "").strip()
if name and name not in seen:
seen.add(name)
names.append(name)
return ",".join(names)
def _build_history_export_business_columns(schema: Any) -> List[Dict[str, Any]]:
from app.services.oa_related_approval import LAYOUT_SKIP, _schema_rules
cols: List[Dict[str, Any]] = []
for rule in _schema_rules(schema):
typ = str(rule.get("type") or "")
if not typ or typ in LAYOUT_SKIP:
continue
field = str(rule.get("field") or "").strip()
if not field:
continue
label = str(rule.get("title") or rule.get("label") or field)
if typ == "relatedApproval":
cols.append(
{
"key": field,
"label": label,
"kind": "related",
"subLabel": "关联标题",
}
)
else:
cols.append({"key": field, "label": label, "kind": "business"})
return cols
def _merge_history_export_business_columns(col_lists: List[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
merged: List[Dict[str, Any]] = []
seen = set()
for cols in col_lists:
for c in cols:
key = str(c.get("key") or "")
if not key or key in seen:
continue
seen.add(key)
merged.append(dict(c))
return merged
def _history_export_query(
db: Session,
*,
tenant_no: str,
user_id: str,
can_view_all: bool,
app_id: str,
instance_id: Optional[str] = None,
instance_ids: Optional[List[str]] = None,
initiator_name: Optional[str] = None,
apply_time_from: Optional[str] = None,
apply_time_to: Optional[str] = None,
finish_time_from: Optional[str] = None,
finish_time_to: Optional[str] = None,
instance_status: Optional[str] = None,
):
q = db.query(OaInstance).filter(
OaInstance.tenant_no == tenant_no,
OaInstance.del_flag == 0,
OaInstance.app_id == app_id,
)
if not can_view_all:
q = q.filter(OaInstance.initiator_user_id == user_id)
initiator_name = None
clean_ids = [str(x).strip() for x in (instance_ids or []) if str(x).strip()]
if clean_ids:
q = q.filter(OaInstance.id.in_(clean_ids))
else:
iid = (instance_id or "").strip()
if iid:
q = q.filter(OaInstance.id.like(f"%{iid}%"))
q, _, _ = _apply_instance_list_filters(
q,
initiator_name=initiator_name,
apply_time_from=apply_time_from,
apply_time_to=apply_time_to,
instance_status=instance_status,
)
ft0 = _parse_day_start(finish_time_from)
ft1 = _parse_day_end(finish_time_to)
if ft0 is not None or ft1 is not None:
q = q.filter(OaInstance.status.in_(tuple(_FINISH_STATUSES)))
if ft0 is not None:
q = q.filter(OaInstance.update_time >= ft0)
if ft1 is not None:
q = q.filter(OaInstance.update_time <= ft1)
return q
def export_instance_history(
db: Session,
*,
tenant_no: str,
user_id: str,
can_view_all: bool,
app_id: str,
instance_id: Optional[str] = None,
instance_ids: Optional[List[str]] = None,
initiator_name: Optional[str] = None,
apply_time_from: Optional[str] = None,
apply_time_to: Optional[str] = None,
finish_time_from: Optional[str] = None,
finish_time_to: Optional[str] = None,
instance_status: Optional[str] = None,
) -> Dict[str, Any]:
from app.services.oa_related_approval import flatten_form_fields_for_summary
app_id = (app_id or "").strip()
if not app_id:
raise OaEngineError("appId 不能为空")
app = oa_crud.get_app(db, app_id, tenant_no)
app_name = (app.app_name or "") if app else ""
q = _history_export_query(
db,
tenant_no=tenant_no,
user_id=user_id,
can_view_all=can_view_all,
app_id=app_id,
instance_id=instance_id,
instance_ids=instance_ids,
initiator_name=initiator_name,
apply_time_from=apply_time_from,
apply_time_to=apply_time_to,
finish_time_from=finish_time_from,
finish_time_to=finish_time_to,
instance_status=instance_status,
)
total = q.count()
if total > _HISTORY_EXPORT_MAX_ROWS:
raise OaEngineError(f"导出条数超过上限 {_HISTORY_EXPORT_MAX_ROWS},请缩小筛选范围")
insts = q.order_by(OaInstance.create_time.desc()).limit(_HISTORY_EXPORT_MAX_ROWS).all()
if not insts:
pub = oa_crud._find_latest_published(db, app_id, tenant_no)
schema = _loads(getattr(pub, "form_schema_json", None), {}) if pub else {}
business_columns = _build_history_export_business_columns(schema)
return {
"appName": app_name,
"businessColumns": business_columns,
"fixedColumns": [{"key": k, "label": lb} for k, lb in _FIXED_HISTORY_EXPORT_COLUMNS],
"rows": [],
"total": 0,
}
ver_by_id: Dict[str, OaAppVersion] = {}
ver_ids = list({i.app_version_id for i in insts if i.app_version_id})
if ver_ids:
for v in (
db.query(OaAppVersion)
.filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
.all()
):
ver_by_id[v.id] = v
pub = oa_crud._find_latest_published(db, app_id, tenant_no)
pub_schema = _loads(getattr(pub, "form_schema_json", None), {}) if pub else {}
col_lists = [_build_history_export_business_columns(pub_schema)]
for inst in insts:
ver = ver_by_id.get(inst.app_version_id or "")
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
col_lists.append(_build_history_export_business_columns(schema))
business_columns = _merge_history_export_business_columns(col_lists)
inst_ids = [i.id for i in insts]
all_tasks = (
db.query(OaTask)
.filter(OaTask.instance_id.in_(inst_ids), OaTask.del_flag == 0)
.all()
)
tasks_by_inst: Dict[str, List[OaTask]] = {}
user_ids: List[str] = []
for t in all_tasks:
tasks_by_inst.setdefault(t.instance_id, []).append(t)
if t.assignee_user_id:
user_ids.append(t.assignee_user_id)
all_events = (
db.query(OaInstanceEvent)
.filter(OaInstanceEvent.instance_id.in_(inst_ids), OaInstanceEvent.del_flag == 0)
.order_by(OaInstanceEvent.create_time.asc())
.all()
)
events_by_inst: Dict[str, List[OaInstanceEvent]] = {}
for e in all_events:
events_by_inst.setdefault(e.instance_id, []).append(e)
if e.actor_user_id:
user_ids.append(e.actor_user_id)
for inst in insts:
if inst.initiator_user_id:
user_ids.append(inst.initiator_user_id)
name_map = _user_name_map(db, user_ids)
related_ids: List[str] = []
flat_by_inst: Dict[str, List[Dict[str, Any]]] = {}
for inst in insts:
ver = ver_by_id.get(inst.app_version_id or "")
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
form_data = _loads(inst.form_data_json, {}) or {}
flat = flatten_form_fields_for_summary(schema, form_data)
flat_by_inst[inst.id] = flat
for f in flat:
for rid in f.get("relatedIds") or []:
rid_s = str(rid or "").strip()
if rid_s:
related_ids.append(rid_s)
related_title_map: Dict[str, str] = {}
if related_ids:
rel_insts = (
db.query(OaInstance)
.filter(
OaInstance.tenant_no == tenant_no,
OaInstance.del_flag == 0,
OaInstance.id.in_(list(set(related_ids))),
)
.all()
)
rel_app_ids = list({r.app_id for r in rel_insts if r.app_id})
rel_app_map = {}
if rel_app_ids:
for a in db.query(OaApp).filter(OaApp.id.in_(rel_app_ids)).all():
rel_app_map[a.id] = a.app_name or ""
for r in rel_insts:
initiator = name_map.get(r.initiator_user_id or "", "")
related_title_map[r.id] = (
f"{initiator}提交的{rel_app_map.get(r.app_id, '')}" if initiator else (r.title or r.id)
)
pending_by_inst: Dict[str, List[OaTask]] = {}
for t in all_tasks:
if (t.task_type or "") == "approval" and (t.status or "") == "pending":
pending_by_inst.setdefault(t.instance_id, []).append(t)
rows_out: List[Dict[str, Any]] = []
for idx, inst in enumerate(insts, start=1):
ver = ver_by_id.get(inst.app_version_id or "")
flow_root = None
if ver:
try:
flow_root = _flow_root(_loads(ver.flow_tree_json, {}))
except OaEngineError:
flow_root = None
tasks = _sort_tasks_for_detail(tasks_by_inst.get(inst.id, []), flow_root=flow_root)
events = events_by_inst.get(inst.id, [])
adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
progress = build_progress_items(
inst=inst,
flow_root=flow_root,
tasks=tasks,
name_map=name_map,
events=events,
db=db,
advanced=adv,
)
summary = _instance_progress_summary(
inst,
app_name=app_name,
initiator_user_name=name_map.get(inst.initiator_user_id or "", ""),
pending_approval_tasks=pending_by_inst.get(inst.id, []),
name_map=name_map,
)
st = (inst.status or "").strip()
finish_time = (
inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
if st in _FINISH_STATUSES and inst.update_time
else ""
)
duration_end = inst.update_time if st in _FINISH_STATUSES else None
business: Dict[str, Any] = {}
flat_map = {str(f.get("fieldKey") or ""): f for f in flat_by_inst.get(inst.id, [])}
for col in business_columns:
key = str(col.get("key") or "")
field = flat_map.get(key) or {}
if col.get("kind") == "related":
ids = field.get("relatedIds") or []
business[key] = f"已关联 {len(ids)} 条" if ids else ""
titles = [related_title_map.get(str(i), str(i)) for i in ids]
business[f"{key}__titles"] = "、".join(t for t in titles if t)
elif field.get("fieldType") in ("upload", "fcUpload", "image", "oaAttachment"):
business[key] = _export_upload_cell_value(field)
else:
business[key] = str(field.get("value") or "")
rows_out.append(
{
"index": idx,
"id": inst.id,
"business": business,
"fixed": {
"instanceNo": inst.id,
"createTime": summary.get("applyTime") or "",
"initiatorName": summary.get("initiatorUserName") or "",
"currentAssignee": summary.get("currentApproverNames") or "-",
"approvalResult": _EXPORT_RESULT_TEXT.get(st, st),
"approvalStatus": _EXPORT_STATUS_TEXT.get(st, st),
"updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
if inst.update_time
else "",
"finishTime": finish_time,
"initiatorDept": _dept_name(
db, inst.initiator_dept_id, tenant_no=tenant_no
),
"title": inst.title or "",
"historyApprovers": _collect_history_approver_names(progress),
"duration": _format_export_duration(inst.create_time, duration_end),
"approvalRecord": _format_export_progress_record(progress),
},
}
)
return {
"appName": app_name,
"businessColumns": business_columns,
"fixedColumns": [{"key": k, "label": lb} for k, lb in _FIXED_HISTORY_EXPORT_COLUMNS],
"rows": rows_out,
"total": len(rows_out),
}
def list_tasks(
db: Session,
*,
+45 -1
View File
@@ -144,6 +144,36 @@ def _format_upload_display(raw: Any) -> str:
return "、".join(names) if names else "0 个"
def _upload_files_meta(raw: Any) -> List[Dict[str, str]]:
"""摘要/列表用:上传字段规范化为 {url, name} 列表,供前端缩略图展示。"""
if raw is None or raw == "" or raw == []:
return []
if isinstance(raw, list):
items: List[Any] = raw
elif isinstance(raw, str):
items = [x.strip() for x in raw.split(",") if x.strip()]
elif isinstance(raw, dict):
items = [raw]
else:
items = [raw]
files: List[Dict[str, str]] = []
for it in items:
if it is None or it == "":
continue
if isinstance(it, dict):
urls = _extract_upload_urls(it)
if not urls:
continue
for u in urls:
files.append({"url": u, "name": _upload_display_name(it, u)})
continue
s = str(it).strip()
if not s:
continue
files.append({"url": s, "name": _upload_display_name(s, s)})
return files
def _normalize_related_ids(raw: Any) -> List[str]:
"""Order-preserving dedupe of related approval instance ids."""
if isinstance(raw, list):
@@ -408,14 +438,28 @@ def flatten_form_fields_for_summary(schema: Any, form_data: Optional[Dict[str, A
if typ == "relatedApproval":
ids = _normalize_related_ids(raw)
item: Dict[str, Any] = {
"fieldKey": field,
"label": label,
"value": f"已关联 {len(ids)} 条",
"relatedIds": ids,
}
out.append(item)
continue
if typ in UPLOAD_TYPES:
text, full = _format_value(typ, raw)
item = {
"fieldKey": field,
"label": label,
"value": text,
"fieldType": typ,
"files": _upload_files_meta(raw),
}
if full:
item["fullWidth"] = True
out.append(item)
continue
text, full = _format_value(typ, raw)
item = {"label": label, "value": text}
item = {"fieldKey": field, "label": label, "value": text}
if full:
item["fullWidth"] = True
out.append(item)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+76 -78
View File
@@ -1,11 +1,11 @@
# Graph Report - app (2026-08-28)
## Corpus Check
- Large corpus: 262 files · ~314,011 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
- Large corpus: 262 files · ~315,778 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
## Summary
- 5427 nodes · 29881 edges · 42 communities detected
- Extraction: 31% EXTRACTED · 69% INFERRED · 0% AMBIGUOUS · INFERRED: 20501 edges (avg confidence: 0.6)
- 5439 nodes · 29946 edges · 42 communities detected
- Extraction: 31% EXTRACTED · 69% INFERRED · 0% AMBIGUOUS · INFERRED: 20530 edges (avg confidence: 0.6)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
@@ -53,8 +53,8 @@
- [[_COMMUNITY_Community 43|Community 43]]
## God Nodes (most connected - your core abstractions)
1. `success_response()` - 760 edges
2. `error_response()` - 630 edges
1. `success_response()` - 761 edges
2. `error_response()` - 631 edges
3. `SysUser` - 605 edges
4. `BizQcTaskResult` - 352 edges
5. `BizProductionMaterial` - 335 edges
@@ -67,8 +67,8 @@
## Surprising Connections (you probably didn't know these)
- `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog` [INFERRED]
core/api_request_log_middleware.py → models/models.py
- `get_menu_list()` --calls--> `success_response()` [INFERRED]
api/menu.py → core/response.py
- `历史库存变动(biz_material_stock_log,只读)。` --uses--> `SysUser` [INFERRED]
api/material.py → models/models.py
- `微信小程序登录业务:openid + 手机号一致性校验。` --uses--> `SysUser` [INFERRED]
services/wechat_login_service.py → models/models.py
- `已绑定 openid 时静默换票(无需手机号授权)。` --uses--> `SysUser` [INFERRED]
@@ -80,132 +80,132 @@
### Community 0 - "Community 0"
Cohesion: 0.01
Nodes (1053): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+1045 more)
Nodes (1026): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+1018 more)
### Community 1 - "Community 1"
Cohesion: 0.01
Nodes (720): generate_uuid(), allocate_next_biz_no(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号, 原子分配下一个业务协议号。 须在 create 事务内调用;使用 MySQL LAST_INSERT_ID 保证并发安全。, create_cell_sample_type(), get_cell_sample_type() (+712 more)
Nodes (763): set_current_process_route(), generate_uuid(), allocate_next_biz_no(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号, 原子分配下一个业务协议号。 须在 create 事务内调用;使用 MySQL LAST_INSERT_ID 保证并发安全。, create_cell_sample_type() (+755 more)
### Community 2 - "Community 2"
Cohesion: 0.01
Nodes (656): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+648 more)
Nodes (598): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+590 more)
### Community 3 - "Community 3"
Cohesion: 0.09
Nodes (370): 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), 小程序菜单:当前用户在「移动端菜单」树下有权限的入口。, 宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。, 按路由匹配移动端宫格图标(与 mobile-h5 van-icon 字形名对齐)。, ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。 (+362 more)
Cohesion: 0.01
Nodes (421): _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), batch_create_storage_slots(), create_access_log(), create_storage_slot(), create_uv_disinfect_log(), delete_access_log() (+413 more)
### Community 4 - "Community 4"
Cohesion: 0.06
Nodes (314): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+306 more)
Nodes (444): 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), 小程序菜单:当前用户在「移动端菜单」树下有权限的入口。, 宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。, 按路由匹配移动端宫格图标(与 mobile-h5 van-icon 字形名对齐)。, AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody (+436 more)
### Community 5 - "Community 5"
Cohesion: 0.02
Nodes (292): _ensure_review_side_write(), eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), lookup_attachments_by_urls() (+284 more)
Cohesion: 0.06
Nodes (313): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+305 more)
### Community 6 - "Community 6"
Cohesion: 0.02
Nodes (266): _normalize_date_bound(), _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception() (+258 more)
Nodes (257): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _copied_app_name(), copy_app(), create_app(), create_group(), _create_version() (+249 more)
### Community 7 - "Community 7"
Cohesion: 0.03
Nodes (248): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+240 more)
Cohesion: 0.02
Nodes (258): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), build_void_type_label_map(), format_protocol_status_label() (+250 more)
### Community 8 - "Community 8"
Cohesion: 0.01
Nodes (169): 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, wechat_login(), get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin (+161 more)
Cohesion: 0.02
Nodes (136): mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id(), wechat_bind() (+128 more)
### Community 9 - "Community 9"
Cohesion: 0.02
Nodes (209): list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id(), _resolve_user_role_scopes(), delete(), get_multi_with_filters(), count_pending_protocol_invoice_requests() (+201 more)
Cohesion: 0.03
Nodes (151): get_next_cell_product_code(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes() (+143 more)
### Community 10 - "Community 10"
Cohesion: 0.03
Nodes (151): build_void_type_label_map(), format_protocol_status_label(), resolve_void_type_label(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain() (+143 more)
Nodes (125): 验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错), verify_password(), get_protocol_nos_used_in_protocols(), _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict() (+117 more)
### Community 11 - "Community 11"
Cohesion: 0.03
Nodes (110): 验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错), verify_password(), _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict() (+102 more)
Cohesion: 0.05
Nodes (85): _body_to_payload(), _get_current_user_id(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception(), _resolve_reception_id(), _lookup_attachment_by_url() (+77 more)
### Community 12 - "Community 12"
Cohesion: 0.05
Nodes (113): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+105 more)
Cohesion: 0.44
Nodes (75): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+67 more)
### Community 13 - "Community 13"
Cohesion: 0.04
Nodes (81): _collect_descendants(), _current_user(), _find_mobile_root(), list_my_mobile_menus(), _mobile_menu_icon(), _mobile_menu_icon_colors(), _user_menu_ids(), _jwt_user_payload() (+73 more)
Cohesion: 0.05
Nodes (66): _collect_descendants(), _current_user(), _find_mobile_root(), list_my_mobile_menus(), _mobile_menu_icon(), _mobile_menu_icon_colors(), _user_menu_ids(), _jwt_user_payload() (+58 more)
### Community 14 - "Community 14"
Cohesion: 0.06
Nodes (62): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+54 more)
Cohesion: 0.08
Nodes (57): api_save_application(), auto_generate_schedules(), complete_application(), create_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products() (+49 more)
### Community 15 - "Community 15"
Cohesion: 0.07
Nodes (38): get_designer_data(), list_results_by_task(), _list_saved_results_by_task(), list_saved_results_by_task_ids(), _merge_saved_into_template(), _resolve_task_item_ref(), _row_to_dict(), save_results_for_task() (+30 more)
### Community 16 - "Community 16"
Cohesion: 0.12
Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块 支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3) Args: out_trade_no: 商户订单号 total, 申请退款 Args: out_trade_no: 商户订单号 total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API) Args: out_trade_no: 商户订单号 tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
### Community 16 - "Community 16"
Cohesion: 0.21
Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
### Community 17 - "Community 17"
Cohesion: 0.2
Nodes (18): _code_exists(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status(), list_materials() (+10 more)
Cohesion: 0.19
Nodes (27): _amount_for_row(), _batch_map(), deduct_for_task(), _deduct_task_usage_legacy_batch(), _display_remark(), _fmt_date_val(), _linked_qc_task_no_map(), list_linked_task_usages() (+19 more)
### Community 18 - "Community 18"
Cohesion: 0.19
Nodes (17): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), _kit_trip_fields(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐) (+9 more)
Cohesion: 0.2
Nodes (19): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+11 more)
### Community 19 - "Community 19"
Cohesion: 0.24
Nodes (9): 微信消息通知服务 支持微信小程序模板消息推送 使用方式: from app.core.wechat_message import Wech, 发送模板消息 Args: openid: 用户openid templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
Cohesion: 0.22
Nodes (15): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐), 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。 (+7 more)
### Community 20 - "Community 20"
Cohesion: 0.23
Nodes (12): Config, ProductBase, ProductCategoryBase, ProductCategoryCreate, ProductCategoryResponse, ProductCreate, ProductResponse, ProductUnitBase (+4 more)
Cohesion: 0.27
Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
### Community 21 - "Community 21"
Cohesion: 0.32
Nodes (13): _assert_item_ids_exist(), create_template(), filter_active_ids(), get_template(), join_id_list(), list_active_templates(), list_templates(), parse_id_csv() (+5 more)
### Community 22 - "Community 22"
Cohesion: 0.27
Nodes (12): _field_label(), _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props() (+4 more)
### Community 22 - "Community 22"
### Community 23 - "Community 23"
Cohesion: 0.24
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
### Community 24 - "Community 24"
Cohesion: 0.24
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
### Community 23 - "Community 23"
### Community 25 - "Community 25"
Cohesion: 0.27
Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
### Community 26 - "Community 26"
Cohesion: 0.31
Nodes (10): create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), OA 收款账户 CRUD(oa_receive_account), row_to_dict(), soft_delete_account() (+2 more)
### Community 24 - "Community 24"
Cohesion: 0.31
Nodes (10): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。, _row_to_dict() (+2 more)
### Community 25 - "Community 25"
Cohesion: 0.33
Nodes (8): _build_tree(), _cascade_soft_delete(), get_menu(), list_menus(), list_menus_tree(), _row_to_dict(), soft_delete_menu(), update_menu()
### Community 26 - "Community 26"
### Community 27 - "Community 27"
Cohesion: 0.29
Nodes (2): BaseModel, SoftDeleteModel
### Community 27 - "Community 27"
### Community 28 - "Community 28"
Cohesion: 0.38
Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
### Community 28 - "Community 28"
### Community 29 - "Community 29"
Cohesion: 0.4
Nodes (5): batch_matches_stock_status(), normalize_batch_stock_status(), 质检物料入库批次:按剩余数量过滤的语义辅助。, 纯函数判定(供单测);list_batches 用 SQL 等价条件。, 返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。
### Community 29 - "Community 29"
### Community 30 - "Community 30"
Cohesion: 0.5
Nodes (3): BaseSettings, Config, Settings
### Community 30 - "Community 30"
### Community 31 - "Community 31"
Cohesion: 0.5
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
### Community 31 - "Community 31"
Cohesion: 0.67
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
### Community 32 - "Community 32"
Cohesion: 0.67
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
@@ -249,11 +249,9 @@ Nodes (1): 发送催单通知给配送员 Args: del
## Knowledge Gaps
- **178 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+173 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `Community 26`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
- **Thin community `Community 27`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 30`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 31`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
- **Thin community `Community 31`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 32`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -279,17 +277,17 @@ Nodes (1): 发送催单通知给配送员 Args: del
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 9`, `Community 11`, `Community 12`, `Community 13`, `Community 22`?**
_High betweenness centrality (0.121) - this node is a cross-community bridge._
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 6`, `Community 8`, `Community 9`, `Community 12`, `Community 13`, `Community 31`?**
_High betweenness centrality (0.046) - this node is a cross-community bridge._
- **Why does `OaEngineError` connect `Community 7` to `Community 1`, `Community 2`?**
_High betweenness centrality (0.039) - this node is a cross-community bridge._
- **Are the 915 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
_`str` has 915 INFERRED edges - model-reasoned connections that need verification._
- **Are the 759 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
_`success_response()` has 759 INFERRED edges - model-reasoned connections that need verification._
- **Are the 629 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
_`error_response()` has 629 INFERRED edges - model-reasoned connections that need verification._
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 10`, `Community 12`, `Community 13`, `Community 15`, `Community 23`, `Community 24`?**
_High betweenness centrality (0.105) - this node is a cross-community bridge._
- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 8`, `Community 9`, `Community 11`, `Community 13`, `Community 14`, `Community 15`?**
_High betweenness centrality (0.045) - this node is a cross-community bridge._
- **Why does `OaEngineError` connect `Community 6` to `Community 1`, `Community 2`?**
_High betweenness centrality (0.036) - this node is a cross-community bridge._
- **Are the 922 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
_`str` has 922 INFERRED edges - model-reasoned connections that need verification._
- **Are the 760 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
_`success_response()` has 760 INFERRED edges - model-reasoned connections that need verification._
- **Are the 630 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
_`error_response()` has 630 INFERRED edges - model-reasoned connections that need verification._
- **Are the 603 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
_`SysUser` has 603 INFERRED edges - model-reasoned connections that need verification._
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -208,9 +208,9 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pre_transit_sample.py": 1786938943.6929982,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc_sms_notification.py": 1780326875.7886882,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation_sample_collection.py": 1780457788.2998126,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1787885396.9678786,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1787892843.1222966,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1785213144.2881503,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1787112431.3662364,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1787887339.1254666,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1786010386.7044516,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1786945487.513935,
@@ -227,7 +227,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_collection.py": 1780457788.2093296,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1779266168.8693042,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/clinical_payment.py": 1781750841.074568,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1787885250.853435,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1787892839.3046124,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_overtime_hours.py": 1786373766.5113294,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1778727966.7277644,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/qc_work_order_fee_summary.py": 1783329884.6125586,
@@ -245,7 +245,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_trip_days.py": 1785407394.0606225,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_payment_installment.py": 1781432208.0765035,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_sample_list_summary.py": 1785207474.9871266,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_related_approval.py": 1786413868.1112978,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_related_approval.py": 1787892816.3154016,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_message.py": 1786689383.1677845,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_leave_hours.py": 1785397787.0493405,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_resignation_kit_sync.py": 1785405020.285242,
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""为 SOP 模板正文表非主操作区行添加 sop-row-hidden,并重新生成 seed SQL。"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TEMPLATES_DIR = ROOT / "docs" / "sop-templates"
SQL_DIR = ROOT / "docs" / "sql"
SOP_CODES = [
"SOP-CC-001-E01-01",
"SOP-CC-002-E01-01",
"SOP-CC-003-E01-01",
"SOP-CC-006-E01-01",
"SOP-SC-003-E01-01",
"SOP-SC-004-E01-01",
"SOP-SC-005-E01-01",
"SOP-SC-006-E01-01",
"SOP-SC-007-E01-01",
"SOP-SC-008-E01-01",
"SOP-SC-009-E01-01",
"SOP-SC-010-E01-01",
"SOP-SC-011-E01-01",
"SOP-SC-012-E01-01",
"SOP-SC-013-E01-01",
"SOP-SC-014-E01-01",
]
SC014_NK_ROW_OLD = (
'<tr>\n'
' <td style="border:1px solid #334155; padding:8px; text-align:center; vertical-align:middle;">'
"NK细胞<br />培养</td>\n"
' <td colspan="11" style="border:1px solid #334155; padding:10px; vertical-align:top; word-wrap:break-word; overflow-wrap:break-word;">'
)
SC014_NK_ROW_NEW = (
'<tr class="sop-fill-row">\n'
' <td class="sop-fill-label" style="border:1px solid #334155; padding:8px; text-align:center; vertical-align:middle; height:100%;">'
"NK细胞<br />培养</td>\n"
' <td class="sop-fill-cell" colspan="11" style="border:1px solid #334155; padding:10px; vertical-align:top; height:100%; word-wrap:break-word; overflow-wrap:break-word;">'
)
def _is_tr_open(html: str, pos: int) -> bool:
if not html.startswith("<tr", pos):
return False
nxt = html[pos + 3 : pos + 4]
return nxt in ("", " ", ">", "/", "\n", "\t", "\r")
def _split_top_level_trs(html: str) -> list[str]:
rows: list[str] = []
i = 0
n = len(html)
while i < n:
start = html.find("<tr", i)
if start < 0 or not _is_tr_open(html, start):
break
depth = 0
pos = start
while pos < n:
if _is_tr_open(html, pos):
depth += 1
pos += 3
continue
if html.startswith("</tr>", pos):
depth -= 1
pos += 5
if depth == 0:
rows.append(html[start:pos])
i = pos
break
continue
pos += 1
else:
raise ValueError("unclosed <tr> in sop-body-table tbody")
return rows
def _add_hidden_class(tr_html: str) -> str:
open_tag = tr_html.split(">", 1)[0] + ">"
if "sop-fill-row" in open_tag:
return tr_html
def _patch_tr_tag(match: re.Match[str]) -> str:
tag = match.group(0)
if 'class="' in tag:
return re.sub(
r'class="([^"]*)"',
lambda m: f'class="{m.group(1)} sop-row-hidden"',
tag,
count=1,
)
return tag.replace("<tr", '<tr class="sop-row-hidden"', 1)
return re.sub(r"<tr[^>]*>", _patch_tr_tag, tr_html, count=1)
def _find_body_tbody_span(html: str) -> tuple[int, int] | None:
body_start = html.find('<table class="sop-body-table"')
if body_start < 0:
return None
tbody_open = html.find("<tbody>", body_start)
if tbody_open < 0:
return None
inner_start = tbody_open + len("<tbody>")
table_depth = 1
tbody_depth = 1
pos = inner_start
n = len(html)
while pos < n:
if html.startswith("<table", pos):
table_depth += 1
pos += 6
continue
if html.startswith("</table>", pos):
table_depth -= 1
pos += 8
continue
if html.startswith("<tbody>", pos):
tbody_depth += 1
pos += 7
continue
if html.startswith("</tbody>", pos):
tbody_depth -= 1
pos += 8
if tbody_depth == 0:
return inner_start, pos - 8
continue
pos += 1
raise ValueError("无法定位 sop-body-table 的 tbody 结束位置")
def process_body_table(html: str) -> str:
span = _find_body_tbody_span(html)
if span is None:
return html
inner_start, inner_end = span
tbody_inner = html[inner_start:inner_end]
rows = _split_top_level_trs(tbody_inner)
processed = [_add_hidden_class(row) for row in rows]
return html[:inner_start] + "".join(processed) + html[inner_end:]
def process_template(html: str, code: str) -> str:
html = html.replace(
'<table class="sop-header-table"',
'<table class="sop-header-table sop-row-hidden"',
1,
)
if code == "SOP-SC-014-E01-01":
html = html.replace(SC014_NK_ROW_OLD, SC014_NK_ROW_NEW)
html = html.replace("sop-a4-pages-2", "sop-a4-pages-1")
html = re.sub(
r"min-height:\s*594mm;\s*height:\s*auto;",
"height: 297mm; min-height: 297mm;",
html,
)
return process_body_table(html)
def sql_escape(html: str) -> str:
return html.replace("\\", "\\\\").replace("'", "''")
def write_seed_sql(code: str, html: str) -> None:
short = code.replace("SOP-", "").replace("-E01-01", "").lower()
sql_path = SQL_DIR / f"2026-08-25-seed-sop-{short}-content.sql"
comment = html.split("\n", 1)[0].strip().removeprefix("<!-- ").removesuffix(" -->")
escaped = sql_escape(html)
sql_path.write_text(
"\n".join(
[
f"-- 种子数据: {code} {comment.split('(')[0].strip()}",
f"-- 源文件: docs/sop-templates/{code}.html",
"",
"UPDATE `biz_process`",
"SET `sop_content` = '"
f"{escaped}"
"',",
" `update_time` = NOW()",
f"WHERE `process_code` = '{code}'",
" AND `del_flag` = 0;",
"",
]
),
encoding="utf-8",
)
def write_deploy_bundle() -> Path:
"""合并 seed + sync SQL,供测试/生产一次性执行。"""
deploy_path = SQL_DIR / "2026-08-28-sop-visible-section-only-deploy.sql"
parts = [
"-- =============================================================================",
"-- SOP 精简展示:仅保留主操作记录区(页眉与其余正文行均已隐藏)",
"-- 生成时间: 由 backend/scripts/apply_sop_row_hidden.py 自动生成",
"-- 执行顺序: 先 seed 工艺库 → 再 sync 路线/任务步骤",
"--",
"-- 【生产环境】推荐方式(二选一):",
"-- A) mysql -h <host> -u <user> -p <dbname> < docs/sql/2026-08-28-sop-visible-section-only-deploy.sql",
"-- B) ENV_FILE=.env.production python3 backend/scripts/run_sop_visible_deploy.py",
"-- =============================================================================",
"",
]
for code in SOP_CODES:
short = code.replace("SOP-", "").replace("-E01-01", "").lower()
seed = SQL_DIR / f"2026-08-25-seed-sop-{short}-content.sql"
sync = SQL_DIR / f"2026-08-25-sync-sop-{short}-content-to-routes-tasks.sql"
parts.append(f"-- ---------- {code} seed ----------")
parts.append(seed.read_text(encoding="utf-8").strip())
parts.append("")
if sync.exists():
parts.append(f"-- ---------- {code} sync ----------")
parts.append(sync.read_text(encoding="utf-8").strip())
parts.append("")
deploy_path.write_text("\n".join(parts) + "\n", encoding="utf-8")
return deploy_path
def main() -> None:
for code in SOP_CODES:
path = TEMPLATES_DIR / f"{code}.html"
html = path.read_text(encoding="utf-8")
updated = process_template(html, code)
path.write_text(updated, encoding="utf-8")
write_seed_sql(code, updated)
print(f"{code}: done")
deploy = write_deploy_bundle()
print(f"deploy bundle: {deploy}")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""在测试/本地库执行 SOP 精简展示部署 SQL。"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from sqlalchemy import create_engine, text # noqa: E402
try:
from app.core.config import settings # noqa: E402
except Exception as exc: # pragma: no cover
raise SystemExit(f"无法加载 backend 配置: {exc}") from exc
SQL_DIR = ROOT / "docs" / "sql"
SOP_CODES = [
"SOP-CC-001-E01-01",
"SOP-CC-002-E01-01",
"SOP-CC-003-E01-01",
"SOP-CC-006-E01-01",
"SOP-SC-003-E01-01",
"SOP-SC-004-E01-01",
"SOP-SC-005-E01-01",
"SOP-SC-006-E01-01",
"SOP-SC-007-E01-01",
"SOP-SC-008-E01-01",
"SOP-SC-009-E01-01",
"SOP-SC-010-E01-01",
"SOP-SC-011-E01-01",
"SOP-SC-012-E01-01",
"SOP-SC-013-E01-01",
"SOP-SC-014-E01-01",
]
UPDATE_STMT_RE = re.compile(r"\bUPDATE\b.+?;", re.IGNORECASE | re.DOTALL)
def _strip_sql_comments(sql: str) -> str:
lines = []
for line in sql.splitlines():
if line.strip().startswith("--"):
continue
lines.append(line)
return "\n".join(lines)
def _run_seed_file(conn, path: Path) -> int:
sql = path.read_text(encoding="utf-8").strip()
if not sql:
return 0
result = conn.execute(text(sql))
return result.rowcount or 0
def _run_sync_file(conn, path: Path) -> int:
raw = _strip_sql_comments(path.read_text(encoding="utf-8"))
total = 0
for stmt in UPDATE_STMT_RE.findall(raw):
result = conn.execute(text(stmt.strip()))
total += result.rowcount or 0
return total
def main() -> None:
engine = create_engine(settings.DATABASE_URL)
total = 0
with engine.begin() as conn:
for code in SOP_CODES:
short = code.replace("SOP-", "").replace("-E01-01", "").lower()
seed = SQL_DIR / f"2026-08-25-seed-sop-{short}-content.sql"
sync = SQL_DIR / f"2026-08-25-sync-sop-{short}-content-to-routes-tasks.sql"
n1 = _run_seed_file(conn, seed)
n2 = _run_sync_file(conn, sync) if sync.exists() else 0
total += n1 + n2
print(f"{code}: process={n1}, sync={n2}")
print(f"done, affected rows (approx): {total}")
if __name__ == "__main__":
main()
+55 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
from app.models.models import OaApp, OaAppVersion, OaInstance, OaTask, SysUser
from app.models.models import OaApp, OaAppVersion, OaInstance, OaInstanceEvent, OaTask, SysUser
from app.services import oa_engine
@@ -23,6 +23,7 @@ class TestOaInstanceHistory(unittest.TestCase):
SysUser.__table__,
OaInstance.__table__,
OaTask.__table__,
OaInstanceEvent.__table__,
],
)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
@@ -50,7 +51,7 @@ class TestOaInstanceHistory(unittest.TestCase):
version_no=1,
status="published",
is_latest_published="1",
form_schema_json='{"rule":[{"type":"input","field":"leaveType","title":"请假类型"},{"type":"input","field":"days","title":"天数"}]}',
form_schema_json='{"rule":[{"type":"input","field":"leaveType","title":"请假类型"},{"type":"input","field":"days","title":"天数"},{"type":"upload","field":"voucher","title":"付款凭证"}]}',
flow_tree_json="{}",
advanced_settings_json="{}",
tenant_no="zkxw",
@@ -87,7 +88,7 @@ class TestOaInstanceHistory(unittest.TestCase):
app_version_id="v-leave",
title="请假-张三",
initiator_user_id="u-a",
form_data_json='{"leaveType":"年假","days":"2"}',
form_data_json='{"leaveType":"年假","days":"2","voucher":"f33a569a9dd64f2c853899498cd47498.png"}',
status="completed",
tenant_no="zkxw",
create_dept_id="",
@@ -203,6 +204,57 @@ class TestOaInstanceHistory(unittest.TestCase):
if pf:
self.assertGreaterEqual(len(pf), 1)
def test_preview_fields_keep_upload_files(self):
data = oa_engine.list_instance_history(
self.db,
tenant_no="zkxw",
user_id="u-a",
can_view_all=False,
app_id="app-leave",
)
pf = data["list"][0].get("previewFields") or []
voucher = next((f for f in pf if f.get("label") == "付款凭证"), None)
self.assertIsNotNone(voucher)
self.assertEqual(voucher.get("fieldType"), "upload")
self.assertEqual(
voucher.get("files"),
[
{
"url": "f33a569a9dd64f2c853899498cd47498.png",
"name": "f33a569a9dd64f2c853899498cd47498.png",
}
],
)
def test_export_history_rows(self):
data = oa_engine.export_instance_history(
self.db,
tenant_no="zkxw",
user_id="u-a",
can_view_all=False,
app_id="app-leave",
)
self.assertEqual(data["total"], 1)
self.assertGreaterEqual(len(data["businessColumns"]), 1)
self.assertEqual(len(data["fixedColumns"]), 13)
row = data["rows"][0]
self.assertEqual(row["id"], "inst-alice-done")
self.assertEqual(row["business"].get("leaveType"), "年假")
self.assertEqual(row["fixed"].get("initiatorName"), "张三")
self.assertIn("approvalRecord", row["fixed"])
def test_export_selected_ids_only(self):
data = oa_engine.export_instance_history(
self.db,
tenant_no="zkxw",
user_id="u-a",
can_view_all=True,
app_id="app-leave",
instance_ids=["inst-bob-run"],
)
self.assertEqual(data["total"], 1)
self.assertEqual(data["rows"][0]["id"], "inst-bob-run")
if __name__ == "__main__":
unittest.main()
+17 -2
View File
@@ -27,11 +27,19 @@ class TestFlatten(unittest.TestCase):
fields = flatten_form_fields_for_summary(schema, data)
labels = [f["label"] for f in fields]
self.assertNotIn("说明", labels)
self.assertEqual(fields[0], {"label": "事由", "value": "出差"})
self.assertEqual(fields[0], {"fieldKey": "reason", "label": "事由", "value": "出差"})
self.assertEqual(
fields[1]["value"],
"a.png、b.png",
)
self.assertEqual(fields[1].get("fieldType"), "upload")
self.assertEqual(
fields[1].get("files"),
[
{"url": "https://cdn.example.com/a.png", "name": "a.png"},
{"url": "https://cdn.example.com/b.png", "name": "b.png"},
],
)
self.assertTrue(fields[1].get("fullWidth"))
self.assertEqual(fields[2]["value"], "已关联 2 条")
self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"])
@@ -117,6 +125,13 @@ class TestFlatten(unittest.TestCase):
fields[0]["value"],
"a.pdf、b.pdf",
)
self.assertEqual(
fields[0].get("files"),
[
{"url": "https://cdn.example.com/a.pdf", "name": "a.pdf"},
{"url": "/mini/b.pdf", "name": "b.pdf"},
],
)
def test_extract_links_with_field_key_and_dedupe(self):
schema = {
@@ -207,7 +222,7 @@ class TestFlatten(unittest.TestCase):
fields = flatten_form_fields_for_summary(schema, {"kit1": {"x": 1}, "reason": "学习"})
labels = [f["label"] for f in fields]
self.assertNotIn("出差套件", labels)
self.assertEqual(fields[0], {"label": "出差事由", "value": "学习"})
self.assertEqual(fields[0], {"fieldKey": "reason", "label": "出差事由", "value": "学习"})
class TestValidateLinkable(unittest.TestCase):