zlwu 3 months ago
parent
commit
c2cba3e668
53 changed files with 2754 additions and 949 deletions
  1. BIN
      backend.zip
  2. 1 1
      backend/app.pid
  3. 63 0
      backend/app/api/pl/base_data.py
  4. 14 0
      backend/app/api/pl/plan.py
  5. 12 0
      backend/app/models/pl_models.py
  6. 16 0
      backend/app/schemas/pl/base_data.py
  7. 314 0
      backend/app/services/pl_cert_required_service.py
  8. 14 1
      backend/app/services/pl_personnel_service.py
  9. 3 5
      backend/graphify-out/.graphify_labels.json
  10. 199 209
      backend/graphify-out/GRAPH_REPORT.md
  11. 0 0
      backend/graphify-out/cache/ast/02201f8488ad81c2add94b41c44b09828da457b96ed9bf4307c2b257e5f1f833.json
  12. 0 0
      backend/graphify-out/cache/ast/123d08b4548931e30b486ed9a24dc299ed075435b9bb3919e79200c7c2055333.json
  13. 0 0
      backend/graphify-out/cache/ast/34f3d1d723890e294aac765a66d7e86ca87f4288f42cec674bb9151de091fe22.json
  14. 0 0
      backend/graphify-out/cache/ast/3be4253f6a131c632536d32b1f087a8838675bbb4b282e96cec1e2fb5063e9fd.json
  15. 0 0
      backend/graphify-out/cache/ast/45e8c537286f7ff6da75a37d0add654f0f6ae5e92165d5e42a764ecafba3c830.json
  16. 0 0
      backend/graphify-out/cache/ast/620cced501c465d38c97ff3e3fd29da4d25b940e8a1c816a2b74a9547d278db2.json
  17. 0 0
      backend/graphify-out/cache/ast/6b079cc6949a526f96edf0bcff31c43793c5c7a9570f18259693148c215a2cff.json
  18. 0 0
      backend/graphify-out/cache/ast/95969972c0773528b91d9728d303feae5ca7187d89a49495d5129dcedffc19dc.json
  19. 0 0
      backend/graphify-out/cache/ast/ce2b9ccf1faa70c6e084cc298aebe1b112f6515b0618f88fa29f3674c2f9649d.json
  20. 0 0
      backend/graphify-out/cache/ast/ecd3d8e5dbdb55a8b25c434ad9935bacdf275e23af051e45fea79be017d1c633.json
  21. 0 0
      backend/graphify-out/cache/ast/f644a9595a6c96924390a655ed92dee806b0dfc8cfb2001061d469a0d80caf91.json
  22. 0 0
      backend/graphify-out/cache/ast/fca395a2abc3bbf141be8b4ebd59a4c368f12368eb4fc28e0da254fcd35b7a50.json
  23. 0 0
      backend/graphify-out/cache/stat-index.json
  24. 112 112
      backend/graphify-out/graph.json
  25. 24 0
      backend/scripts/migration_20260712_pl_personnel_affiliation.sql
  26. 52 0
      backend/scripts/migration_20260712_pl_personnel_type.sql
  27. 24 0
      docs/sql/migration_20260712_pl_personnel_affiliation.sql
  28. 52 0
      docs/sql/migration_20260712_pl_personnel_type.sql
  29. 2 0
      docs/sql/pl_tables.sql
  30. 5 4
      docs/sql/shanxia2.0.sql
  31. 340 0
      docs/superpowers/specs/2026-07-12-pl-required-cert-slots-design.md
  32. 77 57
      graphify-out/manifest.json
  33. 5 11
      web-admin/graphify-out/.graphify_labels.json
  34. 212 222
      web-admin/graphify-out/GRAPH_REPORT.md
  35. 112 112
      web-admin/graphify-out/graph.json
  36. 5 0
      web-admin/src/api/pl/index.ts
  37. 136 14
      web-admin/src/components/business/pl-cert-form-list/index.vue
  38. 97 50
      web-admin/src/components/business/pl-plan-archive-dialog/index.vue
  39. 17 2
      web-admin/src/components/business/pl-vehicle-cert-part-tabs/index.vue
  40. 19 5
      web-admin/src/components/business/pl-vehicle-cert-part-view/index.vue
  41. 18 6
      web-admin/src/components/business/pl-vehicle-cert-personnel-tabs/index.vue
  42. 8 1
      web-admin/src/components/business/pl-vehicle-personnel-view-list/PersonnelViewPanel.vue
  43. 2 1
      web-admin/src/components/business/pl-vehicle-view-dialog/index.vue
  44. 241 0
      web-admin/src/utils/pl-cert-slots.ts
  45. 1 1
      web-admin/src/utils/pl-display.ts
  46. 113 6
      web-admin/src/utils/pl-personnel-cert.ts
  47. 80 14
      web-admin/src/utils/pl-vehicle-cert.ts
  48. 14 26
      web-admin/src/views/pl/carrier/index.vue
  49. 145 17
      web-admin/src/views/pl/personnel/index.vue
  50. 66 34
      web-admin/src/views/pl/plan-archive/index.vue
  51. 1 1
      web-admin/src/views/pl/plan/index.vue
  52. 47 22
      web-admin/src/views/pl/self-vehicle/index.vue
  53. 91 15
      web-admin/src/views/pl/vehicle/index.vue

BIN
backend.zip


+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-47712
+6907

+ 63 - 0
backend/app/api/pl/base_data.py

@@ -48,6 +48,10 @@ from app.services.pl_cert_service import (
     nearest_expire_date,
     save_owner_certs,
 )
+from app.services.pl_cert_required_service import (
+    validate_personnel_required_certs,
+    validate_vehicle_save_certs,
+)
 from app.services.pl_carrier_service import (
     carrier_eligible_for_transport,
     carrier_qualification_complete,
@@ -409,6 +413,15 @@ def create_vehicle(
     if existing:
         raise HTTPException(status_code=400, detail="车牌号已存在")
 
+    validate_vehicle_save_certs(
+        db,
+        tenant_no,
+        vehicle_type=payload.vehicle_type,
+        trailer_no=payload.trailer_no,
+        cert_rows=payload.cert_list,
+        personnel_rows=payload.personnel_list,
+    )
+
     vehicle = PlVehicle(
         id=generate_uuid(),
         tenant_no=tenant_no,
@@ -434,6 +447,7 @@ def create_vehicle(
             tenant_no,
             user_id=user.id,
             dept_id=user.dept_id,
+            vehicle_type=payload.vehicle_type,
         )
         save_owner_certs(
             db,
@@ -483,6 +497,26 @@ def update_vehicle(
         exclude_unset=True, exclude={"personnel_list", "cert_list"}
     )
 
+    effective_vehicle_type = data.get("vehicle_type", vehicle.vehicle_type)
+    effective_trailer_no = data.get("trailer_no", vehicle.trailer_no)
+    effective_cert_rows = (
+        cert_list
+        if cert_list is not None
+        else list_owner_certs(db, tenant_no, "vehicle", vehicle.id)
+    )
+    effective_personnel_rows = personnel_list
+    if effective_personnel_rows is None:
+        effective_personnel_rows = load_vehicle_personnel_rows(db, tenant_no, vehicle.id)
+
+    validate_vehicle_save_certs(
+        db,
+        tenant_no,
+        vehicle_type=effective_vehicle_type,
+        trailer_no=effective_trailer_no,
+        cert_rows=effective_cert_rows,
+        personnel_rows=effective_personnel_rows,
+    )
+
     for key, value in data.items():
         setattr(vehicle, key, value)
 
@@ -497,6 +531,7 @@ def update_vehicle(
                 tenant_no,
                 user_id=user.id,
                 dept_id=user.dept_id,
+                vehicle_type=effective_vehicle_type,
             )
         if cert_list is not None:
             save_owner_certs(
@@ -581,6 +616,9 @@ def _personnel_to_detail(
 def list_personnel(
     q: Optional[str] = Query(default=None, description="模糊搜索姓名/手机号"),
     status: Optional[str] = Query(default=None, description="状态"),
+    personnel_affiliation: Optional[str] = Query(
+        default=None, alias="personnelAffiliation", description="人员归属 self/carrier"
+    ),
     page: int = Query(default=1, ge=1),
     page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
     db: Session = Depends(get_db),
@@ -595,6 +633,9 @@ def list_personnel(
     if status:
         query = query.filter(PlPersonnel.status == status)
 
+    if personnel_affiliation:
+        query = query.filter(PlPersonnel.personnel_affiliation == personnel_affiliation)
+
     if q:
         query = query.filter(
             or_(
@@ -626,6 +667,12 @@ def create_personnel(
 ) -> PersonnelDetailRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
 
+    validate_personnel_required_certs(
+        payload.cert_list,
+        payload.personnel_type,
+        subject_label=f"「{payload.personnel_name}」",
+    )
+
     personnel = PlPersonnel(
         id=generate_uuid(),
         tenant_no=tenant_no,
@@ -633,6 +680,8 @@ def create_personnel(
         phone_no=payload.phone_no,
         age=payload.age,
         id_card_no=payload.id_card_no,
+        personnel_type=payload.personnel_type,
+        personnel_affiliation=payload.personnel_affiliation,
         status=payload.status,
         remark=payload.remark,
         create_by=user.id,
@@ -684,6 +733,20 @@ def update_personnel(
     cert_list = payload.cert_list
     data = payload.model_dump(exclude_unset=True, exclude={"cert_list"})
 
+    effective_personnel_type = data.get("personnel_type", personnel.personnel_type)
+    effective_cert_rows = (
+        cert_list
+        if cert_list is not None
+        else list_owner_certs(db, tenant_no, "personnel", personnel.id)
+    )
+    subject_name = data.get("personnel_name", personnel.personnel_name)
+
+    validate_personnel_required_certs(
+        effective_cert_rows,
+        effective_personnel_type,
+        subject_label=f"「{subject_name}」",
+    )
+
     for key, value in data.items():
         setattr(personnel, key, value)
 

+ 14 - 0
backend/app/api/pl/plan.py

@@ -72,6 +72,7 @@ from app.services.pl_plan_service import (
     sync_transport_prefill_for_plan,
     update_plan_product_certs,
     validate_create_full,
+    validate_hazard_cert_ready_for_transport,
 )
 
 router = APIRouter(prefix="/plan", tags=["运输计划-计划管理"])
@@ -1079,6 +1080,19 @@ def archive_plan(
 
     already_archived = plan.archive_status == "archived"
 
+    if not already_archived:
+        products = (
+            db.query(PlPlanProduct)
+            .filter(
+                PlPlanProduct.plan_id == plan.id,
+                PlPlanProduct.tenant_no == tenant_no,
+                PlPlanProduct.del_flag == 0,
+            )
+            .order_by(PlPlanProduct.order_num)
+            .all()
+        )
+        validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
+
     if payload is None:
         if already_archived:
             raise HTTPException(status_code=400, detail="计划已归档,请提交归档表单以调整内容")

+ 12 - 0
backend/app/models/pl_models.py

@@ -90,6 +90,18 @@ class PlPersonnel(Base):
     phone_no = Column(String(32), nullable=True, comment="手机号(非必填)")
     age = Column(Integer, nullable=True, comment="年龄")
     id_card_no = Column(String(32), nullable=True, comment="身份证号")
+    personnel_type = Column(
+        String(20),
+        nullable=False,
+        default="both",
+        comment="人员类型 driver-驾驶员 escort-押运员 both-驾驶员兼押运员",
+    )
+    personnel_affiliation = Column(
+        String(20),
+        nullable=False,
+        default="self",
+        comment="人员归属 self-山峡 carrier-非山峡",
+    )
     status = Column(CHAR(1), nullable=False, default="1", comment="状态 1-在职 0-停用")
     tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
     order_num = Column(Integer, nullable=True, comment="排序号")

+ 16 - 0
backend/app/schemas/pl/base_data.py

@@ -272,6 +272,18 @@ class PersonnelBase(BaseModel):
     phone_no: Optional[str] = Field(default=None, max_length=32, alias="phoneNo", description="手机号")
     age: Optional[int] = Field(default=None, description="年龄")
     id_card_no: Optional[str] = Field(default=None, max_length=32, alias="idCardNo", description="身份证号")
+    personnel_type: str = Field(
+        ...,
+        max_length=20,
+        alias="personnelType",
+        description="人员类型 driver/escort/both",
+    )
+    personnel_affiliation: str = Field(
+        default="self",
+        max_length=20,
+        alias="personnelAffiliation",
+        description="人员归属 self-山峡 carrier-非山峡",
+    )
     status: str = Field(default="1", max_length=1, description="状态 1-在职 0-停用")
     remark: Optional[str] = None
 
@@ -286,6 +298,10 @@ class PersonnelUpdate(BaseModel):
     phone_no: Optional[str] = Field(default=None, max_length=32, alias="phoneNo")
     age: Optional[int] = None
     id_card_no: Optional[str] = Field(default=None, max_length=32, alias="idCardNo")
+    personnel_type: Optional[str] = Field(default=None, max_length=20, alias="personnelType")
+    personnel_affiliation: Optional[str] = Field(
+        default=None, max_length=20, alias="personnelAffiliation"
+    )
     status: Optional[str] = Field(default=None, max_length=1)
     remark: Optional[str] = None
     cert_list: Optional[list[CertRow]] = Field(default=None, alias="certList")

+ 314 - 0
backend/app/services/pl_cert_required_service.py

@@ -0,0 +1,314 @@
+"""运输计划 - 必备证书校验"""
+from __future__ import annotations
+
+from typing import Iterable, Optional, Sequence
+
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+from app.models.pl_models import PlPersonnel
+from app.schemas.pl.base_data import CertRow, VehiclePersonnelRow
+from app.services.pl_cert_service import list_owner_certs
+
+VEHICLE_TYPE_SELF = "self"
+VEHICLE_TYPE_CARRIER = "carrier"
+
+SELF_HEAD_REQUIRED = ("行驶证", "营运证", "保险单")
+SELF_TRAILER_REQUIRED = ("行驶证", "营运证", "保险单", "罐检报告")
+
+CARRIER_HEAD_REQUIRED = ("行驶证", "营运证")
+CARRIER_TRAILER_REQUIRED = ("行驶证", "营运证")
+
+PERSONNEL_DRIVER_REQUIRED = ("身份证", "驾驶证", "从业资格证")
+PERSONNEL_ESCORT_REQUIRED = ("身份证", "从业资格证")
+
+VEHICLE_PART_HEAD = "head"
+VEHICLE_PART_TRAILER = "trailer"
+
+
+def match_required_cert_name(actual_name: str | None, required_name: str) -> bool:
+    name = (actual_name or "").strip()
+    if not name:
+        return False
+    if required_name == "从业资格证":
+        return (
+            name == "从业资格证"
+            or name == "危化品从业资格证"
+            or "从业资格证" in name
+        )
+    return name == required_name
+
+
+def _cert_row_complete(cert: CertRow | dict, required_name: str) -> bool:
+    if isinstance(cert, dict):
+        cert_name = cert.get("cert_name") or cert.get("certName")
+        cert_no = cert.get("cert_no") or cert.get("certNo")
+        expire_date = cert.get("expire_date") or cert.get("expireDate")
+        attachment_urls = cert.get("attachment_urls") or cert.get("attachmentUrls")
+    else:
+        cert_name = cert.cert_name
+        cert_no = cert.cert_no
+        expire_date = cert.expire_date
+        attachment_urls = cert.attachment_urls
+
+    if not match_required_cert_name(cert_name, required_name):
+        return False
+    if not (cert_no or "").strip():
+        return False
+    if not expire_date:
+        return False
+    if not (attachment_urls or "").strip():
+        return False
+    return True
+
+
+def _find_cert_for_required(
+    cert_rows: Sequence[CertRow | dict],
+    required_name: str,
+    *,
+    vehicle_part: str | None = None,
+) -> CertRow | dict | None:
+    for cert in cert_rows:
+        if isinstance(cert, dict):
+            name = cert.get("cert_name") or cert.get("certName")
+            part = cert.get("vehicle_part") or cert.get("vehiclePart")
+        else:
+            name = cert.cert_name
+            part = cert.vehicle_part
+        if vehicle_part is not None and (part or VEHICLE_PART_HEAD) != vehicle_part:
+            continue
+        if match_required_cert_name(name, required_name):
+            return cert
+    return None
+
+
+def _missing_field_labels(cert: CertRow | dict | None, required_name: str) -> list[str]:
+    if cert is None:
+        return ["证号", "有效期", "附件"]
+    missing: list[str] = []
+    if isinstance(cert, dict):
+        cert_no = cert.get("cert_no") or cert.get("certNo")
+        expire_date = cert.get("expire_date") or cert.get("expireDate")
+        attachment_urls = cert.get("attachment_urls") or cert.get("attachmentUrls")
+        cert_name = cert.get("cert_name") or cert.get("certName")
+    else:
+        cert_no = cert.cert_no
+        expire_date = cert.expire_date
+        attachment_urls = cert.attachment_urls
+        cert_name = cert.cert_name
+
+    if not match_required_cert_name(cert_name, required_name):
+        return ["证号", "有效期", "附件"]
+    if not (cert_no or "").strip():
+        missing.append("证号")
+    if not expire_date:
+        missing.append("有效期")
+    if not (attachment_urls or "").strip():
+        missing.append("附件")
+    return missing
+
+
+def collect_required_cert_errors(
+    cert_rows: Sequence[CertRow | dict],
+    required_names: Iterable[str],
+    *,
+    part_label: str = "",
+) -> list[str]:
+    errors: list[str] = []
+    prefix = f"{part_label}" if part_label else ""
+    vehicle_part = None
+    if part_label in ("主车", "head"):
+        vehicle_part = VEHICLE_PART_HEAD
+    elif part_label in ("挂车", "trailer"):
+        vehicle_part = VEHICLE_PART_TRAILER
+
+    for required_name in required_names:
+        cert = _find_cert_for_required(
+            cert_rows, required_name, vehicle_part=vehicle_part
+        )
+        missing = _missing_field_labels(cert, required_name)
+        if missing:
+            label = f"{prefix}「{required_name}」" if prefix else f"「{required_name}」"
+            errors.append(f"{label}缺少:{'、'.join(missing)}")
+    return errors
+
+
+def get_required_personnel_cert_names(personnel_type: str | None) -> tuple[str, ...]:
+    ptype = (personnel_type or "").strip()
+    if ptype == "escort":
+        return PERSONNEL_ESCORT_REQUIRED
+    if ptype in ("driver", "both"):
+        return PERSONNEL_DRIVER_REQUIRED
+    return PERSONNEL_DRIVER_REQUIRED
+
+
+def validate_personnel_type_role_compat(personnel_type: str | None, role: str | None) -> None:
+    ptype = (personnel_type or "").strip()
+    r = (role or "").strip()
+    if not ptype or not r:
+        return
+    if ptype == "driver" and r == "escort":
+        raise HTTPException(status_code=400, detail="驾驶员类型人员不可关联为押运员")
+    if ptype == "escort" and r == "driver":
+        raise HTTPException(status_code=400, detail="押运员类型人员不可关联为驾驶员")
+
+
+def validate_personnel_required_certs(
+    cert_rows: Sequence[CertRow | dict],
+    personnel_type: str | None,
+    *,
+    subject_label: str = "",
+) -> None:
+    ptype = (personnel_type or "").strip()
+    if not ptype:
+        raise HTTPException(status_code=400, detail="人员类型不能为空")
+    required = get_required_personnel_cert_names(ptype)
+    errors = collect_required_cert_errors(cert_rows, required)
+    if errors:
+        prefix = f"人员{subject_label}" if subject_label else "人员"
+        raise HTTPException(
+            status_code=400,
+            detail=f"必备证书未齐全:{prefix}{';'.join(errors)}",
+        )
+
+
+def validate_self_vehicle_required_certs(
+    cert_rows: Sequence[CertRow | dict],
+    *,
+    trailer_no: str | None = None,
+) -> None:
+    errors: list[str] = []
+    errors.extend(
+        collect_required_cert_errors(
+            cert_rows, SELF_HEAD_REQUIRED, part_label="主车"
+        )
+    )
+    if (trailer_no or "").strip():
+        errors.extend(
+            collect_required_cert_errors(
+                cert_rows, SELF_TRAILER_REQUIRED, part_label="挂车"
+            )
+        )
+    if errors:
+        raise HTTPException(
+            status_code=400,
+            detail=f"必备证书未齐全:{';'.join(errors)}",
+        )
+
+
+def validate_carrier_vehicle_required_certs(
+    cert_rows: Sequence[CertRow | dict],
+    *,
+    trailer_no: str | None = None,
+) -> None:
+    errors: list[str] = []
+    errors.extend(
+        collect_required_cert_errors(
+            cert_rows, CARRIER_HEAD_REQUIRED, part_label="主车"
+        )
+    )
+    if (trailer_no or "").strip():
+        errors.extend(
+            collect_required_cert_errors(
+                cert_rows, CARRIER_TRAILER_REQUIRED, part_label="挂车"
+            )
+        )
+    if errors:
+        raise HTTPException(
+            status_code=400,
+            detail=f"必备证书未齐全:{';'.join(errors)}",
+        )
+
+
+def validate_vehicle_personnel_affiliation(
+    db: Session,
+    tenant_no: str,
+    vehicle_type: str | None,
+    personnel_rows: Sequence[VehiclePersonnelRow],
+) -> None:
+    vtype = (vehicle_type or "").strip()
+    if vtype not in (VEHICLE_TYPE_SELF, VEHICLE_TYPE_CARRIER):
+        return
+    label = "山峡" if vtype == VEHICLE_TYPE_SELF else "非山峡"
+    for row in personnel_rows:
+        personnel_id = (row.personnel_id or "").strip()
+        if not personnel_id:
+            continue
+        personnel = (
+            db.query(PlPersonnel)
+            .filter(
+                PlPersonnel.id == personnel_id,
+                PlPersonnel.tenant_no == tenant_no,
+                PlPersonnel.del_flag == 0,
+            )
+            .first()
+        )
+        if not personnel:
+            continue
+        affiliation = (personnel.personnel_affiliation or VEHICLE_TYPE_SELF).strip()
+        if affiliation != vtype:
+            name = (row.personnel_name or personnel.personnel_name or personnel_id).strip()
+            raise HTTPException(
+                status_code=400,
+                detail=f"关联人员「{name}」归属与车辆类型不匹配,{label}车辆仅可关联{label}人员",
+            )
+
+
+def validate_vehicle_personnel_cert_links(
+    db: Session,
+    tenant_no: str,
+    personnel_rows: Sequence[VehiclePersonnelRow],
+) -> None:
+    for row in personnel_rows:
+        personnel_id = (row.personnel_id or "").strip()
+        if not personnel_id:
+            continue
+        personnel = (
+            db.query(PlPersonnel)
+            .filter(
+                PlPersonnel.id == personnel_id,
+                PlPersonnel.tenant_no == tenant_no,
+                PlPersonnel.del_flag == 0,
+            )
+            .first()
+        )
+        if not personnel:
+            continue
+        validate_personnel_type_role_compat(personnel.personnel_type, row.role)
+        cert_rows = list_owner_certs(db, tenant_no, "personnel", personnel_id)
+        role = (row.role or "").strip()
+        required = (
+            PERSONNEL_DRIVER_REQUIRED
+            if role == "driver"
+            else PERSONNEL_ESCORT_REQUIRED
+        )
+        errors = collect_required_cert_errors(cert_rows, required)
+        if errors:
+            name = (row.personnel_name or personnel.personnel_name or personnel_id).strip()
+            raise HTTPException(
+                status_code=400,
+                detail=(
+                    f"关联人员「{name}」必备证书未齐全:{';'.join(errors)}"
+                ),
+            )
+
+
+def validate_vehicle_save_certs(
+    db: Session,
+    tenant_no: str,
+    *,
+    vehicle_type: str | None,
+    trailer_no: str | None,
+    cert_rows: Sequence[CertRow | dict],
+    personnel_rows: Sequence[VehiclePersonnelRow] | None = None,
+) -> None:
+    vtype = (vehicle_type or "").strip()
+    if vtype == VEHICLE_TYPE_SELF:
+        validate_self_vehicle_required_certs(cert_rows, trailer_no=trailer_no)
+    elif vtype == VEHICLE_TYPE_CARRIER:
+        validate_carrier_vehicle_required_certs(cert_rows, trailer_no=trailer_no)
+    if vtype in (VEHICLE_TYPE_SELF, VEHICLE_TYPE_CARRIER) and personnel_rows:
+        validate_vehicle_personnel_affiliation(
+            db, tenant_no, vtype, personnel_rows
+        )
+        validate_vehicle_personnel_cert_links(db, tenant_no, personnel_rows)

+ 14 - 1
backend/app/services/pl_personnel_service.py

@@ -18,6 +18,7 @@ def upsert_personnel(
     *,
     user_id: str,
     dept_id: str,
+    vehicle_type: str = "self",
 ) -> str:
     """按 personnelId → phone → name 弱匹配 upsert,返回 personnel_id。"""
     personnel: Optional[PlPersonnel] = None
@@ -65,12 +66,18 @@ def upsert_personnel(
         personnel.update_by = user_id
         return personnel.id
 
+    affiliation = (vehicle_type or "self").strip()
+    if affiliation not in ("self", "carrier"):
+        affiliation = "self"
+
     personnel = PlPersonnel(
         id=generate_uuid(),
         tenant_no=tenant_no,
         personnel_name=name,
         phone_no=phone,
         age=row.age,
+        personnel_type="both",
+        personnel_affiliation=affiliation,
         status="1",
         create_by=user_id,
         create_dept_id=dept_id,
@@ -88,6 +95,7 @@ def save_vehicle_personnel(
     *,
     user_id: str,
     dept_id: str,
+    vehicle_type: str = "self",
 ) -> None:
     """软删旧关联后批量插入;校验默认司机唯一。"""
     default_count = sum(
@@ -107,7 +115,12 @@ def save_vehicle_personnel(
             raise HTTPException(status_code=400, detail="仅司机角色可设为默认")
 
         personnel_id = upsert_personnel(
-            db, tenant_no, row, user_id=user_id, dept_id=dept_id
+            db,
+            tenant_no,
+            row,
+            user_id=user_id,
+            dept_id=dept_id,
+            vehicle_type=vehicle_type,
         )
         link = PlVehiclePersonnel(
             id=generate_uuid(),

+ 3 - 5
backend/graphify-out/.graphify_labels.json

@@ -122,6 +122,7 @@
   "120": "Community 120",
   "121": "Community 121",
   "122": "Community 122",
+  "123": "Community 123",
   "124": "Community 124",
   "125": "Community 125",
   "126": "Community 126",
@@ -148,6 +149,7 @@
   "147": "Community 147",
   "148": "Community 148",
   "149": "Community 149",
+  "150": "Community 150",
   "151": "Community 151",
   "152": "Community 152",
   "153": "Community 153",
@@ -155,9 +157,5 @@
   "155": "Community 155",
   "156": "Community 156",
   "157": "Community 157",
-  "158": "Community 158",
-  "159": "Community 159",
-  "160": "Community 160",
-  "162": "Community 162",
-  "169": "Community 169"
+  "162": "Community 162"
 }

+ 199 - 209
backend/graphify-out/GRAPH_REPORT.md

@@ -1,16 +1,16 @@
 # Graph Report - backend  (2026-07-12)
 
 ## Corpus Check
-- 245 files · ~172,306 words
+- 248 files · ~173,671 words
 - Verdict: corpus is large enough that graph structure adds value.
 
 ## Summary
-- 5436 nodes · 38642 edges · 161 communities (159 shown, 2 thin omitted)
-- Extraction: 51% EXTRACTED · 49% INFERRED · 0% AMBIGUOUS · INFERRED: 18828 edges (avg confidence: 0.5)
+- 5502 nodes · 38811 edges · 159 communities (157 shown, 2 thin omitted)
+- Extraction: 51% EXTRACTED · 49% INFERRED · 0% AMBIGUOUS · INFERRED: 18852 edges (avg confidence: 0.5)
 - Token cost: 0 input · 0 output
 
 ## Graph Freshness
-- Built from commit: `7b1d4c87`
+- Built from commit: `ffc89d9b`
 - Run `git rev-parse HEAD` and compare to check if the graph is stale.
 - Run `graphify update .` after code changes (no API cost).
 
@@ -138,6 +138,7 @@
 - [[_COMMUNITY_Community 120|Community 120]]
 - [[_COMMUNITY_Community 121|Community 121]]
 - [[_COMMUNITY_Community 122|Community 122]]
+- [[_COMMUNITY_Community 123|Community 123]]
 - [[_COMMUNITY_Community 124|Community 124]]
 - [[_COMMUNITY_Community 125|Community 125]]
 - [[_COMMUNITY_Community 126|Community 126]]
@@ -154,6 +155,7 @@
 - [[_COMMUNITY_Community 138|Community 138]]
 - [[_COMMUNITY_Community 148|Community 148]]
 - [[_COMMUNITY_Community 149|Community 149]]
+- [[_COMMUNITY_Community 150|Community 150]]
 - [[_COMMUNITY_Community 151|Community 151]]
 - [[_COMMUNITY_Community 152|Community 152]]
 - [[_COMMUNITY_Community 153|Community 153]]
@@ -161,11 +163,7 @@
 - [[_COMMUNITY_Community 155|Community 155]]
 - [[_COMMUNITY_Community 156|Community 156]]
 - [[_COMMUNITY_Community 157|Community 157]]
-- [[_COMMUNITY_Community 158|Community 158]]
-- [[_COMMUNITY_Community 159|Community 159]]
-- [[_COMMUNITY_Community 160|Community 160]]
 - [[_COMMUNITY_Community 162|Community 162]]
-- [[_COMMUNITY_Community 169|Community 169]]
 
 ## God Nodes (most connected - your core abstractions)
 1. `Session` - 1521 edges
@@ -191,139 +189,139 @@
 - `Any` --uses--> `OperLogMeta`  [INFERRED]
   scripts/migrate_dibang_data.py → app/core/oper_log_context.py
 
-## Communities (161 total, 2 thin omitted)
+## Communities (159 total, 2 thin omitted)
 
 ### Community 0 - "Community 0"
-Cohesion: 0.10
-Nodes (112): date, DbUserContext, int, Session, str, VehicleMaintenanceApplyCreate, VehicleMaintenanceApplyRead, VehicleMaintenanceApplyReminder (+104 more)
+Cohesion: 0.12
+Nodes (102): date, DbUserContext, int, Session, str, VehicleMaintenanceApplyCreate, VehicleMaintenanceApplyRead, VehicleMaintenanceApplyReminder (+94 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.35
 Nodes (219): Any, bool, str, UploadFile, CRM 线索管理 Pydantic Schemas, CRM 客户管理 Pydantic Schemas, CRM 商机管理 Pydantic Schemas, CRM 客户公海池规则 Pydantic Schemas (+211 more)
 
 ### Community 2 - "Community 2"
-Cohesion: 0.22
-Nodes (29): Any, int, Session, str, create_instance(), create_process_def(), create_task(), get_all_tasks_for_business() (+21 more)
+Cohesion: 0.08
+Nodes (54): CrmContract, CrmContractProductPriceTier, date, datetime, Decimal, Query, _apply_contract_entity_fields(), _apply_contract_scalar_fields() (+46 more)
 
 ### Community 3 - "Community 3"
-Cohesion: 0.07
-Nodes (58): CRM 合同 API 路由 — GET/PUT/DELETE/POST 对齐原 Java CRM, str, CRM 合同 Pydantic Schemas — 对照 CrmContractBo / CrmContractVo, ContractApprovalActionBody, ContractApprovalApproveBody, ContractApprovalByContractBody, ContractApprovalExportBody, ContractApprovalListBody (+50 more)
+Cohesion: 0.06
+Nodes (53): str, CRM 合同 Pydantic Schemas — 对照 CrmContractBo / CrmContractVo, ContractApprovalActionBody, ContractApprovalApproveBody, ContractApprovalByContractBody, ContractApprovalExportBody, ContractApprovalListBody, ContractApprovalListQuery (+45 more)
 
 ### Community 4 - "Community 4"
-Cohesion: 0.07
-Nodes (112): bool, DbUserContext, int, 运输计划管理 - 基础数据 API(承运商、车辆、人员), PlCarrier, PlVehicle, Session, str (+104 more)
+Cohesion: 0.05
+Nodes (109): DbUserContext, int, 运输计划管理 - 基础数据 API(承运商、车辆、人员), PlVehicle, Session, str, VehicleRead, date (+101 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.21
-Nodes (36): register_quote_routes(), bool, CRM 报价单 CRUD — 对照 CrmQuoteServiceImpl.java(P2 全量), Session, str, accept_quote(), create_quote(), _enrich_contact_fields() (+28 more)
+Cohesion: 0.16
+Nodes (44): register_quote_routes(), bool, CRM 报价单 CRUD — 对照 CrmQuoteServiceImpl.java(P2 全量), Session, str, accept_quote(), create_quote(), _enrich_contact_fields() (+36 more)
 
 ### Community 6 - "Community 6"
-Cohesion: 0.05
-Nodes (78): 运输计划管理 - 基础数据 Schema(承运商、车辆、人员、证书), Session, str, TransportInventoryLinkRead, str, TransportSiteLinkRead, CarrierAuditReject, CarrierBase (+70 more)
+Cohesion: 0.16
+Nodes (12): Decimal, str, VehicleRechargeApplyCreate, VehicleRechargeApplyListResponse, VehicleRechargeApplyOption, VehicleRechargeApplyRead, VehicleRechargeApplyUpdate, VehicleRechargeDisburseBoardListResponse (+4 more)
 
 ### Community 7 - "Community 7"
 Cohesion: 0.06
-Nodes (63): int, PlPlan, PlTransport, Session, str, UploadFile, Any, PlPlanTransport (+55 more)
+Nodes (163): bool, PlCarrier, VehicleCreate, VehicleUpdate, bool, DbUserContext, float, int (+155 more)
 
 ### Community 8 - "Community 8"
 Cohesion: 0.04
-Nodes (118): Any, bool, CRM 产品模块 CRUD(产品分类 + 产品 + 附件), int, Session, str, SysOss, int (+110 more)
+Nodes (117): Any, bool, CRM 产品模块 CRUD(产品分类 + 产品 + 附件), int, Session, str, SysOss, int (+109 more)
 
 ### Community 9 - "Community 9"
-Cohesion: 0.07
-Nodes (72): CertRead, date, int, Session, str, int, Session, str (+64 more)
+Cohesion: 0.04
+Nodes (107): Any, bool, CertRow, Session, str, VehiclePersonnelRow, CertRead, CertRow (+99 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.05
-Nodes (126): bool, Decimal, int, PlPlan, PlPlanProduct, PlTransport, PlVehicle, Session (+118 more)
+Cohesion: 0.03
+Nodes (137): Decimal, PlPlanProduct, Session, str, Decimal, PlPlanChange, 计划变更记录表 pl_plan_change, 计划产品明细表 pl_plan_product (+129 more)
 
 ### Community 11 - "Community 11"
 Cohesion: 0.11
-Nodes (98): date, DbUserContext, int, Session, str, VehicleUreaApplyCreate, VehicleUreaApplyRead, VehicleUreaApplyUpdate (+90 more)
+Nodes (101): date, DbUserContext, int, Session, str, VehicleUreaApplyCreate, VehicleUreaApplyRead, VehicleUreaApplyUpdate (+93 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.06
-Nodes (169): _apply_default_invoice_info_to_customers(), category_add(), category_delete(), category_tree(), category_update(), clue_assign(), clue_check_convert(), clue_check_duplicate() (+161 more)
+Cohesion: 0.05
+Nodes (227): attachment_preview_url(), create_dept(), create_dict_data(), create_dict_type(), create_menu(), create_post(), create_role_management(), create_site() (+219 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.05
-Nodes (44): get_plan_arrange_info(), patch_plan_product_certs(), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输) (+36 more)
+Cohesion: 0.04
+Nodes (46): get_plan_arrange_info(), patch_plan_product_certs(), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输) (+38 more)
 
 ### Community 14 - "Community 14"
-Cohesion: 0.05
-Nodes (74): BxUserContext, Session, str, UploadFile, Any, bool, bytes, int (+66 more)
+Cohesion: 0.06
+Nodes (60): Any, bool, bytes, int, str, Exception, bool, bytes (+52 more)
 
 ### Community 15 - "Community 15"
-Cohesion: 0.11
-Nodes (126): date, DbUserContext, int, PlanCreateFull, PlanDriverFeedbackUpdateBody, PlanSiteFeedbackUpdateBody, PlPlan, Session (+118 more)
+Cohesion: 0.10
+Nodes (132): date, DbUserContext, int, PlanCreateFull, PlanDriverFeedbackUpdateBody, PlanSiteFeedbackUpdateBody, PlPlan, Session (+124 more)
 
 ### Community 16 - "Community 16"
-Cohesion: 0.03
-Nodes (67): invoice_info_list(), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list (+59 more)
+Cohesion: 0.02
+Nodes (104): 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list (+96 more)
 
 ### Community 17 - "Community 17"
-Cohesion: 0.09
-Nodes (57): Any, bool, CrmContract, datetime, int, Query, Session, str (+49 more)
+Cohesion: 0.08
+Nodes (70): CRM 合同子模块路由 — 变更/模板/审批独立页, register_contract_sub_routes(), Any, bool, CrmContract, datetime, int, Query (+62 more)
 
 ### Community 18 - "Community 18"
 Cohesion: 0.16
-Nodes (33): Any, bool, BxUserContext, BxWorkflowNode, float, int, ReimburseApplyOut, Session (+25 more)
+Nodes (36): Any, bool, BxUserContext, BxWorkflowNode, float, int, ReimburseApplyOut, Session (+28 more)
 
 ### Community 19 - "Community 19"
-Cohesion: 0.19
-Nodes (18): str, Any, bool, Session, str, CRM 模块 Excel 导出列定义(字段与字典类型对齐 Java *Vo @ExcelDictFormat)。, _yes_no(), _build_workbook() (+10 more)
+Cohesion: 0.06
+Nodes (31): 供方详情 — Java: CrmSupplierInfoController.getInfo, 供方详情 — Java: CrmSupplierInfoController.getInfo, 供方详情 — Java: CrmSupplierInfoController.getInfo, 供方详情 — Java: CrmSupplierInfoController.getInfo, 供方详情 — Java: CrmSupplierInfoController.getInfo, 供方详情 — Java: CrmSupplierInfoController.getInfo, 供方详情 — Java: CrmSupplierInfoController.getInfo, 供方详情 — Java: CrmSupplierInfoController.getInfo (+23 more)
 
 ### Community 20 - "Community 20"
-Cohesion: 0.08
-Nodes (40): AttachmentUploadResponse, ChangePasswordRequest, Config, LoginRequest, LoginResponse, Pydantic schemas - 用户认证, ResetPasswordRequest, UserCreate (+32 more)
+Cohesion: 0.07
+Nodes (41): AttachmentUploadResponse, ChangePasswordRequest, Config, LoginRequest, LoginResponse, Pydantic schemas - 用户认证, ResetPasswordRequest, UserCreate (+33 more)
 
 ### Community 21 - "Community 21"
 Cohesion: 0.11
-Nodes (57): Any, bool, int, Session, str, Any, bool, CRM 线索 CRUD(线索主表 + 转化/分配/无效/等级操作) (+49 more)
+Nodes (58): Any, bool, int, Session, str, Any, bool, CRM 线索 CRUD(线索主表 + 转化/分配/无效/等级操作) (+50 more)
 
 ### Community 22 - "Community 22"
-Cohesion: 0.19
-Nodes (37): bool, DbInboundOutbound, int, PublicInventoryFeedback, PublicInventoryPageRead, UploadFile, bool, int (+29 more)
+Cohesion: 0.09
+Nodes (75): bool, DbInboundOutbound, int, PublicInventoryFeedback, PublicInventoryPageRead, Session, str, UploadFile (+67 more)
 
 ### Community 23 - "Community 23"
-Cohesion: 0.09
-Nodes (116): date, DbUserContext, int, Session, str, VehicleRechargeApplyCreate, VehicleRechargeApplyRead, VehicleRechargeApplyUpdate (+108 more)
+Cohesion: 0.11
+Nodes (108): date, DbUserContext, int, Session, str, VehicleRechargeApplyCreate, VehicleRechargeApplyRead, VehicleRechargeApplyUpdate (+100 more)
 
 ### Community 24 - "Community 24"
 Cohesion: 0.25
 Nodes (18): BxUserContext, Session, str, bool, BxUserContext, Session, str, create_expense_item() (+10 more)
 
 ### Community 25 - "Community 25"
-Cohesion: 0.14
-Nodes (29): Any, bool, BxUserContext, BxWorkflowNode, float, int, Session, str (+21 more)
+Cohesion: 0.11
+Nodes (35): Any, bool, BxUserContext, BxWorkflowNode, float, int, Session, str (+27 more)
 
 ### Community 26 - "Community 26"
 Cohesion: 0.18
-Nodes (50): Any, CRM 商机 CRUD(主表 + 阶段/赢输单/统计/看板/漏斗), CrmOpportunity, date, datetime, Decimal, int, Session (+42 more)
+Nodes (49): Any, CRM 商机 CRUD(主表 + 阶段/赢输单/统计/看板/漏斗), CrmOpportunity, date, datetime, Decimal, int, Session (+41 more)
 
 ### Community 27 - "Community 27"
 Cohesion: 0.10
-Nodes (58): bool, date, DbInboundOutbound, DbPurchasePlan, float, PlPlan, PlPlanProduct, PlTransport (+50 more)
+Nodes (58): bool, date, datetime, DbInboundOutbound, DbPurchasePlan, float, PlPlan, PlPlanProduct (+50 more)
 
 ### Community 28 - "Community 28"
 Cohesion: 0.12
-Nodes (30): Session, str, _append_user_scope_conditions(), apply_data_scope(), _collect_scope_ids(), _get_child_dept_ids(), get_user_dept_id(), 数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3 (+22 more)
+Nodes (32): bool, Session, str, _append_user_scope_conditions(), apply_data_scope(), assert_row_in_data_scope(), _collect_scope_ids(), _get_child_dept_ids() (+24 more)
 
 ### Community 29 - "Community 29"
-Cohesion: 0.10
-Nodes (90): Any, register_contract_routes(), CRM 合同子模块路由 — 变更/模板/审批独立页, register_contract_sub_routes(), Any, bool, CRM 合同 CRUD — 对照 CrmContractServiceImpl.java(列表/详情 P1), Session (+82 more)
+Cohesion: 0.08
+Nodes (85): CRM 合同 API 路由 — GET/PUT/DELETE/POST 对齐原 Java CRM, register_contract_routes(), bool, CRM 合同 CRUD — 对照 CrmContractServiceImpl.java(列表/详情 P1), Session, str, batch_assign_owner(), batch_submit_approval() (+77 more)
 
 ### Community 30 - "Community 30"
-Cohesion: 0.16
-Nodes (51): AdvanceApplyCreate, AdvanceApplyOut, AdvanceApplyUpdate, AdvanceApprovalCreate, BxUserContext, int, Session, str (+43 more)
+Cohesion: 0.36
+Nodes (31): AdvanceApplyCreate, AdvanceApplyOut, AdvanceApplyUpdate, AdvanceApprovalCreate, BxUserContext, int, Session, str (+23 more)
 
 ### Community 31 - "Community 31"
-Cohesion: 0.12
-Nodes (44): 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线), refresh_token(), bool, Redis, Request, str, _T, bool (+36 more)
+Cohesion: 0.13
+Nodes (43): 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线), refresh_token(), bool, Redis, Request, str, _T, bool (+35 more)
 
 ### Community 32 - "Community 32"
 Cohesion: 0.07
-Nodes (118): DbUserContext, FreightSettlementDisburseBody, FreightSettlementSubmitApprovalBody, FreightSettlementUpdate, int, Session, str, bool (+110 more)
+Nodes (123): DbUserContext, FreightSettlementDisburseBody, FreightSettlementSubmitApprovalBody, FreightSettlementUpdate, int, Session, str, bool (+115 more)
 
 ### Community 33 - "Community 33"
 Cohesion: 0.15
@@ -331,11 +329,11 @@ Nodes (30): Any, bool, int, Session, str, approve_quote(), can_user_approve(), c
 
 ### Community 34 - "Community 34"
 Cohesion: 0.04
-Nodes (48): create_plan_full(), _generate_plan_no(), 创建运输计划(完整表单:合同产品 + 可选运输安排), 创建运输计划(完整表单:合同产品 + 可选运输安排), 创建运输计划(完整表单:合同产品 + 可选运输安排), 创建运输计划(完整表单:合同产品 + 可选预选车辆,不自动安排运输), 创建运输计划(完整表单:合同产品 + 可选预选车辆,不自动安排运输), 创建运输计划(完整表单:合同产品 + 可选预选车辆,不自动安排运输) (+40 more)
+Nodes (48): PlInventoryTransaction, PlInventoryWarning, 计划-运输关联表 pl_plan_transport, 运费结算表 pl_freight_settlement, 库存流水表 pl_inventory_transaction, 运费结算表 pl_freight_settlement, 结算单-运输关联表 pl_freight_settlement_transport, 结算单-运输关联表 pl_freight_settlement_transport (+40 more)
 
 ### Community 35 - "Community 35"
-Cohesion: 0.21
-Nodes (60): InboundOutboundRead, bool, PurchasePlanRead, PurchasePlanWithFillLink, DbUserContext, DbWeighingSlip, Session, str (+52 more)
+Cohesion: 0.17
+Nodes (39): DbPurchasePlan, DbUserContext, Session, str, InboundOutboundRead, _apply_plan_product_update(), _apply_plan_update(), _base_query() (+31 more)
 
 ### Community 36 - "Community 36"
 Cohesion: 0.40
@@ -346,24 +344,24 @@ Cohesion: 0.13
 Nodes (40): Any, bool, date, int, Session, str, Session, str (+32 more)
 
 ### Community 38 - "Community 38"
-Cohesion: 0.18
-Nodes (44): AdvanceIssueRequest, BxUserContext, int, ReimburseApplyOut, Session, str, datetime, str (+36 more)
+Cohesion: 0.17
+Nodes (64): AdvanceIssueRequest, BxUserContext, int, ReimburseApplyOut, Session, str, BxUserContext, int (+56 more)
 
 ### Community 39 - "Community 39"
-Cohesion: 0.14
-Nodes (40): DbInboundOutbound, DbUserContext, Session, str, Session, str, user_info_from_context(), _apply_inventory_product_update() (+32 more)
+Cohesion: 0.21
+Nodes (37): DbInboundOutbound, DbUserContext, Session, str, CreateLinkForPlanPayload, _apply_inventory_product_update(), _apply_inventory_update(), _base_query() (+29 more)
 
 ### Community 40 - "Community 40"
-Cohesion: 0.15
-Nodes (33): CRM 报价单 API 路由 — 挂接到 crm.router, CRM 报价单 Pydantic Schemas, BaseModel, QuoteAcceptBody, QuoteApprovalApproveBody, QuoteApprovalListBody, QuoteApprovalRefreshNamesBody, QuoteApprovalWithdrawBody (+25 more)
+Cohesion: 0.12
+Nodes (31): CRM 报价单 API 路由 — 挂接到 crm.router, CRM 报价单 Pydantic Schemas, QuoteAcceptBody, QuoteApprovalApproveBody, QuoteApprovalListBody, QuoteApprovalRefreshNamesBody, QuoteApprovalWithdrawBody, QuoteCreateBody (+23 more)
 
 ### Community 41 - "Community 41"
 Cohesion: 0.07
-Nodes (42): _db_pool_keepalive_loop(), _error_json(), lifespan(), _mask_secret(), Exception, int, Request, str (+34 more)
+Nodes (41): _db_pool_keepalive_loop(), _error_json(), lifespan(), _mask_secret(), Exception, int, Request, str (+33 more)
 
 ### Community 42 - "Community 42"
-Cohesion: 0.35
-Nodes (30): BxUserContext, int, Session, str, approve_reimburse(), approved_by_me(), _build_details_data(), create_reimburse() (+22 more)
+Cohesion: 0.05
+Nodes (44): Any, datetime, Decimal, calculate_quote_amount(), _decimal_value(), _parse_optional_datetime(), _period_key(), Java: statisticsOverview (+36 more)
 
 ### Community 43 - "Community 43"
 Cohesion: 0.13
@@ -371,39 +369,39 @@ Nodes (37): Any, bool, Decimal, int, Session, str, batch_insert_quote_products()
 
 ### Community 44 - "Community 44"
 Cohesion: 0.07
-Nodes (95): Any, int, Session, str, Any, bool, CRM 客户 CRUD(客户主表 + 转移/成交/公海池操作), CrmCustomer (+87 more)
+Nodes (98): pool_rule_toggle(), Any, int, Session, str, Any, bool, CRM 客户 CRUD(客户主表 + 转移/成交/公海池操作) (+90 more)
 
 ### Community 45 - "Community 45"
 Cohesion: 0.13
 Nodes (15): Any, bool, int, str, get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.html, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total_amo (+7 more)
 
 ### Community 46 - "Community 46"
-Cohesion: 0.13
-Nodes (30): Any, bool, CRM 供方信息 CRUD — 对照 CrmSupplierInfoServiceImpl.java, int, Session, str, _apply_list_filters(), create_supplier_info() (+22 more)
+Cohesion: 0.18
+Nodes (11): generate_contract_no(), Java: generateContractNo — {供方编码}/X-JT + yyyyMMdd + 3位流水, Java: generateContractNo — {供方编码}/X-JT + yyyyMMdd + 3位流水, Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}, Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}, Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}, Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}, Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号} (+3 more)
 
 ### Community 47 - "Community 47"
 Cohesion: 0.16
 Nodes (33): Any, bool, int, Session, str, Any, Session, str (+25 more)
 
 ### Community 48 - "Community 48"
-Cohesion: 0.14
-Nodes (121): date, DbUserContext, int, Session, str, bool, DbUserContext, float (+113 more)
+Cohesion: 0.16
+Nodes (94): date, DbUserContext, int, Session, str, DocumentReminderRead, DocumentReminderUpdate, InventoryLedgerResponse (+86 more)
 
 ### Community 49 - "Community 49"
-Cohesion: 0.24
-Nodes (20): Any, bool, str, _actor_ids(), _actor_names(), assert_row_deletable_by_creator(), assert_rows_deletable_by_creator(), can_delete_by_creator() (+12 more)
+Cohesion: 0.38
+Nodes (22): DbUserContext, int, Session, str, PlTransportAddress, 运输通用地址库 pl_transport_address, 运输通用地址库 pl_transport_address, 运输通用地址库 pl_transport_address (+14 more)
 
 ### Community 50 - "Community 50"
 Cohesion: 0.12
-Nodes (31): datetime, int, str, datetime, date, datetime, str, 审批轮次过滤 — 驳回后重新提交时,进度接口仅展示当前轮次。 (+23 more)
+Nodes (35): datetime, int, str, datetime, date, datetime, str, 审批轮次过滤 — 驳回后重新提交时,进度接口仅展示当前轮次。 (+27 more)
 
 ### Community 51 - "Community 51"
-Cohesion: 0.16
-Nodes (31): Any, bool, CrmContractProductPriceTier, date, Decimal, int, Session, str (+23 more)
+Cohesion: 0.17
+Nodes (32): Any, bool, CrmContractProductPriceTier, date, Decimal, int, Session, str (+24 more)
 
 ### Community 52 - "Community 52"
-Cohesion: 0.08
-Nodes (25): batch_assign_owner(), download_contract_pdf(), Java: batchAssignOwner, Java: downloadContractPdf — 返回合同文件 URL/路径, Java: batchAssignOwner, Java: batchAssignOwner, Java: batchAssignOwner, Java: batchAssignOwner (+17 more)
+Cohesion: 0.25
+Nodes (8): Java Controller TODO — 保持 pending,记录备注, Java Controller TODO — 保持 pending,记录备注, Java Controller TODO — 保持 pending,记录备注, Java Controller TODO — 保持 pending,记录备注, Java Controller TODO — 保持 pending,记录备注, Java Controller TODO — 保持 pending,记录备注, Java Controller TODO — 保持 pending,记录备注, submit_change_approval()
 
 ### Community 53 - "Community 53"
 Cohesion: 0.14
@@ -414,56 +412,56 @@ Cohesion: 0.23
 Nodes (10): Any, bool, int, Session, str, camel_to_snake(), CRUDBase, get_model_attr() (+2 more)
 
 ### Community 55 - "Community 55"
-Cohesion: 0.23
-Nodes (30): Engine, int, Path, build_id_maps(), clip_text(), count_rows(), export_mapping(), fetch_rows() (+22 more)
+Cohesion: 0.15
+Nodes (35): Engine, Path, build_id_maps(), clip_text(), count_rows(), export_mapping(), fetch_rows(), legacy_id_to_str() (+27 more)
 
 ### Community 56 - "Community 56"
 Cohesion: 0.22
-Nodes (12): Any, bool, str, WfProcessInstance, _LocalCallbackFn, _dispatch_local(), 工作流回调服务 —— 流程状态变更时通知业务系统, 注册本地回调函数。当 callback_url 以 path 开头时直接调用 fn。 (+4 more)
+Nodes (29): Any, int, Session, str, create_instance(), create_process_def(), create_task(), get_all_tasks_for_business() (+21 more)
 
 ### Community 57 - "Community 57"
-Cohesion: 0.46
-Nodes (13): bool, date, PlCarrier, str, bool, assert_carrier_eligible_for_transport(), carrier_agreement_expired(), carrier_audit_approved() (+5 more)
+Cohesion: 0.22
+Nodes (49): InboundOutboundRead, bool, PurchasePlanRead, PurchasePlanWithFillLink, DbUserContext, DbWeighingSlip, Session, str (+41 more)
 
 ### Community 58 - "Community 58"
 Cohesion: 0.19
-Nodes (20): int, str, Any, bool, int, Session, str, business_type_from_path() (+12 more)
+Nodes (21): DbUserContext, Session, str, Session, str, int, Session, str (+13 more)
 
 ### Community 59 - "Community 59"
-Cohesion: 0.33
-Nodes (10): Any, int, Session, str, list_ethnics(), 民族码表 CRUD(全局 sys_ethnic), _row_to_dict(), 民族码表 sys_ethnic(国标 GB/T 3304,全局) (+2 more)
+Cohesion: 0.24
+Nodes (20): Any, bool, str, _actor_ids(), _actor_names(), assert_row_deletable_by_creator(), assert_rows_deletable_by_creator(), can_delete_by_creator() (+12 more)
 
 ### Community 60 - "Community 60"
-Cohesion: 0.22
-Nodes (9): Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval (+1 more)
+Cohesion: 0.19
+Nodes (18): str, Any, bool, Session, str, CRM 模块 Excel 导出列定义(字段与字典类型对齐 Java *Vo @ExcelDictFormat)。, _yes_no(), _build_workbook() (+10 more)
 
 ### Community 61 - "Community 61"
 Cohesion: 0.24
 Nodes (25): DbUserContext, float, Session, str, Session, str, DashboardCargoStat, DashboardPartnerCargoStat (+17 more)
 
 ### Community 62 - "Community 62"
-Cohesion: 0.10
-Nodes (21): _build_contract_filters(), export_contracts(), Java: validateContractTypeAndProductsPresence, Java: queryList for export, Java: queryList for export, Java: queryList for export, Java: queryList for export, Java: buildQueryWrapper (+13 more)
+Cohesion: 0.08
+Nodes (40): Any, Any, str, Any, 与门户 JWT / apply_data_scope 所需的 user_info 字段对齐。, _build_customer_address(), _change_row_to_dict(), contract_row_to_dict() (+32 more)
 
 ### Community 63 - "Community 63"
 Cohesion: 0.08
 Nodes (25): 技术栈, 1. 创建虚拟环境, 项目结构, 1. 创建虚拟环境, 2. 安装依赖, 2. 安装依赖, 3. 配置环境变量, 快速开始 (+17 more)
 
 ### Community 64 - "Community 64"
-Cohesion: 0.05
-Nodes (46): PlInventoryTransaction, PlInventoryWarning, 计划-运输关联表 pl_plan_transport, 运费结算表 pl_freight_settlement, 库存流水表 pl_inventory_transaction, 运费结算表 pl_freight_settlement, 结算单-运输关联表 pl_freight_settlement_transport, 结算单-运输关联表 pl_freight_settlement_transport (+38 more)
+Cohesion: 0.21
+Nodes (27): approve_task(), _assert_wf_instance_in_scope(), _business_type_label(), create_process_def(), delete_process_def(), _enrich_task_list(), get_business_all_tasks(), _get_business_form_fields() (+19 more)
 
 ### Community 65 - "Community 65"
-Cohesion: 0.28
-Nodes (8): date, datetime, str, _date_to_str(), _dt_to_str(), PagedQuery, PagedResponse, bx 原系统 API 响应包装(code=0 / msg / data.rows),与门户 success_response 分离。
+Cohesion: 0.12
+Nodes (33): BxUserContext, int, Session, str, Any, str, bool, BxUserContext (+25 more)
 
 ### Community 66 - "Community 66"
-Cohesion: 0.14
-Nodes (28): str, T, bool, date, datetime, int, Session, str (+20 more)
+Cohesion: 0.18
+Nodes (24): bool, date, datetime, int, Session, str, ReimburseApplyOut, allocate_reimburse_title_seq() (+16 more)
 
 ### Community 67 - "Community 67"
-Cohesion: 0.21
-Nodes (17): bool, int, Redis, Request, str, _claims_from_auth(), _coerce_post_method_override_to_get(), _get_redis_client() (+9 more)
+Cohesion: 0.20
+Nodes (18): bool, int, Redis, Request, str, _claims_from_auth(), _coerce_post_method_override_to_get(), _get_redis_client() (+10 more)
 
 ### Community 68 - "Community 68"
 Cohesion: 0.25
@@ -474,8 +472,8 @@ Cohesion: 0.21
 Nodes (16): Any, int, str, BusinessException, InvalidStatusTransitionException, log_api_request(), log_delivery_operation(), log_operation() (+8 more)
 
 ### Community 70 - "Community 70"
-Cohesion: 0.11
-Nodes (32): Any, str, bool, BxUserContext, int, Session, str, Any (+24 more)
+Cohesion: 0.14
+Nodes (25): bool, BxUserContext, int, Session, str, Any, bool, int (+17 more)
 
 ### Community 71 - "Community 71"
 Cohesion: 0.16
@@ -494,25 +492,25 @@ Cohesion: 0.06
 Nodes (88): Any, Decimal, Session, str, _advance_after_approval(), _approval_to_dict(), can_user_approve_settlement(), cancel_draft_settlement() (+80 more)
 
 ### Community 75 - "Community 75"
-Cohesion: 0.33
-Nodes (3): 地磅 API 路由聚合 — REST 风格,协议对齐原 simple-dibang-xiaoshou。, 地磅(db)Pydantic schemas — 对齐原 simple-dibang-xiaoshou 契约。, 地磅 API 路由聚合 — REST 风格,协议对齐原 simple-dibang-xiaoshou。
+Cohesion: 0.28
+Nodes (11): operlog_detail(), operlog_list(), 操作日志分页列表(基于 API 请求日志适配 RuoYi OperLog 结构), 操作日志分页列表(基于 API 请求日志适配 RuoYi OperLog 结构), _user_info(), Session, OperLogDetailBody, OperLogListBody (+3 more)
 
 ### Community 76 - "Community 76"
-Cohesion: 0.09
-Nodes (49): API 路由包 — 山峡化工生产一体化门户, create_leave(), delete_leave(), get_leave(), _leave_local_callback(), leave_workflow_callback(), list_leaves(), submit_leave() (+41 more)
+Cohesion: 0.27
+Nodes (17): create_leave(), delete_leave(), get_leave(), _leave_local_callback(), leave_workflow_callback(), list_leaves(), submit_leave(), update_leave() (+9 more)
 
 ### Community 77 - "Community 77"
-Cohesion: 0.33
-Nodes (3): _coerce_id(), OffsetItem, ReimburseDetailCreate
+Cohesion: 0.19
+Nodes (20): int, str, Any, bool, int, Session, str, business_type_from_path() (+12 more)
 
 ### Community 78 - "Community 78"
-Cohesion: 0.13
-Nodes (37): CrmContract, CrmContractProductPriceTier, date, datetime, Decimal, int, Query, contract_row_to_dict() (+29 more)
-
-### Community 79 - "Community 79"
 Cohesion: 0.28
 Nodes (18): Any, int, Session, str, _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept() (+10 more)
 
+### Community 79 - "Community 79"
+Cohesion: 0.34
+Nodes (16): Any, bool, int, Session, str, create_dict_type(), dict_type_code_exists(), get_dict_type() (+8 more)
+
 ### Community 80 - "Community 80"
 Cohesion: 0.31
 Nodes (20): Any, bool, CrmOpportunity, datetime, int, Session, str, create_opportunity_follow() (+12 more)
@@ -534,16 +532,16 @@ Cohesion: 0.34
 Nodes (19): check_python(), create_venv(), ensure_dirs(), ensure_valid_venv(), ensure_venv_and_runtime(), install_dependencies(), is_running(), log_error() (+11 more)
 
 ### Community 85 - "Community 85"
-Cohesion: 0.09
-Nodes (27): Any, datetime, Decimal, _decimal_value(), _parse_optional_datetime(), _period_key(), Java: statisticsOverview, Java: statisticsOverview (+19 more)
+Cohesion: 0.29
+Nodes (16): Any, int, Session, str, BizLeaveRequest, create_leave(), get_leave(), list_leaves() (+8 more)
 
 ### Community 86 - "Community 86"
 Cohesion: 0.35
 Nodes (17): Any, bool, int, Session, str, _build_tree(), _cascade_soft_delete(), create_menu() (+9 more)
 
 ### Community 87 - "Community 87"
-Cohesion: 0.10
-Nodes (21): cancel_plan(), 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。 (+13 more)
+Cohesion: 0.09
+Nodes (22): cancel_plan(), 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。, 作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。 (+14 more)
 
 ### Community 88 - "Community 88"
 Cohesion: 0.37
@@ -554,40 +552,40 @@ Cohesion: 0.22
 Nodes (17): Any, bool, Session, str, list_share_logs(), _parse_user_agent(), CRM 报价单分享访问日志 CRUD — 对照 CrmQuoteShareLogServiceImpl.java, Java: statisticsVisits (+9 more)
 
 ### Community 90 - "Community 90"
-Cohesion: 0.10
-Nodes (22): date, Query, _build_quote_filters(), generate_quote_no(), _parse_optional_date(), Java: toContract — 返回 contractId, Java: toContract — 返回 contractId, Java: toContract — 返回 contractId (+14 more)
+Cohesion: 0.38
+Nodes (15): Any, bool, int, Session, str, create_site(), get_site(), list_sites() (+7 more)
 
 ### Community 91 - "Community 91"
-Cohesion: 0.29
-Nodes (16): Any, int, Session, str, BizLeaveRequest, create_leave(), get_leave(), list_leaves() (+8 more)
+Cohesion: 0.10
+Nodes (21): _generate_plan_no(), 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD###### (+13 more)
 
 ### Community 92 - "Community 92"
-Cohesion: 0.45
-Nodes (20): DbUserContext, int, Session, str, PlTransportAddress, 运输通用地址库 pl_transport_address, _address_to_read(), _build_address_query() (+12 more)
+Cohesion: 0.10
+Nodes (21): create_new_version(), Java: createNewVersion — 返回新报价单 id, Java: toContract — 返回 contractId, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id (+13 more)
 
 ### Community 93 - "Community 93"
 Cohesion: 0.34
 Nodes (16): Any, bool, int, Session, str, create_tenant(), get_tenant(), list_tenants() (+8 more)
 
 ### Community 94 - "Community 94"
-Cohesion: 0.39
-Nodes (8): bool, BxUserContext, Session, str, 按业务类型获取工作流;已知类型不存在时自动创建空定义(无节点)。, 按业务类型获取工作流;已知类型不存在时自动创建空定义(无节点)。, WorkflowService, BxWorkflowDef
+Cohesion: 0.41
+Nodes (13): BxUserContext, Session, str, UploadFile, bind_biz(), delete_attachment(), download_file(), list_by_biz() (+5 more)
 
 ### Community 95 - "Community 95"
 Cohesion: 0.23
 Nodes (14): int, Session, str, _allocate_inbound_outbound_id(), _ensure_sequence_row(), max_numeric_inbound_outbound_id(), next_inbound_outbound_id(), 出入库单 id 生成 — 租户内纯数字自增,并发安全。 (+6 more)
 
 ### Community 96 - "Community 96"
-Cohesion: 0.17
-Nodes (20): Any, int, str, get_oper_log_meta(), OperLogMeta, 请求级操作日志元数据(ContextVar), 供异步落库线程使用,避免 ContextVar 跨线程丢失。, set_oper_log_meta() (+12 more)
+Cohesion: 0.20
+Nodes (10): Decimal, VehicleUreaApplyCreate, VehicleUreaApplyListResponse, VehicleUreaApplyRead, VehicleUreaApplyUpdate, VehicleUreaDisburseBoardListResponse, VehicleUreaDisburseCreate, VehicleUreaDisburseListResponse (+2 more)
 
 ### Community 97 - "Community 97"
 Cohesion: 0.38
 Nodes (15): Any, bool, int, Session, str, create_post(), get_post(), list_posts() (+7 more)
 
 ### Community 98 - "Community 98"
-Cohesion: 0.38
-Nodes (15): Any, bool, int, Session, str, create_site(), get_site(), list_sites() (+7 more)
+Cohesion: 0.41
+Nodes (11): Any, str, build_quote_html(), _default_value(), _format_date(), _format_discount(), _format_money(), generate_quote_pdf() (+3 more)
 
 ### Community 99 - "Community 99"
 Cohesion: 0.33
@@ -599,23 +597,23 @@ Nodes (14): is_valid_dict_type(), load_portal_baseline(), load_sx(), load_zkxw()
 
 ### Community 101 - "Community 101"
 Cohesion: 0.22
-Nodes (17): Session, str, str, 附件上传 bizType 与 MinIO 目录映射(对齐原 CRM oss 上传语义), 优先使用显式 folder;否则按 bizType 映射默认目录。, 优先使用显式 folder;否则按 bizType 映射默认目录。, 优先使用显式 folder;否则按 bizType 映射默认目录。, resolve_upload_folder() (+9 more)
+Nodes (18): Any, bool, bytes, int, Request, str, _claims_from_auth(), _format_body_bytes() (+10 more)
 
 ### Community 102 - "Community 102"
-Cohesion: 0.12
-Nodes (16): int, _fill_contract_linked_count(), list_quotes(), Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount (+8 more)
+Cohesion: 0.35
+Nodes (5): Any, str, WfProcessInstance, WorkflowEngine, WfProcessInstance
 
 ### Community 103 - "Community 103"
-Cohesion: 0.41
-Nodes (11): Any, str, build_quote_html(), _default_value(), _format_date(), _format_discount(), _format_money(), generate_quote_pdf() (+3 more)
+Cohesion: 0.12
+Nodes (16): int, _fill_contract_linked_count(), list_quotes(), Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount (+8 more)
 
 ### Community 104 - "Community 104"
-Cohesion: 0.39
-Nodes (8): int, LeaveCreate, LeaveUpdate, Config, LeaveCreate, LeaveResponse, LeaveUpdate, Pydantic schemas - 请假申请
+Cohesion: 0.14
+Nodes (15): date, Query, _build_quote_filters(), _parse_optional_date(), Java: toContract — 返回 contractId, Java: toContract — 返回 contractId, Java: buildQueryWrapper, Java: buildQueryWrapper (+7 more)
 
 ### Community 105 - "Community 105"
-Cohesion: 0.16
-Nodes (25): Any, bool, bytes, int, Request, str, bool, int (+17 more)
+Cohesion: 0.17
+Nodes (20): Any, int, str, get_oper_log_meta(), OperLogMeta, 请求级操作日志元数据(ContextVar), 供异步落库线程使用,避免 ContextVar 跨线程丢失。, set_oper_log_meta() (+12 more)
 
 ### Community 106 - "Community 106"
 Cohesion: 0.36
@@ -626,48 +624,52 @@ Cohesion: 0.60
 Nodes (10): Any, Session, str, _apply_create_range(), _base_query(), get_clue_level_statistics(), get_clue_overview_statistics(), get_clue_owner_statistics() (+2 more)
 
 ### Community 108 - "Community 108"
-Cohesion: 0.35
-Nodes (5): Any, str, WfProcessInstance, WorkflowEngine, WfProcessInstance
+Cohesion: 0.22
+Nodes (12): Any, bool, str, WfProcessInstance, _LocalCallbackFn, _dispatch_local(), 工作流回调服务 —— 流程状态变更时通知业务系统, 注册本地回调函数。当 callback_url 以 path 开头时直接调用 fn。 (+4 more)
 
 ### Community 109 - "Community 109"
 Cohesion: 0.14
 Nodes (22): Session, str, bool, Session, str, get_user_menu_codes(), 菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms), 读取用户授权的菜单权限标识集合(与前端 meta.menuCode / perms 对齐)。 (+14 more)
 
 ### Community 110 - "Community 110"
-Cohesion: 0.05
-Nodes (38): create_contract(), create_from_opportunity(), generate_contract_name(), generate_contract_no(), _normalize_framework_contract_data(), _opportunity_products_to_contract_products(), Java: normalizeFrameworkContractData, Java: createFromOpportunity — 返回合同 id (+30 more)
+Cohesion: 0.18
+Nodes (11): generate_contract_name(), Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName (+3 more)
 
 ### Community 111 - "Community 111"
-Cohesion: 0.17
-Nodes (42): ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody, TaskActionBody, Any (+34 more)
+Cohesion: 0.19
+Nodes (38): ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, StartInstanceBody, Any, bool, WfProcessInstance, bool (+30 more)
 
 ### Community 112 - "Community 112"
-Cohesion: 0.08
-Nodes (103): attachment_download_url(), attachment_preview_url(), AttachmentDownloadUrlBody, create_dept(), create_dict_data(), create_dict_type(), create_menu(), create_post() (+95 more)
+Cohesion: 0.26
+Nodes (27): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, MenuCreateBody (+19 more)
 
 ### Community 113 - "Community 113"
 Cohesion: 0.10
 Nodes (20): create_from_quote(), _quote_products_to_contract_products(), Java: createFromQuote — 返回合同 id, Java: renewContract — 基础续签, Java: createFromQuote — 返回合同 id, Java: renewContract — 基础续签, Java: createFromQuote — 返回合同 id, Java: createFromQuote — 返回合同 id (+12 more)
 
+### Community 114 - "Community 114"
+Cohesion: 0.19
+Nodes (11): Decimal, VehicleMaintenanceApplyCreate, VehicleMaintenanceApplyListResponse, VehicleMaintenanceApplyRead, VehicleMaintenanceApplyReminder, VehicleMaintenanceApplyUpdate, VehicleMaintenanceProcessBoardListResponse, VehicleMaintenanceProcessCreate (+3 more)
+
 ### Community 115 - "Community 115"
 Cohesion: 0.29
 Nodes (9): AST, expr, field_has_alias(), iter_field_assignments(), main(), bool, int, str (+1 more)
 
 ### Community 116 - "Community 116"
-Cohesion: 0.13
-Nodes (15): calculate_quote_amount(), create_new_version(), Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id, Java: createNewVersion — 返回新报价单 id (+7 more)
+Cohesion: 0.15
+Nodes (13): ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), Java: viewByShareCode, Java: viewByShareCode, Java: viewByShareCode, Java: viewByShareCode (+5 more)
 
 ### Community 117 - "Community 117"
-Cohesion: 0.39
-Nodes (6): _inject_user_info_after_tenant(), patch_contract_routes(), patch_quote_routes(), patch_route_file(), Path, str
+Cohesion: 0.44
+Nodes (8): bool, int, Session, str, ApiRequestLogCRUD, SysApiRequestLog, SysApiRequestLogHis, SysApiRequestLog
 
 ### Community 118 - "Community 118"
-Cohesion: 0.34
-Nodes (16): Any, bool, int, Session, str, create_dict_type(), dict_type_code_exists(), get_dict_type() (+8 more)
+Cohesion: 0.20
+Nodes (10): create_from_opportunity(), _opportunity_products_to_contract_products(), Java: createFromOpportunity — 返回合同 id, Java: createFromOpportunity — 返回合同 id, Java: createFromOpportunity — 返回合同 id, Java: createFromOpportunity — 返回合同 id, Java: createFromOpportunity — 返回合同 id, Java: createFromOpportunity — 返回合同 id (+2 more)
 
 ### Community 119 - "Community 119"
-Cohesion: 0.15
-Nodes (13): ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), Java: viewByShareCode, Java: viewByShareCode, Java: viewByShareCode, Java: viewByShareCode (+5 more)
+Cohesion: 0.29
+Nodes (10): datetime, int, Session, str, BizNoKind, allocate_next_biz_no(), _format_biz_no(), _prefix_for() (+2 more)
 
 ### Community 120 - "Community 120"
 Cohesion: 0.27
@@ -681,6 +683,10 @@ Nodes (9): batch_submit_contract_approval(), Java: batchSubmitApproval, Java: ba
 Cohesion: 0.32
 Nodes (7): str, eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci()
 
+### Community 123 - "Community 123"
+Cohesion: 0.33
+Nodes (10): Any, int, Session, str, list_ethnics(), 民族码表 CRUD(全局 sys_ethnic), _row_to_dict(), 民族码表 sys_ethnic(国标 GB/T 3304,全局) (+2 more)
+
 ### Community 124 - "Community 124"
 Cohesion: 0.18
 Nodes (9): 报销(bx)API 路由聚合 — REST 风格,协议对齐 bx 原系统。, 报销(bx)Pydantic 模型 — 响应协议与 bx 原系统保持一致, bx_health(), 报销(bx)API 路由聚合 — REST 风格,协议对齐 bx 原系统。, P0 健康检查:验证 bx 路由已挂载且响应协议为 code=0。, P0 健康检查:验证 bx 路由已挂载且响应协议为 code=0。, P0 健康检查:验证 bx 路由已挂载且响应协议为 code=0。, P0 健康检查:验证 bx 路由已挂载且响应协议为 code=0。 (+1 more)
@@ -690,40 +696,40 @@ Cohesion: 0.22
 Nodes (9): generate_template_no(), Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}, Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}, Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}, Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}, Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}, Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}, Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号} (+1 more)
 
 ### Community 126 - "Community 126"
-Cohesion: 0.17
-Nodes (10): FreightSettlementApprovalActionBody, FreightSettlementByTransportRead, FreightSettlementCreate, FreightSettlementDisburseBody, FreightSettlementListItem, FreightSettlementSubmitApprovalBody, SettlementApprovalFlowItem, SettlementPlanSummary (+2 more)
+Cohesion: 0.05
+Nodes (48): BaseModel, AdvanceApplyOut, AdvanceApprovalCreate, AttachmentOut, PagedQuery, PagedResponse, ReimburseApplyCreate, ReimburseApplyUpdate (+40 more)
 
 ### Community 127 - "Community 127"
-Cohesion: 0.16
-Nodes (37): DbPurchasePlan, DbUserContext, Session, str, InboundOutboundRead, _apply_plan_product_update(), _apply_plan_update(), _base_query() (+29 more)
+Cohesion: 0.32
+Nodes (7): Session, str, apply_product_snapshot_to_weighing(), ProductSnapshot, 地磅模块 — CRM 产品解析与快照写入。, 校验 CRM 产品并返回名称/规格快照。新建单据 product_id 必填。, resolve_crm_product()
 
 ### Community 128 - "Community 128"
-Cohesion: 0.24
-Nodes (8): Decimal, VehicleUreaApplyCreate, VehicleUreaApplyListResponse, VehicleUreaApplyUpdate, VehicleUreaDisburseBoardListResponse, VehicleUreaDisburseCreate, VehicleUreaDisburseListResponse, VehicleUreaDisburseUpdate
+Cohesion: 0.29
+Nodes (5): API 路由包 — 山峡化工生产一体化门户, get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,14 个一级菜单), 运营端角色守卫:admin / super_admin 可访问。, require_admin_or_super_admin()
 
 ### Community 129 - "Community 129"
-Cohesion: 0.18
-Nodes (11): Java: createNewVersion — 返回新报价单 id, Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人) (+3 more)
+Cohesion: 0.38
+Nodes (6): date, datetime, str, _date_to_str(), _dt_to_str(), bx 原系统 API 响应包装(code=0 / msg / data.rows),与门户 success_response 分离。
 
 ### Community 130 - "Community 130"
-Cohesion: 0.07
-Nodes (34): _avatar_url_field(), _build_user_info_payload(), get_current_user_profile(), get_web_auth_menu_codes(), _jwt_user_payload(), logout(), public_quote_share(), 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。 (+26 more)
+Cohesion: 0.09
+Nodes (29): _avatar_url_field(), _build_user_info_payload(), get_current_user_profile(), _jwt_user_payload(), logout(), public_quote_share(), 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。 (+21 more)
 
 ### Community 131 - "Community 131"
 Cohesion: 0.40
 Nodes (3): BaseSettings, Config, Settings
 
 ### Community 132 - "Community 132"
-Cohesion: 0.18
-Nodes (11): Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus (+3 more)
+Cohesion: 0.33
+Nodes (5): _coerce_optional_id(), WorkflowDefCreate, WorkflowDefUpdate, WorkflowNodeCreate, WorkflowNodeOut
 
 ### Community 134 - "Community 134"
-Cohesion: 0.18
-Nodes (11): 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。, 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。, 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。, 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。, 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。, 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。, 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。, 解析司机信息:优先 pl_personnel,兼容旧 pl_driver。 (+3 more)
+Cohesion: 0.33
+Nodes (6): attachment_download_url(), 返回带 Content-Disposition 的下载 URL,保证浏览器使用原始文件名。, 返回带 Content-Disposition 的下载 URL,保证浏览器使用原始文件名。, 返回带 Content-Disposition 的下载 URL,保证浏览器使用原始文件名。, 返回带 Content-Disposition 的下载 URL,保证浏览器使用原始文件名。, 返回带 Content-Disposition 的下载 URL,保证浏览器使用原始文件名。
 
 ### Community 135 - "Community 135"
-Cohesion: 0.20
-Nodes (10): Java: deleteWithValidByIds — 软删除, Java: deleteWithValidByIds — 软删除, Java: deleteWithValidByIds — 软删除, Java: deleteWithValidByIds — 软删除, Java: deleteWithValidByIds — 软删除, Java: deleteWithValidByIds — 软删除, Java: deleteWithValidByIds, Java: deleteWithValidByIds — 软删除 (+2 more)
+Cohesion: 0.33
+Nodes (3): 地磅 API 路由聚合 — REST 风格,协议对齐原 simple-dibang-xiaoshou。, 地磅(db)Pydantic schemas — 对齐原 simple-dibang-xiaoshou 契约。, 地磅 API 路由聚合 — REST 风格,协议对齐原 simple-dibang-xiaoshou。
 
 ### Community 136 - "Community 136"
 Cohesion: 0.18
@@ -739,11 +745,15 @@ Nodes (4): str, _coerce_id(), ExpenseItemCreate, ExpenseItemUpdate
 
 ### Community 149 - "Community 149"
 Cohesion: 0.33
-Nodes (5): _coerce_optional_id(), WorkflowDefCreate, WorkflowDefUpdate, WorkflowNodeCreate, WorkflowNodeOut
+Nodes (3): _coerce_id(), OffsetItem, ReimburseDetailCreate
+
+### Community 150 - "Community 150"
+Cohesion: 0.40
+Nodes (5): list_roles_for_designer(), 返回角色下拉选项(label=角色名,value=角色ID UUID),供流程设计器审批人选择使用。     与 /roles 不同,此端点返回角色 UUID, 返回角色下拉选项(label=角色名,value=角色ID UUID),供流程设计器审批人选择使用。     与 /roles 不同,此端点返回角色 UUID, 返回角色下拉选项(label=角色名,value=角色ID UUID),供流程设计器审批人选择使用。     与 /roles 不同,此端点返回角色 UUID, 返回角色下拉选项(label=角色名,value=角色ID UUID),供流程设计器审批人选择使用。     与 /roles 不同,此端点返回角色 UUID
 
 ### Community 151 - "Community 151"
-Cohesion: 0.03
-Nodes (60): 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll (+52 more)
+Cohesion: 0.10
+Nodes (21): invoice_info_list(), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价) (+13 more)
 
 ### Community 152 - "Community 152"
 Cohesion: 0.38
@@ -754,40 +764,20 @@ Cohesion: 0.40
 Nodes (5): Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), web_login_rsa_public_key()
 
 ### Community 154 - "Community 154"
-Cohesion: 0.29
-Nodes (10): datetime, int, Session, str, BizNoKind, allocate_next_biz_no(), _format_biz_no(), _prefix_for() (+2 more)
-
-### Community 155 - "Community 155"
 Cohesion: 0.40
-Nodes (4): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID, ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID, ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
+Nodes (5): get_web_auth_menu_codes(), 获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。, 获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。, 获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。, 获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。
 
 ### Community 156 - "Community 156"
 Cohesion: 0.40
-Nodes (4): ContactByCustomerBody, ContactDetailBody, ContactSetPrimaryBody, CRM 联系人 Pydantic Schemas
+Nodes (3): AdvanceApplyCreate, AdvanceApplyUpdate, _coerce_optional_id()
 
 ### Community 157 - "Community 157"
-Cohesion: 0.40
-Nodes (4): CreateLinkForPlanPayload, InboundOutboundBase, InboundOutboundCreate, InboundOutboundUpdate
-
-### Community 158 - "Community 158"
-Cohesion: 0.50
-Nodes (3): Config, PageResult, ResponseModel
-
-### Community 159 - "Community 159"
 Cohesion: 0.50
-Nodes (3): PurchasePlanBase, PurchasePlanCreate, PurchasePlanUpdate
-
-### Community 160 - "Community 160"
-Cohesion: 0.50
-Nodes (3): WeighingSlipBase, WeighingSlipCreate, WeighingSlipUpdate
+Nodes (4): list_roles_for_select(), 返回运营端角色下拉选项(label=库中角色名,value=Web 角色码)。, 返回运营端角色下拉选项(label=库中角色名,value=Web 角色码)。, 返回运营端角色下拉选项(label=库中角色名,value=Web 角色码)。
 
 ### Community 162 - "Community 162"
-Cohesion: 0.50
-Nodes (3): Any, str, 与门户 JWT / apply_data_scope 所需的 user_info 字段对齐。
-
-### Community 169 - "Community 169"
-Cohesion: 0.18
-Nodes (12): Session, Session, get_current_user(), get_current_user_by_token(), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期), 获取当前用户(sync 依赖,配合 sync 路由在线程池执行,避免阻塞事件循环)。 (+4 more)
+Cohesion: 0.12
+Nodes (17): Session, Session, get_current_user(), get_current_user_by_token(), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期), 获取当前用户(sync 依赖,配合 sync 路由在线程池执行,避免阻塞事件循环)。 (+9 more)
 
 ## Knowledge Gaps
 - **77 isolated node(s):** `Session`, `Request`, `bool`, `bool`, `Config` (+72 more)
@@ -797,12 +787,12 @@ Nodes (12): Session, Session, get_current_user(), get_current_user_by_token(), 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `Session` connect `Community 112` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 18`, `Community 19`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`, `Community 34`, `Community 35`, `Community 37`, `Community 38`, `Community 39`, `Community 42`, `Community 43`, `Community 44`, `Community 46`, `Community 47`, `Community 48`, `Community 51`, `Community 52`, `Community 53`, `Community 54`, `Community 58`, `Community 59`, `Community 60`, `Community 61`, `Community 62`, `Community 66`, `Community 68`, `Community 70`, `Community 71`, `Community 72`, `Community 73`, `Community 74`, `Community 76`, `Community 78`, `Community 79`, `Community 80`, `Community 81`, `Community 85`, `Community 86`, `Community 87`, `Community 89`, `Community 90`, `Community 91`, `Community 92`, `Community 93`, `Community 94`, `Community 95`, `Community 97`, `Community 98`, `Community 99`, `Community 101`, `Community 102`, `Community 104`, `Community 105`, `Community 107`, `Community 108`, `Community 109`, `Community 110`, `Community 111`, `Community 113`, `Community 116`, `Community 118`, `Community 119`, `Community 121`, `Community 125`, `Community 127`, `Community 129`, `Community 130`, `Community 132`, `Community 134`, `Community 135`, `Community 151`, `Community 154`, `Community 169`?**
+- **Why does `Session` connect `Community 12` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 15`, `Community 16`, `Community 17`, `Community 18`, `Community 19`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`, `Community 35`, `Community 37`, `Community 38`, `Community 39`, `Community 42`, `Community 43`, `Community 44`, `Community 46`, `Community 47`, `Community 48`, `Community 49`, `Community 50`, `Community 51`, `Community 52`, `Community 53`, `Community 54`, `Community 56`, `Community 57`, `Community 58`, `Community 60`, `Community 61`, `Community 62`, `Community 64`, `Community 65`, `Community 66`, `Community 68`, `Community 70`, `Community 71`, `Community 72`, `Community 73`, `Community 74`, `Community 75`, `Community 76`, `Community 77`, `Community 78`, `Community 79`, `Community 80`, `Community 81`, `Community 85`, `Community 86`, `Community 87`, `Community 89`, `Community 90`, `Community 91`, `Community 92`, `Community 93`, `Community 94`, `Community 95`, `Community 97`, `Community 99`, `Community 102`, `Community 103`, `Community 104`, `Community 107`, `Community 109`, `Community 111`, `Community 112`, `Community 113`, `Community 116`, `Community 117`, `Community 118`, `Community 119`, `Community 121`, `Community 123`, `Community 125`, `Community 126`, `Community 127`, `Community 130`, `Community 134`, `Community 150`, `Community 151`, `Community 154`, `Community 157`, `Community 162`?**
   _High betweenness centrality (0.257) - this node is a cross-community bridge._
-- **Why does `BaseModel` connect `Community 3` to `Community 0`, `Community 1`, `Community 128`, `Community 4`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 14`, `Community 15`, `Community 18`, `Community 148`, `Community 149`, `Community 22`, `Community 23`, `Community 24`, `Community 152`, `Community 20`, `Community 156`, `Community 29`, `Community 158`, `Community 30`, `Community 157`, `Community 159`, `Community 160`, `Community 35`, `Community 32`, `Community 38`, `Community 39`, `Community 40`, `Community 42`, `Community 48`, `Community 61`, `Community 65`, `Community 76`, `Community 77`, `Community 104`, `Community 111`, `Community 112`, `Community 114`, `Community 126`, `Community 127`?**
-  _High betweenness centrality (0.058) - this node is a cross-community bridge._
-- **Why does `int` connect `Community 55` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 29`, `Community 30`, `Community 32`, `Community 33`, `Community 35`, `Community 37`, `Community 38`, `Community 41`, `Community 42`, `Community 43`, `Community 44`, `Community 45`, `Community 46`, `Community 47`, `Community 48`, `Community 50`, `Community 51`, `Community 52`, `Community 53`, `Community 54`, `Community 58`, `Community 59`, `Community 66`, `Community 67`, `Community 68`, `Community 69`, `Community 70`, `Community 71`, `Community 72`, `Community 73`, `Community 74`, `Community 76`, `Community 78`, `Community 79`, `Community 80`, `Community 83`, `Community 86`, `Community 91`, `Community 92`, `Community 93`, `Community 94`, `Community 95`, `Community 96`, `Community 97`, `Community 98`, `Community 99`, `Community 101`, `Community 102`, `Community 104`, `Community 105`, `Community 108`, `Community 111`, `Community 112`, `Community 118`, `Community 121`, `Community 135`, `Community 154`?**
-  _High betweenness centrality (0.040) - this node is a cross-community bridge._
+- **Why does `BaseModel` connect `Community 3` to `Community 0`, `Community 1`, `Community 132`, `Community 4`, `Community 6`, `Community 7`, `Community 9`, `Community 11`, `Community 15`, `Community 17`, `Community 18`, `Community 148`, `Community 149`, `Community 22`, `Community 23`, `Community 24`, `Community 152`, `Community 20`, `Community 155`, `Community 156`, `Community 30`, `Community 32`, `Community 35`, `Community 38`, `Community 39`, `Community 40`, `Community 48`, `Community 57`, `Community 58`, `Community 61`, `Community 64`, `Community 65`, `Community 66`, `Community 75`, `Community 76`, `Community 96`, `Community 111`, `Community 112`, `Community 114`, `Community 126`?**
+  _High betweenness centrality (0.055) - this node is a cross-community bridge._
+- **Why does `int` connect `Community 38` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 18`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 29`, `Community 30`, `Community 32`, `Community 33`, `Community 37`, `Community 41`, `Community 43`, `Community 44`, `Community 45`, `Community 47`, `Community 48`, `Community 49`, `Community 50`, `Community 51`, `Community 53`, `Community 54`, `Community 55`, `Community 56`, `Community 57`, `Community 62`, `Community 64`, `Community 65`, `Community 66`, `Community 67`, `Community 68`, `Community 69`, `Community 70`, `Community 71`, `Community 72`, `Community 73`, `Community 74`, `Community 76`, `Community 77`, `Community 78`, `Community 79`, `Community 80`, `Community 83`, `Community 85`, `Community 86`, `Community 90`, `Community 93`, `Community 95`, `Community 97`, `Community 99`, `Community 101`, `Community 102`, `Community 103`, `Community 105`, `Community 111`, `Community 112`, `Community 117`, `Community 119`, `Community 121`, `Community 123`?**
+  _High betweenness centrality (0.044) - this node is a cross-community bridge._
 - **Are the 337 inferred relationships involving `str` (e.g. with `AdvanceApplyCreate` and `AdvanceApplyOut`) actually correct?**
   _`str` has 337 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 333 inferred relationships involving `Session` (e.g. with `AdvanceApplyCreate` and `AdvanceApplyOut`) actually correct?**
@@ -810,4 +800,4 @@ _Questions this graph is uniquely positioned to answer:_
 - **Are the 222 inferred relationships involving `int` (e.g. with `AdvanceApplyCreate` and `AdvanceApplyOut`) actually correct?**
   _`int` has 222 INFERRED edges - model-reasoned connections that need verification._
 - **What connects `定期 ping MySQL,避免远程库/NAT 回收空闲 TCP 后首请求长时间卡住。`, `数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误`, `未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin` to the rest of the system?**
-  _1493 weakly-connected nodes found - possible documentation gaps or missing edges._
+  _1525 weakly-connected nodes found - possible documentation gaps or missing edges._

File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/02201f8488ad81c2add94b41c44b09828da457b96ed9bf4307c2b257e5f1f833.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/123d08b4548931e30b486ed9a24dc299ed075435b9bb3919e79200c7c2055333.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/34f3d1d723890e294aac765a66d7e86ca87f4288f42cec674bb9151de091fe22.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/3be4253f6a131c632536d32b1f087a8838675bbb4b282e96cec1e2fb5063e9fd.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/45e8c537286f7ff6da75a37d0add654f0f6ae5e92165d5e42a764ecafba3c830.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/620cced501c465d38c97ff3e3fd29da4d25b940e8a1c816a2b74a9547d278db2.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/6b079cc6949a526f96edf0bcff31c43793c5c7a9570f18259693148c215a2cff.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/95969972c0773528b91d9728d303feae5ca7187d89a49495d5129dcedffc19dc.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/ce2b9ccf1faa70c6e084cc298aebe1b112f6515b0618f88fa29f3674c2f9649d.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/ecd3d8e5dbdb55a8b25c434ad9935bacdf275e23af051e45fea79be017d1c633.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/f644a9595a6c96924390a655ed92dee806b0dfc8cfb2001061d469a0d80caf91.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/fca395a2abc3bbf141be8b4ebd59a4c368f12368eb4fc28e0da254fcd35b7a50.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/stat-index.json


File diff suppressed because it is too large
+ 112 - 112
backend/graphify-out/graph.json


+ 24 - 0
backend/scripts/migration_20260712_pl_personnel_affiliation.sql

@@ -0,0 +1,24 @@
+-- pl_personnel 增加人员归属:self-山峡 carrier-非山峡
+SET @col_exists := (
+  SELECT COUNT(*)
+  FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'pl_personnel'
+    AND COLUMN_NAME = 'personnel_affiliation'
+);
+
+SET @ddl := IF(
+  @col_exists = 0,
+  'ALTER TABLE `pl_personnel`
+     ADD COLUMN `personnel_affiliation` varchar(20) NOT NULL DEFAULT ''self''
+       COMMENT ''人员归属 self-山峡 carrier-非山峡''
+       AFTER `personnel_type`',
+  'SELECT ''personnel_affiliation already exists'' AS msg'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `pl_personnel`
+SET `personnel_affiliation` = 'self'
+WHERE `personnel_affiliation` IS NULL OR `personnel_affiliation` = '';

+ 52 - 0
backend/scripts/migration_20260712_pl_personnel_type.sql

@@ -0,0 +1,52 @@
+-- pl_personnel 增加人员类型;历史数据按车辆关联推断
+-- 可重复执行(列已存在则跳过加列,UPDATE 可重复跑)
+
+SET @col_exists := (
+  SELECT COUNT(*)
+  FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'pl_personnel'
+    AND COLUMN_NAME = 'personnel_type'
+);
+
+SET @ddl := IF(
+  @col_exists = 0,
+  'ALTER TABLE `pl_personnel`
+     ADD COLUMN `personnel_type` varchar(20) NOT NULL DEFAULT ''both''
+     COMMENT ''人员类型 driver-驾驶员 escort-押运员 both-驾驶员兼押运员''
+     AFTER `id_card_no`',
+  'SELECT 1'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 仅有 driver 关联
+UPDATE pl_personnel p
+SET p.personnel_type = 'driver'
+WHERE p.del_flag = 0
+  AND (p.personnel_type IS NULL OR p.personnel_type = '' OR p.personnel_type = 'both')
+  AND EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'driver'
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'escort'
+  );
+
+-- 仅有 escort 关联
+UPDATE pl_personnel p
+SET p.personnel_type = 'escort'
+WHERE p.del_flag = 0
+  AND (p.personnel_type IS NULL OR p.personnel_type = '' OR p.personnel_type = 'both')
+  AND EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'escort'
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'driver'
+  );
+
+-- 同时有 driver + escort 或无关联保持 both(默认)

+ 24 - 0
docs/sql/migration_20260712_pl_personnel_affiliation.sql

@@ -0,0 +1,24 @@
+-- pl_personnel 增加人员归属:self-山峡 carrier-非山峡
+SET @col_exists := (
+  SELECT COUNT(*)
+  FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'pl_personnel'
+    AND COLUMN_NAME = 'personnel_affiliation'
+);
+
+SET @ddl := IF(
+  @col_exists = 0,
+  'ALTER TABLE `pl_personnel`
+     ADD COLUMN `personnel_affiliation` varchar(20) NOT NULL DEFAULT ''self''
+       COMMENT ''人员归属 self-山峡 carrier-非山峡''
+       AFTER `personnel_type`',
+  'SELECT ''personnel_affiliation already exists'' AS msg'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `pl_personnel`
+SET `personnel_affiliation` = 'self'
+WHERE `personnel_affiliation` IS NULL OR `personnel_affiliation` = '';

+ 52 - 0
docs/sql/migration_20260712_pl_personnel_type.sql

@@ -0,0 +1,52 @@
+-- pl_personnel 增加人员类型;历史数据按车辆关联推断
+-- 可重复执行(列已存在则跳过加列,UPDATE 可重复跑)
+
+SET @col_exists := (
+  SELECT COUNT(*)
+  FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'pl_personnel'
+    AND COLUMN_NAME = 'personnel_type'
+);
+
+SET @ddl := IF(
+  @col_exists = 0,
+  'ALTER TABLE `pl_personnel`
+     ADD COLUMN `personnel_type` varchar(20) NOT NULL DEFAULT ''both''
+     COMMENT ''人员类型 driver-驾驶员 escort-押运员 both-驾驶员兼押运员''
+     AFTER `id_card_no`',
+  'SELECT 1'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 仅有 driver 关联
+UPDATE pl_personnel p
+SET p.personnel_type = 'driver'
+WHERE p.del_flag = 0
+  AND (p.personnel_type IS NULL OR p.personnel_type = '' OR p.personnel_type = 'both')
+  AND EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'driver'
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'escort'
+  );
+
+-- 仅有 escort 关联
+UPDATE pl_personnel p
+SET p.personnel_type = 'escort'
+WHERE p.del_flag = 0
+  AND (p.personnel_type IS NULL OR p.personnel_type = '' OR p.personnel_type = 'both')
+  AND EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'escort'
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM pl_vehicle_personnel vp
+    WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'driver'
+  );
+
+-- 同时有 driver + escort 或无关联保持 both(默认)

+ 2 - 0
docs/sql/pl_tables.sql

@@ -81,6 +81,8 @@ CREATE TABLE `pl_personnel` (
   `phone_no` varchar(32) DEFAULT NULL COMMENT '手机号(非必填)',
   `age` int(11) DEFAULT NULL COMMENT '年龄',
   `id_card_no` varchar(32) DEFAULT NULL COMMENT '身份证号',
+  `personnel_type` varchar(20) NOT NULL DEFAULT 'both' COMMENT '人员类型 driver-驾驶员 escort-押运员 both-驾驶员兼押运员',
+  `personnel_affiliation` varchar(20) NOT NULL DEFAULT 'self' COMMENT '人员归属 self-山峡 carrier-非山峡',
   `status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 1-在职 0-停用',
   `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
   `order_num` int(11) DEFAULT NULL COMMENT '排序号',

+ 5 - 4
docs/sql/shanxia2.0.sql

@@ -2752,6 +2752,7 @@ CREATE TABLE `pl_personnel` (
   `phone_no` varchar(32) DEFAULT NULL COMMENT '手机号(非必填)',
   `age` int(11) DEFAULT NULL COMMENT '年龄',
   `id_card_no` varchar(32) DEFAULT NULL COMMENT '身份证号',
+  `personnel_type` varchar(20) NOT NULL DEFAULT 'both' COMMENT '人员类型 driver-驾驶员 escort-押运员 both-驾驶员兼押运员',
   `status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 1-在职 0-停用',
   `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
   `order_num` int(11) DEFAULT NULL COMMENT '排序号',
@@ -2772,10 +2773,10 @@ CREATE TABLE `pl_personnel` (
 -- Records of pl_personnel
 -- ----------------------------
 BEGIN;
-INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('3C13B995C2B149E8AA50222609D030A1', '里斯', '13099922123', 32, '123123', '1', 'shanxia2.0', NULL, '123123', 'd3000000000000000000000000000001', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-20 07:45:10', 'd3000000000000000000000000000001', '2026-06-20 07:55:22', 0);
-INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('4B753C6E5CE446AB9208C449A76BEA0C', '张三', '13900022112', 35, '340829181823123123123', '1', 'shanxia2.0', NULL, '备注', 'd3000000000000000000000000000001', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-19 22:56:38', 'd3000000000000000000000000000001', '2026-06-20 07:44:42', 0);
-INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('9AB6B5474343428C80C24503EB7771DC', '刘道银', '130023312', 32, '34082219912312', '1', 'shanxia2.0', NULL, '司机', 'BFB4CBEAB7054F48BDE82B55A985798C', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-28 14:57:39', 'd3000000000000000000000000000001', '2026-06-28 16:11:58', 0);
-INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BD11DEEA3C1E4C8EA75CDF18A6EED21F', '吴春生', '1390001231', 32, '340822121323', '1', 'shanxia2.0', NULL, '押运员', 'BFB4CBEAB7054F48BDE82B55A985798C', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-28 14:56:57', 'd3000000000000000000000000000001', '2026-06-28 16:11:58', 0);
+INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `personnel_type`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('3C13B995C2B149E8AA50222609D030A1', '里斯', '13099922123', 32, '123123', 'both', '1', 'shanxia2.0', NULL, '123123', 'd3000000000000000000000000000001', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-20 07:45:10', 'd3000000000000000000000000000001', '2026-06-20 07:55:22', 0);
+INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `personnel_type`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('4B753C6E5CE446AB9208C449A76BEA0C', '张三', '13900022112', 35, '340829181823123123123', 'both', '1', 'shanxia2.0', NULL, '备注', 'd3000000000000000000000000000001', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-19 22:56:38', 'd3000000000000000000000000000001', '2026-06-20 07:44:42', 0);
+INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `personnel_type`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('9AB6B5474343428C80C24503EB7771DC', '刘道银', '130023312', 32, '34082219912312', 'driver', '1', 'shanxia2.0', NULL, '司机', 'BFB4CBEAB7054F48BDE82B55A985798C', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-28 14:57:39', 'd3000000000000000000000000000001', '2026-06-28 16:11:58', 0);
+INSERT INTO `pl_personnel` (`id`, `personnel_name`, `phone_no`, `age`, `id_card_no`, `personnel_type`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BD11DEEA3C1E4C8EA75CDF18A6EED21F', '吴春生', '1390001231', 32, '340822121323', 'escort', '1', 'shanxia2.0', NULL, '押运员', 'BFB4CBEAB7054F48BDE82B55A985798C', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-28 14:56:57', 'd3000000000000000000000000000001', '2026-06-28 16:11:58', 0);
 COMMIT;
 
 -- ----------------------------

+ 340 - 0
docs/superpowers/specs/2026-07-12-pl-required-cert-slots-design.md

@@ -0,0 +1,340 @@
+# 运输管理 · 固定必备证书槽位 — 设计文档
+
+> **日期**: 2026-07-12  
+> **状态**: 已实现(2026-07-12)  
+> **前置**: `docs/superpowers/specs/2026-06-19-pl-vehicle-personnel-cert-design.md`(动态多证、首期不配置必检)  
+> **界面模式**: P01 标准列表 + 弹窗表单(`/#/pl/vehicle`、`/#/pl/self-vehicle`、`/#/pl/personnel`)
+
+## 背景与问题
+
+### 现状(2026-06-19 上线后)
+
+| 环节 | 行为 |
+|------|------|
+| 证书录入 | 用户点击「添加证书」→ 手选/手输证书名称 → 填写证号、有效期 → 上传附件 |
+| 前端校验 | 仅有证书行时「证书名称不能为空」;**不**校验必备证种齐全 |
+| 必备常数 | `pl-vehicle-cert.ts` / `pl-personnel-cert.ts` 已定义必备名称,但 `validateVehicleRequiredCerts` 已 `@deprecated` |
+| 缺失提示 | 查看态标题可显示「(证书缺失)」;**保存不阻断** |
+| 人员类型 | `pl_personnel` **无**人员类型字段;司机/押运员角色仅在车辆关联子表 `pl_vehicle_personnel.role` |
+| 承运商车辆 | 「全部车辆信息」含自有 + 承运商;必备缺失检查仅对 `vehicleType=self` |
+
+### 业务诉求(2026-07-12 确认)
+
+1. **固定必备槽位**:打开编辑弹窗时,系统预置必备证书行,名称锁定不可改、不可删。
+2. **必备三项必填**:每个必备槽位的 **证号、有效期、附件** 均必填。
+3. **可增补其他证件**:除必备行外,仍可「添加其他证书」(名称可选/可输,可删)。
+4. **山峡自有车强制**:仅 `vehicleType=self` 的车辆强制主车/挂车必备;**承运商等非山峡车辆不强制**。
+5. **人员类型**:人员主数据新增类型——驾驶员、押运员、驾驶员兼押运员;按类型预置必备证书。
+6. **历史数据**:**一律阻断**——只要保存(含仅改备注),必备证书未补齐则拒绝。
+
+### 与 2026-06-19 设计的关系
+
+2026-06-19 明确「首期不配置必检规则、安排运输不硬性拦截证书」。本次变更:
+
+- **升级** 车辆/人员主数据保存时的证书完整性要求(仅山峡自有车 + 全部人员主数据)。
+- **不变更** 运输计划安排运输时对 `hazard_cert_ready` 的既有逻辑(危化品计划级证书另案)。
+- **沿用** `pl_cert` 动态多证表结构,不新增证书实例表。
+
+---
+
+## 已确认设计
+
+| # | 决策 | 说明 |
+|---|------|------|
+| D1 | **必备槽位预置 + 锁定** | 必备行 `certName` 只读、不可删除;额外行可自由增删 |
+| D2 | **必备字段:证号 + 有效期 + 附件** | 提醒天数维持默认 30,不作为必备条件 |
+| D3 | **仅山峡自有车强制车辆证** | `vehicleType=self` 时校验主车 3 证、有挂车号时挂车 4 证;`carrier` 不校验 |
+| D4 | **人员类型三选一** | `driver` 驾驶员 / `escort` 押运员 / `both` 驾驶员兼押运员;人员主数据必填 |
+| D5 | **人员必备按类型** | driver、both → 身份证+驾驶证+从业资格证;escort → 身份证+从业资格证 |
+| D6 | **从业资格证别名** | 「危化品从业资格证」等等价匹配「从业资格证」(沿用 `matchRequiredCertName`) |
+| D7 | **保存一律阻断** | 历史数据缺必备证时,任何字段保存均须先补齐 |
+| D8 | **前后端双校验** | 前端 UX + 后端 API 400,防绕过 |
+| D9 | **关联人员角色兼容** | 车辆子表 `role` 保留;保存山峡自有车时校验人员类型与 role 可匹配(见 §三) |
+| D10 | **挂车动态** | 无 `trailerNo` 不要求挂车槽位;填写挂车号后立即要求 4 项必备 |
+
+---
+
+## 一、必备证书清单
+
+### 1.1 山峡自有车辆(`vehicleType=self`)
+
+| 部位 | 条件 | 必备证书(各需证号+有效期+附件) |
+|------|------|----------------------------------|
+| 主车 | 始终 | 行驶证、营运证、保险单 |
+| 挂车 | `trailerNo` 非空 | 行驶证、营运证、保险单、罐检报告 |
+
+常数来源:`PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES`、`PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES`(`web-admin/src/utils/pl-vehicle-cert.ts`)。
+
+### 1.2 承运商 / 非自有车辆(`vehicleType=carrier`)
+
+- 不预置必备行(或预置但不阻断,**推荐:不预置、不校验**,与现网自由录入一致)。
+- 用户仍可手动添加任意证书。
+
+### 1.3 人员(`pl_personnel.personnel_type`)
+
+| personnel_type | 展示文案 | 必备证书 |
+|----------------|---------|---------|
+| `driver` | 驾驶员 | 身份证、驾驶证、从业资格证 |
+| `escort` | 押运员 | 身份证、从业资格证 |
+| `both` | 驾驶员兼押运员 | 身份证、驾驶证、从业资格证 |
+
+常数来源:`PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES`、`PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES`。
+
+### 1.4 「证书已填」判定
+
+必备槽位 **同时满足**:
+
+```text
+certName  匹配目标名称(从业资格证含别名规则)
+certNo    非空 trim
+expireDate 非空
+attachmentUrls 非空 trim(至少一个 URL)
+```
+
+---
+
+## 二、数据模型
+
+### 2.1 `pl_personnel` 新增字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `personnel_type` | varchar(20) | ✅(新数据;迁移见 §2.3) | `driver` / `escort` / `both` |
+
+DDL 示例:
+
+```sql
+ALTER TABLE `pl_personnel`
+  ADD COLUMN `personnel_type` varchar(20) NOT NULL DEFAULT 'both'
+  COMMENT '人员类型 driver-驾驶员 escort-押运员 both-驾驶员兼押运员'
+  AFTER `id_card_no`;
+```
+
+同步:`docs/sql/pl_tables.sql`、`docs/sql/shanxia2.0.sql`;增量脚本 `backend/scripts/migration_20260712_pl_personnel_type.sql`。
+
+### 2.2 字典(可选增强)
+
+| dict_type | 值 |
+|-----------|-----|
+| `pl_personnel_type` | driver-驾驶员, escort-押运员, both-驾驶员兼押运员 |
+
+列表 Tag、表单下拉可与字典对齐;**必备规则仍硬编码于前后端 util**,不依赖运行时字典配置(YAGNI)。
+
+### 2.3 历史数据迁移 · 人员类型
+
+按优先级推断 `personnel_type`:
+
+1. 若 `pl_vehicle_personnel` 中该人仅有 `role=driver` → `driver`
+2. 若仅有 `role=escort` → `escort`
+3. 若同时存在 driver 与 escort 关联 → `both`
+4. 若无关联记录 → 默认 `both`(必备要求最宽,避免误标 escort 导致缺驾驶证仍可通过)
+
+迁移后运营需对人员主数据做一次补录/核对(D7 保存阻断会驱动补齐)。
+
+### 2.4 证书表
+
+`pl_cert` **无结构变更**。必备/额外均存同一表;必备行通过 `cert_name` +(车辆)`vehicle_part` 识别。
+
+可选:必备行写入时设 `order_num` 靠前,额外行在后。
+
+---
+
+## 三、业务规则
+
+### 3.1 车辆保存(POST/PUT `/transport/vehicle`)
+
+```
+if vehicle_type == 'self':
+    校验主车必备 3 证齐全(证号+有效期+附件)
+    if trailer_no 非空:
+        校验挂车必备 4 证齐全
+    校验关联人员(见 3.2)
+else:
+    跳过车辆必备校验(仅校验 certName 非空等基础项,若有证书行)
+```
+
+「山峡自有车辆信息」页固定 `vehicleType=self`,与「全部车辆信息」中自有行逻辑一致。
+
+### 3.2 山峡自有车 · 关联人员
+
+保存时除人员主数据证书外,额外校验:
+
+| 检查项 | 规则 |
+|--------|------|
+| 类型与 role 匹配 | `escort` 类型不可关联为 `driver`;`driver` 类型不可关联为 `escort`;`both` 均可 |
+| 角色必备证书 | 关联行的 `role=driver` 时,该人员证书须满足 driver 必备;`role=escort` 时满足 escort 必备 |
+
+说明:人员主数据按 `personnel_type` 校验一次;若 `both` 且仅关联为 escort,主数据仍须驾驶证(因类型为 both)。若未来需放宽,可另开变更。
+
+### 3.3 人员保存(POST/PUT `/transport/personnel`)
+
+- `personnel_type` 必填。
+- 按类型校验必备证书(D5)。
+- **任意**字段更新(含仅 `remark`)均执行完整必备校验(D7)。
+
+### 3.4 挂车号动态
+
+- 编辑中 `trailerNo` 从空变为非空:前端立即展示挂车 4 个必备空槽;保存时校验。
+- 从非空变为空:挂车必备校验跳过;已有挂车 `vehicle_part=trailer` 证书行保存策略——**保留数据但不阻断**(挂车号清空后不再校验挂车必备;证书行仍随 `certList` 提交,后端按现有 upsert 逻辑处理)。
+
+---
+
+## 四、前端设计
+
+### 4.1 组件策略
+
+**方案**:扩展或替换 `PlCertFormList` → `PlCertSlotFormList`(推荐新组件,原组件供承运商等非强制场景复用)。
+
+| 模式 | props | 行为 |
+|------|-------|------|
+| `required` | `requiredCertNames`, `vehiclePart?`, `strictFields=true` | 预置必备行、锁定名称、校验三字段 |
+| `free` | 无 required | 等同现有 `PlCertFormList` |
+
+`PlVehicleCertPartTabs`:
+
+- `vehicleType=self` → 各 part 传入对应 `requiredCertNames`
+- `vehicleType=carrier` → 子组件 `mode=free`
+
+### 4.2 页面改动
+
+| 页面 | 变更 |
+|------|------|
+| `pl/self-vehicle/index.vue` | 证书区 `required`;保存前 `validateSelfVehicleCerts` |
+| `pl/vehicle/index.vue` | 依 `formData.vehicleType` 切换 required/free;查看态缺失 suffix 逻辑保持 |
+| `pl/personnel/index.vue` | 表单加「人员类型」;证书区按类型 `required`;列表加类型列 Tag |
+
+### 4.3 表单 · 人员类型
+
+```text
+人员类型*  [ el-select ]
+  - 驾驶员 (driver)
+  - 押运员 (escort)
+  - 驾驶员兼押运员 (both)
+```
+
+- 新增默认:`both` 或空(须必选,无默认值更安全 → **无默认,必选**)。
+- 修改类型:重新计算必备槽位;已有同名校验数据保留,新增必备行补空槽。
+
+### 4.4 UI 分区
+
+```
+┌─ 人员证书 ──────────────────────────────────────┐
+│ ▎必备证书                                      │
+│   身份证    证号*  有效期*  附件*   🔒          │
+│   驾驶证    ...                                 │
+│ ▎其他证书                                      │
+│   [+ 添加其他证书]                              │
+└─────────────────────────────────────────────────┘
+```
+
+必备区与额外区可用分组标题或表格分段区分;必备行删除按钮隐藏。
+
+### 4.5 校验与错误提示
+
+- 保存失败:`ElMessage.warning` + 可选 `ElMessageBox.alert` 列出缺失项。
+- 文案示例:`主车「行驶证」缺少:证号、附件`。
+- 长表单:`scroll-to-error` 滚动至证书区首个缺失槽。
+
+---
+
+## 五、后端设计
+
+### 5.1 新增服务 · `pl_cert_required_service.py`(或并入 `pl_cert_service.py`)
+
+```python
+def get_required_vehicle_cert_names(vehicle_type, trailer_no, part) -> list[str]
+def get_required_personnel_cert_names(personnel_type) -> list[str]
+def validate_required_certs(cert_rows, required_names, *, part_label=None) -> None  # raises HTTPException 400
+def validate_personnel_type_role_compat(personnel_type, role) -> None
+```
+
+### 5.2 API 接入点
+
+| 路由 | 校验时机 |
+|------|---------|
+| `POST/PUT .../vehicle` | `vehicle_type=self` 时校验 cert_list + 关联人员 |
+| `POST/PUT .../personnel` | 始终校验 personnel_type + cert_list |
+
+错误响应示例:
+
+```json
+{
+  "detail": "必备证书未齐全:主车「保险单」缺少有效期、附件;人员「张三」类型为驾驶员,缺少驾驶证"
+}
+```
+
+### 5.3 Schema
+
+- `PersonnelBase` / `PersonnelCreate` / `PersonnelUpdate` / `PersonnelRead` 增加 `personnel_type`(alias `personnelType`)。
+- `VehicleCreate/Update` 无需新字段;沿用 `vehicle_type`。
+
+---
+
+## 六、OpenSpec / 工程规范
+
+建议在 `openspec/changes/transport-plan-management/` 或新 change 中增补 Scenario:
+
+- 山峡自有车保存 SHALL 阻断缺失必备车辆证。
+- 承运商车辆保存 SHALL NOT 因缺失必备车辆证阻断。
+- 人员保存 SHALL 按 personnel_type 阻断缺失必备人员证。
+
+交叉核对:`openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md`(Tag 展示人员类型、按钮图标等)。
+
+---
+
+## 七、验收标准
+
+1. 山峡自有车新增:打开弹窗即见主车 3 个必备空槽;填挂车号后出现挂车 4 槽。
+2. 必备槽位:名称不可编辑、不可删除;证号/有效期/附件任一缺失则无法保存。
+3. 可添加「道路运输证」等额外证书并正常保存。
+4. 承运商车辆在「全部车辆信息」中无必备槽位(或有不阻断),缺证仍可保存。
+5. 人员页必选类型;三类人员必备槽位正确;改类型后槽位同步。
+6. 历史自有车/人员:仅改备注时若缺必备证,保存返回明确错误。
+7. 山峡自有车:驾驶员类型不可选为押运员 role(及反向);both 均可。
+8. 后端 duplicate 校验:直接调 API 缺必备证返回 400。
+9. `python scripts/check_field_consistency.py` 通过(personnel_type 字段)。
+10. 迁移脚本可重复执行;现有 `pl_cert` 数据不丢失。
+
+---
+
+## 八、风险与缓解
+
+| 风险 | 缓解 |
+|------|------|
+| 历史大量缺证导致无法保存 | D7 为业务明确要求;上线前导出缺证清单,运营批量补录 |
+| 人员类型迁移不准 | 迁移规则 §2.3 + 列表展示类型供人工修正 |
+| 组件复杂度上升 | 新组件 `PlCertSlotFormList`,承运商继续用旧组件 |
+| 车辆页切换类型 self↔carrier | 切换为 carrier 时隐藏必备槽位且不校验;切回 self 时重新注入必备槽并校验 |
+| both 类型关联 escort 仍要驾驶证 | 设计已说明;若业务异议可后续改为「按 role 动态必备」 |
+
+---
+
+## 九、工作量估算
+
+| 模块 | 预估 |
+|------|------|
+| DB + 迁移 + Schema | 0.5 天 |
+| 后端必备校验服务 + API | 0.5 天 |
+| `PlCertSlotFormList` + 车辆 tabs 接入 | 1 天 |
+| 人员页类型字段 + 证书接入 | 0.5 天 |
+| 三页联调 + 历史场景自测 | 0.5~1 天 |
+| **合计** | **约 3~3.5 人天** |
+
+---
+
+## 十、实施任务清单(概要)
+
+1. SQL:`personnel_type` 列 + 迁移推断脚本  
+2. Backend:Model / Schema / `_row_to_dict` / 校验服务 / vehicle & personnel API  
+3. Frontend utils:统一 `validateRequiredCertSlots`、`buildRequiredCertRows`  
+4. Component:`PlCertSlotFormList`  
+5. Pages:`self-vehicle`、`vehicle`、`personnel`  
+6. 查看态:人员类型 Tag;缺失 suffix 与必备校验口径一致  
+7. 自测 + `check_field_consistency.py`  
+8. OpenSpec scenario 增补(可选同 PR)
+
+---
+
+## 附录:与现有常数对照
+
+需求与 `pl-vehicle-cert.ts` / `pl-personnel-cert.ts` **完全一致**,无需调整必备名称列表,仅需 **启用** 校验并改造 UI 为固定槽位。

+ 77 - 57
graphify-out/manifest.json

@@ -210,8 +210,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/pl_models.py": {
-    "mtime": 1783777743.341288,
-    "ast_hash": "c922ece8f7295870b9b64f61ee6ddae9",
+    "mtime": 1783845022.041298,
+    "ast_hash": "1e988545bba72069f18f5deaf74915e7",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/models.py": {
@@ -320,8 +320,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/base_data.py": {
-    "mtime": 1783176696.4542158,
-    "ast_hash": "6ef5cf534eb50f012ed9bda34df152d9",
+    "mtime": 1783845022.3061872,
+    "ast_hash": "378d20f7fbaca5f1c88ac1d322535994",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/vehicle_urea.py": {
@@ -445,7 +445,7 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/contract.py": {
-    "mtime": 1783823649.766528,
+    "mtime": 1783845455.3648205,
     "ast_hash": "6553f54ca1fd19086c170095f402a628",
     "semantic_hash": ""
   },
@@ -750,8 +750,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm_contract_routes.py": {
-    "mtime": 1783763319.0708206,
-    "ast_hash": "44223920a99b6b0796a49232e8c93a8e",
+    "mtime": 1783825526.6141698,
+    "ast_hash": "46a845a2d7ce98f2486ad45bde2d9612",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/workflow.py": {
@@ -765,8 +765,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/plan.py": {
-    "mtime": 1783760540.4064815,
-    "ast_hash": "e5c5c37075fe5e8adb90b6f1859071b7",
+    "mtime": 1783845558.7014904,
+    "ast_hash": "853850cff4f1ebd6ff197681e21e81f6",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/transport.py": {
@@ -800,8 +800,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/base_data.py": {
-    "mtime": 1782831664.104135,
-    "ast_hash": "705af7431e010950bd742926a8767600",
+    "mtime": 1783845063.8368685,
+    "ast_hash": "8529164d201cbaed199ea77d3a33fb71",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/vehicle_urea.py": {
@@ -965,8 +965,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_personnel_service.py": {
-    "mtime": 1783239403.8478649,
-    "ast_hash": "4d0591f2ccd5eed7b4949a11f838d95c",
+    "mtime": 1783845044.1152287,
+    "ast_hash": "94f2517f259023b46dfef7f0f74fede3",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_quote_pdf.py": {
@@ -2530,8 +2530,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/pl-personnel-cert.ts": {
-    "mtime": 1783142758.6120825,
-    "ast_hash": "103281f7dcc60fb29211c534ed6e6744",
+    "mtime": 1783845064.4713657,
+    "ast_hash": "0e5d989ac54b364a5983a5f9af9fd6d6",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/patch-el-select-filterable.ts": {
@@ -2590,8 +2590,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/pl-display.ts": {
-    "mtime": 1783242794.3651183,
-    "ast_hash": "02e465faedad1f9a9ad7b42bdb4a60cd",
+    "mtime": 1783846376.254574,
+    "ast_hash": "e0e2f44cd091369aed67791e684b3c98",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/view-dialog-refresh.ts": {
@@ -2685,8 +2685,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/pl-vehicle-cert.ts": {
-    "mtime": 1783142762.1595736,
-    "ast_hash": "63cdbcaab03c15b70d8045462e56c93d",
+    "mtime": 1783844738.0471256,
+    "ast_hash": "873ca5c7a0f7afbfa44925292f3f68ff",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/protocol-payment-installment.ts": {
@@ -3575,8 +3575,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-vehicle-cert-personnel-tabs/index.vue": {
-    "mtime": 1783239419.8721654,
-    "ast_hash": "975163ea136bc1a966b166dc399f16b5",
+    "mtime": 1783844806.526282,
+    "ast_hash": "4661518a5a0ae9a2ad4384cfbeee5a42",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue": {
@@ -3585,8 +3585,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-cert-form-list/index.vue": {
-    "mtime": 1783204483.7404983,
-    "ast_hash": "ba0c4c8ae84a295116d6b6d48ed853cc",
+    "mtime": 1783844774.5544193,
+    "ast_hash": "675188fc7671fde3487a942e3b9af0d3",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/product-view-dialog/index.vue": {
@@ -3625,8 +3625,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-vehicle-cert-part-tabs/index.vue": {
-    "mtime": 1783203118.0167286,
-    "ast_hash": "ae5b4d29397ae52e65acd8308084ea92",
+    "mtime": 1783844759.0212061,
+    "ast_hash": "a8237cabea3fef3788d758e43fb360ec",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-product-name-hazard-cell/index.vue": {
@@ -3640,8 +3640,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-archive-dialog/index.vue": {
-    "mtime": 1783754641.7341132,
-    "ast_hash": "4ffb80920f360ffd3be7c5031656e6b7",
+    "mtime": 1783846283.221872,
+    "ast_hash": "a81c97a14c6f9aad011061c50194b296",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-view-tabs/index.vue": {
@@ -3715,8 +3715,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-vehicle-cert-part-view/index.vue": {
-    "mtime": 1783176286.6399972,
-    "ast_hash": "82094acfee492822cf747e537ef0e586",
+    "mtime": 1783844812.829093,
+    "ast_hash": "4cb51d89cbfacbc635f405599710ffa2",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-arrange-dialog/index.vue": {
@@ -3735,8 +3735,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-vehicle-view-dialog/index.vue": {
-    "mtime": 1783239462.7506347,
-    "ast_hash": "f4ba884a00eaba522145a7595a8568dc",
+    "mtime": 1783844817.3452597,
+    "ast_hash": "379405598f0f3c19e06d65bc1cbaed6b",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-inbound-slip-print-dialog/index.vue": {
@@ -3765,13 +3765,13 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/system-view-dialog/ViewKvTableCell.vue": {
-    "mtime": 1783203672.1663585,
-    "ast_hash": "40d984c0fe2ba989f54a3dd8a1b62ee0",
+    "mtime": 1783826496.3181424,
+    "ast_hash": "81ea08367bbe5878972f438eb3ec4b77",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/system-view-dialog/types.ts": {
-    "mtime": 1783203671.4708154,
-    "ast_hash": "6ebd9545f517277abc2e913e6b986a6c",
+    "mtime": 1783826501.0256994,
+    "ast_hash": "f9a7e100b725653c28513dd7e325c301",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/system-view-dialog/ViewHighlights.vue": {
@@ -3800,8 +3800,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-vehicle-personnel-view-list/PersonnelViewPanel.vue": {
-    "mtime": 1783753612.7487419,
-    "ast_hash": "eca43ee259a74b2b83da5f037efe6c3d",
+    "mtime": 1783844813.70631,
+    "ast_hash": "7a27cdd0fc0db3ab1de01a16174729e9",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/ListPagination/index.vue": {
@@ -3980,8 +3980,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/pl/index.ts": {
-    "mtime": 1783754611.920795,
-    "ast_hash": "8ca7f86a901ee6834fd39adeb9bcc473",
+    "mtime": 1783845066.0273972,
+    "ast_hash": "fa2ffcb5318c1ed1a6a06855422880d9",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/pl/public-transport.ts": {
@@ -4165,8 +4165,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/vehicle/index.vue": {
-    "mtime": 1783252180.3044844,
-    "ast_hash": "1c9691b756903ec22f8b302fc81bbe5d",
+    "mtime": 1783845088.2792692,
+    "ast_hash": "23f9cedc7e3b87b4d670e429cf736a55",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/vehicle-recharge-record/index.vue": {
@@ -4180,8 +4180,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/plan/index.vue": {
-    "mtime": 1783750515.7251935,
-    "ast_hash": "503f7ef7167e32d3bb0ebc78489bf973",
+    "mtime": 1783841453.747368,
+    "ast_hash": "9ae88b869dc2c937b027f7fdc6ac60aa",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/plan/arrange.vue": {
@@ -4285,23 +4285,23 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/plan-archive/index.vue": {
-    "mtime": 1783750518.4763997,
-    "ast_hash": "ba57b39a05f387fabae440c908c46985",
+    "mtime": 1783846246.026981,
+    "ast_hash": "8e2207eb52be4b0dca7811323f61aabf",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/self-vehicle/index.vue": {
-    "mtime": 1783252181.1993465,
-    "ast_hash": "ccda242ae74e22346703bcefaf58f903",
+    "mtime": 1783845096.14697,
+    "ast_hash": "25357bf2f887716989f8197285b5964e",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/carrier/index.vue": {
-    "mtime": 1783821190.6414018,
-    "ast_hash": "047b4cc96dc747d17f802d892ba4f424",
+    "mtime": 1783841744.362471,
+    "ast_hash": "1fcfd92cfd0d2b344d2110d7164b4b2a",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/personnel/index.vue": {
-    "mtime": 1783203297.0313385,
-    "ast_hash": "2ccaa7d6c84a1d2b660b75412ef816a3",
+    "mtime": 1783845137.1568706,
+    "ast_hash": "f12fbbc1e2a8e2e7cc1d3c6df3ceff6f",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/vehicle-maintenance-process/index.vue": {
@@ -4485,8 +4485,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/contract/index.vue": {
-    "mtime": 1783749577.7739928,
-    "ast_hash": "ce91084329a7737179e85701ec10f737",
+    "mtime": 1783826680.883232,
+    "ast_hash": "d072970a9ba6eb25f2a738cb3f5f653b",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/contract/detail.vue": {
@@ -4520,8 +4520,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue": {
-    "mtime": 1783753625.445439,
-    "ast_hash": "ea172441d0463ff69fcbb57cc998a1e5",
+    "mtime": 1783826496.7785032,
+    "ast_hash": "24d49c1edca30ad1243380953b540b2f",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/contract/components/ContractAttachmentPanel.vue": {
@@ -4540,8 +4540,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/contract/components/ApprovalDialog.vue": {
-    "mtime": 1783753615.3856215,
-    "ast_hash": "0722dd2ab21a746ddd7e2ecdbc5d0e4f",
+    "mtime": 1783826784.9388635,
+    "ast_hash": "c2a695de1bbd282d616715c5bde1d414",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/contract/components/RenewDialog.vue": {
@@ -7398,5 +7398,25 @@
     "mtime": 1783777759.8294604,
     "ast_hash": "73869416708f10036beec5769af334bb",
     "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_cert_required_service.py": {
+    "mtime": 1783845035.534594,
+    "ast_hash": "279f299a4969244bad7f866ae9ee97fb",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/scripts/migration_20260712_pl_personnel_type.sql": {
+    "mtime": 1783843576.7474706,
+    "ast_hash": "82dcd1f60a2b486cc94f34bfb2228bd6",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/pl-cert-slots.ts": {
+    "mtime": 1783844749.9400146,
+    "ast_hash": "33ea084eaaba3ec98c170d8651ca83f9",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/scripts/migration_20260712_pl_personnel_affiliation.sql": {
+    "mtime": 1783845019.9129975,
+    "ast_hash": "f97c57b5fac34dc5d47491e9de4c1769",
+    "semantic_hash": ""
   }
 }

+ 5 - 11
web-admin/graphify-out/.graphify_labels.json

@@ -267,14 +267,12 @@
   "265": "Community 265",
   "266": "Community 266",
   "267": "Community 267",
-  "268": "Community 268",
   "269": "Community 269",
   "270": "Community 270",
   "271": "Community 271",
   "272": "Community 272",
   "273": "Community 273",
   "274": "Community 274",
-  "275": "Community 275",
   "276": "Community 276",
   "277": "Community 277",
   "278": "Community 278",
@@ -618,7 +616,6 @@
   "616": "Community 616",
   "617": "Community 617",
   "618": "Community 618",
-  "619": "Community 619",
   "620": "Community 620",
   "621": "Community 621",
   "622": "Community 622",
@@ -682,13 +679,13 @@
   "680": "Community 680",
   "681": "Community 681",
   "682": "Community 682",
-  "683": "Community 683",
   "684": "Community 684",
   "685": "Community 685",
   "686": "Community 686",
   "687": "Community 687",
   "688": "Community 688",
   "689": "Community 689",
+  "690": "Community 690",
   "691": "Community 691",
   "692": "Community 692",
   "693": "Community 693",
@@ -713,28 +710,25 @@
   "713": "Community 713",
   "714": "Community 714",
   "715": "Community 715",
+  "716": "Community 716",
   "717": "Community 717",
+  "718": "Community 718",
   "719": "Community 719",
+  "720": "Community 720",
   "721": "Community 721",
   "722": "Community 722",
   "723": "Community 723",
   "724": "Community 724",
   "725": "Community 725",
-  "726": "Community 726",
   "727": "Community 727",
   "728": "Community 728",
   "729": "Community 729",
   "730": "Community 730",
-  "731": "Community 731",
-  "732": "Community 732",
   "733": "Community 733",
-  "734": "Community 734",
   "735": "Community 735",
   "736": "Community 736",
   "737": "Community 737",
   "738": "Community 738",
   "739": "Community 739",
-  "740": "Community 740",
-  "741": "Community 741",
-  "743": "Community 743"
+  "742": "Community 742"
 }

File diff suppressed because it is too large
+ 212 - 222
web-admin/graphify-out/GRAPH_REPORT.md


File diff suppressed because it is too large
+ 112 - 112
web-admin/graphify-out/graph.json


+ 5 - 0
web-admin/src/api/pl/index.ts

@@ -147,6 +147,8 @@ export interface Personnel {
   phoneNo?: string
   age?: number
   idCardNo?: string
+  personnelType: string
+  personnelAffiliation: string
   status: string
   remark?: string
   nearestExpireDate?: string
@@ -162,6 +164,8 @@ export interface PersonnelPayload {
   phoneNo?: string
   age?: number | null
   idCardNo?: string
+  personnelType: string
+  personnelAffiliation: string
   status: string
   remark?: string
   certList?: CertRow[]
@@ -170,6 +174,7 @@ export interface PersonnelPayload {
 export interface PersonnelQuery {
   q?: string
   status?: string
+  personnelAffiliation?: string
   page?: number
   pageSize?: number
 }

+ 136 - 14
web-admin/src/components/business/pl-cert-form-list/index.vue

@@ -3,11 +3,11 @@
     <div class="cert-form-header">
       <span class="cert-form-title">{{ title }}</span>
       <el-button type="primary" size="small" @click="handleAddCert">
-        <el-icon><Plus /></el-icon>添加证书
+        <el-icon><Plus /></el-icon>{{ addButtonLabel }}
       </el-button>
     </div>
 
-    <div v-if="!certList.length" class="cert-empty">暂无证书,点击「添加证书」</div>
+    <div v-if="!certList.length" class="cert-empty">{{ emptyHint }}</div>
 
     <el-table v-else-if="layout === 'table'" :data="certList" stripe class="list-table cert-form-table">
       <el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -20,7 +20,7 @@
       >
         <template #default="{ row }">
           <el-select
-            v-if="hasTrailerPlateOption"
+            v-if="hasTrailerPlateOption && !isRowRequired(row)"
             :model-value="normalizeVehicleCertPart(row.vehiclePart)"
             placeholder="请选择车牌号"
             style="width: 100%"
@@ -33,12 +33,17 @@
               :value="option.value"
             />
           </el-select>
-          <span v-else>{{ headPlateLabel }}</span>
+          <span v-else>{{ resolvePlateLabel(row) }}</span>
         </template>
       </el-table-column>
       <el-table-column label="证书名称" min-width="160">
         <template #default="{ row }">
+          <span v-if="isRowRequired(row)" class="cert-name-locked">
+            {{ row.certName }}
+            <el-tag type="info" size="small">必备</el-tag>
+          </span>
           <el-select
+            v-else
             v-model="row.certName"
             placeholder="请选择或输入"
             filterable
@@ -87,8 +92,8 @@
         </template>
       </el-table-column>
       <el-table-column label="操作" min-width="60" align="center" header-align="center">
-        <template #default="{ $index }">
-          <el-button link type="danger" @click="handleRemoveCert($index)">
+        <template #default="{ row, $index }">
+          <el-button v-if="!isRowRequired(row)" link type="danger" @click="handleRemoveCert($index)">
             <el-icon><Delete /></el-icon>
           </el-button>
         </template>
@@ -98,15 +103,19 @@
     <div v-else class="cert-card-list">
       <div v-for="(cert, index) in certList" :key="cert._key" class="cert-card">
         <div class="cert-card-head">
-          <span class="cert-card-index">证书 {{ index + 1 }}</span>
-          <el-button link type="danger" @click="handleRemoveCert(index)">
+          <span class="cert-card-index">
+            {{ isRowRequired(cert) ? `必备 · ${cert.certName}` : `证书 ${index + 1}` }}
+          </span>
+          <el-button v-if="!isRowRequired(cert)" link type="danger" @click="handleRemoveCert(index)">
             <el-icon><Delete /></el-icon>删除
           </el-button>
         </div>
 
         <div class="form-grid-4 cert-card-grid">
           <el-form-item label="证书名称" required>
+            <span v-if="isRowRequired(cert)" class="cert-name-locked">{{ cert.certName }}</span>
             <el-select
+              v-else
               v-model="cert.certName"
               placeholder="请选择或输入证书名称"
               filterable
@@ -155,12 +164,23 @@ import { Delete, Plus } from '@element-plus/icons-vue'
 import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
 import type { CertRow } from '@/api/pl'
 import {
+  ensureCarrierVehicleCertSlots,
+  ensureSelfVehicleCertSlots,
+  mergeCertListWithRequiredSlots,
+  type CertRowWithMeta
+} from '@/utils/pl-cert-slots'
+import { matchRequiredCertName } from '@/utils/pl-personnel-cert'
+import {
+  PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
+  PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
+  PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
+  PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
   PL_VEHICLE_CERT_PART_HEAD,
   PL_VEHICLE_CERT_PART_TRAILER,
   normalizeVehicleCertPart
 } from '@/utils/pl-vehicle-cert'
 
-type CertRowWithKey = CertRow & { _key: string }
+type CertRowWithKey = CertRowWithMeta & { _key: string }
 type CertUploadRef = InstanceType<typeof AttachmentUploadField>
 
 const props = withDefaults(
@@ -169,10 +189,16 @@ const props = withDefaults(
     uploadFolder: string
     certNameOptions?: string[]
     layout?: 'card' | 'table'
-    /** 列表模式:展示车牌号列,可从主车/挂车车牌中选择 */
     showVehiclePartColumn?: boolean
     licensePlate?: string
     trailerNo?: string
+    /** 人员/单段车辆:固定必备证书名称 */
+    requiredCertNames?: readonly string[]
+    requiredVehiclePart?: string
+    /** 山峡自有车统一表格:按主车/挂车自动注入必备槽位 */
+    enforceSelfVehicleRequired?: boolean
+    /** 承运商车辆:按主车/挂车注入必备槽位(行驶证、营运证) */
+    enforceCarrierVehicleRequired?: boolean
   }>(),
   {
     title: '证书',
@@ -180,10 +206,27 @@ const props = withDefaults(
     layout: 'card',
     showVehiclePartColumn: false,
     licensePlate: '',
-    trailerNo: ''
+    trailerNo: '',
+    requiredCertNames: () => [],
+    requiredVehiclePart: PL_VEHICLE_CERT_PART_HEAD,
+    enforceSelfVehicleRequired: false,
+    enforceCarrierVehicleRequired: false
   }
 )
 
+const hasRequiredMode = computed(
+  () =>
+    props.enforceSelfVehicleRequired ||
+    props.enforceCarrierVehicleRequired ||
+    (props.requiredCertNames?.length ?? 0) > 0
+)
+
+const addButtonLabel = computed(() => (hasRequiredMode.value ? '添加其他证书' : '添加证书'))
+
+const emptyHint = computed(() =>
+  hasRequiredMode.value ? '暂无证书' : '暂无证书,点击「添加证书」'
+)
+
 const headPlateLabel = computed(() => props.licensePlate?.trim() || '—')
 const hasTrailerPlateOption = computed(() => Boolean(props.trailerNo?.trim()))
 
@@ -203,23 +246,88 @@ const vehiclePlateOptions = computed(() => {
   return options
 })
 
+function resolvePlateLabel(row: CertRowWithKey) {
+  const part = normalizeVehicleCertPart(row.vehiclePart)
+  if (part === PL_VEHICLE_CERT_PART_TRAILER) {
+    return props.trailerNo?.trim() || '挂车'
+  }
+  return headPlateLabel.value
+}
+
+function requiredNamesForRow(row: CertRowWithKey): readonly string[] {
+  if (props.requiredCertNames?.length) {
+    return props.requiredCertNames
+  }
+  if (!props.enforceSelfVehicleRequired && !props.enforceCarrierVehicleRequired) return []
+  const part = normalizeVehicleCertPart(row.vehiclePart)
+  if (props.enforceCarrierVehicleRequired) {
+    return part === PL_VEHICLE_CERT_PART_TRAILER
+      ? PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
+      : PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES
+  }
+  return part === PL_VEHICLE_CERT_PART_TRAILER
+    ? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
+    : PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
+}
+
+function isRowRequired(row: CertRowWithKey) {
+  if (row._required) return true
+  const requiredNames = requiredNamesForRow(row)
+  if (!requiredNames.length) return false
+  return requiredNames.some(name => matchRequiredCertName(row.certName, name))
+}
+
+function syncRequiredSlots() {
+  if (props.enforceSelfVehicleRequired) {
+    certList.value = ensureSelfVehicleCertSlots(certList.value, props.trailerNo)
+    return
+  }
+  if (props.enforceCarrierVehicleRequired) {
+    certList.value = ensureCarrierVehicleCertSlots(certList.value, props.trailerNo)
+    return
+  }
+  if (props.requiredCertNames?.length) {
+    certList.value = mergeCertListWithRequiredSlots(certList.value, props.requiredCertNames, {
+      vehiclePart: props.requiredVehiclePart
+    })
+  }
+}
+
 function handleVehiclePartChange(row: CertRowWithKey, value: string) {
+  if (isRowRequired(row)) return
   row.vehiclePart = normalizeVehicleCertPart(value)
 }
 
 const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
 
+watch(
+  () =>
+    [
+      props.enforceSelfVehicleRequired,
+      props.enforceCarrierVehicleRequired,
+      props.requiredCertNames,
+      props.trailerNo
+    ] as const,
+  () => {
+    if (hasRequiredMode.value) {
+      syncRequiredSlots()
+    }
+  },
+  { immediate: true }
+)
+
 const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
 
 function createEmptyCert(): CertRowWithKey {
   return {
     _key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
-    vehiclePart: PL_VEHICLE_CERT_PART_HEAD,
+    vehiclePart: props.requiredVehiclePart || PL_VEHICLE_CERT_PART_HEAD,
     certName: '',
     certNo: '',
     expireDate: '',
     remindDays: 30,
-    attachmentUrls: ''
+    attachmentUrls: '',
+    _required: false
   }
 }
 
@@ -228,6 +336,8 @@ function handleAddCert() {
 }
 
 function handleRemoveCert(index: number) {
+  const row = certList.value[index]
+  if (row && isRowRequired(row)) return
   const next = [...certList.value]
   next.splice(index, 1)
   certList.value = next
@@ -250,7 +360,11 @@ function clearUploadRefs() {
   certUploadRefs.value = {}
 }
 
-defineExpose({ hasUploading, clearUploadRefs })
+function refreshRequiredSlots() {
+  syncRequiredSlots()
+}
+
+defineExpose({ hasUploading, clearUploadRefs, refreshRequiredSlots })
 </script>
 
 <style scoped lang="scss">
@@ -282,6 +396,14 @@ defineExpose({ hasUploading, clearUploadRefs })
   background: #f8fafc;
 }
 
+.cert-name-locked {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  font-weight: 500;
+  color: var(--text, #1e293b);
+}
+
 .cert-form-table {
   border-top: 1px solid var(--mint-border, #d9e8e2);
 

+ 97 - 50
web-admin/src/components/business/pl-plan-archive-dialog/index.vue

@@ -26,61 +26,67 @@
           </section>
 
           <section class="pl-plan-form-section pl-plan-freight-section">
-            <h4 class="pl-plan-subtitle">运费信息</h4>
+            <div class="pl-plan-freight-section-head">
+              <h4 class="pl-plan-subtitle">运费信息</h4>
+              <label class="pl-plan-freight-switch">
+                <span class="pl-plan-freight-switch-label">是否有运输费用</span>
+                <el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
+              </label>
+            </div>
             <el-form
+              v-if="formData.hasFreight === 1"
               :model="formData"
               label-width="140px"
               label-position="right"
               class="pl-plan-freight-form"
             >
-              <div class="form-grid-4">
-                <el-form-item label="是否有运输费用">
-                  <el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
+              <div class="form-grid-4 pl-plan-archive-freight-grid">
+                <el-form-item label="运费计算方式">
+                  <el-select v-model="formData.freightType">
+                    <el-option label="按重量" value="by_weight" />
+                    <el-option label="按趟" value="by_trip" />
+                  </el-select>
+                </el-form-item>
+                <el-form-item label="运输公里数">
+                  <el-input-number
+                    v-model="formData.distanceKm"
+                    :min="0"
+                    :precision="2"
+                  />
+                </el-form-item>
+                <el-form-item
+                  v-if="formData.freightType === 'by_weight'"
+                  :label="`运输量(${freightUnitLabel})`"
+                >
+                  <el-input-number
+                    v-model="freightCalc.transportQty.value"
+                    :min="0"
+                    :precision="3"
+                    @change="freightCalc.markTransportQtyTouched()"
+                  />
+                </el-form-item>
+                <div v-else class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
+
+                <div class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
+                <el-form-item
+                  v-if="formData.freightType === 'by_weight'"
+                  :label="`运输单价(元/${freightUnitLabel})`"
+                >
+                  <el-input-number
+                    v-model="freightCalc.freightPrice.value"
+                    :min="0"
+                    :precision="2"
+                  />
+                </el-form-item>
+                <div v-else class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
+                <el-form-item label="运费金额(元)">
+                  <el-input-number
+                    v-model="freightCalc.freightAmount.value"
+                    :min="0"
+                    :precision="2"
+                    @change="freightCalc.markFreightAmountTouched()"
+                  />
                 </el-form-item>
-                <template v-if="formData.hasFreight === 1">
-                  <el-form-item label="运输公里数">
-                    <el-input-number
-                      v-model="formData.distanceKm"
-                      :min="0"
-                      :precision="2"
-                    />
-                  </el-form-item>
-                  <el-form-item label="运费计算方式">
-                    <el-select v-model="formData.freightType">
-                      <el-option label="按重量" value="by_weight" />
-                      <el-option label="按趟" value="by_trip" />
-                    </el-select>
-                  </el-form-item>
-                  <el-form-item
-                    v-if="formData.freightType === 'by_weight'"
-                    :label="`运输量(${freightUnitLabel})`"
-                  >
-                    <el-input-number
-                      v-model="freightCalc.transportQty.value"
-                      :min="0"
-                      :precision="3"
-                      @change="freightCalc.markTransportQtyTouched()"
-                    />
-                  </el-form-item>
-                  <el-form-item
-                    v-if="formData.freightType === 'by_weight'"
-                    :label="`运输单价(元/${freightUnitLabel})`"
-                  >
-                    <el-input-number
-                      v-model="freightCalc.freightPrice.value"
-                      :min="0"
-                      :precision="2"
-                    />
-                  </el-form-item>
-                  <el-form-item label="运费金额(元)">
-                    <el-input-number
-                      v-model="freightCalc.freightAmount.value"
-                      :min="0"
-                      :precision="2"
-                      @change="freightCalc.markFreightAmountTouched()"
-                    />
-                  </el-form-item>
-                </template>
               </div>
             </el-form>
           </section>
@@ -119,7 +125,10 @@ import {
 import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
 import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
 import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
-import { enrichPlanProductsHazardFromCrm } from '@/composables/usePlToxicProductCertCheck'
+import {
+  buildMissingHazardCertReadyMessage,
+  enrichPlanProductsHazardFromCrm
+} from '@/composables/usePlToxicProductCertCheck'
 import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
 import { useDictStore } from '@/store/modules/dict'
 import { pickBxData } from '@/utils/http/bx'
@@ -302,6 +311,16 @@ async function handleSubmit() {
     return
   }
 
+  if (!isAdjustMode.value) {
+    const certMessage = buildMissingHazardCertReadyMessage(plan.value, productRows.value)
+    if (certMessage) {
+      await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法归档', {
+        type: 'warning'
+      })
+      return
+    }
+  }
+
   submitLoading.value = true
   try {
     const body: PlanArchiveBody = {
@@ -392,6 +411,7 @@ async function handleSubmit() {
 .pl-plan-freight-section {
   .pl-plan-subtitle {
     margin-top: 4px;
+    margin-bottom: 0;
   }
 
   :deep(.el-form-item__label) {
@@ -400,6 +420,28 @@ async function handleSubmit() {
   }
 }
 
+.pl-plan-freight-section-head {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 16px;
+  margin-bottom: 10px;
+}
+
+.pl-plan-freight-switch {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+  cursor: pointer;
+}
+
+.pl-plan-freight-switch-label {
+  font-size: 13px;
+  color: var(--mint-text-muted, #64748b);
+  white-space: nowrap;
+}
+
 .pl-plan-freight-form {
   :deep(.el-form-item) {
     margin-bottom: 0;
@@ -418,8 +460,13 @@ async function handleSubmit() {
     min-width: 0;
   }
 
-  .form-grid-4 {
+  .pl-plan-archive-freight-grid {
     align-items: center;
+    row-gap: 12px;
+  }
+
+  .pl-plan-archive-freight-grid-cell {
+    min-height: 1px;
   }
 
   :deep(.el-select),

+ 17 - 2
web-admin/src/components/business/pl-vehicle-cert-part-tabs/index.vue

@@ -11,6 +11,8 @@
       show-vehicle-part-column
       :license-plate="licensePlate"
       :trailer-no="trailerNo"
+      :enforce-self-vehicle-required="enforceSelfVehicleRequired"
+      :enforce-carrier-vehicle-required="enforceCarrierVehicleRequired"
     />
 
     <el-tabs
@@ -73,13 +75,19 @@ const props = withDefaults(
     certNameOptions?: string[]
     title?: string
     layout?: 'card' | 'table'
+    /** 山峡自有车:注入必备证书槽位并校验 */
+    enforceSelfVehicleRequired?: boolean
+    /** 承运商车辆:注入必备证书槽位并校验 */
+    enforceCarrierVehicleRequired?: boolean
   }>(),
   {
     licensePlate: '',
     trailerNo: '',
     certNameOptions: () => [],
     title: '车辆证书',
-    layout: 'card'
+    layout: 'card',
+    enforceSelfVehicleRequired: false,
+    enforceCarrierVehicleRequired: false
   }
 )
 
@@ -149,9 +157,16 @@ function clearUploadRefs() {
   trailerCertFormRef.value?.clearUploadRefs()
 }
 
+function refreshRequiredSlots() {
+  unifiedCertFormRef.value?.refreshRequiredSlots?.()
+  headCertFormRef.value?.refreshRequiredSlots?.()
+  trailerCertFormRef.value?.refreshRequiredSlots?.()
+}
+
 defineExpose({
   hasUploading,
-  clearUploadRefs
+  clearUploadRefs,
+  refreshRequiredSlots
 })
 </script>
 

+ 19 - 5
web-admin/src/components/business/pl-vehicle-cert-part-view/index.vue

@@ -46,7 +46,8 @@ import type { ViewTableMode } from '@/components/business/system-view-dialog/typ
 import {
   PL_VEHICLE_CERT_PART_HEAD,
   PL_VEHICLE_CERT_PART_TRAILER,
-  getSelfVehiclePartCertMissingSuffix,
+  getVehiclePartCertMissingSuffix,
+  type PlVehicleCertCheckType,
   splitVehicleCertsByPart
 } from '@/utils/pl-vehicle-cert'
 
@@ -56,6 +57,8 @@ const props = withDefaults(
     licensePlate?: string
     trailerNo?: string
     vehicleId?: string
+    vehicleCertCheckType?: PlVehicleCertCheckType
+    /** @deprecated 使用 vehicleCertCheckType */
     checkSelfVehicleCerts?: boolean
     tableMode?: ViewTableMode
     certLayout?: 'card' | 'table'
@@ -65,6 +68,7 @@ const props = withDefaults(
     licensePlate: '',
     trailerNo: '',
     vehicleId: '',
+    vehicleCertCheckType: undefined,
     checkSelfVehicleCerts: false,
     tableMode: 'triple',
     certLayout: 'card'
@@ -98,16 +102,26 @@ const scopedCertList = computed(() => {
 const headCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).headCerts)
 const trailerCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).trailerCerts)
 
+const resolvedVehicleCertCheckType = computed<PlVehicleCertCheckType | undefined>(() => {
+  if (props.vehicleCertCheckType) return props.vehicleCertCheckType
+  return props.checkSelfVehicleCerts ? 'self' : undefined
+})
+
 const headTabLabelWithHint = computed(() => {
-  if (!props.checkSelfVehicleCerts) return headTabLabel.value
-  return headTabLabel.value + getSelfVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD)
+  const checkType = resolvedVehicleCertCheckType.value
+  if (!checkType) return headTabLabel.value
+  return (
+    headTabLabel.value +
+    getVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD, checkType)
+  )
 })
 
 const trailerTabLabelWithHint = computed(() => {
-  if (!props.checkSelfVehicleCerts) return trailerTabLabel.value
+  const checkType = resolvedVehicleCertCheckType.value
+  if (!checkType) return trailerTabLabel.value
   return (
     trailerTabLabel.value +
-    getSelfVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER)
+    getVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER, checkType)
   )
 })
 

+ 18 - 6
web-admin/src/components/business/pl-vehicle-cert-personnel-tabs/index.vue

@@ -7,7 +7,7 @@
         :license-plate="licensePlate"
         :trailer-no="trailerNo"
         :vehicle-id="vehicleId"
-        :check-self-vehicle-certs="checkSelfVehicleCerts"
+        :vehicle-cert-check-type="vehicleCertCheckType"
         :table-mode="tableMode"
         :cert-layout="certLayout"
       />
@@ -20,7 +20,7 @@
     >
       <PersonnelViewPanel
         :person="person"
-        :check-self-vehicle-certs="checkSelfVehicleCerts"
+        :check-linked-personnel-certs="showLinkedPersonnelCertCheck"
         :table-mode="tableMode"
         :cert-layout="certLayout"
       />
@@ -34,7 +34,7 @@ import PersonnelViewPanel from '@/components/business/pl-vehicle-personnel-view-
 import type { CertRead } from '@/api/pl'
 import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
 import type { ViewTableMode } from '@/components/business/system-view-dialog/types'
-import { getSelfVehicleCertMissingSuffix } from '@/utils/pl-vehicle-cert'
+import { getVehicleCertMissingSuffix, type PlVehicleCertCheckType } from '@/utils/pl-vehicle-cert'
 import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
 import { formatPlPersonnelNameWithRole } from '@/utils/pl-display'
 
@@ -45,6 +45,10 @@ const props = withDefaults(
     trailerNo?: string
     vehicleId?: string
     personnelList?: VehiclePersonnelViewRow[]
+    vehicleCertCheckType?: PlVehicleCertCheckType
+    /** 山峡自有车:关联人员证书缺失提示 */
+    checkLinkedPersonnelCerts?: boolean
+    /** @deprecated 使用 vehicleCertCheckType + checkLinkedPersonnelCerts */
     checkSelfVehicleCerts?: boolean
     tableMode?: ViewTableMode
     certLayout?: 'card' | 'table'
@@ -55,6 +59,8 @@ const props = withDefaults(
     trailerNo: '',
     vehicleId: '',
     personnelList: () => [],
+    vehicleCertCheckType: undefined,
+    checkLinkedPersonnelCerts: false,
     checkSelfVehicleCerts: false,
     tableMode: 'triple',
     certLayout: 'card'
@@ -73,23 +79,29 @@ const tabsInstanceKey = computed(
 
 const vehicleCertTabLabel = computed(() => {
   const label = '车辆证书'
-  if (!props.checkSelfVehicleCerts) return label
+  const checkType = props.vehicleCertCheckType
+  if (!checkType) return label
   return (
     label +
-    getSelfVehicleCertMissingSuffix({
+    getVehicleCertMissingSuffix({
+      vehicleType: checkType,
       certList: props.certList,
       trailerNo: props.trailerNo
     })
   )
 })
 
+const showLinkedPersonnelCertCheck = computed(
+  () => props.checkLinkedPersonnelCerts || props.checkSelfVehicleCerts
+)
+
 function getPersonKey(person: VehiclePersonnelViewRow, index: number) {
   return `person-${props.vehicleId || props.licensePlate || 'vehicle'}-${person.personnelId || `${person.personnelName}-${index}`}`
 }
 
 function getPersonTabLabel(person: VehiclePersonnelViewRow) {
   const suffix =
-    props.checkSelfVehicleCerts && person.role
+    showLinkedPersonnelCertCheck.value && person.role
       ? getPersonnelCertMissingSuffix(person.certList || [], person.role as 'driver' | 'escort')
       : ''
   return `${formatPlPersonnelNameWithRole(person.personnelName, person.role)}${suffix}`

+ 8 - 1
web-admin/src/components/business/pl-vehicle-personnel-view-list/PersonnelViewPanel.vue

@@ -38,19 +38,26 @@ const props = withDefaults(
     person: VehiclePersonnelViewRow
     showTitle?: boolean
     checkSelfVehicleCerts?: boolean
+    /** @deprecated 使用 checkLinkedPersonnelCerts */
+    checkLinkedPersonnelCerts?: boolean
     tableMode?: ViewTableMode
     certLayout?: 'card' | 'table'
   }>(),
   {
     showTitle: false,
     checkSelfVehicleCerts: false,
+    checkLinkedPersonnelCerts: false,
     tableMode: 'triple',
     certLayout: 'card'
   }
 )
 
+const showPersonnelCertCheck = computed(
+  () => props.checkLinkedPersonnelCerts || props.checkSelfVehicleCerts
+)
+
 const personnelCertMissingSuffix = computed(() => {
-  if (!props.checkSelfVehicleCerts || !props.person.role) return ''
+  if (!showPersonnelCertCheck.value || !props.person.role) return ''
   return getPersonnelCertMissingSuffix(
     props.person.certList || [],
     props.person.role as 'driver' | 'escort'

+ 2 - 1
web-admin/src/components/business/pl-vehicle-view-dialog/index.vue

@@ -18,7 +18,8 @@
         :trailer-no="detail?.trailerNo"
         :vehicle-id="detail?.id"
         :personnel-list="personnelList"
-        :check-self-vehicle-certs="detail?.vehicleType === 'self'"
+        :vehicle-cert-check-type="detail?.vehicleType === 'self' || detail?.vehicleType === 'carrier' ? detail.vehicleType : undefined"
+        :check-linked-personnel-certs="detail?.vehicleType === 'self'"
         cert-layout="table"
       />
     </template>

+ 241 - 0
web-admin/src/utils/pl-cert-slots.ts

@@ -0,0 +1,241 @@
+import type { CertRow } from '@/api/pl'
+import { matchRequiredCertName } from '@/utils/pl-personnel-cert'
+import {
+  PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
+  PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
+  PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
+  PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
+  PL_VEHICLE_CERT_PART_HEAD,
+  PL_VEHICLE_CERT_PART_TRAILER,
+  type PlVehicleCertCheckType,
+  getVehicleHeadRequiredCertNames,
+  getVehicleTrailerRequiredCertNames,
+  normalizeVehicleCertPart
+} from '@/utils/pl-vehicle-cert'
+
+export type CertRowWithMeta = CertRow & { _key?: string; _required?: boolean }
+
+export function isRequiredCertSlotComplete(
+  cert: CertRow | undefined,
+  requiredName: string
+): boolean {
+  if (!cert) return false
+  if (!matchRequiredCertName(cert.certName, requiredName)) return false
+  if (!cert.certNo?.trim()) return false
+  if (!cert.expireDate) return false
+  if (!cert.attachmentUrls?.trim()) return false
+  return true
+}
+
+export function getMissingRequiredCertFieldLabels(
+  cert: CertRow | undefined,
+  requiredName: string
+): string[] {
+  if (!cert || !matchRequiredCertName(cert.certName, requiredName)) {
+    return ['证号', '有效期', '附件']
+  }
+  const missing: string[] = []
+  if (!cert.certNo?.trim()) missing.push('证号')
+  if (!cert.expireDate) missing.push('有效期')
+  if (!cert.attachmentUrls?.trim()) missing.push('附件')
+  return missing
+}
+
+export function collectRequiredCertSlotErrors(
+  certList: CertRow[],
+  requiredNames: readonly string[],
+  partLabel = ''
+): string[] {
+  const errors: string[] = []
+  for (const requiredName of requiredNames) {
+    const matched = certList.find(c => matchRequiredCertName(c.certName, requiredName))
+    const missing = getMissingRequiredCertFieldLabels(matched, requiredName)
+    if (missing.length) {
+      const prefix = partLabel ? `${partLabel}「${requiredName}」` : `「${requiredName}」`
+      errors.push(`${prefix}缺少:${missing.join('、')}`)
+    }
+  }
+  return errors
+}
+
+export function collectSelfVehicleRequiredCertErrors(
+  certList: CertRow[],
+  trailerNo?: string | null
+): string[] {
+  return collectVehicleRequiredCertErrors(certList, trailerNo, 'self')
+}
+
+export function collectCarrierVehicleRequiredCertErrors(
+  certList: CertRow[],
+  trailerNo?: string | null
+): string[] {
+  return collectVehicleRequiredCertErrors(certList, trailerNo, 'carrier')
+}
+
+export function collectVehicleRequiredCertErrors(
+  certList: CertRow[],
+  trailerNo: string | null | undefined,
+  vehicleType: PlVehicleCertCheckType
+): string[] {
+  const headCerts = certList.filter(
+    c => normalizeVehicleCertPart(c.vehiclePart) === PL_VEHICLE_CERT_PART_HEAD
+  )
+  const trailerCerts = certList.filter(
+    c => normalizeVehicleCertPart(c.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
+  )
+  const errors = [
+    ...collectRequiredCertSlotErrors(
+      headCerts,
+      getVehicleHeadRequiredCertNames(vehicleType),
+      '主车'
+    ),
+    ...(trailerNo?.trim()
+      ? collectRequiredCertSlotErrors(
+          trailerCerts,
+          getVehicleTrailerRequiredCertNames(vehicleType),
+          '挂车'
+        )
+      : [])
+  ]
+  return errors
+}
+
+function createEmptyCertRow(
+  certName: string,
+  vehiclePart?: string,
+  required = false
+): CertRowWithMeta {
+  return {
+    _key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+    vehiclePart,
+    certName,
+    certNo: '',
+    expireDate: '',
+    remindDays: 30,
+    attachmentUrls: '',
+    _required: required
+  }
+}
+
+function findCertIndexForRequired(
+  certs: CertRowWithMeta[],
+  requiredName: string,
+  vehiclePart?: string
+): number {
+  return certs.findIndex(cert => {
+    if (vehiclePart && normalizeVehicleCertPart(cert.vehiclePart) !== vehiclePart) {
+      return false
+    }
+    return matchRequiredCertName(cert.certName, requiredName)
+  })
+}
+
+export function mergeCertListWithRequiredSlots(
+  existing: CertRowWithMeta[],
+  requiredNames: readonly string[],
+  options?: { vehiclePart?: string }
+): CertRowWithMeta[] {
+  const vehiclePart = options?.vehiclePart
+  const next = existing.map(cert => ({ ...cert }))
+  const normalizedPart = vehiclePart ? normalizeVehicleCertPart(vehiclePart) : null
+
+  const extras = next.filter(cert => {
+    if (normalizedPart && normalizeVehicleCertPart(cert.vehiclePart) !== normalizedPart) {
+      return false
+    }
+    return !requiredNames.some(name => matchRequiredCertName(cert.certName, name))
+  })
+
+  const requiredRows: CertRowWithMeta[] = requiredNames.map(requiredName => {
+    const idx = findCertIndexForRequired(next, requiredName, normalizedPart || undefined)
+    if (idx >= 0) {
+      return {
+        ...next[idx],
+        certName: requiredName,
+        vehiclePart: normalizedPart || next[idx].vehiclePart || PL_VEHICLE_CERT_PART_HEAD,
+        _required: true
+      }
+    }
+    return createEmptyCertRow(
+      requiredName,
+      normalizedPart || PL_VEHICLE_CERT_PART_HEAD,
+      true
+    )
+  })
+
+  const otherPartRows = normalizedPart
+    ? next.filter(cert => normalizeVehicleCertPart(cert.vehiclePart) !== normalizedPart)
+    : []
+
+  return [...otherPartRows, ...requiredRows, ...extras.map(c => ({ ...c, _required: false }))]
+}
+
+function ensureVehicleCertSlotsByNames(
+  certList: CertRowWithMeta[],
+  trailerNo: string | null | undefined,
+  headRequired: readonly string[],
+  trailerRequired: readonly string[]
+): CertRowWithMeta[] {
+  let merged = mergeCertListWithRequiredSlots(certList, headRequired, {
+    vehiclePart: PL_VEHICLE_CERT_PART_HEAD
+  })
+  if (trailerNo?.trim()) {
+    merged = mergeCertListWithRequiredSlots(merged, trailerRequired, {
+      vehiclePart: PL_VEHICLE_CERT_PART_TRAILER
+    })
+  } else {
+    merged = stripTrailerRequiredSlots(merged, trailerRequired)
+  }
+  return merged
+}
+
+export function ensureSelfVehicleCertSlots(
+  certList: CertRowWithMeta[],
+  trailerNo?: string | null
+): CertRowWithMeta[] {
+  return ensureVehicleCertSlotsByNames(
+    certList,
+    trailerNo,
+    PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
+    PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
+  )
+}
+
+export function ensureCarrierVehicleCertSlots(
+  certList: CertRowWithMeta[],
+  trailerNo?: string | null
+): CertRowWithMeta[] {
+  return ensureVehicleCertSlotsByNames(
+    certList,
+    trailerNo,
+    PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
+    PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
+  )
+}
+
+export function ensureVehicleCertSlots(
+  certList: CertRowWithMeta[],
+  trailerNo: string | null | undefined,
+  vehicleType: PlVehicleCertCheckType
+): CertRowWithMeta[] {
+  return vehicleType === 'carrier'
+    ? ensureCarrierVehicleCertSlots(certList, trailerNo)
+    : ensureSelfVehicleCertSlots(certList, trailerNo)
+}
+
+export function stripTrailerRequiredSlots(
+  certList: CertRowWithMeta[],
+  trailerRequiredNames: readonly string[] = PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
+): CertRowWithMeta[] {
+  return certList.filter(cert => {
+    if (normalizeVehicleCertPart(cert.vehiclePart) !== PL_VEHICLE_CERT_PART_TRAILER) {
+      return true
+    }
+    return !trailerRequiredNames.some(name => matchRequiredCertName(cert.certName, name))
+  })
+}
+
+export function formatRequiredCertErrors(errors: string[]): string {
+  if (!errors.length) return ''
+  return `必备证书未齐全:${errors.join(';')}`
+}

+ 1 - 1
web-admin/src/utils/pl-display.ts

@@ -225,7 +225,7 @@ export function getPlPlanFollowStatusLabel(status?: string) {
 }
 
 export function getPlPlanArchiveStatusTagType(status?: string) {
-  return status === 'archived' ? 'info' : 'success'
+  return status === 'archived' ? 'info' : 'danger'
 }
 
 export function getPlPlanArchiveStatusLabel(status?: string) {

+ 113 - 6
web-admin/src/utils/pl-personnel-cert.ts

@@ -11,6 +11,95 @@ export const PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES = [
 export const PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES = ['身份证', '从业资格证'] as const
 
 export type PersonnelCertRole = 'driver' | 'escort'
+export type PersonnelType = 'driver' | 'escort' | 'both'
+
+export const PL_PERSONNEL_TYPE_OPTIONS = [
+  { value: 'driver', label: '驾驶员' },
+  { value: 'escort', label: '押运员' },
+  { value: 'both', label: '驾驶员兼押运员' }
+] as const
+
+export type PersonnelAffiliation = 'self' | 'carrier'
+
+export const PL_PERSONNEL_AFFILIATION_OPTIONS = [
+  { value: 'self', label: '山峡' },
+  { value: 'carrier', label: '非山峡' }
+] as const
+
+export function getPersonnelAffiliationLabel(affiliation?: string | null): string {
+  const found = PL_PERSONNEL_AFFILIATION_OPTIONS.find(item => item.value === affiliation)
+  return found?.label || affiliation || '—'
+}
+
+export function getPersonnelAffiliationTagType(
+  affiliation?: string | null
+): 'success' | 'warning' | 'info' {
+  if (affiliation === 'self') return 'success'
+  if (affiliation === 'carrier') return 'warning'
+  return 'info'
+}
+
+export function validatePersonnelAffiliationVehicleCompat(
+  personnelAffiliation: string | undefined,
+  vehicleType: string | undefined,
+  onError: (message: string) => void
+): boolean {
+  if (!personnelAffiliation || !vehicleType) return true
+  if (personnelAffiliation === vehicleType) return true
+  const vehicleLabel = vehicleType === 'self' ? '山峡' : '非山峡'
+  onError(`${vehicleLabel}车辆仅可关联${vehicleLabel}人员`)
+  return false
+}
+
+export function getPersonnelTypeLabel(type?: string | null): string {
+  const found = PL_PERSONNEL_TYPE_OPTIONS.find(item => item.value === type)
+  return found?.label || type || '—'
+}
+
+export function getPersonnelTypeTagType(type?: string | null): 'success' | 'warning' | 'primary' | 'info' {
+  if (type === 'driver') return 'primary'
+  if (type === 'escort') return 'warning'
+  if (type === 'both') return 'success'
+  return 'info'
+}
+
+export function getRequiredPersonnelCertNamesByType(
+  personnelType?: string | null
+): readonly string[] {
+  if (personnelType === 'escort') {
+    return PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES
+  }
+  return PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES
+}
+
+export function validatePersonnelTypeRoleCompat(
+  personnelType: string | undefined,
+  role: string | undefined,
+  onError: (message: string) => void
+): boolean {
+  if (!personnelType || !role) return true
+  if (personnelType === 'driver' && role === 'escort') {
+    onError('驾驶员类型人员不可关联为押运员')
+    return false
+  }
+  if (personnelType === 'escort' && role === 'driver') {
+    onError('押运员类型人员不可关联为驾驶员')
+    return false
+  }
+  return true
+}
+
+export function isPersonnelCertStrictComplete(
+  cert: Pick<CertRow, 'certName' | 'certNo' | 'expireDate' | 'attachmentUrls'> | undefined,
+  requiredName: string
+): boolean {
+  if (!cert?.certName?.trim()) return false
+  if (!matchRequiredCertName(cert.certName, requiredName)) return false
+  if (!cert.certNo?.trim()) return false
+  if (!cert.expireDate) return false
+  if (!cert.attachmentUrls?.trim()) return false
+  return true
+}
 
 export function matchRequiredCertName(
   actualName: string | undefined,
@@ -28,8 +117,13 @@ export function matchRequiredCertName(
   return name === requiredName
 }
 
-export function isPersonnelCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
-  return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
+export function isPersonnelCertUploaded(cert?: Pick<CertRow, 'certName' | 'certNo' | 'expireDate' | 'attachmentUrls'>) {
+  return Boolean(
+    cert?.certName?.trim() &&
+      cert?.certNo?.trim() &&
+      cert?.expireDate &&
+      cert?.attachmentUrls?.trim()
+  )
 }
 
 export function getRequiredPersonnelCertNames(role: PersonnelCertRole): readonly string[] {
@@ -55,15 +149,28 @@ export function hasPersonnelCertMissing(
   return getMissingPersonnelCertNames(role, certList).length > 0
 }
 
-/** 人员主数据页:至少满足押运员必备证书(身份证 + 从业资格证) */
-export function hasPersonnelMasterCertMissing(certList: CertRow[] = []): boolean {
-  return hasPersonnelCertMissing('escort', certList)
+/** 人员主数据页:按人员类型检查必备证书 */
+export function hasPersonnelMasterCertMissing(
+  certList: CertRow[] = [],
+  personnelType?: string | null
+): boolean {
+  const required = getRequiredPersonnelCertNamesByType(personnelType || 'both')
+  return required.some(requiredName => {
+    const matched = certList.find(cert => matchRequiredCertName(cert.certName, requiredName))
+    return !isPersonnelCertStrictComplete(matched, requiredName)
+  })
 }
 
 export function getPersonnelCertMissingSuffix(
   certList: CertRow[] = [],
-  role?: PersonnelCertRole
+  role?: PersonnelCertRole,
+  personnelType?: string | null
 ): string {
+  if (personnelType) {
+    return hasPersonnelMasterCertMissing(certList, personnelType)
+      ? PL_PERSONNEL_CERT_MISSING_SUFFIX
+      : ''
+  }
   if (role) {
     return hasPersonnelCertMissing(role, certList) ? PL_PERSONNEL_CERT_MISSING_SUFFIX : ''
   }

+ 80 - 14
web-admin/src/utils/pl-vehicle-cert.ts

@@ -21,6 +21,12 @@ export const PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = [
   '罐检报告'
 ] as const
 
+export const PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
+
+export const PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
+
+export type PlVehicleCertCheckType = 'self' | 'carrier'
+
 export const PL_VEHICLE_CERT_NAME_OPTIONS = [
   '行驶证',
   '营运证',
@@ -70,18 +76,40 @@ export function mergeVehicleCertsByPart<T extends CertRow>(
   return [...normalizedHead, ...normalizedTrailer]
 }
 
-export function isVehicleCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
-  return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
+export function isVehicleCertUploaded(cert?: Pick<CertRow, 'certName' | 'certNo' | 'expireDate' | 'attachmentUrls'>) {
+  return Boolean(
+    cert?.certName?.trim() &&
+      cert?.certNo?.trim() &&
+      cert?.expireDate &&
+      cert?.attachmentUrls?.trim()
+  )
 }
 
-export function getMissingSelfVehiclePartCertNames(
+export function getVehicleHeadRequiredCertNames(
+  vehicleType: PlVehicleCertCheckType
+): readonly string[] {
+  return vehicleType === 'carrier'
+    ? PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES
+    : PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
+}
+
+export function getVehicleTrailerRequiredCertNames(
+  vehicleType: PlVehicleCertCheckType
+): readonly string[] {
+  return vehicleType === 'carrier'
+    ? PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
+    : PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
+}
+
+export function getMissingVehiclePartCertNames(
   certList: CertRow[] = [],
-  part: VehicleCertPart
+  part: VehicleCertPart,
+  vehicleType: PlVehicleCertCheckType
 ): string[] {
   const requiredNames =
     part === PL_VEHICLE_CERT_PART_TRAILER
-      ? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
-      : PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
+      ? getVehicleTrailerRequiredCertNames(vehicleType)
+      : getVehicleHeadRequiredCertNames(vehicleType)
 
   return requiredNames.filter(requiredName => {
     const matched = certList.find(cert => cert.certName?.trim() === requiredName)
@@ -89,41 +117,79 @@ export function getMissingSelfVehiclePartCertNames(
   })
 }
 
-export function hasSelfVehicleCertMissing(options: {
+export function getMissingSelfVehiclePartCertNames(
+  certList: CertRow[] = [],
+  part: VehicleCertPart
+): string[] {
+  return getMissingVehiclePartCertNames(certList, part, 'self')
+}
+
+export function hasVehicleCertMissing(options: {
+  vehicleType: PlVehicleCertCheckType
   certList?: CertRow[]
   trailerNo?: string | null
 }): boolean {
-  const { certList = [], trailerNo } = options
+  const { vehicleType, certList = [], trailerNo } = options
   const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList)
   const hasTrailer = Boolean(trailerNo?.trim())
 
-  if (getMissingSelfVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD).length > 0) {
+  if (
+    getMissingVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD, vehicleType).length > 0
+  ) {
     return true
   }
 
-  if (hasTrailer && getMissingSelfVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER).length > 0) {
+  if (
+    hasTrailer &&
+    getMissingVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER, vehicleType)
+      .length > 0
+  ) {
     return true
   }
 
   return false
 }
 
+export function hasSelfVehicleCertMissing(options: {
+  certList?: CertRow[]
+  trailerNo?: string | null
+}): boolean {
+  return hasVehicleCertMissing({ ...options, vehicleType: 'self' })
+}
+
+export function getVehicleCertMissingSuffix(options: {
+  vehicleType: PlVehicleCertCheckType
+  certList?: CertRow[]
+  trailerNo?: string | null
+}): string {
+  return hasVehicleCertMissing(options) ? PL_VEHICLE_CERT_MISSING_SUFFIX : ''
+}
+
 export function getSelfVehicleCertMissingSuffix(options: {
   certList?: CertRow[]
   trailerNo?: string | null
 }): string {
-  return hasSelfVehicleCertMissing(options) ? PL_VEHICLE_CERT_MISSING_SUFFIX : ''
+  return getVehicleCertMissingSuffix({ ...options, vehicleType: 'self' })
 }
 
-export function getSelfVehiclePartCertMissingSuffix(
+export function getVehiclePartCertMissingSuffix(
   certList: CertRow[] = [],
-  part: VehicleCertPart
+  part: VehicleCertPart,
+  vehicleType?: PlVehicleCertCheckType | null
 ): string {
-  return getMissingSelfVehiclePartCertNames(certList, part).length > 0
+  if (!vehicleType) return ''
+  return getMissingVehiclePartCertNames(certList, part, vehicleType).length > 0
     ? PL_VEHICLE_CERT_MISSING_SUFFIX
     : ''
 }
 
+export function getSelfVehiclePartCertMissingSuffix(
+  certList: CertRow[] = [],
+  part: VehicleCertPart
+): string {
+  return getVehiclePartCertMissingSuffix(certList, part, 'self')
+}
+
 /** @deprecated 保存不再阻断证书完整性,仅校验证书名称非空等基础项 */
 export function validateVehicleRequiredCerts(options: {
   headCerts: CertRow[]

+ 14 - 26
web-admin/src/views/pl/carrier/index.vue

@@ -426,6 +426,7 @@ const formData = reactive({
 const createQualificationRules = {
   businessLicenseUrls: [
     {
+      required: true,
       validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
         if (!value || !String(value).trim()) {
           callback(new Error('请上传营业执照'))
@@ -438,6 +439,7 @@ const createQualificationRules = {
   ],
   roadTransportLicenseUrls: [
     {
+      required: true,
       validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
         if (!value || !String(value).trim()) {
           callback(new Error('请上传道路经营许可证'))
@@ -450,6 +452,7 @@ const createQualificationRules = {
   ],
   transportAgreementUrls: [
     {
+      required: true,
       validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
         if (!value || !String(value).trim()) {
           callback(new Error('请上传运输协议'))
@@ -462,10 +465,12 @@ const createQualificationRules = {
   ]
 }
 
-const formRules = computed(() => ({
+const formRules = {
   carrierName: [{ required: true, message: '请输入承运商名称', trigger: 'blur' }],
-  ...(isEdit.value ? {} : createQualificationRules)
-}))
+  contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
+  contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
+  ...createQualificationRules
+}
 
 function handleCertFieldChange(
   field: 'businessLicenseUrls' | 'roadTransportLicenseUrls' | 'transportAgreementUrls'
@@ -658,29 +663,12 @@ async function handleSubmit() {
       contactPhone: formData.contactPhone,
       status: formData.status,
       remark: formData.remark,
-      ...(isEdit.value
-        ? {
-            ...(formData.businessLicenseUrls
-              ? { businessLicenseUrls: formData.businessLicenseUrls }
-              : {}),
-            ...(formData.roadTransportLicenseUrls
-              ? { roadTransportLicenseUrls: formData.roadTransportLicenseUrls }
-              : {}),
-            ...(formData.transportAgreementUrls
-              ? { transportAgreementUrls: formData.transportAgreementUrls }
-              : {}),
-            ...(formData.transportAgreementExpireDate
-              ? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
-              : {})
-          }
-        : {
-            businessLicenseUrls: formData.businessLicenseUrls,
-            roadTransportLicenseUrls: formData.roadTransportLicenseUrls,
-            transportAgreementUrls: formData.transportAgreementUrls,
-            ...(formData.transportAgreementExpireDate
-              ? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
-              : {})
-          })
+      businessLicenseUrls: formData.businessLicenseUrls,
+      roadTransportLicenseUrls: formData.roadTransportLicenseUrls,
+      transportAgreementUrls: formData.transportAgreementUrls,
+      ...(formData.transportAgreementExpireDate
+        ? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
+        : {})
     }
     if (isEdit.value) {
       await carrierApi.update(formData.id, payload)

+ 145 - 17
web-admin/src/views/pl/personnel/index.vue

@@ -24,6 +24,16 @@
                 <el-option label="停用" value="0" />
               </el-select>
             </el-form-item>
+            <el-form-item label="人员归属">
+              <el-select v-model="searchForm.personnelAffiliation" placeholder="全部" clearable>
+                <el-option
+                  v-for="option in PL_PERSONNEL_AFFILIATION_OPTIONS"
+                  :key="option.value"
+                  :label="option.label"
+                  :value="option.value"
+                />
+              </el-select>
+            </el-form-item>
           </div>
           <div class="filter-actions">
             <div class="filter-actions-end">
@@ -112,6 +122,32 @@
               <el-link type="primary" @click="handleView(row)">{{ row.personnelName }}</el-link>
             </template>
           </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('personnelAffiliation')"
+            label="人员归属"
+            min-width="100"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <el-tag :type="getPersonnelAffiliationTagType(row.personnelAffiliation)" size="small">
+                {{ getPersonnelAffiliationLabel(row.personnelAffiliation) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('personnelType')"
+            label="人员类型"
+            min-width="130"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <el-tag :type="getPersonnelTypeTagType(row.personnelType)" size="small">
+                {{ getPersonnelTypeLabel(row.personnelType) }}
+              </el-tag>
+            </template>
+          </el-table-column>
           <el-table-column
             v-if="isColumnVisible('phoneNo')"
             prop="phoneNo"
@@ -224,6 +260,26 @@
           <el-form-item label="身份证号" prop="idCardNo">
             <el-input v-model="formData.idCardNo" placeholder="请输入身份证号" maxlength="32" />
           </el-form-item>
+          <el-form-item label="人员归属" prop="personnelAffiliation">
+            <el-select v-model="formData.personnelAffiliation" placeholder="请选择人员归属">
+              <el-option
+                v-for="option in PL_PERSONNEL_AFFILIATION_OPTIONS"
+                :key="option.value"
+                :label="option.label"
+                :value="option.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="人员类型" prop="personnelType">
+            <el-select v-model="formData.personnelType" placeholder="请选择人员类型">
+              <el-option
+                v-for="option in PL_PERSONNEL_TYPE_OPTIONS"
+                :key="option.value"
+                :label="option.label"
+                :value="option.value"
+              />
+            </el-select>
+          </el-form-item>
           <el-form-item label="状态" prop="status">
             <el-select v-model="formData.status" placeholder="请选择">
               <el-option label="在职" value="1" />
@@ -248,6 +304,7 @@
           title="人员证书"
           upload-folder="pl/personnel-cert"
           :cert-name-options="certNameOptions"
+          :required-cert-names="requiredPersonnelCertNames"
           layout="table"
         />
       </el-form>
@@ -311,7 +368,21 @@ import { pickBxPageResult } from '@/utils/http/bx'
 import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
 import { formatDateTime } from '@/utils/format'
 import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
-import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
+import {
+  collectRequiredCertSlotErrors,
+  formatRequiredCertErrors,
+  mergeCertListWithRequiredSlots
+} from '@/utils/pl-cert-slots'
+import {
+  getPersonnelCertMissingSuffix,
+  getRequiredPersonnelCertNamesByType,
+  getPersonnelTypeLabel,
+  getPersonnelTypeTagType,
+  getPersonnelAffiliationLabel,
+  getPersonnelAffiliationTagType,
+  PL_PERSONNEL_AFFILIATION_OPTIONS,
+  PL_PERSONNEL_TYPE_OPTIONS
+} from '@/utils/pl-personnel-cert'
 
 type CertListItem = CertRow & { _key?: string }
 type CertFormListRef = InstanceType<typeof PlCertFormList>
@@ -319,6 +390,8 @@ type CertFormListRef = InstanceType<typeof PlCertFormList>
 const loading = ref(false)
 
 const listColumnDefs = [
+  { prop: 'personnelAffiliation', label: '人员归属' },
+  { prop: 'personnelType', label: '人员类型' },
   { prop: 'phoneNo', label: '手机号' },
   { prop: 'age', label: '年龄' },
   { prop: 'idCardNo', label: '身份证号' },
@@ -359,7 +432,8 @@ const certNameOptions = [
 
 const searchForm = reactive({
   q: '',
-  status: ''
+  status: '',
+  personnelAffiliation: ''
 })
 
 const pagination = reactive({
@@ -374,12 +448,20 @@ const formData = reactive({
   phoneNo: '',
   age: null as number | null,
   idCardNo: '',
+  personnelType: '',
+  personnelAffiliation: 'self',
   status: '1',
   remark: ''
 })
 
+const requiredPersonnelCertNames = computed(() =>
+  getRequiredPersonnelCertNamesByType(formData.personnelType)
+)
+
 const formRules = {
   personnelName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
+  personnelAffiliation: [{ required: true, message: '请选择人员归属', trigger: 'change' }],
+  personnelType: [{ required: true, message: '请选择人员类型', trigger: 'change' }],
   status: [{ required: true, message: '请选择状态', trigger: 'change' }]
 }
 
@@ -410,7 +492,7 @@ const viewHeroMeta = computed(() => {
 const viewDialogTitle = computed(() => {
   const name = viewDetail.value?.personnelName || '人员详情'
   if (!viewDetail.value) return name
-  return name + getPersonnelCertMissingSuffix(viewDetail.value.certList || [])
+  return name + getPersonnelCertMissingSuffix(viewDetail.value.certList || [], undefined, viewDetail.value.personnelType)
 })
 
 const viewSections = computed<ViewSection[]>(() => {
@@ -423,6 +505,16 @@ const viewSections = computed<ViewSection[]>(() => {
       fields: [
         { label: '姓名', value: d.personnelName },
         { label: '手机号', value: d.phoneNo || '—' },
+        {
+          label: '人员归属',
+          value: getPersonnelAffiliationLabel(d.personnelAffiliation),
+          tagType: getPersonnelAffiliationTagType(d.personnelAffiliation)
+        },
+        {
+          label: '人员类型',
+          value: getPersonnelTypeLabel(d.personnelType),
+          tagType: getPersonnelTypeTagType(d.personnelType)
+        },
         { label: '年龄', value: d.age ?? '—' },
         { label: '身份证号', value: maskIdCard(d.idCardNo) },
         {
@@ -443,7 +535,7 @@ const viewSections = computed<ViewSection[]>(() => {
       tableMode: 'triple'
     },
     {
-      title: `人员证书${viewDetail.value ? getPersonnelCertMissingSuffix(viewDetail.value.certList || []) : ''}`,
+      title: `人员证书${viewDetail.value ? getPersonnelCertMissingSuffix(viewDetail.value.certList || [], undefined, viewDetail.value.personnelType) : ''}`,
       fields: [],
       slot: 'certs'
     }
@@ -451,15 +543,39 @@ const viewSections = computed<ViewSection[]>(() => {
 })
 
 function validateCerts(): boolean {
+  if (!formData.personnelType) {
+    ElMessage.warning('请选择人员类型')
+    return false
+  }
+  const errors = collectRequiredCertSlotErrors(
+    certList.value,
+    requiredPersonnelCertNames.value
+  )
+  if (errors.length) {
+    ElMessage.warning(formatRequiredCertErrors(errors))
+    return false
+  }
   for (const cert of certList.value) {
-    if (!cert.certName?.trim()) {
-      ElMessage.warning('证书名称不能为空')
+    if (!cert._required && !cert.certName?.trim()) {
+      ElMessage.warning('其他证书名称不能为空')
       return false
     }
   }
   return true
 }
 
+watch(
+  () => formData.personnelType,
+  () => {
+    if (!formData.personnelType) return
+    certList.value = mergeCertListWithRequiredSlots(
+      certList.value,
+      getRequiredPersonnelCertNamesByType(formData.personnelType)
+    )
+    certFormListRef.value?.refreshRequiredSlots?.()
+  }
+)
+
 function resetCertList() {
   certList.value = []
   certFormListRef.value?.clearUploadRefs()
@@ -471,6 +587,7 @@ async function fetchData() {
     const res = await personnelApi.list({
       q: searchForm.q || undefined,
       status: searchForm.status || undefined,
+      personnelAffiliation: searchForm.personnelAffiliation || undefined,
       page: pagination.page,
       pageSize: pagination.pageSize
     })
@@ -492,6 +609,7 @@ function handleSearch() {
 function handleReset() {
   searchForm.q = ''
   searchForm.status = ''
+  searchForm.personnelAffiliation = ''
   selectedRows.value = []
   pagination.page = 1
   fetchData()
@@ -548,6 +666,8 @@ function handleAdd() {
     phoneNo: '',
     age: null,
     idCardNo: '',
+    personnelType: '',
+    personnelAffiliation: 'self',
     status: '1',
     remark: ''
   })
@@ -583,19 +703,24 @@ async function handleEdit(row: Personnel) {
       phoneNo: detail.phoneNo || '',
       age: detail.age ?? null,
       idCardNo: detail.idCardNo || '',
+      personnelType: detail.personnelType || 'both',
+      personnelAffiliation: detail.personnelAffiliation || 'self',
       status: detail.status,
       remark: detail.remark || ''
     })
-    certList.value = (detail.certList || []).map(c => ({
-      _key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
-      certId: c.id || c.certId,
-      certName: c.certName,
-      certNo: c.certNo,
-      expireDate: c.expireDate,
-      remindDays: c.remindDays ?? 30,
-      attachmentUrls: c.attachmentUrls || ''
-    }))
-    certFormListRef.value?.clearUploadRefs()
+    certList.value = mergeCertListWithRequiredSlots(
+      (detail.certList || []).map(c => ({
+        _key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
+        certId: c.id || c.certId,
+        certName: c.certName,
+        certNo: c.certNo,
+        expireDate: c.expireDate,
+        remindDays: c.remindDays ?? 30,
+        attachmentUrls: c.attachmentUrls || ''
+      })),
+      getRequiredPersonnelCertNamesByType(detail.personnelType || 'both')
+    )
+    certFormListRef.value?.refreshRequiredSlots?.()
   } catch {
     ElMessage.error('加载人员详情失败')
   }
@@ -616,6 +741,8 @@ async function handleSubmit() {
       phoneNo: formData.phoneNo || undefined,
       age: formData.age ?? undefined,
       idCardNo: formData.idCardNo || undefined,
+      personnelType: formData.personnelType,
+      personnelAffiliation: formData.personnelAffiliation,
       status: formData.status,
       remark: formData.remark || undefined,
       certList: certList.value.map(c => ({
@@ -664,7 +791,8 @@ async function handleDelete(row: Personnel) {
 useDbInstantSearch(
   () => ({
     q: searchForm.q,
-    status: searchForm.status
+    status: searchForm.status,
+    personnelAffiliation: searchForm.personnelAffiliation
   }),
   handleSearch
 )

+ 66 - 34
web-admin/src/views/pl/plan-archive/index.vue

@@ -148,36 +148,6 @@
             min-width="120"
             align="center"
           />
-          <el-table-column
-            v-if="isColumnVisible('planType')"
-            prop="planType"
-            label="单据类型"
-            min-width="100"
-            align="center"
-          >
-            <template #default="{ row }">
-              <el-tag :type="getPlanTypeTagType(row.planType)" size="small">
-                {{ getPlanTypeLabel(row.planType) }}
-              </el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column
-            v-if="isColumnVisible('customerName')"
-            prop="customerName"
-            label="客户名称"
-            min-width="180"
-            show-overflow-tooltip
-          />
-          <el-table-column v-if="isColumnVisible('products')" label="产品信息" min-width="240">
-            <template #default="{ row }">
-              <PlPlanListProductCell :products="row.products" />
-            </template>
-          </el-table-column>
-          <el-table-column v-if="isColumnVisible('planAttachments')" label="计划附件" min-width="200">
-            <template #default="{ row }">
-              <PlPlanListCertCell :products="row.products" compact hide-product />
-            </template>
-          </el-table-column>
           <el-table-column
             v-if="isColumnVisible('archiveStatus')"
             prop="archiveStatus"
@@ -217,6 +187,36 @@
               </el-tag>
             </template>
           </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('planType')"
+            prop="planType"
+            label="单据类型"
+            min-width="100"
+            align="center"
+          >
+            <template #default="{ row }">
+              <el-tag :type="getPlanTypeTagType(row.planType)" size="small">
+                {{ getPlanTypeLabel(row.planType) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('customerName')"
+            prop="customerName"
+            label="客户名称"
+            min-width="180"
+            show-overflow-tooltip
+          />
+          <el-table-column v-if="isColumnVisible('products')" label="产品信息" min-width="240">
+            <template #default="{ row }">
+              <PlPlanListProductCell :products="row.products" />
+            </template>
+          </el-table-column>
+          <el-table-column v-if="isColumnVisible('planAttachments')" label="计划附件" min-width="200">
+            <template #default="{ row }">
+              <PlPlanListCertCell :products="row.products" compact hide-product />
+            </template>
+          </el-table-column>
           <el-table-column
             v-if="isColumnVisible('licensePlate')"
             prop="licensePlate"
@@ -313,6 +313,11 @@ import {
   Search
 } from '@element-plus/icons-vue'
 import { planApi, type Plan } from '@/api/pl'
+import {
+  buildMissingHazardCertReadyMessage,
+  enrichPlanProductsHazardFromCrm
+} from '@/composables/usePlToxicProductCertCheck'
+import { pickBxData } from '@/utils/http/bx'
 import PlPlanArchiveDialog from '@/components/business/pl-plan-archive-dialog/index.vue'
 import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
 import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
@@ -347,13 +352,13 @@ const planList = ref<Plan[]>([])
 
 const listColumnDefs = [
   { prop: 'planDate', label: '计划日期' },
+  { prop: 'archiveStatus', label: '归档状态' },
+  { prop: 'followStatus', label: '跟进状态' },
+  { prop: 'transportStatus', label: '运输状态' },
   { prop: 'planType', label: '单据类型' },
   { prop: 'customerName', label: '客户名称' },
   { prop: 'products', label: '产品信息' },
   { prop: 'planAttachments', label: '计划附件' },
-  { prop: 'archiveStatus', label: '归档状态' },
-  { prop: 'followStatus', label: '跟进状态' },
-  { prop: 'transportStatus', label: '运输状态' },
   { prop: 'licensePlate', label: '车牌号' },
   { prop: 'personnelSummary', label: '人员' },
   { prop: 'followUserName', label: '跟进人', defaultVisible: false }
@@ -530,7 +535,34 @@ function handleEdit(row: Plan) {
   formDialogVisible.value = true
 }
 
-function handleArchive(row: Plan) {
+async function handleArchive(row: Plan) {
+  if (row.archiveStatus === 'archived') {
+    archivePlanId.value = row.id
+    archiveDialogVisible.value = true
+    return
+  }
+
+  try {
+    const res = await planApi.get(row.id)
+    const data = pickBxData<Plan>(res)
+    if (!data?.id) {
+      throw new Error('运输计划数据无效')
+    }
+    const rawProducts = Array.isArray(data.products) ? data.products : []
+    const enrichedProducts = await enrichPlanProductsHazardFromCrm(rawProducts)
+    const certMessage = buildMissingHazardCertReadyMessage(data, enrichedProducts)
+    if (certMessage) {
+      await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法归档', {
+        type: 'warning'
+      })
+      return
+    }
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '加载运输计划失败'
+    ElMessage.error(message.includes('加载') ? message : `加载运输计划失败:${message}`)
+    return
+  }
+
   archivePlanId.value = row.id
   archiveDialogVisible.value = true
 }

+ 1 - 1
web-admin/src/views/pl/plan/index.vue

@@ -197,7 +197,7 @@
             align="center"
           >
             <template #default="{ row }">
-              <el-tag :type="row.transportStatus === 'arranged' ? 'success' : 'info'" size="small">
+              <el-tag :type="row.transportStatus === 'arranged' ? 'success' : 'danger'" size="small">
                 {{ row.transportStatus === 'arranged' ? '已安排' : '待安排' }}
               </el-tag>
             </template>

+ 47 - 22
web-admin/src/views/pl/self-vehicle/index.vue

@@ -316,6 +316,7 @@
           upload-folder="pl/vehicle-cert"
           :cert-name-options="certNameOptions"
           layout="table"
+          enforce-self-vehicle-required
         />
       </el-form>
       <template #footer>
@@ -362,7 +363,8 @@
           :trailer-no="viewDetail?.trailerNo"
           :vehicle-id="viewDetail?.id"
           :personnel-list="viewPersonnelList"
-          check-self-vehicle-certs
+          vehicle-cert-check-type="self"
+          check-linked-personnel-certs
           cert-layout="table"
         />
       </template>
@@ -403,12 +405,16 @@ import {
   getPlVehicleStatusTagType,
   normalizePlPersonnelSummaryDisplay
 } from '@/utils/pl-display'
+import {
+  collectSelfVehicleRequiredCertErrors,
+  ensureSelfVehicleCertSlots,
+  formatRequiredCertErrors
+} from '@/utils/pl-cert-slots'
 import {
   PL_VEHICLE_CERT_NAME_OPTIONS,
-  splitVehicleCertsByPart,
-  getSelfVehicleCertMissingSuffix,
-  validateVehicleCertNamesNotEmpty
+  getSelfVehicleCertMissingSuffix
 } from '@/utils/pl-vehicle-cert'
+import { validatePersonnelTypeRoleCompat, validatePersonnelAffiliationVehicleCompat } from '@/utils/pl-personnel-cert'
 import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
 
 const VEHICLE_TYPE_SELF = 'self'
@@ -628,7 +634,7 @@ function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
 }
 
 async function loadPersonnelOptions() {
-  const res = await personnelApi.list({ status: '1', pageSize: 100 })
+  const res = await personnelApi.list({ status: '1', pageSize: 100, personnelAffiliation: 'self' })
   personnelOptions.value = pickBxList<Personnel>(res)
 }
 
@@ -682,14 +688,29 @@ function validateSubTables(): boolean {
       ElMessage.warning('请选择人员类型')
       return false
     }
+    const person = personnelOptions.value.find(p => p.id === row.personnelId)
+    if (
+      !validatePersonnelTypeRoleCompat(person?.personnelType, row.role, message =>
+        ElMessage.warning(message)
+      )
+    ) {
+      return false
+    }
+    if (
+      !validatePersonnelAffiliationVehicleCompat(person?.personnelAffiliation, 'self', message =>
+        ElMessage.warning(message)
+      )
+    ) {
+      return false
+    }
   }
 
-  const hasTrailer = Boolean(formData.trailerNo?.trim())
-  const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList.value)
-  return validateVehicleCertNamesNotEmpty(
-    [headCerts, ...(hasTrailer ? [trailerCerts] : [])],
-    message => ElMessage.warning(message)
-  )
+  const certErrors = collectSelfVehicleRequiredCertErrors(certList.value, formData.trailerNo)
+  if (certErrors.length) {
+    ElMessage.warning(formatRequiredCertErrors(certErrors))
+    return false
+  }
+  return true
 }
 
 async function fetchData() {
@@ -794,6 +815,7 @@ function handleAdd() {
     remark: ''
   })
   resetSubTables()
+  certList.value = ensureSelfVehicleCertSlots([], formData.trailerNo)
   loadPersonnelOptions()
   dialogVisible.value = true
 }
@@ -845,17 +867,20 @@ async function handleEdit(row: Vehicle) {
         age: p.age ?? null
       }))
     )
-    certList.value = (detail.certList || []).map(c => ({
-      _key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
-      certId: c.id || c.certId,
-      vehiclePart: c.vehiclePart,
-      certName: c.certName,
-      certNo: c.certNo,
-      expireDate: c.expireDate,
-      remindDays: c.remindDays ?? 30,
-      attachmentUrls: c.attachmentUrls || ''
-    }))
-    certFormListRef.value?.clearUploadRefs()
+    certList.value = ensureSelfVehicleCertSlots(
+      (detail.certList || []).map(c => ({
+        _key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
+        certId: c.id || c.certId,
+        vehiclePart: c.vehiclePart,
+        certName: c.certName,
+        certNo: c.certNo,
+        expireDate: c.expireDate,
+        remindDays: c.remindDays ?? 30,
+        attachmentUrls: c.attachmentUrls || ''
+      })),
+      formData.trailerNo
+    )
+    certFormListRef.value?.refreshRequiredSlots?.()
   } catch {
     dialogVisible.value = false
     ElMessage.error('加载车辆详情失败')

+ 91 - 15
web-admin/src/views/pl/vehicle/index.vue

@@ -335,6 +335,8 @@
           upload-folder="pl/vehicle-cert"
           :cert-name-options="certNameOptions"
           layout="table"
+          :enforce-self-vehicle-required="formData.vehicleType === 'self'"
+          :enforce-carrier-vehicle-required="formData.vehicleType === 'carrier'"
         />
       </el-form>
       <template #footer>
@@ -381,7 +383,10 @@
           :trailer-no="viewDetail?.trailerNo"
           :vehicle-id="viewDetail?.id"
           :personnel-list="viewPersonnelList"
-          :check-self-vehicle-certs="viewDetail?.vehicleType === 'self'"
+          :vehicle-cert-check-type="viewVehicleCertCheckType"
+          :check-linked-personnel-certs="
+            viewDetail?.vehicleType === 'self' || viewDetail?.vehicleType === 'carrier'
+          "
           cert-layout="table"
         />
       </template>
@@ -416,12 +421,18 @@ import { pickBxList, pickBxPageResult } from '@/utils/http/bx'
 import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
 import { formatDateTime } from '@/utils/format'
 import { formatPlVehiclePlateDisplay, normalizePlPersonnelSummaryDisplay } from '@/utils/pl-display'
+import {
+  collectCarrierVehicleRequiredCertErrors,
+  collectSelfVehicleRequiredCertErrors,
+  ensureVehicleCertSlots,
+  formatRequiredCertErrors
+} from '@/utils/pl-cert-slots'
 import {
   PL_VEHICLE_CERT_NAME_OPTIONS,
-  splitVehicleCertsByPart,
-  getSelfVehicleCertMissingSuffix,
-  validateVehicleCertNamesNotEmpty
+  getVehicleCertMissingSuffix,
+  type PlVehicleCertCheckType
 } from '@/utils/pl-vehicle-cert'
+import { validatePersonnelTypeRoleCompat, validatePersonnelAffiliationVehicleCompat } from '@/utils/pl-personnel-cert'
 import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
 
 type CertListItem = CertRow & { _key?: string }
@@ -534,12 +545,19 @@ const viewHeroMeta = computed(() => {
   return parts.join(' · ')
 })
 
+const viewVehicleCertCheckType = computed<PlVehicleCertCheckType | undefined>(() => {
+  const type = viewDetail.value?.vehicleType
+  return type === 'self' || type === 'carrier' ? type : undefined
+})
+
 const viewDialogTitle = computed(() => {
   const plate = viewDetail.value?.licensePlate || '车辆详情'
-  if (!viewDetail.value || viewDetail.value.vehicleType !== 'self') return plate
+  const checkType = viewVehicleCertCheckType.value
+  if (!viewDetail.value || !checkType) return plate
   return (
     plate +
-    getSelfVehicleCertMissingSuffix({
+    getVehicleCertMissingSuffix({
+      vehicleType: checkType,
       certList: viewDetail.value.certList,
       trailerNo: viewDetail.value.trailerNo
     })
@@ -682,7 +700,13 @@ function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
 }
 
 async function loadPersonnelOptions() {
-  const res = await personnelApi.list({ status: '1', pageSize: 100 })
+  const affiliation =
+    formData.vehicleType === 'carrier' ? 'carrier' : formData.vehicleType === 'self' ? 'self' : undefined
+  const res = await personnelApi.list({
+    status: '1',
+    pageSize: 100,
+    personnelAffiliation: affiliation
+  })
   personnelOptions.value = pickBxList<Personnel>(res)
 }
 
@@ -736,14 +760,41 @@ function validateSubTables(): boolean {
       ElMessage.warning('请选择人员类型')
       return false
     }
+    if (formData.vehicleType === 'self' || formData.vehicleType === 'carrier') {
+      const person = personnelOptions.value.find(p => p.id === row.personnelId)
+      if (
+        !validatePersonnelTypeRoleCompat(person?.personnelType, row.role, message =>
+          ElMessage.warning(message)
+        )
+      ) {
+        return false
+      }
+      if (
+        !validatePersonnelAffiliationVehicleCompat(
+          person?.personnelAffiliation,
+          formData.vehicleType,
+          message => ElMessage.warning(message)
+        )
+      ) {
+        return false
+      }
+    }
   }
 
-  const hasTrailer = Boolean(formData.trailerNo?.trim())
-  const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList.value)
-  return validateVehicleCertNamesNotEmpty(
-    [headCerts, ...(hasTrailer ? [trailerCerts] : [])],
-    message => ElMessage.warning(message)
-  )
+  if (formData.vehicleType === 'self') {
+    const certErrors = collectSelfVehicleRequiredCertErrors(certList.value, formData.trailerNo)
+    if (certErrors.length) {
+      ElMessage.warning(formatRequiredCertErrors(certErrors))
+      return false
+    }
+  } else if (formData.vehicleType === 'carrier') {
+    const certErrors = collectCarrierVehicleRequiredCertErrors(certList.value, formData.trailerNo)
+    if (certErrors.length) {
+      ElMessage.warning(formatRequiredCertErrors(certErrors))
+      return false
+    }
+  }
+  return true
 }
 
 async function fetchData() {
@@ -850,6 +901,7 @@ function handleAdd() {
     remark: ''
   })
   resetSubTables()
+  certList.value = ensureVehicleCertSlots([], formData.trailerNo, formData.vehicleType as PlVehicleCertCheckType)
   loadPersonnelOptions()
   dialogVisible.value = true
 }
@@ -898,7 +950,7 @@ async function handleEdit(row: Vehicle) {
         age: p.age ?? null
       }))
     )
-    certList.value = (detail.certList || []).map(c => ({
+    const mappedCerts = (detail.certList || []).map(c => ({
       _key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
       certId: c.id || c.certId,
       vehiclePart: c.vehiclePart,
@@ -908,7 +960,15 @@ async function handleEdit(row: Vehicle) {
       remindDays: c.remindDays ?? 30,
       attachmentUrls: c.attachmentUrls || ''
     }))
-    certFormListRef.value?.clearUploadRefs()
+    certList.value =
+      detail.vehicleType === 'self' || detail.vehicleType === 'carrier'
+        ? ensureVehicleCertSlots(
+            mappedCerts,
+            formData.trailerNo,
+            detail.vehicleType as PlVehicleCertCheckType
+          )
+        : mappedCerts
+    certFormListRef.value?.refreshRequiredSlots?.()
   } finally {
     dialogLoading.value = false
   }
@@ -993,6 +1053,22 @@ async function handleDelete(row: Vehicle) {
   fetchData()
 }
 
+watch(
+  () => formData.vehicleType,
+  async type => {
+    if (!dialogVisible.value) return
+    await loadPersonnelOptions()
+    if (type === 'self' || type === 'carrier') {
+      certList.value = ensureVehicleCertSlots(
+        certList.value,
+        formData.trailerNo,
+        type as PlVehicleCertCheckType
+      )
+      certFormListRef.value?.refreshRequiredSlots?.()
+    }
+  }
+)
+
 useDbInstantSearch(
   () => ({
     q: searchForm.q,

Some files were not shown because too many files changed in this diff