zlwu 2 месяцев назад
Родитель
Сommit
a9ba60cf6b

+ 1 - 0
backend/app/api/admin.py

@@ -339,6 +339,7 @@ async def list_users(
     result_list = [
         user_svc._row_to_dict(r, dept_name=dept_map.get(r.dept_id or "", "")) for r in items
     ]
+    user_svc.enrich_user_dicts_with_relations(db, result_list)
     tp = user_svc.total_pages(total, pageSize)
     return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
 

+ 61 - 17
backend/app/crud/collection_protocol.py

@@ -296,12 +296,66 @@ def _lookup_attachment_by_url(
     return row
 
 
+def lookup_attachments_by_urls(
+    db: Session,
+    urls: List[str],
+    tenant_no: Optional[str],
+) -> Dict[str, BizAttachment]:
+    """批量按 file_url 精确匹配附件,避免列表场景逐 URL 查库。
+
+    返回 normalize(url) -> BizAttachment。未命中的 URL 不回退 LIKE,
+    由调用方按无元数据展示;详情单条查询仍可用 `_lookup_attachment_by_url`。
+    """
+    keys: List[str] = []
+    seen = set()
+    for url in urls:
+        key = _normalize_attachment_url_key(url)
+        if not key or key in seen:
+            continue
+        seen.add(key)
+        keys.append(key)
+    if not keys:
+        return {}
+    q = db.query(BizAttachment).filter(
+        BizAttachment.del_flag == 0,
+        BizAttachment.file_url.in_(keys),
+    )
+    if tenant_no:
+        q = q.filter(BizAttachment.tenant_no == tenant_no)
+    out: Dict[str, BizAttachment] = {}
+    for row in q.all():
+        k = _normalize_attachment_url_key(row.file_url or "")
+        if k and k not in out:
+            out[k] = row
+    return out
+
+
+def _attachment_item_from_row(url: str, att: Optional[BizAttachment]) -> Dict[str, Any]:
+    if att:
+        size = att.file_size or 0
+        return {
+            "id": att.id,
+            "fileUrl": url,
+            "fileName": att.original_name or url.rsplit("/", 1)[-1],
+            "fileSize": size,
+            "fileSizeDisplay": _format_attachment_file_size(size),
+            "fileType": att.file_type or "",
+        }
+    name = url.rsplit("/", 1)[-1] if "/" in url else url
+    return {"fileUrl": url, "fileName": name, "fileSize": 0}
+
+
 def urls_to_attachment_list(
     urls_str: Optional[str],
     db: Optional[Session] = None,
     tenant_no: Optional[str] = None,
+    *,
+    attachment_by_url: Optional[Dict[str, BizAttachment]] = None,
 ) -> List[Dict[str, Any]]:
-    """逗号分隔 URL → 前端附件卡片结构;有 db 时从 biz_attachment 取原始文件名。"""
+    """逗号分隔 URL → 前端附件卡片结构;有 db 时从 biz_attachment 取原始文件名。
+
+    传入 `attachment_by_url`(normalize key → row)时只做内存匹配,不再逐条查库。
+    """
     if not urls_str:
         return []
     result: List[Dict[str, Any]] = []
@@ -309,22 +363,12 @@ def urls_to_attachment_list(
         u = url.strip()
         if not u:
             continue
-        att = _lookup_attachment_by_url(db, u, tenant_no) if db else None
-        if att:
-            size = att.file_size or 0
-            result.append(
-                {
-                    "id": att.id,
-                    "fileUrl": u,
-                    "fileName": att.original_name or u.rsplit("/", 1)[-1],
-                    "fileSize": size,
-                    "fileSizeDisplay": _format_attachment_file_size(size),
-                    "fileType": att.file_type or "",
-                }
-            )
-        else:
-            name = u.rsplit("/", 1)[-1] if "/" in u else u
-            result.append({"fileUrl": u, "fileName": name, "fileSize": 0})
+        att: Optional[BizAttachment] = None
+        if attachment_by_url is not None:
+            att = attachment_by_url.get(_normalize_attachment_url_key(u))
+        elif db:
+            att = _lookup_attachment_by_url(db, u, tenant_no)
+        result.append(_attachment_item_from_row(u, att))
     return result
 
 

+ 100 - 4
backend/app/crud/qc_inspection_batch.py

@@ -189,6 +189,80 @@ def _assignee_names(db: Session, user_ids: List[str]) -> str:
     return "、".join(name_map.get(uid, uid) for uid in user_ids)
 
 
+def _prefetch_batch_side_data(
+    db: Session,
+    batches: List[BizQcWorkOrderInspectionBatch],
+    tenant_no: str,
+) -> Dict[str, Any]:
+    """一次预取多批次的负责人 / 实验室项 / 第三方项 / 用户名。"""
+    batch_ids = [b.id for b in batches if b and b.id]
+    assignees_by_batch: Dict[str, List[str]] = {bid: [] for bid in batch_ids}
+    lab_by_batch: Dict[str, List[str]] = {bid: [] for bid in batch_ids}
+    third_by_batch: Dict[str, List[str]] = {bid: [] for bid in batch_ids}
+    name_map: Dict[str, str] = {}
+    if not batch_ids:
+        return {
+            "assignees_by_batch": assignees_by_batch,
+            "lab_by_batch": lab_by_batch,
+            "third_by_batch": third_by_batch,
+            "name_map": name_map,
+        }
+
+    for batch_id, user_id in (
+        db.query(BizQcBatchAssignee.batch_id, BizQcBatchAssignee.user_id)
+        .filter(
+            BizQcBatchAssignee.batch_id.in_(batch_ids),
+            BizQcBatchAssignee.tenant_no == tenant_no,
+            BizQcBatchAssignee.del_flag == 0,
+        )
+        .order_by(BizQcBatchAssignee.order_num.asc())
+        .all()
+    ):
+        assignees_by_batch.setdefault(batch_id, []).append(user_id)
+
+    for batch_id, lab_id in (
+        db.query(BizQcWorkOrderLabItem.batch_id, BizQcWorkOrderLabItem.lab_test_item_id)
+        .filter(
+            BizQcWorkOrderLabItem.batch_id.in_(batch_ids),
+            BizQcWorkOrderLabItem.del_flag == 0,
+        )
+        .order_by(BizQcWorkOrderLabItem.order_num.asc())
+        .all()
+    ):
+        if lab_id:
+            lab_by_batch.setdefault(batch_id, []).append(lab_id)
+
+    for batch_id, third_id in (
+        db.query(BizQcWorkOrderThirdItem.batch_id, BizQcWorkOrderThirdItem.third_party_item_id)
+        .filter(
+            BizQcWorkOrderThirdItem.batch_id.in_(batch_ids),
+            BizQcWorkOrderThirdItem.del_flag == 0,
+        )
+        .order_by(BizQcWorkOrderThirdItem.order_num.asc())
+        .all()
+    ):
+        if third_id:
+            third_by_batch.setdefault(batch_id, []).append(third_id)
+
+    all_user_ids = _unique_ids(
+        [uid for ids in assignees_by_batch.values() for uid in ids]
+    )
+    if all_user_ids:
+        for row in (
+            db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
+            .filter(SysUser.id.in_(all_user_ids), SysUser.del_flag == 0)
+            .all()
+        ):
+            name_map[row.id] = row.nick_name or row.user_no or row.id
+
+    return {
+        "assignees_by_batch": assignees_by_batch,
+        "lab_by_batch": lab_by_batch,
+        "third_by_batch": third_by_batch,
+        "name_map": name_map,
+    }
+
+
 def _apply_assignees_to_batch_tasks(
     db: Session,
     batch_id: str,
@@ -590,9 +664,18 @@ def _batch_to_dict(
     *,
     tenant_no: str,
     tasks: Optional[List[Dict[str, Any]]] = None,
+    side_data: Optional[Dict[str, Any]] = None,
 ) -> Dict[str, Any]:
-    assignee_ids = _batch_assignee_user_ids(db, batch.id, tenant_no)
-    lab_ids, third_ids = _batch_item_ids(db, batch.id)
+    if side_data is not None:
+        assignee_ids = list(side_data["assignees_by_batch"].get(batch.id, []))
+        lab_ids = list(side_data["lab_by_batch"].get(batch.id, []))
+        third_ids = list(side_data["third_by_batch"].get(batch.id, []))
+        name_map = side_data.get("name_map") or {}
+        assignee_names = "、".join(name_map.get(uid, uid) for uid in assignee_ids)
+    else:
+        assignee_ids = _batch_assignee_user_ids(db, batch.id, tenant_no)
+        lab_ids, third_ids = _batch_item_ids(db, batch.id)
+        assignee_names = _assignee_names(db, assignee_ids)
     task_list = tasks or []
     completed = sum(1 for t in task_list if (t.get("taskStatus") or "") == "ywc")
     return {
@@ -602,7 +685,7 @@ def _batch_to_dict(
         "batchName": batch.batch_name,
         "orderNum": batch.order_num or 0,
         "assigneeUserIds": assignee_ids,
-        "assigneeUserNames": _assignee_names(db, assignee_ids),
+        "assigneeUserNames": assignee_names,
         "hasLab": bool(lab_ids),
         "hasThirdParty": bool(third_ids),
         "labItemIds": lab_ids,
@@ -619,6 +702,8 @@ def _build_inspection_batch_list(
     batches: List[BizQcWorkOrderInspectionBatch],
     all_tasks: List[Dict[str, Any]],
     tenant_no: str,
+    *,
+    side_data: Optional[Dict[str, Any]] = None,
 ) -> List[Dict[str, Any]]:
     tasks_by_batch: Dict[str, List[Dict[str, Any]]] = {}
     orphan: List[Dict[str, Any]] = []
@@ -629,8 +714,17 @@ def _build_inspection_batch_list(
             tasks_by_batch.setdefault(bid, []).append(t)
         else:
             orphan.append(t)
+    resolved_side = side_data
+    if resolved_side is None and batches:
+        resolved_side = _prefetch_batch_side_data(db, batches, tenant_no)
     out = [
-        _batch_to_dict(db, b, tenant_no=tenant_no, tasks=tasks_by_batch.get(b.id, []))
+        _batch_to_dict(
+            db,
+            b,
+            tenant_no=tenant_no,
+            tasks=tasks_by_batch.get(b.id, []),
+            side_data=resolved_side,
+        )
         for b in batches
     ]
     if orphan and out:
@@ -700,6 +794,7 @@ def list_inspection_batches_by_work_order_ids(
         .all()
     )
     tasks_by_wo = task_svc.list_tasks_by_work_order_ids(db, ids, tenant_no)
+    side_data = _prefetch_batch_side_data(db, batches, tenant_no)
     batches_by_wo: Dict[str, List[BizQcWorkOrderInspectionBatch]] = {}
     for batch in batches:
         batches_by_wo.setdefault(batch.work_order_id, []).append(batch)
@@ -711,6 +806,7 @@ def list_inspection_batches_by_work_order_ids(
             batches_by_wo.get(wo_id, []),
             tasks_by_wo.get(wo_id, []),
             tenant_no,
+            side_data=side_data,
         )
     return out
 

+ 58 - 6
backend/app/crud/qc_task.py

@@ -9,7 +9,7 @@ from sqlalchemy import and_, func, or_
 from sqlalchemy.orm import Session
 
 from app.core.utils import generate_uuid
-from app.crud.collection_protocol import urls_to_attachment_list
+from app.crud.collection_protocol import lookup_attachments_by_urls, urls_to_attachment_list
 from app.crud import sample_reception as sample_reception_svc
 from app.crud.qc_work_order import (
     _load_sample_type_name_map,
@@ -25,6 +25,7 @@ from app.core.db_collation import eq_unicode, unicode_ci
 from app.utils.sample_special import apply_special_to_item_from_samples
 from app.crud import qc_task_assignee as assignee_svc
 from app.models.models import (
+    BizAttachment,
     BizCollectionProtocol,
     BizProtocolSample,
     BizQcTask,
@@ -65,13 +66,30 @@ def _conclusion_label(v: Optional[str]) -> str:
     return _CONCLUSION_LABELS.get(key, key)
 
 
+def _split_attachment_urls(urls_str: Optional[str]) -> List[str]:
+    if not urls_str:
+        return []
+    return [u.strip() for u in urls_str.split(",") if u and u.strip()]
+
+
 def _task_to_dict(
     t: BizQcTask,
     work_order_no: str = "",
     db: Optional[Session] = None,
     tenant_no: Optional[str] = None,
+    *,
+    attachment_by_url: Optional[Dict[str, BizAttachment]] = None,
 ) -> Dict[str, Any]:
     tn = tenant_no or t.tenant_no
+    if db:
+        attachment_files = urls_to_attachment_list(
+            t.third_party_attachments,
+            db,
+            tn,
+            attachment_by_url=attachment_by_url,
+        )
+    else:
+        attachment_files = []
     return {
         "id": t.id,
         "workOrderId": t.work_order_id,
@@ -90,7 +108,7 @@ def _task_to_dict(
         "conclusion": t.conclusion or "",
         "conclusionLabel": _conclusion_label(t.conclusion),
         "thirdPartyAttachments": t.third_party_attachments or "",
-        "attachmentFiles": urls_to_attachment_list(t.third_party_attachments, db, tn) if db else [],
+        "attachmentFiles": attachment_files,
         "completedTime": _fmt_dt(t.completed_time),
         "completedBy": t.completed_by or "",
         "remark": t.remark or "",
@@ -372,12 +390,46 @@ def _task_row_to_dict(
     wo: BizQcWorkOrder,
     db: Session,
     tenant_no: Optional[str],
+    *,
+    attachment_by_url: Optional[Dict[str, BizAttachment]] = None,
 ) -> Dict[str, Any]:
-    out = _task_to_dict(t, wo.work_order_no or "", db, tenant_no)
+    out = _task_to_dict(
+        t,
+        wo.work_order_no or "",
+        db,
+        tenant_no,
+        attachment_by_url=attachment_by_url,
+    )
     out.update(_task_work_order_context(wo, t))
     return out
 
 
+def _attachment_map_for_task_rows(
+    db: Session,
+    rows: List[Tuple[BizQcTask, BizQcWorkOrder]],
+    tenant_no: Optional[str],
+) -> Dict[str, BizAttachment]:
+    urls: List[str] = []
+    for t, _wo in rows:
+        urls.extend(_split_attachment_urls(t.third_party_attachments))
+    if not urls:
+        return {}
+    return lookup_attachments_by_urls(db, urls, tenant_no)
+
+
+def _task_rows_to_dicts(
+    db: Session,
+    rows: List[Tuple[BizQcTask, BizQcWorkOrder]],
+    tenant_no: Optional[str],
+) -> List[Dict[str, Any]]:
+    """将任务行批量转为字典;附件元数据一次预取,避免 N+1。"""
+    att_map = _attachment_map_for_task_rows(db, rows, tenant_no)
+    return [
+        _task_row_to_dict(t, wo, db, tenant_no, attachment_by_url=att_map)
+        for t, wo in rows
+    ]
+
+
 def create_task_for_line(
     db: Session,
     *,
@@ -501,7 +553,7 @@ def list_tasks(
         .limit(page_size)
         .all()
     )
-    items = [_task_row_to_dict(t, wo, db, tenant_no) for t, wo in rows]
+    items = _task_rows_to_dicts(db, rows, tenant_no)
     items = _enrich_tasks_reception_fields(db, items)
     return assignee_svc.enrich_tasks_with_assignees(db, items, tenant_no), int(total)
 
@@ -592,7 +644,7 @@ def list_mine_tasks(
         .limit(page_size)
         .all()
     )
-    items = [_task_row_to_dict(t, wo, db, tenant_no) for t, wo in rows]
+    items = _task_rows_to_dicts(db, rows, tenant_no)
     items = _enrich_tasks_reception_fields(db, items)
     items = _enrich_mine_tasks_with_work_order_summaries(db, rows, items, tenant_no)
     return assignee_svc.enrich_tasks_with_assignees(db, items, tenant_no), int(total)
@@ -631,7 +683,7 @@ def list_tasks_by_work_order_ids(
         .order_by(BizQcTask.work_order_id.asc(), BizQcTask.order_num.asc())
         .all()
     )
-    items = [_task_row_to_dict(t, wo, db, tenant_no) for t, wo in rows]
+    items = _task_rows_to_dicts(db, rows, tenant_no)
     items = _enrich_tasks_reception_fields(db, items)
     items = assignee_svc.enrich_tasks_with_assignees(db, items, tenant_no)
     grouped: Dict[str, List[Dict[str, Any]]] = {wo_id: [] for wo_id in ids}

+ 40 - 13
backend/app/crud/user.py

@@ -256,25 +256,22 @@ def sync_post_user_bindings(
     db.commit()
 
 
-def get_user_with_relations(db: Session, user_id: str) -> Optional[Dict[str, Any]]:
+def _empty_user_relation_fields(d: Dict[str, Any]) -> None:
+    d["roleIds"] = []
+    d["postIds"] = []
+    d["postInheritedRoleIds"] = []
+    d["effectiveRoleIds"] = []
+    d["effectiveRoleNames"] = []
+
+
+def _attach_user_relations(db: Session, d: Dict[str, Any], user_id: str) -> None:
+    """为用户 dict 补全职位/角色关联字段(与详情接口字段一致)。"""
     from app.services.web_auth_roles import (
         get_effective_role_ids,
         get_post_inherited_role_ids,
         get_role_names_by_ids,
     )
 
-    u = get_user(db, user_id)
-    if not u:
-        return None
-
-    dept_name = ""
-    if u.dept_id:
-        dept = db.query(SysDept).filter(SysDept.id == u.dept_id).first()
-        if dept:
-            dept_name = dept.dept_name
-
-    d = _row_to_dict(u, dept_name=dept_name)
-
     role_ids = [
         r.role_id
         for r in db.query(SysUserRole)
@@ -289,6 +286,36 @@ def get_user_with_relations(db: Session, user_id: str) -> Optional[Dict[str, Any
     d["postInheritedRoleIds"] = post_inherited_role_ids
     d["effectiveRoleIds"] = effective_role_ids
     d["effectiveRoleNames"] = get_role_names_by_ids(db, effective_role_ids)
+
+
+def enrich_user_dicts_with_relations(
+    db: Session, items: List[Dict[str, Any]]
+) -> List[Dict[str, Any]]:
+    """批量就地补全列表项的职位/角色关联字段,供列表列展示。"""
+    if not items:
+        return items
+    for d in items:
+        uid = str(d.get("id") or "").strip()
+        if not uid:
+            _empty_user_relation_fields(d)
+            continue
+        _attach_user_relations(db, d, uid)
+    return items
+
+
+def get_user_with_relations(db: Session, user_id: str) -> Optional[Dict[str, Any]]:
+    u = get_user(db, user_id)
+    if not u:
+        return None
+
+    dept_name = ""
+    if u.dept_id:
+        dept = db.query(SysDept).filter(SysDept.id == u.dept_id).first()
+        if dept:
+            dept_name = dept.dept_name
+
+    d = _row_to_dict(u, dept_name=dept_name)
+    _attach_user_relations(db, d, user_id)
     return d
 
 

Разница между файлами не показана из-за своего большого размера
+ 151 - 356
backend/graphify-out/.graphify_analysis.json


Разница между файлами не показана из-за своего большого размера
+ 232 - 155
backend/graphify-out/.graphify_extract.json


+ 80 - 95
backend/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
 # Graph Report - app  (2026-08-06)
 
 ## Corpus Check
-- Large corpus: 242 files · ~296,924 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 242 files · ~297,473 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 5120 nodes · 27972 edges · 43 communities detected
-- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19105 edges (avg confidence: 0.6)
+- 5134 nodes · 28066 edges · 40 communities detected
+- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19173 edges (avg confidence: 0.6)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -43,21 +43,18 @@
 - [[_COMMUNITY_Community 31|Community 31]]
 - [[_COMMUNITY_Community 32|Community 32]]
 - [[_COMMUNITY_Community 33|Community 33]]
-- [[_COMMUNITY_Community 34|Community 34]]
-- [[_COMMUNITY_Community 35|Community 35]]
 - [[_COMMUNITY_Community 36|Community 36]]
+- [[_COMMUNITY_Community 37|Community 37]]
+- [[_COMMUNITY_Community 38|Community 38]]
 - [[_COMMUNITY_Community 39|Community 39]]
 - [[_COMMUNITY_Community 40|Community 40]]
 - [[_COMMUNITY_Community 41|Community 41]]
-- [[_COMMUNITY_Community 42|Community 42]]
-- [[_COMMUNITY_Community 43|Community 43]]
-- [[_COMMUNITY_Community 44|Community 44]]
 
 ## God Nodes (most connected - your core abstractions)
 1. `success_response()` - 732 edges
 2. `error_response()` - 605 edges
-3. `SysUser` - 591 edges
-4. `BizQcTaskResult` - 345 edges
+3. `SysUser` - 595 edges
+4. `BizQcTaskResult` - 346 edges
 5. `BizProductionMaterial` - 329 edges
 6. `BizProductionMaterialUsage` - 323 edges
 7. `BizQcMaterialUsage` - 321 edges
@@ -70,231 +67,219 @@
   core/api_request_log_middleware.py → models/models.py
 - `get_menu_list()` --calls--> `success_response()`  [INFERRED]
   api/menu.py → core/response.py
+- `中试服务交付管理 API(交付信息、交付物料、交付方式)` --uses--> `SysUser`  [INFERRED]
+  api/pilot_delivery.py → models/models.py
 - `保留匹配节点及其全部上级,供关键词检索后仍能正确组树。` --uses--> `SysDept`  [INFERRED]
   crud/dept.py → models/models.py
 - `物品单位 CRUD(biz_item_unit)` --uses--> `BizItemUnit`  [INFERRED]
   crud/item_unit.py → models/models.py
-- `快递公司 CRUD(biz_express_company)` --uses--> `BizExpressCompany`  [INFERRED]
-  crud/express_company.py → models/models.py
 
 ## Communities
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (952): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+944 more)
+Nodes (943): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+935 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.01
-Nodes (716): generate_uuid(), ApiRequestLogCRUD, delete(), get_multi_with_filters(), allocate_next_biz_no(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号 (+708 more)
+Nodes (647): generate_uuid(), create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types(), 细胞样本类型 CRUD(biz_cell_sample_type), _row_to_dict(), soft_delete_cell_sample_type(), type_code_exists() (+639 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.01
-Nodes (675): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+667 more)
+Nodes (606): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+598 more)
 
 ### Community 3 - "Community 3"
-Cohesion: 0.05
-Nodes (615): BatchRefundFinanceSubmitBody, 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API, 持久化协议子资源。      缴费(含明细校验)须在样本之前:明细校验失败时不得已占用样本编码。     各子表 CRUD 内部仍可能分段 commit,创建接, 与列表接口一致:按租户 + 角色数据权限判断是否可访问该协议单。, If mother info exists but no customerId, create a customer record and link it., RefundFinanceSubmitBody, Base, 细胞产品 CRUD(biz_cell_product) (+607 more)
+Cohesion: 0.06
+Nodes (566): BatchRefundFinanceSubmitBody, 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API, 持久化协议子资源。      缴费(含明细校验)须在样本之前:明细校验失败时不得已占用样本编码。     各子表 CRUD 内部仍可能分段 commit,创建接, 与列表接口一致:按租户 + 角色数据权限判断是否可访问该协议单。, If mother info exists but no customerId, create a customer record and link it., RefundFinanceSubmitBody, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), Base (+558 more)
 
 ### Community 4 - "Community 4"
 Cohesion: 0.01
-Nodes (366): _normalize_date_bound(), complete_protocol_invoice_request(), create_collection_protocol(), delete_collection_protocol(), _draft_fields_from_body(), _ensure_customer_for_mother(), get_collection_protocol_detail(), _get_current_user_id() (+358 more)
+Nodes (348): _normalize_date_bound(), complete_protocol_invoice_request(), create_collection_protocol(), delete_collection_protocol(), _ensure_customer_for_mother(), get_collection_protocol_detail(), _get_current_user_id(), get_invoice_eligible_payments() (+340 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.02
-Nodes (231): 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() (+223 more)
+Cohesion: 0.01
+Nodes (305): _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(), _resolve_reception_id() (+297 more)
 
 ### Community 6 - "Community 6"
-Cohesion: 0.02
-Nodes (200): _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(), create_cell_products_batch() (+192 more)
+Cohesion: 0.03
+Nodes (215): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+207 more)
 
 ### Community 7 - "Community 7"
 Cohesion: 0.02
-Nodes (132): get_designer_data(), _error_json(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+124 more)
+Nodes (123): get_designer_data(), _error_json(), lifespan(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+115 more)
 
 ### Community 8 - "Community 8"
-Cohesion: 0.02
-Nodes (174): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+166 more)
+Cohesion: 0.03
+Nodes (147): _avatar_url_field(), _build_current_user_payload(), _build_user_info_payload(), get_current_user_profile(), get_web_auth_menu_codes(), _jwt_user_payload(), logout(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线) (+139 more)
 
 ### Community 9 - "Community 9"
 Cohesion: 0.03
-Nodes (148): _batch_line_to_dict(), _unused_amount(), _unused_line_to_dict(), _filter_material_type(), _get_configured_material(), get_formula(), _line_to_dict(), replace_formula() (+140 more)
+Nodes (155): _batch_line_to_dict(), cancel_check(), _check_header_to_dict(), complete_check(), _get_check(), get_check_detail(), _line_counts(), list_checks() (+147 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.05
-Nodes (113): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+105 more)
+Cohesion: 0.03
+Nodes (150): api_save_application(), get_protocol_nos_used_in_protocols(), _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict() (+142 more)
 
 ### Community 11 - "Community 11"
 Cohesion: 0.03
-Nodes (93): _jwt_user_payload(), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。, refresh_token(), web_login(), web_login_rsa_public_key(), lifespan(), create_access_token() (+85 more)
+Nodes (141): _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(), create_cell_product() (+133 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.08
-Nodes (52): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+44 more)
+Cohesion: 0.05
+Nodes (112): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+104 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.11
-Nodes (39): _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() (+31 more)
+Cohesion: 0.45
+Nodes (71): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+63 more)
 
 ### Community 14 - "Community 14"
 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 15 - "Community 15"
-Cohesion: 0.16
-Nodes (22): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+14 more)
+Cohesion: 0.17
+Nodes (29): _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() (+21 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)
+Cohesion: 0.19
+Nodes (20): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+12 more)
 
 ### Community 17 - "Community 17"
 Cohesion: 0.21
-Nodes (16): _field_label(), _is_non_empty_str(), _is_required(), _is_valid_receive_account_item(), normalize_account_fields(), _pick(), _pick_order_num(), _pick_str() (+8 more)
+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 18 - "Community 18"
-Cohesion: 0.27
-Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
+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)
 
 ### Community 19 - "Community 19"
 Cohesion: 0.27
-Nodes (11): _accept_conclusion_label(), _base_query(), _ensure_notification(), _fmt_dt(), generate_sms_content(), list_notifications(), _normalize_date_bound(), resolve_notification() (+3 more)
+Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
 
 ### Community 20 - "Community 20"
-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)
-
-### Community 21 - "Community 21"
-Cohesion: 0.23
-Nodes (12): Config, ProductBase, ProductCategoryBase, ProductCategoryCreate, ProductCategoryResponse, ProductCreate, ProductResponse, ProductUnitBase (+4 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 23 - "Community 23"
+### Community 21 - "Community 21"
 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"
+### Community 22 - "Community 22"
 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 25 - "Community 25"
+### Community 23 - "Community 23"
 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 26 - "Community 26"
+### 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 27 - "Community 27"
+### Community 25 - "Community 25"
 Cohesion: 0.29
 Nodes (2): BaseModel, SoftDeleteModel
 
-### Community 28 - "Community 28"
-Cohesion: 0.32
-Nodes (5): create_leave(), get_leave(), list_leaves(), _row_to_dict(), soft_delete_leave()
-
-### Community 29 - "Community 29"
+### Community 26 - "Community 26"
 Cohesion: 0.38
 Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。     约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;     若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
 
-### Community 30 - "Community 30"
+### Community 27 - "Community 27"
 Cohesion: 0.5
 Nodes (3): BaseSettings, Config, Settings
 
-### Community 31 - "Community 31"
+### Community 28 - "Community 28"
 Cohesion: 0.5
 Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
 
-### Community 32 - "Community 32"
+### Community 29 - "Community 29"
 Cohesion: 0.67
 Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
 
-### Community 33 - "Community 33"
+### Community 30 - "Community 30"
 Cohesion: 0.67
 Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
 
-### Community 34 - "Community 34"
+### Community 31 - "Community 31"
 Cohesion: 1.0
 Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
 
-### Community 35 - "Community 35"
+### Community 32 - "Community 32"
 Cohesion: 1.0
 Nodes (1): 请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
 
-### Community 36 - "Community 36"
+### Community 33 - "Community 33"
 Cohesion: 1.0
 Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
 
-### Community 39 - "Community 39"
+### Community 36 - "Community 36"
 Cohesion: 1.0
 Nodes (1): 发送订单状态通知                  Args:             openid: 用户openid             ord
 
-### Community 40 - "Community 40"
+### Community 37 - "Community 37"
 Cohesion: 1.0
 Nodes (1): 发送支付成功通知                  Args:             openid: 用户openid             ord
 
-### Community 41 - "Community 41"
+### Community 38 - "Community 38"
 Cohesion: 1.0
 Nodes (1): 发送配送通知                  Args:             openid: 配送员openid             task
 
-### Community 42 - "Community 42"
+### Community 39 - "Community 39"
 Cohesion: 1.0
 Nodes (1): 发送配送时效预警                  Args:             openid: 配送员openid             ta
 
-### Community 43 - "Community 43"
+### Community 40 - "Community 40"
 Cohesion: 1.0
 Nodes (1): 发送退款结果通知                  Args:             openid: 用户openid             ord
 
-### Community 44 - "Community 44"
+### Community 41 - "Community 41"
 Cohesion: 1.0
 Nodes (1): 发送催单通知给配送员                  Args:             deliver_openid: 配送员openid
 
 ## Knowledge Gaps
 - **163 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+158 more)
   These have ≤1 connection - possible missing edges or undocumented components.
-- **Thin community `Community 27`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
+- **Thin community `Community 25`** (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 31`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
+- **Thin community `Community 28`** (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): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
+- **Thin community `Community 29`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 33`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
+- **Thin community `Community 30`** (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.
-- **Thin community `Community 34`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
+- **Thin community `Community 31`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 35`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
+- **Thin community `Community 32`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 36`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
+- **Thin community `Community 33`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 39`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 36`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 40`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 37`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 41`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
+- **Thin community `Community 38`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 42`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
+- **Thin community `Community 39`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 43`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 40`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 44`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
+- **Thin community `Community 41`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## 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 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 15`, `Community 23`, `Community 24`?**
-  _High betweenness centrality (0.112) - this node is a cross-community bridge._
-- **Why does `success_response()` connect `Community 0` to `Community 32`, `Community 2`, `Community 4`, `Community 7`, `Community 10`, `Community 11`?**
-  _High betweenness centrality (0.048) - this node is a cross-community bridge._
-- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 15`, `Community 16`, `Community 18`, `Community 19`, `Community 23`, `Community 24`, `Community 25`, `Community 28`?**
-  _High betweenness centrality (0.047) - this node is a cross-community bridge._
-- **Are the 861 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
-  _`str` has 861 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 15`, `Community 16`, `Community 21`, `Community 22`?**
+  _High betweenness centrality (0.107) - this node is a cross-community bridge._
+- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 10`, `Community 29`?**
+  _High betweenness centrality (0.045) - this node is a cross-community bridge._
+- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 21`, `Community 22`, `Community 23`?**
+  _High betweenness centrality (0.038) - this node is a cross-community bridge._
+- **Are the 862 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
+  _`str` has 862 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 731 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
   _`success_response()` has 731 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 604 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
   _`error_response()` has 604 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 589 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
-  _`SysUser` has 589 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 593 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
+  _`SysUser` has 593 INFERRED edges - model-reasoned connections that need verification._

Разница между файлами не показана из-за своего большого размера
+ 112 - 112
backend/graphify-out/graph.json


+ 5 - 5
backend/graphify-out/manifest.json

@@ -77,7 +77,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/leave.py": 1778666088.7680702,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_release_review.py": 1785302033.9598167,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/equipment.py": 1782717312.9816003,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task.py": 1785145511.4501932,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task.py": 1786010990.3157854,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_route.py": 1785239935.4692264,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_code.py": 1782709076.5173395,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_result.py": 1785289887.2915215,
@@ -87,7 +87,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1785911428.7739174,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1779255909.8554008,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_batch.py": 1782404955.550011,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1785912167.414323,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1786010386.011198,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1785209206.5290542,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/material.py": 1781522283.6438622,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_type.py": 1779265621.5212462,
@@ -105,7 +105,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_third_party_item.py": 1779265661.482352,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_production_reception.py": 1782722028.9202535,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent.py": 1779512669.9847593,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1785205960.3759406,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1786010916.3920326,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/cleanroom_garment.py": 1782264452.4901433,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1783428516.0973594,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/biz_no_generator.py": 1779882385.4674172,
@@ -158,7 +158,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_sms_notification.py": 1781063495.0317714,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1783921714.096361,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_pack.py": 1779265715.416005,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_batch.py": 1785145511.4497666,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_batch.py": 1786011049.96145,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_prep.py": 1781514953.3551314,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material.py": 1779284415.64655,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol_no.py": 1779882001.753651,
@@ -201,7 +201,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1785213144.2881503,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1785836159.1492305,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1785910043.2475295,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1786010386.7044516,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1782376501.8919773,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1783920522.841086,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/ethnic.py": 1779266243.9511197,

+ 32 - 0
backend/tests/test_collection_protocol_save_bundle.py

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
 from app.crud.collection_protocol import (
     _normalize_fields,
     attachments_to_urls,
+    lookup_attachments_by_urls,
     urls_to_attachment_list,
     upsert_party_customer,
 )
@@ -57,6 +58,37 @@ class TestAttachmentsHelpers(unittest.TestCase):
         self.assertEqual(items[0]["fileName"], "合同扫描件.pdf")
         self.assertEqual(items[0]["id"], "att-1")
 
+    def test_urls_to_attachment_list_uses_prefetched_map(self):
+        att = MagicMock()
+        att.id = "att-2"
+        att.original_name = "报告.pdf"
+        att.file_url = "qc/a.pdf"
+        att.file_size = 1024
+        att.file_type = "application/pdf"
+        items = urls_to_attachment_list(
+            "qc/a.pdf,qc/missing.pdf",
+            db=MagicMock(),
+            tenant_no="t1",
+            attachment_by_url={"qc/a.pdf": att},
+        )
+        self.assertEqual(len(items), 2)
+        self.assertEqual(items[0]["fileName"], "报告.pdf")
+        self.assertEqual(items[0]["id"], "att-2")
+        self.assertEqual(items[1]["fileName"], "missing.pdf")
+        self.assertNotIn("id", items[1])
+
+    def test_lookup_attachments_by_urls_batches_exact_match(self):
+        db = MagicMock()
+        att = MagicMock()
+        att.file_url = "qc/a.pdf"
+        q = MagicMock()
+        db.query.return_value = q
+        q.filter.return_value = q
+        q.all.return_value = [att]
+        out = lookup_attachments_by_urls(db, ["qc/a.pdf", "qc/a.pdf", ""], "t1")
+        self.assertEqual(out["qc/a.pdf"], att)
+        db.query.assert_called_once()
+
 
 class TestUpsertPartyCustomer(unittest.TestCase):
     @patch("app.crud.collection_protocol.get_party_customer")

+ 35 - 0
backend/tests/test_user_list_relations.py

@@ -0,0 +1,35 @@
+"""用户列表须返回职位/角色关联字段,供前端列表列展示。"""
+
+import inspect
+
+from app.api import admin as admin_api
+from app.crud import user as user_crud
+
+
+def test_admin_list_users_enriches_role_and_post_relations():
+    """回归:列表不得仅用 _row_to_dict,须补全职位/有效角色。"""
+    source = inspect.getsource(admin_api.list_users)
+    assert "enrich_user_dicts_with_relations" in source
+
+
+def test_enrich_user_dicts_with_relations_empty_list():
+    class _DummySession:
+        def query(self, *args, **kwargs):  # pragma: no cover
+            raise AssertionError("空列表不应访问数据库")
+
+    assert user_crud.enrich_user_dicts_with_relations(_DummySession(), []) == []
+
+
+def test_enrich_user_dicts_with_relations_fills_empty_fields_without_id():
+    class _DummySession:
+        def query(self, *args, **kwargs):  # pragma: no cover
+            raise AssertionError("无 id 条目不应访问数据库")
+
+    items = [{"userNo": "u1"}]
+    out = user_crud.enrich_user_dicts_with_relations(_DummySession(), items)
+    assert out is items
+    assert items[0]["roleIds"] == []
+    assert items[0]["postIds"] == []
+    assert items[0]["postInheritedRoleIds"] == []
+    assert items[0]["effectiveRoleIds"] == []
+    assert items[0]["effectiveRoleNames"] == []

+ 1 - 1
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,7 +1,7 @@
 # Graph Report - src  (2026-08-06)
 
 ## Corpus Check
-- Large corpus: 961 files · ~626,784 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 961 files · ~626,790 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
 - 3371 nodes · 4776 edges · 66 communities detected

+ 2 - 2
web-admin/graphify-out/manifest.json

@@ -17,8 +17,8 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/index.ts": 1781272556.3597305,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/directive/directive.d.ts": 1781272556.3601494,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api/api.d.ts": 1781272556.3606477,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1786004653.5328317,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1786004576.7525966,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1786006340.0068274,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1786005792.7525885,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1781272556.364766,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1782394569.8806834,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/composables/useAttachmentActions.ts": 1783921717.4015942,

+ 12 - 2
待同步到生产的sql脚本

@@ -1,2 +1,12 @@
-docs/sql/oa_instance_draft.sql
-docs/sql/oa_task_cc_is_read.sql
+# 用户/角色/职位迁移(测试库 zkxw-sc 已于 2026-08-06 执行完成)
+# 脚本:backend/scripts/migrate_user_role_to_post.py
+# 执行:
+#   cd backend
+#   python scripts/migrate_user_role_to_post.py --execute --confirm-database zkxw-sc
+#
+# 结果校验(zkxw-sc):
+#   sys_role=30, sys_post=30, sys_post_role=30, sys_user_post=55, sys_user_role有效=0
+#
+# 导出到生产前:确认测试环境登录/菜单/数据权限正常,再整库或按表导出:
+#   sys_post / sys_post_role / sys_user_post / sys_user_role
+# (及依赖的 sys_role 若生产已有则无需覆盖角色主数据)

Некоторые файлы не были показаны из-за большого количества измененных файлов