zlwu 3 ماه پیش
والد
کامیت
8be55c08e9
32فایلهای تغییر یافته به همراه3342 افزوده شده و 611 حذف شده
  1. 1 1
      backend/app.pid
  2. 168 0
      backend/app/api/crm.py
  3. 377 0
      backend/app/crud/crm/price_letter.py
  4. 31 0
      backend/app/models/crm_models.py
  5. 70 0
      backend/app/schemas/crm/price_letter.py
  6. 12 1
      backend/graphify-out/.graphify_labels.json
  7. 202 152
      backend/graphify-out/GRAPH_REPORT.md
  8. 0 0
      backend/graphify-out/cache/ast/6f233150e253813a7c9da30e6e5d6046fdea01baa67c2589a332fe511ab795f9.json
  9. 0 0
      backend/graphify-out/cache/ast/b09bff73a40f4af48c1a13475e6bb1af3f4da174dcf393067ddfa8adfa3f09a4.json
  10. 0 0
      backend/graphify-out/cache/ast/c94681aea5f0ae5f08969088f1bb57ca4fdb5dcf586161c16e2455aab1ca162c.json
  11. 0 0
      backend/graphify-out/cache/ast/ceaa64369ef66891310f391836c580beefd5a69854b3a5a0539a419744a311b0.json
  12. 0 0
      backend/graphify-out/cache/ast/d15105a475ef5ad816381c1e20683874240a80601b1b8b132a972b68276483e0.json
  13. 0 0
      backend/graphify-out/cache/ast/ed2ea10761f2798e8980084d754284d3fcb94f1284ae7085c35c16108fe24846.json
  14. 0 0
      backend/graphify-out/cache/stat-index.json
  15. 112 112
      backend/graphify-out/graph.json
  16. 208 0
      backend/scripts/migration_20260715_crm_price_letter.sql
  17. 28 0
      backend/scripts/migration_20260715_crm_price_letter_bill_price.sql
  18. 10 4
      backend/scripts/seed_sys_menu.sql
  19. 46 0
      docs/sql/shanxia2.0.sql
  20. 134 19
      graphify-out/manifest.json
  21. 19 3
      web-admin/graphify-out/.graphify_labels.json
  22. 225 207
      web-admin/graphify-out/GRAPH_REPORT.md
  23. 112 112
      web-admin/graphify-out/graph.json
  24. BIN
      web-admin/public/SXYZ.jpg
  25. BIN
      web-admin/public/sxyz.png
  26. 26 0
      web-admin/src/api/crm/price-letter.ts
  27. 66 0
      web-admin/src/hooks/business/usePriceLetterPerm.ts
  28. 7 0
      web-admin/src/router/modules/sqxp.ts
  29. 162 0
      web-admin/src/utils/crm/price-letter-template.ts
  30. 1 0
      web-admin/src/utils/router/menu-icon.ts
  31. 115 0
      web-admin/src/views/crm/price-letter/components/VoidDialog.vue
  32. 1210 0
      web-admin/src/views/crm/price-letter/index.vue

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-39937
+74168

+ 168 - 0
backend/app/api/crm.py

@@ -4379,6 +4379,174 @@ def clue_statistics_owner(
         return error_response(500, str(e))
 
 
+# ── 调价函管理 ────────────────────────────────────────────────
+
+from app.crud.crm.price_letter import (
+    _row_to_dict as price_letter_row_to_dict,
+    create_price_letter,
+    get_price_letter,
+    list_price_letters,
+    total_pages as price_letter_total_pages,
+    update_price_letter,
+    void_price_letter,
+)
+from app.schemas.crm.price_letter import (
+    PriceLetterCreateBody,
+    PriceLetterDetailBody,
+    PriceLetterListBody,
+    PriceLetterUpdateBody,
+    PriceLetterVoidBody,
+)
+
+
+@router.post("/price-letters/list")
+def price_letter_list(
+    body: PriceLetterListBody,
+    _user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    """分页查询调价函列表"""
+    try:
+        tenant_no = get_tenant_no(db, _user)
+        user_info = crm_user_info(_user)
+        rows, total = list_price_letters(
+            db,
+            tenant_no,
+            page=body.page,
+            page_size=body.pageSize,
+            letter_type=body.letter_type,
+            customer_name=body.customer_name,
+            product_name=body.product_name,
+            status=body.status,
+            exclude_status=body.exclude_status,
+            adjust_date_from=body.adjust_date_from,
+            adjust_date_to=body.adjust_date_to,
+            keyword=body.keyword,
+            user_info=user_info,
+        )
+        tp = price_letter_total_pages(total, body.pageSize)
+        return success_response(
+            {
+                "list": rows,
+                "total": total,
+                "page": body.page,
+                "pageSize": body.pageSize,
+                "totalPages": tp,
+            }
+        )
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(500, str(e))
+
+
+@router.post("/price-letters/detail")
+def price_letter_detail(
+    body: PriceLetterDetailBody,
+    _user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    """调价函详情"""
+    try:
+        tenant_no = get_tenant_no(db, _user)
+        user_info = crm_user_info(_user)
+        obj = get_price_letter(db, body.id)
+        if not obj or obj.tenant_no != tenant_no:
+            return error_response(404, "调价函不存在")
+        ensure_crm_create_by_row_in_scope(db, obj, user_info, tenant_no, message="无权访问该调价函")
+        return success_response(price_letter_row_to_dict(obj))
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(500, str(e))
+
+
+@router.post("/price-letters/add")
+@oper_log(title="调价函管理", business_type=1)
+def price_letter_add(
+    body: PriceLetterCreateBody,
+    _user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    """新增调价函"""
+    try:
+        tenant_no = get_tenant_no(db, _user)
+        user_id = _user.get("user_id", "") or _user.get("id", "")
+        dept_id = _user.get("dept_id", "") or _user.get("create_dept_id", "") or ""
+        kwargs = body.model_dump(exclude_unset=True)
+        obj = create_price_letter(
+            db,
+            tenant_no=tenant_no,
+            user_id=user_id,
+            dept_id=dept_id,
+            **kwargs,
+        )
+        return success_response(price_letter_row_to_dict(obj), message="创建成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(500, str(e))
+
+
+@router.post("/price-letters/update")
+@oper_log(title="调价函管理", business_type=2)
+def price_letter_update(
+    body: PriceLetterUpdateBody,
+    _user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    """编辑调价函"""
+    try:
+        tenant_no = get_tenant_no(db, _user)
+        user_info = crm_user_info(_user)
+        user_id = _user.get("user_id", "") or _user.get("id", "")
+        data = body.model_dump(exclude_unset=True)
+        letter_id = data.pop("id", None)
+        if not letter_id:
+            return error_response(400, "缺少调价函ID")
+        obj = update_price_letter(
+            db,
+            letter_id,
+            tenant_no=tenant_no,
+            user_id=user_id,
+            user_info=user_info,
+            **data,
+        )
+        return success_response(price_letter_row_to_dict(obj), message="更新成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(500, str(e))
+
+
+@router.post("/price-letters/void")
+@oper_log(title="调价函管理", business_type=2)
+def price_letter_void(
+    body: PriceLetterVoidBody,
+    _user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    """作废调价函(不可删除)"""
+    try:
+        tenant_no = get_tenant_no(db, _user)
+        user_info = crm_user_info(_user)
+        user_id = _user.get("user_id", "") or _user.get("id", "")
+        obj = void_price_letter(
+            db,
+            body.id,
+            tenant_no=tenant_no,
+            invalid_reason=body.invalid_reason,
+            remark=body.remark or "",
+            user_id=user_id,
+            user_info=user_info,
+        )
+        return success_response(price_letter_row_to_dict(obj), message="作废成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(500, str(e))
+
+
 from app.api.crm_quote_routes import register_quote_routes
 from app.api.crm_contract_routes import register_contract_routes
 from app.api.crm_contract_sub_routes import register_contract_sub_routes

+ 377 - 0
backend/app/crud/crm/price_letter.py

@@ -0,0 +1,377 @@
+"""CRM 调价函档案 CRUD"""
+from __future__ import annotations
+
+import math
+import re
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any, Dict, List, Optional, Tuple
+
+from sqlalchemy import func, or_
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.crud.crm.customer import get_customer
+from app.crud.crm.data_scope_helpers import (
+    apply_crm_create_by_scope,
+    ensure_crm_create_by_row_in_scope,
+)
+from app.crud.crm.product import get_product
+from app.models.crm_models import CrmPriceLetter
+
+LETTER_TYPE_RECEIVE = "receive"
+LETTER_TYPE_SEND = "send"
+STATUS_NORMAL = "normal"
+STATUS_INVALID = "invalid"
+
+
+def total_pages(total: int, page_size: int) -> int:
+    if page_size <= 0:
+        return 0
+    return math.ceil(total / page_size)
+
+
+def _parse_date(value: Any) -> Optional[date]:
+    if value is None or value == "":
+        return None
+    if isinstance(value, datetime):
+        return value.date()
+    if isinstance(value, date):
+        return value
+    text = str(value).strip()
+    if not text:
+        return None
+    for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S"):
+        try:
+            return datetime.strptime(text[:19] if " " in text else text[:10], fmt).date()
+        except ValueError:
+            continue
+    raise ValueError(f"日期格式无效: {value}")
+
+
+def _strip_html(text: Optional[str]) -> str:
+    if not text:
+        return ""
+    return re.sub(r"<[^>]+>", "", text).replace("&nbsp;", " ").strip()
+
+
+def _row_to_dict(row: CrmPriceLetter) -> Dict[str, Any]:
+    return {
+        "id": row.id,
+        "letterType": row.letter_type or "",
+        "adjustDate": row.adjust_date.strftime("%Y-%m-%d") if row.adjust_date else "",
+        "customerId": row.customer_id or "",
+        "customerName": row.customer_name or "",
+        "productId": row.product_id or "",
+        "productName": row.product_name or "",
+        "price": float(row.price) if row.price is not None else None,
+        "billPrice": float(row.bill_price) if row.bill_price is not None else None,
+        "content": row.content or "",
+        "attachmentUrls": row.attachment_urls or "",
+        "status": row.status or STATUS_NORMAL,
+        "invalidReason": row.invalid_reason or "",
+        "invalidTime": row.invalid_time.strftime("%Y-%m-%d %H:%M:%S") if row.invalid_time else "",
+        "invalidBy": row.invalid_by or "",
+        "orderNum": row.order_num if row.order_num is not None else 0,
+        "remark": row.remark or "",
+        "createBy": row.create_by or "",
+        "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
+        "updateBy": row.update_by or "",
+        "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
+    }
+
+
+def _validate_letter_payload(
+    *,
+    letter_type: str,
+    content: Optional[str],
+    attachment_urls: Optional[str],
+) -> None:
+    lt = (letter_type or "").strip()
+    if lt not in (LETTER_TYPE_RECEIVE, LETTER_TYPE_SEND):
+        raise ValueError("调价函类型无效,仅支持 receive 或 send")
+    att = (attachment_urls or "").strip()
+    has_att = bool(att)
+    has_content = bool(_strip_html(content))
+    if lt == LETTER_TYPE_RECEIVE:
+        if not has_att:
+            raise ValueError("接受调价函须上传附件")
+    elif lt == LETTER_TYPE_SEND:
+        if not has_content and not has_att:
+            raise ValueError("发出调价函须填写富文本内容或上传附件")
+
+
+def list_price_letters(
+    db: Session,
+    tenant_no: str,
+    *,
+    page: int = 1,
+    page_size: int = 10,
+    letter_type: Optional[str] = None,
+    customer_name: Optional[str] = None,
+    product_name: Optional[str] = None,
+    status: Optional[str] = None,
+    exclude_status: Optional[str] = None,
+    adjust_date_from: Optional[str] = None,
+    adjust_date_to: Optional[str] = None,
+    keyword: Optional[str] = None,
+    user_info=None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    q = db.query(CrmPriceLetter).filter(
+        CrmPriceLetter.tenant_no == tenant_no,
+        CrmPriceLetter.del_flag == 0,
+    )
+    if user_info:
+        q = apply_crm_create_by_scope(q, CrmPriceLetter, user_info, db, tenant_no)
+
+    if letter_type:
+        q = q.filter(CrmPriceLetter.letter_type == letter_type.strip())
+    cn = (customer_name or "").strip()
+    if cn:
+        q = q.filter(CrmPriceLetter.customer_name.like(f"%{cn}%"))
+    pn = (product_name or "").strip()
+    if pn:
+        q = q.filter(CrmPriceLetter.product_name.like(f"%{pn}%"))
+    if status:
+        q = q.filter(CrmPriceLetter.status == status.strip())
+    if exclude_status:
+        q = q.filter(CrmPriceLetter.status != exclude_status.strip())
+
+    d_from = _parse_date(adjust_date_from) if adjust_date_from else None
+    d_to = _parse_date(adjust_date_to) if adjust_date_to else None
+    if d_from:
+        q = q.filter(CrmPriceLetter.adjust_date >= d_from)
+    if d_to:
+        q = q.filter(CrmPriceLetter.adjust_date <= d_to)
+
+    kw = (keyword or "").strip()
+    if kw:
+        like = f"%{kw}%"
+        q = q.filter(
+            or_(
+                CrmPriceLetter.customer_name.like(like),
+                CrmPriceLetter.product_name.like(like),
+                CrmPriceLetter.remark.like(like),
+            )
+        )
+
+    total = q.order_by(None).with_entities(func.count(CrmPriceLetter.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        q.order_by(CrmPriceLetter.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [_row_to_dict(r) for r in rows], int(total)
+
+
+def get_price_letter(db: Session, letter_id: str) -> Optional[CrmPriceLetter]:
+    if not letter_id:
+        return None
+    return (
+        db.query(CrmPriceLetter)
+        .filter(CrmPriceLetter.id == letter_id, CrmPriceLetter.del_flag == 0)
+        .first()
+    )
+
+
+def create_price_letter(
+    db: Session,
+    *,
+    tenant_no: str,
+    user_id: str = None,
+    dept_id: str = "",
+    **kwargs,
+) -> CrmPriceLetter:
+    letter_type = (kwargs.get("letter_type") or "").strip()
+    content = kwargs.get("content")
+    attachment_urls = kwargs.get("attachment_urls")
+    _validate_letter_payload(
+        letter_type=letter_type, content=content, attachment_urls=attachment_urls
+    )
+
+    customer_id = (kwargs.get("customer_id") or "").strip()
+    product_id = (kwargs.get("product_id") or "").strip()
+    if not customer_id:
+        raise ValueError("请选择客户")
+    if not product_id:
+        raise ValueError("请选择产品")
+
+    customer_name = (kwargs.get("customer_name") or "").strip()
+    product_name = (kwargs.get("product_name") or "").strip()
+    cust = get_customer(db, customer_id)
+    if not cust or cust.tenant_no != tenant_no or int(cust.del_flag or 0) != 0:
+        raise ValueError("客户不存在")
+    if not customer_name:
+        customer_name = cust.customer_name or ""
+    prod = get_product(db, product_id)
+    if not prod or prod.tenant_no != tenant_no or int(prod.del_flag or 0) != 0:
+        raise ValueError("产品不存在")
+    if not product_name:
+        product_name = prod.product_name or ""
+
+    adjust_date = _parse_date(kwargs.get("adjust_date"))
+    if not adjust_date:
+        raise ValueError("请选择调价日期")
+    price = kwargs.get("price")
+    if price is None:
+        raise ValueError("请填写调整后现金价格")
+    try:
+        price_val = Decimal(str(price))
+    except Exception as exc:
+        raise ValueError("调整后现金价格格式无效") from exc
+    if price_val < 0:
+        raise ValueError("调整后现金价格不能为负数")
+
+    bill_price = kwargs.get("bill_price")
+    if bill_price is None:
+        raise ValueError("请填写调整后汇票价格")
+    try:
+        bill_price_val = Decimal(str(bill_price))
+    except Exception as exc:
+        raise ValueError("调整后汇票价格格式无效") from exc
+    if bill_price_val < 0:
+        raise ValueError("调整后汇票价格不能为负数")
+
+    # receive: clear rich text; send: keep content
+    saved_content = "" if letter_type == LETTER_TYPE_RECEIVE else (content or "")
+
+    obj = CrmPriceLetter(
+        id=generate_uuid(),
+        letter_type=letter_type,
+        adjust_date=adjust_date,
+        customer_id=customer_id,
+        customer_name=customer_name,
+        product_id=product_id,
+        product_name=product_name,
+        price=price_val,
+        bill_price=bill_price_val,
+        content=saved_content,
+        attachment_urls=(attachment_urls or "").strip() or None,
+        status=STATUS_NORMAL,
+        tenant_no=tenant_no,
+        remark=kwargs.get("remark"),
+        create_by=user_id,
+        create_dept_id=dept_id or "",
+        update_by=user_id,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_price_letter(
+    db: Session,
+    letter_id: str,
+    *,
+    tenant_no: str,
+    user_id: str = None,
+    user_info=None,
+    **kwargs,
+) -> CrmPriceLetter:
+    obj = get_price_letter(db, letter_id)
+    if not obj or obj.tenant_no != tenant_no:
+        raise ValueError("调价函不存在")
+    ensure_crm_create_by_row_in_scope(db, obj, user_info, tenant_no, message="无权操作该调价函")
+    if obj.status == STATUS_INVALID:
+        raise ValueError("已作废的调价函不可编辑")
+
+    letter_type = (kwargs.get("letter_type") if "letter_type" in kwargs else obj.letter_type) or ""
+    letter_type = str(letter_type).strip()
+    content = kwargs.get("content") if "content" in kwargs else obj.content
+    attachment_urls = (
+        kwargs.get("attachment_urls") if "attachment_urls" in kwargs else obj.attachment_urls
+    )
+    _validate_letter_payload(
+        letter_type=letter_type, content=content, attachment_urls=attachment_urls
+    )
+
+    if "customer_id" in kwargs and kwargs.get("customer_id"):
+        customer_id = str(kwargs.get("customer_id")).strip()
+        cust = get_customer(db, customer_id)
+        if not cust or cust.tenant_no != tenant_no or int(cust.del_flag or 0) != 0:
+            raise ValueError("客户不存在")
+        obj.customer_id = customer_id
+        obj.customer_name = (kwargs.get("customer_name") or "").strip() or (cust.customer_name or "")
+    elif "customer_name" in kwargs and kwargs.get("customer_name"):
+        obj.customer_name = str(kwargs.get("customer_name")).strip()
+
+    if "product_id" in kwargs and kwargs.get("product_id"):
+        product_id = str(kwargs.get("product_id")).strip()
+        prod = get_product(db, product_id)
+        if not prod or prod.tenant_no != tenant_no or int(prod.del_flag or 0) != 0:
+            raise ValueError("产品不存在")
+        obj.product_id = product_id
+        obj.product_name = (kwargs.get("product_name") or "").strip() or (prod.product_name or "")
+    elif "product_name" in kwargs and kwargs.get("product_name"):
+        obj.product_name = str(kwargs.get("product_name")).strip()
+
+    if "adjust_date" in kwargs:
+        d = _parse_date(kwargs.get("adjust_date"))
+        if not d:
+            raise ValueError("请选择调价日期")
+        obj.adjust_date = d
+    if "price" in kwargs and kwargs.get("price") is not None:
+        try:
+            price_val = Decimal(str(kwargs.get("price")))
+        except Exception as exc:
+            raise ValueError("调整后现金价格格式无效") from exc
+        if price_val < 0:
+            raise ValueError("调整后现金价格不能为负数")
+        obj.price = price_val
+    if "bill_price" in kwargs and kwargs.get("bill_price") is not None:
+        try:
+            bill_price_val = Decimal(str(kwargs.get("bill_price")))
+        except Exception as exc:
+            raise ValueError("调整后汇票价格格式无效") from exc
+        if bill_price_val < 0:
+            raise ValueError("调整后汇票价格不能为负数")
+        obj.bill_price = bill_price_val
+
+    obj.letter_type = letter_type
+    if letter_type == LETTER_TYPE_RECEIVE:
+        obj.content = ""
+    else:
+        obj.content = content or ""
+    obj.attachment_urls = (attachment_urls or "").strip() or None
+    if "remark" in kwargs:
+        obj.remark = kwargs.get("remark")
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def void_price_letter(
+    db: Session,
+    letter_id: str,
+    *,
+    tenant_no: str,
+    invalid_reason: str,
+    remark: str = "",
+    user_id: str = None,
+    user_info=None,
+) -> CrmPriceLetter:
+    if not (invalid_reason or "").strip():
+        raise ValueError("作废原因不能为空")
+    obj = get_price_letter(db, letter_id)
+    if not obj or obj.tenant_no != tenant_no:
+        raise ValueError("调价函不存在")
+    ensure_crm_create_by_row_in_scope(db, obj, user_info, tenant_no, message="无权操作该调价函")
+    if obj.status == STATUS_INVALID:
+        raise ValueError("该调价函已作废")
+    obj.status = STATUS_INVALID
+    obj.invalid_reason = invalid_reason.strip()
+    obj.invalid_time = datetime.now()
+    obj.invalid_by = user_id
+    if remark:
+        obj.remark = remark
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.commit()
+    db.refresh(obj)
+    return obj

+ 31 - 0
backend/app/models/crm_models.py

@@ -928,6 +928,37 @@ class CrmClueFollow(Base):
     del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
 
 
+class CrmPriceLetter(Base):
+    """CRM-调价函档案(独立,不关联合同/报价单)"""
+
+    __tablename__ = "crm_price_letter"
+
+    id = Column(String(64), primary_key=True, comment="主键ID(UUID)")
+    letter_type = Column(String(20), nullable=False, comment="类型 receive接受/send发出")
+    adjust_date = Column(Date, nullable=False, comment="调价日期")
+    customer_id = Column(String(64), nullable=False, comment="客户ID")
+    customer_name = Column(String(200), nullable=True, comment="客户名称(冗余)")
+    product_id = Column(String(64), nullable=False, comment="产品ID")
+    product_name = Column(String(200), nullable=True, comment="产品名称(冗余)")
+    price = Column(DECIMAL(15, 2), nullable=False, comment="调整后现金价格(元)")
+    bill_price = Column(DECIMAL(15, 2), nullable=False, default=Decimal("0.00"), comment="调整后汇票价格(元)")
+    content = Column(Text, nullable=True, comment="发出类富文本HTML")
+    attachment_urls = Column(String(4000), nullable=True, comment="附件URL(逗号分隔)")
+    status = Column(String(20), nullable=False, default="normal", comment="状态 normal/invalid")
+    invalid_reason = Column(String(200), nullable=True, comment="作废原因")
+    invalid_time = Column(DateTime, nullable=True, comment="作废时间")
+    invalid_by = Column(String(64), nullable=True, comment="作废人ID")
+    tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
+    order_num = Column(Integer, nullable=True, comment="排序号")
+    remark = Column(String(512), nullable=True, comment="备注")
+    create_by = Column(String(64), nullable=True, comment="创建人ID")
+    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
+    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
+    update_by = Column(String(64), nullable=True, comment="更新人ID")
+    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
+
+
 class CrmSupplierInfo(Base):
     """CRM-供方信息表"""
 

+ 70 - 0
backend/app/schemas/crm/price_letter.py

@@ -0,0 +1,70 @@
+"""CRM 调价函 Pydantic Schemas"""
+
+from decimal import Decimal
+from typing import Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class PriceLetterListBody(BaseModel):
+    """调价函列表查询"""
+
+    model_config = ConfigDict(populate_by_name=True)
+
+    page: int = Field(default=1, ge=1, description="页码")
+    pageSize: int = Field(default=10, ge=1, le=100, description="每页条数")
+    letter_type: Optional[str] = Field(default=None, alias="letterType", description="类型 receive/send")
+    customer_name: Optional[str] = Field(default=None, alias="customerName", description="客户名称模糊")
+    product_name: Optional[str] = Field(default=None, alias="productName", description="产品名称模糊")
+    status: Optional[str] = Field(default=None, description="状态 normal/invalid")
+    exclude_status: Optional[str] = Field(default=None, alias="excludeStatus", description="排除状态")
+    adjust_date_from: Optional[str] = Field(default=None, alias="adjustDateFrom", description="调价日起")
+    adjust_date_to: Optional[str] = Field(default=None, alias="adjustDateTo", description="调价日止")
+    keyword: Optional[str] = Field(default=None, description="客户/产品关键词")
+
+
+class PriceLetterDetailBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    id: str = Field(..., description="主键ID")
+
+
+class PriceLetterCreateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    letter_type: str = Field(..., alias="letterType", description="类型 receive/send")
+    adjust_date: str = Field(..., alias="adjustDate", description="调价日期 YYYY-MM-DD")
+    customer_id: str = Field(..., alias="customerId", description="客户ID")
+    customer_name: Optional[str] = Field(default=None, alias="customerName", description="客户名称")
+    product_id: str = Field(..., alias="productId", description="产品ID")
+    product_name: Optional[str] = Field(default=None, alias="productName", description="产品名称")
+    price: Decimal = Field(..., description="调整后现金价格(元)")
+    bill_price: Decimal = Field(..., alias="billPrice", description="调整后汇票价格(元)")
+    content: Optional[str] = Field(default=None, description="富文本HTML(发出类)")
+    attachment_urls: Optional[str] = Field(default=None, alias="attachmentUrls", description="附件URL逗号分隔")
+    remark: Optional[str] = Field(default=None, description="备注")
+
+
+class PriceLetterUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    id: str = Field(..., description="主键ID")
+    letter_type: Optional[str] = Field(default=None, alias="letterType", description="类型")
+    adjust_date: Optional[str] = Field(default=None, alias="adjustDate", description="调价日期")
+    customer_id: Optional[str] = Field(default=None, alias="customerId", description="客户ID")
+    customer_name: Optional[str] = Field(default=None, alias="customerName", description="客户名称")
+    product_id: Optional[str] = Field(default=None, alias="productId", description="产品ID")
+    product_name: Optional[str] = Field(default=None, alias="productName", description="产品名称")
+    price: Optional[Decimal] = Field(default=None, description="调整后现金价格")
+    bill_price: Optional[Decimal] = Field(default=None, alias="billPrice", description="调整后汇票价格")
+    content: Optional[str] = Field(default=None, description="富文本HTML")
+    attachment_urls: Optional[str] = Field(default=None, alias="attachmentUrls", description="附件URL")
+    remark: Optional[str] = Field(default=None, description="备注")
+
+
+class PriceLetterVoidBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    id: str = Field(..., description="主键ID")
+    invalid_reason: str = Field(..., alias="invalidReason", description="作废原因")
+    remark: Optional[str] = Field(default=None, description="详细说明(写入备注)")

+ 12 - 1
backend/graphify-out/.graphify_labels.json

@@ -163,8 +163,19 @@
   "161": "Community 161",
   "162": "Community 162",
   "163": "Community 163",
+  "164": "Community 164",
+  "165": "Community 165",
+  "166": "Community 166",
   "167": "Community 167",
   "168": "Community 168",
+  "169": "Community 169",
+  "170": "Community 170",
+  "171": "Community 171",
+  "172": "Community 172",
+  "173": "Community 173",
+  "174": "Community 174",
   "175": "Community 175",
-  "176": "Community 176"
+  "176": "Community 176",
+  "177": "Community 177",
+  "178": "Community 178"
 }

+ 202 - 152
backend/graphify-out/GRAPH_REPORT.md

@@ -1,16 +1,16 @@
-# Graph Report - backend  (2026-07-14)
+# Graph Report - backend  (2026-07-15)
 
 ## Corpus Check
-- 273 files · ~200,335 words
+- 292 files · ~204,930 words
 - Verdict: corpus is large enough that graph structure adds value.
 
 ## Summary
-- 6004 nodes · 40850 edges · 168 communities (166 shown, 2 thin omitted)
-- Extraction: 53% EXTRACTED · 47% INFERRED · 0% AMBIGUOUS · INFERRED: 19162 edges (avg confidence: 0.5)
+- 6049 nodes · 41883 edges · 179 communities (177 shown, 2 thin omitted)
+- Extraction: 52% EXTRACTED · 48% INFERRED · 0% AMBIGUOUS · INFERRED: 20053 edges (avg confidence: 0.5)
 - Token cost: 0 input · 0 output
 
 ## Graph Freshness
-- Built from commit: `87f7f6bf`
+- Built from commit: `5c6ace1d`
 - Run `git rev-parse HEAD` and compare to check if the graph is stale.
 - Run `graphify update .` after code changes (no API cost).
 
@@ -169,22 +169,32 @@
 - [[_COMMUNITY_Community 161|Community 161]]
 - [[_COMMUNITY_Community 162|Community 162]]
 - [[_COMMUNITY_Community 163|Community 163]]
+- [[_COMMUNITY_Community 164|Community 164]]
+- [[_COMMUNITY_Community 165|Community 165]]
+- [[_COMMUNITY_Community 166|Community 166]]
 - [[_COMMUNITY_Community 167|Community 167]]
 - [[_COMMUNITY_Community 168|Community 168]]
+- [[_COMMUNITY_Community 169|Community 169]]
+- [[_COMMUNITY_Community 170|Community 170]]
+- [[_COMMUNITY_Community 171|Community 171]]
+- [[_COMMUNITY_Community 172|Community 172]]
+- [[_COMMUNITY_Community 173|Community 173]]
+- [[_COMMUNITY_Community 174|Community 174]]
 - [[_COMMUNITY_Community 175|Community 175]]
 - [[_COMMUNITY_Community 176|Community 176]]
+- [[_COMMUNITY_Community 177|Community 177]]
 
 ## God Nodes (most connected - your core abstractions)
 1. `Session` - 1551 edges
 2. `int` - 475 edges
-3. `BaseModel` - 435 edges
+3. `BaseModel` - 440 edges
 4. `bool` - 379 edges
 5. `Any` - 364 edges
-6. `get_tenant_no()` - 347 edges
+6. `get_tenant_no()` - 352 edges
 7. `DbUserContext` - 326 edges
 8. `DbUserContext` - 322 edges
 9. `SysUser` - 306 edges
-10. `success_response()` - 260 edges
+10. `Session` - 267 edges
 
 ## Surprising Connections (you probably didn't know these)
 - `Namespace` --uses--> `Settings`  [INFERRED]
@@ -198,55 +208,55 @@
 - `int` --uses--> `DbUserContext`  [INFERRED]
   scripts/run_db_auto_generate_plans.py → app/core/db_auth.py
 
-## Communities (168 total, 2 thin omitted)
+## Communities (179 total, 2 thin omitted)
 
 ### Community 0 - "Community 0"
 Cohesion: 0.35
-Nodes (219): Any, bool, str, UploadFile, CRM 线索管理 Pydantic Schemas, CRM 客户管理 Pydantic Schemas, CRM 商机管理 Pydantic Schemas, CRM 客户公海池规则 Pydantic Schemas (+211 more)
+Nodes (230): Any, bool, str, UploadFile, CRM 线索管理 Pydantic Schemas, CRM 客户管理 Pydantic Schemas, CRM 商机管理 Pydantic Schemas, CRM 客户公海池规则 Pydantic Schemas (+222 more)
 
 ### Community 1 - "Community 1"
-Cohesion: 0.10
-Nodes (36): BaseModel, AdvanceApprovalCreate, ContactByCustomerBody, ContactDetailBody, ContactSetPrimaryBody, CRM 联系人 Pydantic Schemas, ContractApprovalPassBody, ContractApprovalTransferBody (+28 more)
+Cohesion: 0.11
+Nodes (37): PlTransportSiteLink, Session, str, TransportSiteLinkRead, _build_driver_url(), CreateFromContractRequest, _get_plan_type_label(), HazardCertReadyBody (+29 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.07
-Nodes (172): _apply_default_invoice_info_to_customers(), category_add(), category_delete(), category_tree(), category_update(), clue_assign(), clue_check_convert(), clue_check_duplicate() (+164 more)
+Nodes (175): _apply_default_invoice_info_to_customers(), category_add(), category_delete(), category_tree(), category_update(), clue_assign(), clue_check_convert(), clue_check_duplicate() (+167 more)
 
 ### Community 3 - "Community 3"
-Cohesion: 0.04
-Nodes (50): PlInventoryTransaction, PlInventoryWarning, 计划-运输关联表 pl_plan_transport, 运费结算表 pl_freight_settlement, 库存流水表 pl_inventory_transaction, 运费结算表 pl_freight_settlement, 结算单-运输关联表 pl_freight_settlement_transport, 结算单-运输关联表 pl_freight_settlement_transport (+42 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 4 - "Community 4"
 Cohesion: 0.21
 Nodes (31): apply_biz_backfill(), BackfillRule, BizRef, build_biz_index(), count_target_rows(), derive_source_url(), fetch_portal_admin_id(), fetch_source_rows() (+23 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.27
-Nodes (16): DbUserContext, Session, str, int, Session, str, PlMenuBadgeStatsRead, menu_badge_stats() (+8 more)
+Cohesion: 0.19
+Nodes (21): DbUserContext, Session, str, Session, str, int, Session, str (+13 more)
 
 ### Community 6 - "Community 6"
 Cohesion: 0.03
-Nodes (148): Decimal, PlPlan, PlPlanProduct, Session, str, Decimal, PlPlanChange, 计划变更记录表 pl_plan_change (+140 more)
+Nodes (146): Decimal, PlPlanProduct, Session, str, Decimal, PlPlanChange, 计划变更记录表 pl_plan_change, 计划产品明细表 pl_plan_product (+138 more)
 
 ### Community 7 - "Community 7"
 Cohesion: 0.21
 Nodes (39): clear_portal_workflow(), _dec(), derive_source_url(), fetch_portal_admin_id(), fetch_source_rows(), legacy_id_to_str(), load_bx_user_mapping(), load_runtime_settings() (+31 more)
 
 ### Community 8 - "Community 8"
-Cohesion: 0.07
-Nodes (128): DbUserContext, FreightSettlementDisburseBody, FreightSettlementSubmitApprovalBody, FreightSettlementUpdate, int, Session, str, bool (+120 more)
+Cohesion: 0.05
+Nodes (118): DbUserContext, FreightSettlementDisburseBody, FreightSettlementSubmitApprovalBody, FreightSettlementUpdate, int, Session, str, bool (+110 more)
 
 ### Community 9 - "Community 9"
-Cohesion: 0.21
-Nodes (36): ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, StartInstanceBody, Any, bool, WfProcessInstance, bool (+28 more)
+Cohesion: 0.19
+Nodes (39): ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, StartInstanceBody, Any, bool, WfProcessInstance, bool (+31 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.09
-Nodes (118): date, DbUserContext, int, Session, str, VehicleRechargeApplyCreate, VehicleRechargeApplyRead, VehicleRechargeApplyUpdate (+110 more)
+Cohesion: 0.10
+Nodes (113): date, DbUserContext, int, Session, str, VehicleRechargeApplyCreate, VehicleRechargeApplyRead, VehicleRechargeApplyUpdate (+105 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.07
-Nodes (122): bool, DbUserContext, float, int, PlPlan, PlTransport, Session, str (+114 more)
+Cohesion: 0.08
+Nodes (136): bool, PlCarrier, VehicleCreate, VehicleUpdate, bool, DbUserContext, float, int (+128 more)
 
 ### Community 12 - "Community 12"
 Cohesion: 0.22
@@ -254,19 +264,19 @@ Nodes (32): _dec(), derive_source_url(), fetch_portal_admin_id(), fetch_source_r
 
 ### Community 13 - "Community 13"
 Cohesion: 0.10
-Nodes (112): date, DbUserContext, int, Session, str, VehicleMaintenanceApplyCreate, VehicleMaintenanceApplyRead, VehicleMaintenanceApplyReminder (+104 more)
+Nodes (111): date, DbUserContext, int, Session, str, VehicleMaintenanceApplyCreate, VehicleMaintenanceApplyRead, VehicleMaintenanceApplyReminder (+103 more)
 
 ### Community 14 - "Community 14"
 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 15 - "Community 15"
-Cohesion: 0.13
-Nodes (57): Any, int, Session, str, Any, bool, CRM 客户 CRUD(客户主表 + 转移/成交/公海池操作), CrmCustomer (+49 more)
+Cohesion: 0.09
+Nodes (77): Any, bool, int, Session, str, Any, int, Session (+69 more)
 
 ### Community 16 - "Community 16"
-Cohesion: 0.16
-Nodes (33): Any, bool, int, Session, str, Any, Session, str (+25 more)
+Cohesion: 0.43
+Nodes (13): Any, Session, str, _customer_base_query(), get_follow_statistics(), get_industry_statistics(), get_level_statistics(), get_overview_statistics() (+5 more)
 
 ### Community 17 - "Community 17"
 Cohesion: 0.12
@@ -274,55 +284,55 @@ Nodes (31): CRM 报价单 API 路由 — 挂接到 crm.router, CRM 报价单 Pyd
 
 ### Community 18 - "Community 18"
 Cohesion: 0.07
-Nodes (97): attachment_download_url(), attachment_preview_url(), AttachmentDownloadUrlBody, create_dept(), create_dict_data(), create_dict_type(), create_menu(), create_post() (+89 more)
+Nodes (101): attachment_download_url(), attachment_preview_url(), AttachmentDownloadUrlBody, create_dept(), create_dict_data(), create_dict_type(), create_menu(), create_post() (+93 more)
 
 ### Community 19 - "Community 19"
-Cohesion: 0.14
-Nodes (38): Any, bool, BxUserContext, BxWorkflowNode, float, int, ReimburseApplyOut, Session (+30 more)
+Cohesion: 0.15
+Nodes (37): Any, bool, BxUserContext, BxWorkflowNode, float, int, ReimburseApplyOut, Session (+29 more)
 
 ### Community 20 - "Community 20"
-Cohesion: 0.24
-Nodes (73): date, DbUserContext, int, Session, str, DocumentReminderRead, DocumentReminderUpdate, InventoryLedgerResponse (+65 more)
+Cohesion: 0.11
+Nodes (124): date, DbUserContext, int, Session, str, DocumentReminderRead, DocumentReminderUpdate, FreightSettlementCreate (+116 more)
 
 ### Community 21 - "Community 21"
 Cohesion: 0.35
-Nodes (30): BxUserContext, int, Session, str, ApiResponse, approve_reimburse(), approved_by_me(), _build_details_data() (+22 more)
+Nodes (30): BxUserContext, int, Session, str, approve_reimburse(), approved_by_me(), _build_details_data(), create_reimburse() (+22 more)
 
 ### Community 22 - "Community 22"
-Cohesion: 0.07
-Nodes (41): AttachmentUploadResponse, ChangePasswordRequest, Config, LoginRequest, LoginResponse, Pydantic schemas - 用户认证, ResetPasswordRequest, UserCreate (+33 more)
+Cohesion: 0.08
+Nodes (40): AttachmentUploadResponse, ChangePasswordRequest, Config, LoginRequest, LoginResponse, Pydantic schemas - 用户认证, ResetPasswordRequest, UserCreate (+32 more)
 
 ### Community 23 - "Community 23"
 Cohesion: 0.09
-Nodes (58): Any, bool, CrmContract, datetime, int, Query, Session, str (+50 more)
+Nodes (57): Any, bool, CrmContract, datetime, int, Query, Session, str (+49 more)
 
 ### Community 24 - "Community 24"
 Cohesion: 0.11
 Nodes (57): Any, bool, int, Session, str, Any, bool, CRM 线索 CRUD(线索主表 + 转化/分配/无效/等级操作) (+49 more)
 
 ### Community 25 - "Community 25"
-Cohesion: 0.08
-Nodes (45): bool, Session, str, Any, int, Session, str, _append_user_scope_conditions() (+37 more)
+Cohesion: 0.12
+Nodes (31): Session, str, _append_user_scope_conditions(), apply_data_scope(), assert_row_in_data_scope(), _collect_scope_ids(), _get_child_dept_ids(), get_user_dept_id() (+23 more)
 
 ### Community 26 - "Community 26"
-Cohesion: 0.09
-Nodes (61): Session, str, bool, date, DbInboundOutbound, DbPurchasePlan, float, PlPlan (+53 more)
+Cohesion: 0.07
+Nodes (74): bool, date, datetime, DbInboundOutbound, DbPurchasePlan, float, PlPlan, PlPlanProduct (+66 more)
 
 ### Community 27 - "Community 27"
-Cohesion: 0.06
-Nodes (59): Any, bool, bytes, int, str, Exception, bool, bytes (+51 more)
+Cohesion: 0.07
+Nodes (46): Exception, bool, bytes, int, Path, str, str, bytes (+38 more)
 
 ### Community 28 - "Community 28"
-Cohesion: 0.10
-Nodes (145): date, DbUserContext, int, PlanCreateFull, PlanDriverFeedbackUpdateBody, PlanSiteFeedbackUpdateBody, PlPlan, Session (+137 more)
+Cohesion: 0.15
+Nodes (107): date, DbUserContext, int, PlanCreateFull, PlanDriverFeedbackUpdateBody, PlanSiteFeedbackUpdateBody, PlPlan, Session (+99 more)
 
 ### Community 29 - "Community 29"
-Cohesion: 0.03
-Nodes (155): register_quote_routes(), Any, bool, CRM 报价单 CRUD — 对照 CrmQuoteServiceImpl.java(P2 全量), date, datetime, Decimal, int (+147 more)
+Cohesion: 0.11
+Nodes (37): Any, CRM 报价单 CRUD — 对照 CrmQuoteServiceImpl.java(P2 全量), datetime, Decimal, Query, _build_quote_filters(), calculate_quote_amount(), create_quote() (+29 more)
 
 ### Community 30 - "Community 30"
 Cohesion: 0.18
-Nodes (49): Any, CRM 商机 CRUD(主表 + 阶段/赢输单/统计/看板/漏斗), CrmOpportunity, date, datetime, Decimal, int, Session (+41 more)
+Nodes (50): Any, CRM 商机 CRUD(主表 + 阶段/赢输单/统计/看板/漏斗), CrmOpportunity, date, datetime, Decimal, int, Session (+42 more)
 
 ### Community 31 - "Community 31"
 Cohesion: 0.22
@@ -337,40 +347,40 @@ Cohesion: 0.10
 Nodes (21): _generate_plan_no(), 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD######, 生成计划编号 SXDDHYYYYMMDD###### (+13 more)
 
 ### Community 34 - "Community 34"
-Cohesion: 0.12
-Nodes (26): datetime, int, str, datetime, str, 审批轮次过滤 — 驳回后重新提交时,进度接口仅展示当前轮次。, format_datetime(), generate_random_password() (+18 more)
+Cohesion: 0.13
+Nodes (21): datetime, int, str, datetime, 审批轮次过滤 — 驳回后重新提交时,进度接口仅展示当前轮次。, format_datetime(), generate_random_password(), get_current_time() (+13 more)
 
 ### Community 35 - "Community 35"
-Cohesion: 0.07
-Nodes (41): _db_pool_keepalive_loop(), _error_json(), lifespan(), _mask_secret(), Exception, int, Request, str (+33 more)
+Cohesion: 0.08
+Nodes (39): _db_pool_keepalive_loop(), _error_json(), lifespan(), _mask_secret(), Exception, int, Request, str (+31 more)
 
 ### Community 36 - "Community 36"
-Cohesion: 0.09
-Nodes (28): 运输计划管理 - 基础数据 Schema(承运商、车辆、人员、证书), CarrierAuditReject, CarrierBase, CarrierCreate, CarrierListResponse, CarrierQualificationBase, CarrierRead, CarrierUpdate (+20 more)
+Cohesion: 0.10
+Nodes (23): 运输计划管理 - 基础数据 Schema(承运商、车辆、人员、证书), CarrierAuditReject, CarrierBase, CarrierCreate, CarrierListResponse, CarrierQualificationBase, CarrierRead, CarrierUpdate (+15 more)
 
 ### Community 37 - "Community 37"
 Cohesion: 0.28
-Nodes (51): InboundOutboundRead, bool, DbPurchasePlan, PurchasePlanRead, PurchasePlanWithFillLink, WeighingSlipRead, bool, date (+43 more)
+Nodes (46): InboundOutboundRead, bool, DbPurchasePlan, PurchasePlanRead, PurchasePlanWithFillLink, WeighingSlipRead, bool, date (+38 more)
 
 ### Community 38 - "Community 38"
-Cohesion: 0.25
-Nodes (23): Any, bool, int, Session, str, create_role(), get_role(), get_role_data_scope() (+15 more)
+Cohesion: 0.13
+Nodes (38): Any, bool, int, Session, str, bool, Session, str (+30 more)
 
 ### Community 39 - "Community 39"
 Cohesion: 0.08
 Nodes (26): patch_plan_product_certs(), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输), 更新计划危化品产品购买证/运输证(不触发安排运输) (+18 more)
 
 ### Community 40 - "Community 40"
-Cohesion: 0.03
-Nodes (90): 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list (+82 more)
+Cohesion: 0.04
+Nodes (45): 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list, 分页查询供方列表 — Java: CrmSupplierInfoController.list (+37 more)
 
 ### Community 41 - "Community 41"
-Cohesion: 0.05
-Nodes (56): CRM 合同 API 路由 — GET/PUT/DELETE/POST 对齐原 Java CRM, CRM 合同 Pydantic Schemas — 对照 CrmContractBo / CrmContractVo, batch_submit_contract_approval(), ContractApprovalActionBody, ContractApprovalApproveBody, ContractApprovalByContractBody, ContractApprovalListBody, ContractApprovalRefreshNamesBody (+48 more)
+Cohesion: 0.07
+Nodes (49): CRM 合同 API 路由 — GET/PUT/DELETE/POST 对齐原 Java CRM, register_contract_routes(), CRM 合同 Pydantic Schemas — 对照 CrmContractBo / CrmContractVo, batch_submit_contract_approval(), ContractApprovalActionBody, ContractApprovalApproveBody, ContractApprovalByContractBody, ContractApprovalListBody (+41 more)
 
 ### Community 42 - "Community 42"
 Cohesion: 0.13
-Nodes (31): Any, bool, BxUserContext, BxWorkflowNode, float, int, Session, str (+23 more)
+Nodes (29): Any, bool, BxUserContext, BxWorkflowNode, float, int, Session, str (+21 more)
 
 ### Community 43 - "Community 43"
 Cohesion: 0.21
@@ -378,43 +388,43 @@ Nodes (34): _dec(), derive_source_url(), fetch_portal_admin_id(), fetch_source_r
 
 ### Community 44 - "Community 44"
 Cohesion: 0.07
-Nodes (34): change_current_user_password(), get_current_user_profile(), get_web_auth_menu_codes(), logout(), public_quote_share(), 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。 (+26 more)
+Nodes (37): _avatar_url_field(), _build_user_info_payload(), change_current_user_password(), get_current_user_profile(), get_web_auth_menu_codes(), _jwt_user_payload(), public_quote_share(), 获取当前用户信息(与 sys_user 字段对应)。 (+29 more)
 
 ### Community 45 - "Community 45"
-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 46 - "Community 46"
-Cohesion: 0.19
-Nodes (32): DbInboundOutbound, DbUserContext, Session, str, _apply_inventory_product_update(), _apply_inventory_update(), _base_query(), create_inventory() (+24 more)
+Cohesion: 0.16
+Nodes (44): DbInboundOutbound, DbUserContext, Session, str, Session, str, CreateLinkForPlanPayload, _apply_inventory_product_update() (+36 more)
 
 ### Community 47 - "Community 47"
 Cohesion: 0.06
-Nodes (37): str, AdvanceApplyOut, AttachmentOut, ContractApprovalPageListBody, ContractChangeSubmitBody, 全局审批记录分页(approval/index.vue), CustomerFollowByCustomerBody, CustomerPoolDetailBody (+29 more)
+Nodes (61): str, BaseModel, AdvanceApplyOut, AdvanceApprovalCreate, AttachmentOut, ReimburseApplyCreate, ReimburseApplyUpdate, ReimburseApprovalCreate (+53 more)
 
 ### Community 48 - "Community 48"
-Cohesion: 0.07
-Nodes (92): bool, DbUserContext, int, 运输计划管理 - 基础数据 API(承运商、车辆、人员), PlCarrier, PlVehicle, Session, str (+84 more)
+Cohesion: 0.08
+Nodes (88): DbUserContext, int, 运输计划管理 - 基础数据 API(承运商、车辆、人员), PlVehicle, Session, str, VehicleRead, date (+80 more)
 
 ### Community 49 - "Community 49"
-Cohesion: 0.13
-Nodes (43): 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线), refresh_token(), bool, Redis, Request, str, _T, bool (+35 more)
+Cohesion: 0.11
+Nodes (46): 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线), refresh_token(), bool, Redis, Request, Session, str, _T (+38 more)
 
 ### Community 50 - "Community 50"
-Cohesion: 0.16
-Nodes (17): Any, bool, str, WfProcessInstance, Any, str, WfProcessInstance, _LocalCallbackFn (+9 more)
+Cohesion: 0.35
+Nodes (5): Any, str, WfProcessInstance, WorkflowEngine, WfProcessInstance
 
 ### Community 51 - "Community 51"
 Cohesion: 0.13
 Nodes (37): Any, bool, Decimal, int, Session, str, batch_insert_quote_products(), calculate_subtotal() (+29 more)
 
 ### Community 52 - "Community 52"
-Cohesion: 0.14
-Nodes (29): Any, bool, int, Session, str, approve_quote(), can_user_approve(), create_approval_records() (+21 more)
+Cohesion: 0.15
+Nodes (30): Any, bool, int, Session, str, approve_quote(), can_user_approve(), create_approval_records() (+22 more)
 
 ### Community 53 - "Community 53"
-Cohesion: 0.30
-Nodes (34): AdvanceIssueRequest, BxUserContext, int, ReimburseApplyOut, Session, str, BatchOffsetRequest, AdvanceFinanceIssueStatsOut (+26 more)
+Cohesion: 0.21
+Nodes (40): AdvanceIssueRequest, BxUserContext, int, ReimburseApplyOut, Session, str, datetime, str (+32 more)
 
 ### Community 54 - "Community 54"
 Cohesion: 0.13
@@ -425,32 +435,32 @@ Cohesion: 0.04
 Nodes (117): Any, bool, CRM 产品模块 CRUD(产品分类 + 产品 + 附件), int, Session, str, SysOss, int (+109 more)
 
 ### Community 56 - "Community 56"
-Cohesion: 0.11
-Nodes (43): Any, bool, CrmContractProductPriceTier, date, Decimal, int, Session, str (+35 more)
+Cohesion: 0.16
+Nodes (33): Any, bool, CrmContractProductPriceTier, date, Decimal, int, Session, str (+25 more)
 
 ### Community 57 - "Community 57"
-Cohesion: 0.36
-Nodes (31): AdvanceApplyCreate, AdvanceApplyOut, AdvanceApplyUpdate, AdvanceApprovalCreate, BxUserContext, int, Session, str (+23 more)
+Cohesion: 0.21
+Nodes (47): AdvanceApplyCreate, AdvanceApplyOut, AdvanceApplyUpdate, AdvanceApprovalCreate, BxUserContext, int, Session, str (+39 more)
 
 ### Community 58 - "Community 58"
 Cohesion: 0.34
 Nodes (16): Any, bool, int, Session, str, create_tenant(), get_tenant(), list_tenants() (+8 more)
 
 ### Community 59 - "Community 59"
-Cohesion: 0.03
-Nodes (66): invoice_info_list(), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll (+58 more)
+Cohesion: 0.04
+Nodes (51): invoice_info_list(), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 按客户ID查询开票信息(与 by-customer 等价), 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll, 供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll (+43 more)
 
 ### Community 60 - "Community 60"
-Cohesion: 0.16
-Nodes (27): DbUserContext, Session, Any, bytes, int, Session, str, PlOcrUsageMonth (+19 more)
+Cohesion: 0.19
+Nodes (26): Any, bytes, int, Session, str, PlOcrUsageMonth, 阿里云 OCR 月度调用计数 pl_ocr_usage_month(按自然月全局统计), PlOcrUsageMonth (+18 more)
 
 ### Community 61 - "Community 61"
 Cohesion: 0.21
 Nodes (30): Engine, Path, build_id_maps(), clip_text(), count_rows(), export_mapping(), fetch_rows(), legacy_id_to_str() (+22 more)
 
 ### Community 62 - "Community 62"
-Cohesion: 0.27
-Nodes (24): DbUserContext, float, Session, str, Session, str, DashboardCargoStat, DashboardPartnerCargoStat (+16 more)
+Cohesion: 0.30
+Nodes (22): DbUserContext, float, Session, str, Session, str, DashboardCargoStat, DashboardPartnerCargoStat (+14 more)
 
 ### Community 63 - "Community 63"
 Cohesion: 0.25
@@ -461,8 +471,8 @@ Cohesion: 0.22
 Nodes (29): Any, int, Session, str, create_instance(), create_process_def(), create_task(), get_all_tasks_for_business() (+21 more)
 
 ### Community 65 - "Community 65"
-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)
+Cohesion: 0.13
+Nodes (24): API 路由包 — 山峡化工生产一体化门户, create_leave(), delete_leave(), get_leave(), _leave_local_callback(), leave_workflow_callback(), list_leaves(), submit_leave() (+16 more)
 
 ### Community 66 - "Community 66"
 Cohesion: 0.08
@@ -489,28 +499,28 @@ Cohesion: 0.26
 Nodes (22): Any, bool, datetime, int, Session, str, BizSampleCode, batch_generate_sample_codes() (+14 more)
 
 ### Community 72 - "Community 72"
-Cohesion: 0.18
-Nodes (23): BxUserContext, int, Session, str, bool, BxUserContext, Session, str (+15 more)
+Cohesion: 0.39
+Nodes (8): bool, BxUserContext, Session, str, 按业务类型获取工作流;已知类型不存在时自动创建空定义(无节点)。, 按业务类型获取工作流;已知类型不存在时自动创建空定义(无节点)。, WorkflowService, BxWorkflowDef
 
 ### Community 73 - "Community 73"
 Cohesion: 0.33
 Nodes (14): Any, int, Session, str, create_dict_data(), get_dict_data(), list_dict_data(), list_dict_data_options() (+6 more)
 
 ### Community 74 - "Community 74"
-Cohesion: 0.13
-Nodes (52): bool, DbInboundOutbound, int, PublicInventoryFeedback, PublicInventoryPageRead, Session, str, UploadFile (+44 more)
+Cohesion: 0.09
+Nodes (75): bool, DbInboundOutbound, int, PublicInventoryFeedback, PublicInventoryPageRead, UploadFile, bool, int (+67 more)
 
 ### Community 75 - "Community 75"
-Cohesion: 0.20
-Nodes (10): Decimal, VehicleUreaApplyCreate, VehicleUreaApplyListResponse, VehicleUreaApplyRead, VehicleUreaApplyUpdate, VehicleUreaDisburseBoardListResponse, VehicleUreaDisburseCreate, VehicleUreaDisburseListResponse (+2 more)
+Cohesion: 0.13
+Nodes (30): Any, bool, CRM 供方信息 CRUD — 对照 CrmSupplierInfoServiceImpl.java, int, Session, str, _apply_list_filters(), create_supplier_info() (+22 more)
 
 ### Community 76 - "Community 76"
 Cohesion: 0.09
 Nodes (23): product_delete(), 删除产品(支持单条 body.id 和批量 body.ids), 删除产品(支持单条 body.id 和批量 body.ids), 删除产品(支持单条 body.id 和批量 body.ids), 删除产品(支持单条 body.id 和批量 body.ids), 删除产品(支持单条 body.id 和批量 body.ids), 删除产品(支持单条 body.id 和批量 body.ids), 删除产品(支持单条 body.id 和批量 body.ids) (+15 more)
 
 ### Community 77 - "Community 77"
-Cohesion: 0.11
-Nodes (101): date, DbUserContext, int, Session, str, VehicleUreaApplyCreate, VehicleUreaApplyRead, VehicleUreaApplyUpdate (+93 more)
+Cohesion: 0.09
+Nodes (106): date, DbUserContext, int, Session, str, VehicleUreaApplyCreate, VehicleUreaApplyRead, VehicleUreaApplyUpdate (+98 more)
 
 ### Community 78 - "Community 78"
 Cohesion: 0.32
@@ -521,8 +531,8 @@ Cohesion: 0.38
 Nodes (22): DbUserContext, int, Session, str, PlTransportAddress, 运输通用地址库 pl_transport_address, 运输通用地址库 pl_transport_address, 运输通用地址库 pl_transport_address (+14 more)
 
 ### Community 80 - "Community 80"
-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.21
+Nodes (21): Any, bool, str, 报销模块 Service 基类 — 租户隔离 + UUID + 软删除。, _actor_ids(), _actor_names(), assert_row_deletable_by_creator(), assert_rows_deletable_by_creator() (+13 more)
 
 ### Community 81 - "Community 81"
 Cohesion: 0.27
@@ -537,36 +547,36 @@ Cohesion: 0.18
 Nodes (11): float, int, str, 发送订单状态通知                  Args:             openid: 用户openid             order_n, 发送支付成功通知                  Args:             openid: 用户openid             order_n, 发送配送通知                  Args:             openid: 配送员openid             task_no:, 发送配送时效预警                  Args:             openid: 配送员openid             task_n, 发送退款结果通知                  Args:             openid: 用户openid             order_n (+3 more)
 
 ### Community 84 - "Community 84"
-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)
+Cohesion: 0.04
+Nodes (61): Decimal, create_contract(), create_from_opportunity(), create_from_quote(), generate_contract_name(), _get_field(), _get_supplier_short_name(), _normalize_framework_contract_data() (+53 more)
 
 ### Community 85 - "Community 85"
 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 86 - "Community 86"
-Cohesion: 0.19
-Nodes (14): bool, date, Session, str, get_last_month_range(), month_range(), 地磅计划按月自动生成 — 迁移自 simple-dibang-xiaoshou/auto-create-plan/auto_create_monthly_pla, 按月自动生成采购计划。      - 默认仅在每月 1 日执行(force=True 时跳过该限制,供手动触发验收)     - 遍历所有租户,按上月计划模板生 (+6 more)
+Cohesion: 0.10
+Nodes (28): bool, date, Session, str, get_last_month_range(), month_range(), 地磅计划按月自动生成 — 迁移自 simple-dibang-xiaoshou/auto-create-plan/auto_create_monthly_pla, 按月自动生成采购计划。      - 默认仅在每月 1 日执行(force=True 时跳过该限制,供手动触发验收)     - 遍历所有租户,按上月计划模板生 (+20 more)
 
 ### Community 87 - "Community 87"
 Cohesion: 0.28
 Nodes (18): Any, int, Session, str, _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept() (+10 more)
 
 ### Community 88 - "Community 88"
-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.10
+Nodes (32): logout(), 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, bool, int (+24 more)
 
 ### Community 89 - "Community 89"
-Cohesion: 0.12
-Nodes (25): list_roles_for_select(), 返回运营端角色下拉选项(label=库中角色名,value=Web 角色码)。, 返回运营端角色下拉选项(label=库中角色名,value=Web 角色码)。, 返回运营端角色下拉选项(label=库中角色名,value=Web 角色码)。, Session, str, bool, Session (+17 more)
+Cohesion: 0.22
+Nodes (28): register_quote_routes(), bool, int, Session, str, accept_quote(), _ensure_quote_in_scope(), generate_pdf() (+20 more)
 
 ### Community 90 - "Community 90"
-Cohesion: 0.19
-Nodes (18): Any, bool, Session, str, list_share_logs(), _parse_user_agent(), CRM 报价单分享访问日志 CRUD — 对照 CrmQuoteShareLogServiceImpl.java, Java: statisticsVisits (+10 more)
+Cohesion: 0.22
+Nodes (17): Any, bool, Session, str, list_share_logs(), _parse_user_agent(), CRM 报价单分享访问日志 CRUD — 对照 CrmQuoteShareLogServiceImpl.java, Java: statisticsVisits (+9 more)
 
 ### Community 91 - "Community 91"
-Cohesion: 0.31
-Nodes (10): _avatar_url_field(), _build_user_info_payload(), _jwt_user_payload(), 与 web-admin `userInfo` 字段约定一致。, 与 web-admin `userInfo` 字段约定一致。, 与 web-admin 静态路由 meta.roles(admin/super_admin)对齐的单元素数组。, 与 web-admin `userInfo` 字段约定一致。, _web_role_list() (+2 more)
+Cohesion: 0.08
+Nodes (24): date, create_new_version(), generate_quote_no(), Java: toContract — 返回 contractId, Java: createNewVersion — 返回新报价单 id, Java: toContract — 返回 contractId, Java: createNewVersion — 返回新报价单 id, Java: toContract — 返回 contractId (+16 more)
 
 ### Community 92 - "Community 92"
 Cohesion: 0.37
@@ -581,8 +591,8 @@ Cohesion: 0.29
 Nodes (16): Any, int, Session, str, BizLeaveRequest, create_leave(), get_leave(), list_leaves() (+8 more)
 
 ### Community 95 - "Community 95"
-Cohesion: 0.44
-Nodes (13): bool, date, PlCarrier, str, PlCarrier, assert_carrier_eligible_for_transport(), carrier_agreement_expired(), carrier_audit_approved() (+5 more)
+Cohesion: 0.10
+Nodes (21): Java: createNewVersion — 返回新报价单 id, Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人), Java: statisticsOverview (+13 more)
 
 ### Community 96 - "Community 96"
 Cohesion: 0.38
@@ -605,12 +615,12 @@ Cohesion: 0.28
 Nodes (13): int, Session, str, _allocate_inbound_outbound_id(), _ensure_sequence_row(), max_numeric_inbound_outbound_id(), next_inbound_outbound_id(), 出入库单 id 生成 — 租户内纯数字自增,并发安全。 (+5 more)
 
 ### Community 101 - "Community 101"
-Cohesion: 0.23
-Nodes (25): int, PlPlan, PlTransport, Session, str, UploadFile, Any, _build_public_page() (+17 more)
+Cohesion: 0.21
+Nodes (18): Session, str, str, 附件上传 bizType 与 MinIO 目录映射(对齐原 CRM oss 上传语义), 优先使用显式 folder;否则按 bizType 映射默认目录。, 优先使用显式 folder;否则按 bizType 映射默认目录。, 优先使用显式 folder;否则按 bizType 映射默认目录。, resolve_upload_folder() (+10 more)
 
 ### Community 102 - "Community 102"
-Cohesion: 0.23
-Nodes (25): approve_task(), _assert_wf_instance_in_scope(), _business_type_label(), delete_process_def(), _enrich_task_list(), get_business_all_tasks(), _get_business_form_fields(), get_designer_data() (+17 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 103 - "Community 103"
 Cohesion: 0.41
@@ -645,8 +655,8 @@ Cohesion: 0.28
 Nodes (12): extract_minio_object_key(), join_relative_urls(), normalize_oss_storage_url(), MinIO 附件 URL 规范化(数据迁移共用)。  背景 ---- CRM / 地磅 / 报销与 shanxia2.0 共用同一 MinIO(bucket 默, 业务表 OSS ID 串 → 相对 URL 串(用于客户证照、跟进附件等)。, 去掉预签名 query(``?X-Amz-...``)。, 将绝对/相对 URL 规范为 MinIO object key(不含 bucket)。, 迁移 CRM ``sys_oss`` 行:``url`` + ``file_name`` → 门户相对路径。 (+4 more)
 
 ### Community 111 - "Community 111"
-Cohesion: 0.18
-Nodes (11): generate_contract_name(), Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName, Java: generateContractName (+3 more)
+Cohesion: 0.22
+Nodes (18): Any, bool, bytes, int, Request, str, _claims_from_auth(), _format_body_bytes() (+10 more)
 
 ### Community 112 - "Community 112"
 Cohesion: 0.17
@@ -654,7 +664,7 @@ Nodes (20): Any, int, str, get_oper_log_meta(), OperLogMeta, 请求级操作日
 
 ### Community 113 - "Community 113"
 Cohesion: 0.08
-Nodes (26): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,14 个一级菜单), Session, Session, get_current_user(), get_current_user_by_token(), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期), 根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期) (+18 more)
+Nodes (24): DbUserContext, Session, Session, Session, str, _auto_fill_create_dept_id(), get_db(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID (+16 more)
 
 ### Community 114 - "Community 114"
 Cohesion: 0.60
@@ -665,12 +675,12 @@ Cohesion: 0.36
 Nodes (10): add_scope_before_order(), ensure_import(), patch_base_data(), patch_get_helper(), patch_inventory(), patch_plan(), patch_transport(), str (+2 more)
 
 ### Community 116 - "Community 116"
-Cohesion: 0.11
-Nodes (19): _build_contract_filters(), export_contracts(), Java: queryList for export, Java: queryList for export, Java: queryList for export, Java: queryList for export, Java: buildQueryWrapper, Java: queryList for export (+11 more)
+Cohesion: 0.07
+Nodes (28): _build_contract_filters(), export_contracts(), ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), Java: queryList for export (+20 more)
 
 ### Community 117 - "Community 117"
 Cohesion: 0.13
-Nodes (39): Any, bool, date, int, Session, str, Session, str (+31 more)
+Nodes (40): Any, bool, date, int, Session, str, Session, str (+32 more)
 
 ### Community 118 - "Community 118"
 Cohesion: 0.38
@@ -697,8 +707,8 @@ Cohesion: 0.38
 Nodes (4): str, _coerce_id(), ExpenseItemCreate, ExpenseItemUpdate
 
 ### Community 124 - "Community 124"
-Cohesion: 0.07
-Nodes (57): operlog_detail(), operlog_list(), 操作日志分页列表(基于 API 请求日志适配 RuoYi OperLog 结构), 操作日志分页列表(基于 API 请求日志适配 RuoYi OperLog 结构), _user_info(), Session, Any, bool (+49 more)
+Cohesion: 0.19
+Nodes (20): int, str, Any, bool, int, Session, str, business_type_from_path() (+12 more)
 
 ### Community 125 - "Community 125"
 Cohesion: 0.12
@@ -713,8 +723,8 @@ Cohesion: 0.19
 Nodes (18): str, Any, bool, Session, str, CRM 模块 Excel 导出列定义(字段与字典类型对齐 Java *Vo @ExcelDictFormat)。, _yes_no(), _build_workbook() (+10 more)
 
 ### Community 129 - "Community 129"
-Cohesion: 0.20
-Nodes (13): check_customer_protect_time(), _execute_single_rule(), _get_all_tenant_nos(), pool_recovery_job(), 公海池自动回收任务(由 APScheduler 调度)      遍历所有租户的启用规则,执行自动回收。, 每天凌晨1点:检查保护期到期客户,跟进次数不足则退回公海, 每天早上9点:保护期到期提醒(3天内到期且跟进不足), remind_customer_recycle() (+5 more)
+Cohesion: 0.30
+Nodes (18): Any, date, int, Session, str, create_price_letter(), get_price_letter(), list_price_letters() (+10 more)
 
 ### Community 140 - "Community 140"
 Cohesion: 0.18
@@ -725,12 +735,12 @@ Cohesion: 0.41
 Nodes (13): BxUserContext, Session, str, UploadFile, bind_biz(), delete_attachment(), download_file(), list_by_biz() (+5 more)
 
 ### Community 142 - "Community 142"
-Cohesion: 0.11
-Nodes (32): Any, str, bool, BxUserContext, int, Session, str, Any (+24 more)
+Cohesion: 0.14
+Nodes (27): bool, BxUserContext, int, Session, str, Any, bool, int (+19 more)
 
 ### Community 143 - "Community 143"
 Cohesion: 0.07
-Nodes (65): CertRead, CertRead, CertRow, date, int, Session, str, CertRead (+57 more)
+Nodes (65): CertRead, Any, CertRead, CertRow, date, int, Session, str (+57 more)
 
 ### Community 144 - "Community 144"
 Cohesion: 0.32
@@ -749,8 +759,8 @@ Cohesion: 0.38
 Nodes (15): Any, bool, int, Session, str, create_post(), get_post(), list_posts() (+7 more)
 
 ### Community 148 - "Community 148"
-Cohesion: 0.07
-Nodes (135): Any, register_contract_routes(), CRM 合同子模块路由 — 变更/模板/审批独立页, register_contract_sub_routes(), Any, bool, CRM 合同 CRUD — 对照 CrmContractServiceImpl.java(列表/详情 P1), CrmContract (+127 more)
+Cohesion: 0.09
+Nodes (82): bool, CRM 合同 CRUD — 对照 CrmContractServiceImpl.java(列表/详情 P1), CrmContract, CrmContractProductPriceTier, date, datetime, int, Query (+74 more)
 
 ### Community 149 - "Community 149"
 Cohesion: 0.18
@@ -761,12 +771,12 @@ Cohesion: 0.23
 Nodes (10): Any, bool, int, Session, str, camel_to_snake(), CRUDBase, get_model_attr() (+2 more)
 
 ### Community 151 - "Community 151"
-Cohesion: 0.20
-Nodes (7): _coerce_id(), OffsetItem, ReimburseApplyCreate, ReimburseApplyOut, ReimburseApplyUpdate, ReimburseApprovalCreate, ReimburseDetailCreate
+Cohesion: 0.33
+Nodes (3): _coerce_id(), OffsetItem, ReimburseDetailCreate
 
 ### Community 152 - "Community 152"
 Cohesion: 0.13
-Nodes (32): DbUserContext, Session, str, _apply_plan_product_update(), _apply_plan_update(), _base_query(), create_plan(), delete_plan() (+24 more)
+Nodes (36): DbUserContext, Session, str, user_info_from_context(), parse_date_str(), plan_to_read(), plan_to_read_with_fill_link(), _apply_plan_product_update() (+28 more)
 
 ### Community 153 - "Community 153"
 Cohesion: 0.18
@@ -805,24 +815,64 @@ Cohesion: 0.40
 Nodes (3): AdvanceApplyCreate, AdvanceApplyUpdate, _coerce_optional_id()
 
 ### Community 162 - "Community 162"
-Cohesion: 0.50
-Nodes (3): Config, PageResult, ResponseModel
+Cohesion: 0.16
+Nodes (12): Decimal, str, VehicleRechargeApplyCreate, VehicleRechargeApplyListResponse, VehicleRechargeApplyOption, VehicleRechargeApplyRead, VehicleRechargeApplyUpdate, VehicleRechargeDisburseBoardListResponse (+4 more)
 
 ### Community 163 - "Community 163"
 Cohesion: 0.83
 Nodes (3): _add_user_info_kwarg(), patch_file(), str
 
+### Community 164 - "Community 164"
+Cohesion: 0.30
+Nodes (13): Any, bool, bytes, int, str, ApiCryptoError, _b64url_decode(), _b64url_encode() (+5 more)
+
+### Community 165 - "Community 165"
+Cohesion: 0.22
+Nodes (12): Any, bool, str, WfProcessInstance, _LocalCallbackFn, _dispatch_local(), 工作流回调服务 —— 流程状态变更时通知业务系统, 注册本地回调函数。当 callback_url 以 path 开头时直接调用 fn。 (+4 more)
+
+### Community 166 - "Community 166"
+Cohesion: 0.15
+Nodes (13): ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), ORM → camelCase(单点真相), Java: viewByShareCode, Java: viewByShareCode, Java: viewByShareCode, Java: viewByShareCode (+5 more)
+
 ### Community 167 - "Community 167"
 Cohesion: 0.49
 Nodes (9): compare_data(), item_key(), load_portal_dicts(), load_sx_dicts(), main(), parse_insert_values(), Path, str (+1 more)
 
 ### Community 168 - "Community 168"
-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)
+Cohesion: 0.44
+Nodes (8): bool, int, Session, str, ApiRequestLogCRUD, SysApiRequestLog, SysApiRequestLogHis, SysApiRequestLog
+
+### Community 169 - "Community 169"
+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 170 - "Community 170"
+Cohesion: 0.18
+Nodes (11): Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus, Java: statisticsStatus (+3 more)
+
+### Community 171 - "Community 171"
+Cohesion: 0.18
+Nodes (11): Java: statisticsTrend, Java: statisticsTrend, Java: statisticsTrend, Java: statisticsTrend, Java: statisticsTrend, Java: statisticsTrend, Java: statisticsTrend, Java: statisticsTrend (+3 more)
+
+### Community 172 - "Community 172"
+Cohesion: 0.39
+Nodes (8): int, LeaveCreate, LeaveUpdate, Config, LeaveCreate, LeaveResponse, LeaveUpdate, Pydantic schemas - 请假申请
+
+### Community 173 - "Community 173"
+Cohesion: 0.22
+Nodes (9): Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval, Java: withdrawApproval (+1 more)
+
+### Community 174 - "Community 174"
+Cohesion: 0.40
+Nodes (4): Any, str, 供 get_tenant_no 等门户工具使用。, 与门户 JWT / apply_data_scope 所需的 user_info 字段对齐。
 
 ### Community 175 - "Community 175"
-Cohesion: 0.50
-Nodes (3): Any, str, 与门户 JWT / apply_data_scope 所需的 user_info 字段对齐。
+Cohesion: 0.14
+Nodes (38): Any, CRM 合同子模块路由 — 变更/模板/审批独立页, register_contract_sub_routes(), Any, str, Any, 与门户 JWT / apply_data_scope 所需的 user_info 字段对齐。, _change_row_to_dict() (+30 more)
+
+### Community 177 - "Community 177"
+Cohesion: 0.33
+Nodes (6): _fill_contract_linked_count(), Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount, Java: fillContractLinkedCount
 
 ## Knowledge Gaps
 - **78 isolated node(s):** `Session`, `Request`, `bool`, `bool`, `Config` (+73 more)
@@ -832,12 +882,12 @@ Nodes (3): Any, str, 与门户 JWT / apply_data_scope 所需的 user_info 字段
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `Session` connect `Community 2` to `Community 0`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 28`, `Community 29`, `Community 30`, `Community 32`, `Community 33`, `Community 37`, `Community 38`, `Community 39`, `Community 40`, `Community 41`, `Community 42`, `Community 44`, `Community 45`, `Community 46`, `Community 47`, `Community 48`, `Community 49`, `Community 50`, `Community 51`, `Community 52`, `Community 53`, `Community 55`, `Community 56`, `Community 57`, `Community 58`, `Community 59`, `Community 62`, `Community 63`, `Community 64`, `Community 65`, `Community 67`, `Community 68`, `Community 71`, `Community 72`, `Community 73`, `Community 74`, `Community 76`, `Community 77`, `Community 78`, `Community 79`, `Community 84`, `Community 86`, `Community 87`, `Community 89`, `Community 90`, `Community 93`, `Community 94`, `Community 96`, `Community 97`, `Community 98`, `Community 100`, `Community 101`, `Community 102`, `Community 104`, `Community 109`, `Community 113`, `Community 114`, `Community 116`, `Community 117`, `Community 120`, `Community 121`, `Community 124`, `Community 127`, `Community 141`, `Community 142`, `Community 143`, `Community 146`, `Community 147`, `Community 148`, `Community 150`, `Community 152`, `Community 154`, `Community 159`, `Community 168`?**
-  _High betweenness centrality (0.248) - this node is a cross-community bridge._
-- **Why does `datetime` connect `Community 34` to `Community 0`, `Community 1`, `Community 2`, `Community 3`, `Community 4`, `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 19`, `Community 20`, `Community 22`, `Community 23`, `Community 24`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 35`, `Community 36`, `Community 37`, `Community 38`, `Community 40`, `Community 41`, `Community 42`, `Community 43`, `Community 45`, `Community 46`, `Community 47`, `Community 48`, `Community 49`, `Community 51`, `Community 52`, `Community 53`, `Community 54`, `Community 55`, `Community 56`, `Community 58`, `Community 60`, `Community 61`, `Community 62`, `Community 64`, `Community 67`, `Community 69`, `Community 70`, `Community 71`, `Community 73`, `Community 74`, `Community 75`, `Community 77`, `Community 78`, `Community 86`, `Community 87`, `Community 90`, `Community 93`, `Community 94`, `Community 95`, `Community 96`, `Community 97`, `Community 101`, `Community 103`, `Community 105`, `Community 107`, `Community 114`, `Community 117`, `Community 118`, `Community 121`, `Community 124`, `Community 125`, `Community 126`, `Community 129`, `Community 140`, `Community 143`, `Community 144`, `Community 145`, `Community 147`, `Community 148`, `Community 152`, `Community 154`, `Community 155`, `Community 159`, `Community 176`?**
-  _High betweenness centrality (0.115) - this node is a cross-community bridge._
-- **Why does `BaseModel` connect `Community 47` to `Community 0`, `Community 1`, `Community 3`, `Community 5`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 143`, `Community 17`, `Community 18`, `Community 19`, `Community 148`, `Community 21`, `Community 20`, `Community 151`, `Community 22`, `Community 155`, `Community 28`, `Community 158`, `Community 161`, `Community 162`, `Community 36`, `Community 37`, `Community 41`, `Community 46`, `Community 176`, `Community 53`, `Community 57`, `Community 60`, `Community 62`, `Community 63`, `Community 65`, `Community 72`, `Community 74`, `Community 75`, `Community 77`, `Community 98`, `Community 101`, `Community 102`, `Community 118`, `Community 123`, `Community 124`?**
-  _High betweenness centrality (0.042) - this node is a cross-community bridge._
+- **Why does `Session` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 28`, `Community 29`, `Community 30`, `Community 32`, `Community 33`, `Community 37`, `Community 38`, `Community 39`, `Community 40`, `Community 41`, `Community 42`, `Community 44`, `Community 45`, `Community 46`, `Community 47`, `Community 48`, `Community 49`, `Community 50`, `Community 51`, `Community 52`, `Community 53`, `Community 55`, `Community 56`, `Community 57`, `Community 58`, `Community 59`, `Community 62`, `Community 63`, `Community 64`, `Community 65`, `Community 67`, `Community 68`, `Community 71`, `Community 72`, `Community 73`, `Community 74`, `Community 75`, `Community 76`, `Community 77`, `Community 78`, `Community 79`, `Community 84`, `Community 86`, `Community 87`, `Community 89`, `Community 90`, `Community 91`, `Community 93`, `Community 94`, `Community 95`, `Community 96`, `Community 97`, `Community 98`, `Community 100`, `Community 101`, `Community 102`, `Community 104`, `Community 109`, `Community 113`, `Community 114`, `Community 116`, `Community 117`, `Community 120`, `Community 121`, `Community 124`, `Community 127`, `Community 141`, `Community 142`, `Community 143`, `Community 146`, `Community 147`, `Community 148`, `Community 150`, `Community 152`, `Community 154`, `Community 159`, `Community 166`, `Community 168`, `Community 169`, `Community 170`, `Community 171`, `Community 172`, `Community 173`, `Community 175`, `Community 177`?**
+  _High betweenness centrality (0.243) - this node is a cross-community bridge._
+- **Why does `datetime` connect `Community 34` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `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 19`, `Community 20`, `Community 22`, `Community 23`, `Community 24`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 35`, `Community 36`, `Community 37`, `Community 38`, `Community 41`, `Community 42`, `Community 43`, `Community 45`, `Community 46`, `Community 47`, `Community 48`, `Community 49`, `Community 51`, `Community 52`, `Community 53`, `Community 54`, `Community 55`, `Community 56`, `Community 58`, `Community 60`, `Community 61`, `Community 62`, `Community 64`, `Community 67`, `Community 69`, `Community 70`, `Community 71`, `Community 73`, `Community 74`, `Community 75`, `Community 77`, `Community 78`, `Community 86`, `Community 87`, `Community 90`, `Community 93`, `Community 94`, `Community 96`, `Community 97`, `Community 101`, `Community 103`, `Community 105`, `Community 107`, `Community 114`, `Community 117`, `Community 118`, `Community 121`, `Community 124`, `Community 125`, `Community 126`, `Community 129`, `Community 140`, `Community 143`, `Community 144`, `Community 145`, `Community 147`, `Community 148`, `Community 152`, `Community 154`, `Community 155`, `Community 159`, `Community 162`, `Community 168`, `Community 175`, `Community 176`?**
+  _High betweenness centrality (0.130) - this node is a cross-community bridge._
+- **Why does `BaseModel` connect `Community 47` to `Community 0`, `Community 1`, `Community 5`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 143`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 22`, `Community 151`, `Community 155`, `Community 28`, `Community 158`, `Community 161`, `Community 162`, `Community 36`, `Community 37`, `Community 41`, `Community 172`, `Community 45`, `Community 46`, `Community 175`, `Community 176`, `Community 53`, `Community 57`, `Community 63`, `Community 65`, `Community 74`, `Community 77`, `Community 98`, `Community 101`, `Community 102`, `Community 113`, `Community 118`, `Community 123`?**
+  _High betweenness centrality (0.047) - this node is a cross-community bridge._
 - **Are the 341 inferred relationships involving `str` (e.g. with `AdvanceApplyCreate` and `AdvanceApplyOut`) actually correct?**
   _`str` has 341 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 337 inferred relationships involving `Session` (e.g. with `AdvanceApplyCreate` and `AdvanceApplyOut`) actually correct?**
@@ -845,4 +895,4 @@ _Questions this graph is uniquely positioned to answer:_
 - **Are the 225 inferred relationships involving `int` (e.g. with `AdvanceApplyCreate` and `AdvanceApplyOut`) actually correct?**
   _`int` has 225 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?**
-  _1556 weakly-connected nodes found - possible documentation gaps or missing edges._
+  _1560 weakly-connected nodes found - possible documentation gaps or missing edges._

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/graphify-out/cache/ast/6f233150e253813a7c9da30e6e5d6046fdea01baa67c2589a332fe511ab795f9.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/graphify-out/cache/ast/b09bff73a40f4af48c1a13475e6bb1af3f4da174dcf393067ddfa8adfa3f09a4.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/graphify-out/cache/ast/c94681aea5f0ae5f08969088f1bb57ca4fdb5dcf586161c16e2455aab1ca162c.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/graphify-out/cache/ast/ceaa64369ef66891310f391836c580beefd5a69854b3a5a0539a419744a311b0.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/graphify-out/cache/ast/d15105a475ef5ad816381c1e20683874240a80601b1b8b132a972b68276483e0.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/graphify-out/cache/ast/ed2ea10761f2798e8980084d754284d3fcb94f1284ae7085c35c16108fe24846.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/graphify-out/cache/stat-index.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 112 - 112
backend/graphify-out/graph.json


+ 208 - 0
backend/scripts/migration_20260715_crm_price_letter.sql

@@ -0,0 +1,208 @@
+-- ============================================================
+-- CRM 调价函管理:建表 + 字典 + 菜单(合同管理下方)
+-- 执行前可先备份 sys_menu;可重复执行(菜单/字典做了 NOT EXISTS 防护)
+-- ============================================================
+
+-- 1) 业务表
+CREATE TABLE IF NOT EXISTS `crm_price_letter` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
+  `letter_type` varchar(20) NOT NULL COMMENT '类型 receive接受/send发出',
+  `adjust_date` date NOT NULL COMMENT '调价日期',
+  `customer_id` varchar(64) NOT NULL COMMENT '客户ID(crm_customer.id)',
+  `customer_name` varchar(200) DEFAULT NULL COMMENT '客户名称(冗余)',
+  `product_id` varchar(64) NOT NULL COMMENT '产品ID(crm_product.id)',
+  `product_name` varchar(200) DEFAULT NULL COMMENT '产品名称(冗余)',
+  `price` decimal(15,2) NOT NULL COMMENT '调整后现金价格(元)',
+  `bill_price` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '调整后汇票价格(元)',
+  `content` mediumtext COMMENT '发出类富文本HTML;接受类为空',
+  `attachment_urls` varchar(4000) DEFAULT NULL COMMENT '附件URL(逗号分隔,兼容上传组件)',
+  `status` varchar(20) NOT NULL DEFAULT 'normal' COMMENT '状态 normal正常/invalid已作废',
+  `invalid_reason` varchar(200) DEFAULT NULL COMMENT '作废原因',
+  `invalid_time` datetime DEFAULT NULL COMMENT '作废时间',
+  `invalid_by` varchar(64) DEFAULT NULL COMMENT '作废人ID',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
+  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人ID',
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人ID',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记(0存在 1删除)',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_price_letter_tenant` (`tenant_no`) USING BTREE,
+  KEY `idx_price_letter_type` (`letter_type`) USING BTREE,
+  KEY `idx_price_letter_status` (`status`) USING BTREE,
+  KEY `idx_price_letter_customer` (`customer_id`) USING BTREE,
+  KEY `idx_price_letter_product` (`product_id`) USING BTREE,
+  KEY `idx_price_letter_adjust_date` (`adjust_date`) USING BTREE,
+  KEY `idx_price_letter_create_time` (`create_time`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-调价函档案(独立,不关联合同/报价单)';
+
+-- 2) 字典类型
+INSERT INTO `sys_dict_type` (
+  `id`, `dict_name`, `dict_type`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'CRM_DTYPE_PRICE_LETTER_TYPE', '调价函类型', 'crm_price_letter_type', 'shanxia2.0', 90, '接受/发出',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_type` WHERE `id` = 'CRM_DTYPE_PRICE_LETTER_TYPE' OR `dict_type` = 'crm_price_letter_type');
+
+INSERT INTO `sys_dict_type` (
+  `id`, `dict_name`, `dict_type`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'CRM_DTYPE_PRICE_LETTER_STATUS', '调价函状态', 'crm_price_letter_status', 'shanxia2.0', 91, '正常/已作废',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_type` WHERE `id` = 'CRM_DTYPE_PRICE_LETTER_STATUS' OR `dict_type` = 'crm_price_letter_status');
+
+-- 3) 字典数据
+INSERT INTO `sys_dict_data` (
+  `id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
+  `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM_DDATA_PRICE_LETTER_TYPE_01', '接受调价函', 'receive', 'warning', 'crm_price_letter_type', 'shanxia2.0', 1,
+  NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_TYPE_01');
+
+INSERT INTO `sys_dict_data` (
+  `id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
+  `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM_DDATA_PRICE_LETTER_TYPE_02', '发出调价函', 'send', 'success', 'crm_price_letter_type', 'shanxia2.0', 2,
+  NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_TYPE_02');
+
+INSERT INTO `sys_dict_data` (
+  `id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
+  `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM_DDATA_PRICE_LETTER_STATUS_01', '正常', 'normal', 'success', 'crm_price_letter_status', 'shanxia2.0', 1,
+  NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_STATUS_01');
+
+INSERT INTO `sys_dict_data` (
+  `id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
+  `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM_DDATA_PRICE_LETTER_STATUS_02', '已作废', 'invalid', 'danger', 'crm_price_letter_status', 'shanxia2.0', 2,
+  NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_STATUS_02');
+
+-- 4) 菜单排序:合同管理(order_num=6) 之后顺延
+UPDATE `sys_menu`
+SET `order_num` = `order_num` + 1,
+    `update_time` = NOW()
+WHERE `parent_id` = 'CRM00000000000000000000000000001'
+  AND `order_num` >= 7
+  AND `del_flag` = 0
+  AND NOT EXISTS (
+    SELECT 1 FROM (
+      SELECT id FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000030' AND `del_flag` = 0
+    ) t
+  );
+
+-- 5) 菜单 + 按钮(无 remove,含 print/void)
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'CRM3000000000000000000000000030', '调价函管理', 'CRM00000000000000000000000000001', 'C',
+  'price-letter', '/crm/price-letter/index', 'crm:priceLetter:list',
+  1, '1', '1', 'shanxia2.0', 7, '独立调价函档案:接受(附件)/发出(富文本+可选附件);不关联合同与报价单',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000030');
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM3000000000000000000000000031', '调价函新增', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:add',
+  1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000031');
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM3000000000000000000000000032', '调价函编辑', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:edit',
+  1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000032');
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM3000000000000000000000000034', '调价函查看', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:query',
+  1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000034');
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM3000000000000000000000000035', '调价函打印', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:print',
+  1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000035');
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT 'CRM3000000000000000000000000036', '调价函作废', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:void',
+  1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000036');
+
+-- 6) 若曾误写「删除」按钮,统一软删(仅作废,无物理删除能力)
+UPDATE `sys_menu`
+SET `del_flag` = 1,
+    `update_time` = NOW(),
+    `remark` = CONCAT(IFNULL(`remark`, ''), ' [disabled: use void]')
+WHERE `id` = 'CRM3000000000000000000000000033'
+  AND `del_flag` = 0;
+
+-- 7) 将调价函菜单/按钮授权给已拥有「合同管理」的角色(可重复执行)
+INSERT INTO `sys_role_menu` (
+  `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT DISTINCT
+  CONCAT('CRM_RM_PL_', SUBSTRING(MD5(CONCAT(rm.role_id, '-', m.id)), 1, 20)),
+  rm.role_id,
+  m.id,
+  COALESCE(NULLIF(rm.tenant_no, ''), 'shanxia2.0'),
+  0,
+  'auto grant price-letter with contract',
+  'system',
+  '',
+  NOW(),
+  NULL,
+  NOW(),
+  0
+FROM `sys_role_menu` rm
+JOIN `sys_menu` m ON m.id IN (
+  'CRM3000000000000000000000000030',
+  'CRM3000000000000000000000000031',
+  'CRM3000000000000000000000000032',
+  'CRM3000000000000000000000000034',
+  'CRM3000000000000000000000000035',
+  'CRM3000000000000000000000000036'
+) AND m.del_flag = 0
+WHERE rm.menu_id = 'CRM3000000000000000000000000020'
+  AND rm.del_flag = 0
+  AND NOT EXISTS (
+    SELECT 1 FROM `sys_role_menu` x
+    WHERE x.role_id = rm.role_id AND x.menu_id = m.id AND x.del_flag = 0
+  );

+ 28 - 0
backend/scripts/migration_20260715_crm_price_letter_bill_price.sql

@@ -0,0 +1,28 @@
+-- 调价函:price 语义改为现金价;新增汇票价 bill_price
+-- 可重复执行
+
+ALTER TABLE `crm_price_letter`
+  MODIFY COLUMN `price` decimal(15,2) NOT NULL COMMENT '调整后现金价格(元)';
+
+SET @col_exists := (
+  SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'crm_price_letter'
+    AND COLUMN_NAME = 'bill_price'
+);
+
+SET @sql := IF(
+  @col_exists = 0,
+  'ALTER TABLE `crm_price_letter` ADD COLUMN `bill_price` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT ''调整后汇票价格(元)'' AFTER `price`',
+  'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 历史数据:汇票价未填时用现金价回填,避免 NOT NULL 后业务误判为 0
+UPDATE `crm_price_letter`
+SET `bill_price` = `price`
+WHERE (`bill_price` IS NULL OR `bill_price` = 0)
+  AND `price` IS NOT NULL
+  AND `del_flag` = 0;

+ 10 - 4
backend/scripts/seed_sys_menu.sql

@@ -16,10 +16,16 @@ INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `co
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000000010', '商机管理', 'CRM00000000000000000000000000001', 'C', 'opportunity', '/crm/opportunity/index', 'crm:opportunity:list', 1, '1', '1', 'shanxia2.0', 4, 'CRM商机列表与跟进', NULL, '', '2026-05-31 16:16:28', NULL, '2026-05-31 16:16:28', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000010', '报价单管理', 'CRM00000000000000000000000000001', 'C', 'quote', '/crm/quote/index', 'crm:quote:list', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000020', '合同管理', 'CRM00000000000000000000000000001', 'C', 'contract', '/crm/contract/index', 'crm:contract:list', 1, '1', '1', 'shanxia2.0', 6, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
-INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000002', '客户管理', 'CRM00000000000000000000000000001', 'C', 'customer', '/crm/customer/index', 'crm:customer:list', 1, '1', '1', 'shanxia2.0', 7, '客户列表管理', NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
-INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000003', '公海池', 'CRM00000000000000000000000000001', 'C', 'customer-pool', '/crm/customer/pool/index', 'crm:customerPool:list', 1, '1', '1', 'shanxia2.0', 8, '公海池客户管理', NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
-INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000011', '线索详情', 'CRM00000000000000000000000000001', 'C', 'clue/detail/:id', '/crm/clue/detail', 'crm:clue:query', 1, '0', '1', 'shanxia2.0', 9, '线索详情页(侧栏不展示)', NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
-INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000000011', '商机详情', 'CRM00000000000000000000000000001', 'C', 'opportunity/detail/:id', '/crm/opportunity/detail', 'crm:opportunity:query', 1, '0', '1', 'shanxia2.0', 10, '商机详情页(侧栏不展示)', NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000030', '调价函管理', 'CRM00000000000000000000000000001', 'C', 'price-letter', '/crm/price-letter/index', 'crm:priceLetter:list', 1, '1', '1', 'shanxia2.0', 7, '独立调价函档案:接受(附件)/发出(富文本+可选附件)', 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000002', '客户管理', 'CRM00000000000000000000000000001', 'C', 'customer', '/crm/customer/index', 'crm:customer:list', 1, '1', '1', 'shanxia2.0', 8, '客户列表管理', NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000003', '公海池', 'CRM00000000000000000000000000001', 'C', 'customer-pool', '/crm/customer/pool/index', 'crm:customerPool:list', 1, '1', '1', 'shanxia2.0', 9, '公海池客户管理', NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000011', '线索详情', 'CRM00000000000000000000000000001', 'C', 'clue/detail/:id', '/crm/clue/detail', 'crm:clue:query', 1, '0', '1', 'shanxia2.0', 10, '线索详情页(侧栏不展示)', NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000000011', '商机详情', 'CRM00000000000000000000000000001', 'C', 'opportunity/detail/:id', '/crm/opportunity/detail', 'crm:opportunity:query', 1, '0', '1', 'shanxia2.0', 11, '商机详情页(侧栏不展示)', NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000031', '调价函新增', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:add', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000032', '调价函编辑', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000034', '调价函查看', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:query', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000035', '调价函打印', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:print', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000036', '调价函作废', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:void', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000002', '基础配置', 'BX00000000000000000000000000001', 'M', 'config', NULL, NULL, 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000006', '备用金', 'BX00000000000000000000000000001', 'M', 'advance', NULL, NULL, 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000010', '报销', 'BX00000000000000000000000000001', 'M', 'reimburse', NULL, NULL, 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);

+ 46 - 0
docs/sql/shanxia2.0.sql

@@ -1326,6 +1326,52 @@ BEGIN;
 COMMIT;
 
 -- ----------------------------
+
+-- ----------------------------
+-- Table structure for crm_price_letter
+-- ----------------------------
+DROP TABLE IF EXISTS `crm_price_letter`;
+CREATE TABLE `crm_price_letter` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
+  `letter_type` varchar(20) NOT NULL COMMENT '类型 receive接受/send发出',
+  `adjust_date` date NOT NULL COMMENT '调价日期',
+  `customer_id` varchar(64) NOT NULL COMMENT '客户ID(crm_customer.id)',
+  `customer_name` varchar(200) DEFAULT NULL COMMENT '客户名称(冗余)',
+  `product_id` varchar(64) NOT NULL COMMENT '产品ID(crm_product.id)',
+  `product_name` varchar(200) DEFAULT NULL COMMENT '产品名称(冗余)',
+  `price` decimal(15,2) NOT NULL COMMENT '调整后现金价格(元)',
+  `bill_price` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '调整后汇票价格(元)',
+  `content` mediumtext COMMENT '发出类富文本HTML;接受类为空',
+  `attachment_urls` varchar(4000) DEFAULT NULL COMMENT '附件URL(逗号分隔,兼容上传组件)',
+  `status` varchar(20) NOT NULL DEFAULT 'normal' COMMENT '状态 normal正常/invalid已作废',
+  `invalid_reason` varchar(200) DEFAULT NULL COMMENT '作废原因',
+  `invalid_time` datetime DEFAULT NULL COMMENT '作废时间',
+  `invalid_by` varchar(64) DEFAULT NULL COMMENT '作废人ID',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
+  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人ID',
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人ID',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记(0存在 1删除)',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_price_letter_tenant` (`tenant_no`) USING BTREE,
+  KEY `idx_price_letter_type` (`letter_type`) USING BTREE,
+  KEY `idx_price_letter_status` (`status`) USING BTREE,
+  KEY `idx_price_letter_customer` (`customer_id`) USING BTREE,
+  KEY `idx_price_letter_product` (`product_id`) USING BTREE,
+  KEY `idx_price_letter_adjust_date` (`adjust_date`) USING BTREE,
+  KEY `idx_price_letter_create_time` (`create_time`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-调价函档案(独立,不关联合同/报价单)';
+
+-- ----------------------------
+-- Records of crm_price_letter
+-- ----------------------------
+BEGIN;
+COMMIT;
+
 -- Table structure for crm_product_category
 -- ----------------------------
 DROP TABLE IF EXISTS `crm_product_category`;

+ 134 - 19
graphify-out/manifest.json

@@ -220,13 +220,13 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/__init__.py": {
-    "mtime": 1784035533.4308472,
+    "mtime": 1784035957.2264023,
     "ast_hash": "22bfaa7ae31dc2432d8db3bbbd6b69ed",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py": {
-    "mtime": 1782853112.7312398,
-    "ast_hash": "1f382330a208d2b2aa05738fb5f4847e",
+    "mtime": 1784119016.7617116,
+    "ast_hash": "c248c7b89d06f74b220d7e222758bfb3",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/db_models.py": {
@@ -450,7 +450,7 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/contract.py": {
-    "mtime": 1784035385.9726775,
+    "mtime": 1784118885.4294813,
     "ast_hash": "6553f54ca1fd19086c170095f402a628",
     "semantic_hash": ""
   },
@@ -730,8 +730,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py": {
-    "mtime": 1783819836.5052142,
-    "ast_hash": "185d877b19159d9d996122fea0bb4f17",
+    "mtime": 1784119033.6826367,
+    "ast_hash": "88dad00a1876ec8cfeb54372ec6e8ab9",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/__init__.py": {
@@ -785,7 +785,7 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/__init__.py": {
-    "mtime": 1784035531.7944577,
+    "mtime": 1784035957.2264116,
     "ast_hash": "7435345efcb6997156778beedc3c1723",
     "semantic_hash": ""
   },
@@ -1190,8 +1190,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/scripts/seed_sys_menu.sql": {
-    "mtime": 1783760394.1548784,
-    "ast_hash": "5ca9ccbc5b637f058c61c7374958df06",
+    "mtime": 1784119136.2375653,
+    "ast_hash": "343d5082132d47fe22b7ff2b451f017c",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/scripts/migrate_pl_plan_subpack_slip_no_format.sql": {
@@ -1230,7 +1230,7 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/requirements.txt": {
-    "mtime": 1784035533.5738842,
+    "mtime": 1784035957.2263825,
     "ast_hash": "ab08de9f2031a70cabd3e6652131daaf",
     "semantic_hash": ""
   },
@@ -2825,8 +2825,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/router/menu-icon.ts": {
-    "mtime": 1783256027.0364676,
-    "ast_hash": "636aa6279dd9eb2b673eb0ee16dbad56",
+    "mtime": 1784119135.7216125,
+    "ast_hash": "c2e433bec4db596312c2915741f10e3b",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/router/sidebar-menu-filter.ts": {
@@ -3485,8 +3485,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-cert-form-list/index.vue": {
-    "mtime": 1784035957.2258303,
-    "ast_hash": "04f218d2f236fa250218d081fc010da4",
+    "mtime": 1784036702.5929704,
+    "ast_hash": "71f64456c73c67faf595e3ba8a73606a",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/product-view-dialog/index.vue": {
@@ -4900,8 +4900,8 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/router/modules/sqxp.ts": {
-    "mtime": 1783254842.214753,
-    "ast_hash": "f760880e5e0dadf73778eb454713257b",
+    "mtime": 1784119116.084193,
+    "ast_hash": "0e84fac0973b43b02702f0a02bbf12a9",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/router/modules/demo.ts": {
@@ -6435,13 +6435,13 @@
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/ocr.py": {
-    "mtime": 1784035784.6057756,
+    "mtime": 1784035957.2258248,
     "ast_hash": "e719bffe004a57f5f586f58d5cb98700",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_ocr_service.py": {
-    "mtime": 1784035790.0164018,
-    "ast_hash": "c2b8d0a6854cd310cd0cce514a6e62ab",
+    "mtime": 1784036700.05545,
+    "ast_hash": "4387f960838bc6d8d9803bc8a102eb73",
     "semantic_hash": ""
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/scripts/test_aliyun_driving_license_ocr.py": {
@@ -6453,5 +6453,120 @@
     "mtime": 1784032423.5405529,
     "ast_hash": "e910608da8b685aedb9156dc76d952d4",
     "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/price_letter.py": {
+    "mtime": 1784118782.40332,
+    "ast_hash": "07f221af4ba5bb52fe36ed59aa0503b5",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/price_letter.py": {
+    "mtime": 1784119032.847632,
+    "ast_hash": "5a57e802fb0c81c4568f06ab1cecc6bb",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/scripts/migration_20260715_crm_price_letter.sql": {
+    "mtime": 1784119746.9710577,
+    "ast_hash": "83e4ec6e6268c3dac06ebd59ff5abfa8",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pyrsa-sign": {
+    "mtime": 1780108930.8218405,
+    "ast_hash": "ca6d722d19465f857518df6683c07876",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/Activate.ps1": {
+    "mtime": 1780108930.8209147,
+    "ast_hash": "0ffe3af6aec8ec23ff6cabdd75c58dfe",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/dotenv": {
+    "mtime": 1780108930.821198,
+    "ast_hash": "5c14a01155877c59f9968e10b34a3dc3",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pyrsa-priv2pub": {
+    "mtime": 1780108930.8217626,
+    "ast_hash": "811c214d89f65ecbf440dbd706fee81a",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pip3": {
+    "mtime": 1780108930.8214478,
+    "ast_hash": "fc880c4901112849f31814356b96608b",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/mako-render": {
+    "mtime": 1780108930.8213224,
+    "ast_hash": "0e2ebd254ae9bb8e534a489e630c3a1d",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/websockets": {
+    "mtime": 1780108930.8221874,
+    "ast_hash": "d614b594134130987e204bff6638b43e",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pyrsa-keygen": {
+    "mtime": 1780108930.82169,
+    "ast_hash": "f9d735fba693df3a955650171a7332d7",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pip": {
+    "mtime": 1780108930.821385,
+    "ast_hash": "fc880c4901112849f31814356b96608b",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/httpx": {
+    "mtime": 1780108930.8212638,
+    "ast_hash": "9e54ed1f82d09bbf6c02ce9533698a05",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pyrsa-decrypt": {
+    "mtime": 1780108930.8215678,
+    "ast_hash": "5ebf9e36dfa5a0be187e4d312c9293b5",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pyrsa-encrypt": {
+    "mtime": 1780108930.8216279,
+    "ast_hash": "dae09bf4722eab1a14285b7f5b8a9cce",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/uvicorn": {
+    "mtime": 1780108930.8220696,
+    "ast_hash": "1a0c807254752fa39efdfee37357d556",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/alembic": {
+    "mtime": 1780108930.8211374,
+    "ast_hash": "c91cfa7671af320b939e904c1fe91fe5",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/pyrsa-verify": {
+    "mtime": 1780108930.8219104,
+    "ast_hash": "9f5fd18ec59bd56a78f33d0e8234bdc0",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/backend/venv.bak.broken/bin/watchfiles": {
+    "mtime": 1780108930.822129,
+    "ast_hash": "6fadd6729df2485367d64111686b949c",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/hooks/business/usePriceLetterPerm.ts": {
+    "mtime": 1784119053.0707357,
+    "ast_hash": "77a0499b0297adc43d627390b66223ee",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/price-letter.ts": {
+    "mtime": 1784119046.372738,
+    "ast_hash": "ebb55693683e5ba26904fcee4c895cbe",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/price-letter/index.vue": {
+    "mtime": 1784119757.6683905,
+    "ast_hash": "9e3f64a33c0635a761be19d2833987b8",
+    "semantic_hash": ""
+  },
+  "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/price-letter/components/VoidDialog.vue": {
+    "mtime": 1784119053.406238,
+    "ast_hash": "14e4acd132e3c49ac48b8af075cb778e",
+    "semantic_hash": ""
   }
 }

+ 19 - 3
web-admin/graphify-out/.graphify_labels.json

@@ -331,6 +331,7 @@
   "329": "Community 329",
   "330": "Community 330",
   "331": "Community 331",
+  "332": "Community 332",
   "333": "Community 333",
   "334": "Community 334",
   "335": "Community 335",
@@ -380,7 +381,9 @@
   "379": "Community 379",
   "380": "Community 380",
   "381": "Community 381",
+  "382": "Community 382",
   "383": "Community 383",
+  "384": "Community 384",
   "385": "Community 385",
   "386": "Community 386",
   "387": "Community 387",
@@ -429,9 +432,13 @@
   "430": "Community 430",
   "431": "Community 431",
   "432": "Community 432",
+  "433": "Community 433",
   "434": "Community 434",
+  "435": "Community 435",
   "436": "Community 436",
   "437": "Community 437",
+  "438": "Community 438",
+  "439": "Community 439",
   "440": "Community 440",
   "441": "Community 441",
   "442": "Community 442",
@@ -642,6 +649,8 @@
   "647": "Community 647",
   "648": "Community 648",
   "649": "Community 649",
+  "650": "Community 650",
+  "651": "Community 651",
   "652": "Community 652",
   "653": "Community 653",
   "654": "Community 654",
@@ -660,6 +669,7 @@
   "667": "Community 667",
   "668": "Community 668",
   "669": "Community 669",
+  "670": "Community 670",
   "671": "Community 671",
   "672": "Community 672",
   "673": "Community 673",
@@ -697,6 +707,7 @@
   "705": "Community 705",
   "706": "Community 706",
   "707": "Community 707",
+  "708": "Community 708",
   "709": "Community 709",
   "710": "Community 710",
   "711": "Community 711",
@@ -707,18 +718,23 @@
   "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",
   "737": "Community 737",
   "738": "Community 738",
   "741": "Community 741",
-  "742": "Community 742",
-  "752": "Community 752",
-  "753": "Community 753"
+  "742": "Community 742"
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 225 - 207
web-admin/graphify-out/GRAPH_REPORT.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 112 - 112
web-admin/graphify-out/graph.json


BIN
web-admin/public/SXYZ.jpg


BIN
web-admin/public/sxyz.png


+ 26 - 0
web-admin/src/api/crm/price-letter.ts

@@ -0,0 +1,26 @@
+import request from '@/utils/http'
+
+/** 调价函分页列表 */
+export function fetchPriceLetterList(data: Record<string, any>) {
+  return request.post({ url: '/web/crm/price-letters/list', data })
+}
+
+/** 调价函详情 */
+export function fetchPriceLetterDetail(data: { id: string }) {
+  return request.post({ url: '/web/crm/price-letters/detail', data })
+}
+
+/** 新增调价函 */
+export function fetchCreatePriceLetter(data: Record<string, any>) {
+  return request.post({ url: '/web/crm/price-letters/add', data })
+}
+
+/** 更新调价函 */
+export function fetchUpdatePriceLetter(data: Record<string, any>) {
+  return request.post({ url: '/web/crm/price-letters/update', data })
+}
+
+/** 作废调价函 */
+export function fetchVoidPriceLetter(data: { id: string; invalidReason: string; remark?: string }) {
+  return request.post({ url: '/web/crm/price-letters/void', data })
+}

+ 66 - 0
web-admin/src/hooks/business/usePriceLetterPerm.ts

@@ -0,0 +1,66 @@
+import { ref } from 'vue'
+import { fetchGetMenuCodes } from '@/api/auth'
+import { useUserStore } from '@/store/modules/user'
+
+const PRICE_LETTER_PERM_MAP: Record<string, string> = {
+  list: 'crm:priceLetter:list',
+  add: 'crm:priceLetter:add',
+  edit: 'crm:priceLetter:edit',
+  query: 'crm:priceLetter:query',
+  print: 'crm:priceLetter:print',
+  void: 'crm:priceLetter:void'
+}
+
+let cachedCodes: Set<string> | null = null
+let cachedAll = false
+let loadingPromise: Promise<void> | null = null
+
+function isSuperAdmin(userStore: ReturnType<typeof useUserStore>) {
+  const roles = userStore.info?.roles || []
+  return userStore.info?.role === 'super_admin' || roles.includes('super_admin')
+}
+
+export function usePriceLetterPerm() {
+  const userStore = useUserStore()
+  const ready = ref(cachedAll || !!cachedCodes)
+
+  async function loadPerms() {
+    if (cachedAll || cachedCodes) {
+      ready.value = true
+      return
+    }
+    if (isSuperAdmin(userStore)) {
+      cachedAll = true
+      ready.value = true
+      return
+    }
+    if (!loadingPromise) {
+      loadingPromise = fetchGetMenuCodes()
+        .then((res) => {
+          if (res?.all || res?.isSuperAdmin) {
+            cachedAll = true
+            cachedCodes = null
+          } else {
+            cachedCodes = new Set(res?.menuCodes || [])
+          }
+        })
+        .catch(() => {
+          cachedCodes = new Set()
+        })
+        .finally(() => {
+          ready.value = true
+          loadingPromise = null
+        })
+    }
+    await loadingPromise
+  }
+
+  function hasPriceLetterPerm(action: keyof typeof PRICE_LETTER_PERM_MAP | string) {
+    if (cachedAll || isSuperAdmin(userStore)) return true
+    const code = PRICE_LETTER_PERM_MAP[action] || String(action)
+    if (!cachedCodes) return true
+    return cachedCodes.has(code) || cachedCodes.has('crm:priceLetter:list')
+  }
+
+  return { ready, loadPerms, hasPriceLetterPerm }
+}

+ 7 - 0
web-admin/src/router/modules/sqxp.ts

@@ -381,6 +381,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
         component: '/crm/contract/index',
         meta: { title: '合同管理', keepAlive: true, menuCode: 'crm:contract:list' }
       },
+      {
+        id: 'CRM3000000000000000000000000030',
+        name: 'CrmPriceLetter',
+        path: 'price-letter',
+        component: '/crm/price-letter/index',
+        meta: { title: '调价函管理', keepAlive: true, menuCode: 'crm:priceLetter:list' }
+      },
       {
         id: 'CRM3000000000000000000000000021',
         name: 'CrmContractForm',

+ 162 - 0
web-admin/src/utils/crm/price-letter-template.ts

@@ -0,0 +1,162 @@
+/** 公章:public/SXYZ.jpg(绝对路径便于富文本/PDF 渲染) */
+const SEAL_SRC = '/SXYZ.jpg'
+
+export type PriceLetterTemplateInput = {
+  customerName: string
+  productName: string
+  adjustDate: string
+  /** 现金价 */
+  price: number | string
+  /** 汇票价 */
+  billPrice: number | string
+}
+
+/**
+ * 将白底印章图抠成透明 PNG(html2canvas 不支持 mix-blend-mode,导出 PDF 前调用)。
+ */
+export async function sealImageToTransparentDataUrl(
+  src: string,
+  whiteThreshold = 245
+): Promise<string> {
+  const absoluteSrc = /^https?:\/\//i.test(src)
+    ? src
+    : new URL(src, window.location.origin).href
+
+  const img = await new Promise<HTMLImageElement>((resolve, reject) => {
+    const el = new Image()
+    el.crossOrigin = 'anonymous'
+    el.onload = () => resolve(el)
+    el.onerror = () => reject(new Error('印章图片加载失败'))
+    el.src = absoluteSrc
+  })
+
+  const canvas = document.createElement('canvas')
+  canvas.width = img.naturalWidth || img.width
+  canvas.height = img.naturalHeight || img.height
+  const ctx = canvas.getContext('2d')
+  if (!ctx) return absoluteSrc
+  ctx.drawImage(img, 0, 0)
+  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
+  const { data } = imageData
+  for (let i = 0; i < data.length; i += 4) {
+    const r = data[i]
+    const g = data[i + 1]
+    const b = data[i + 2]
+    // 近白像素透明;偏红印章保留
+    if (r >= whiteThreshold && g >= whiteThreshold && b >= whiteThreshold) {
+      data[i + 3] = 0
+    } else if (r > 180 && g < 140 && b < 140) {
+      // 红色印章略提透明度,露出底层文字
+      data[i + 3] = Math.min(data[i + 3], 220)
+    }
+  }
+  ctx.putImageData(imageData, 0, 0)
+  return canvas.toDataURL('image/png')
+}
+
+/** 导出/打印前:容器内 alt=印章 的图替换为透明底,并去掉 mix-blend-mode */
+export async function prepareSealImagesForRasterExport(root: HTMLElement): Promise<void> {
+  const seals = Array.from(root.querySelectorAll('img[alt="印章"]')) as HTMLImageElement[]
+  await Promise.all(
+    seals.map(async (img) => {
+      try {
+        const dataUrl = await sealImageToTransparentDataUrl(img.getAttribute('src') || SEAL_SRC)
+        img.src = dataUrl
+        img.style.mixBlendMode = 'normal'
+        img.style.opacity = img.style.opacity || '0.92'
+      } catch {
+        /* 单张失败不影响整体导出 */
+      }
+    })
+  )
+  await Promise.all(
+    seals.map(
+      (img) =>
+        new Promise<void>((resolve) => {
+          if (img.complete) {
+            resolve()
+            return
+          }
+          img.onload = img.onerror = () => resolve()
+        })
+    )
+  )
+}
+
+/** YYYY-MM-DD → 2026年02月26日 */
+export function formatChineseDate(dateStr: string, pad = true): string {
+  const text = String(dateStr || '').trim()
+  if (!text) return ''
+  const m = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
+  if (!m) return text
+  const y = m[1]
+  const mo = pad ? String(m[2]).padStart(2, '0') : String(Number(m[2]))
+  const d = pad ? String(m[3]).padStart(2, '0') : String(Number(m[3]))
+  return `${y}年${mo}月${d}日`
+}
+
+function escapeHtml(raw: string): string {
+  return String(raw || '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
+function formatPriceText(price: number | string): string {
+  const n = Number(price)
+  if (Number.isNaN(n)) return String(price ?? '')
+  if (Number.isInteger(n)) return String(n)
+  return n
+    .toFixed(2)
+    .replace(/\.?0+$/, (s) => (s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s))
+}
+
+function hasValidPrice(price: unknown): boolean {
+  return price !== undefined && price !== null && price !== '' && !Number.isNaN(Number(price))
+}
+
+/**
+ * 发出调价函默认正文 HTML(含落款印章)。
+ * 用户可自由修改;保存后以库内 content 为准。
+ */
+export function buildSendPriceLetterHtml(input: PriceLetterTemplateInput): string {
+  const customer = escapeHtml((input.customerName || '').trim() || '(客户名称)')
+  const product = escapeHtml((input.productName || '').trim() || '(产品名称)')
+  const year = String(new Date().getFullYear())
+  const adjustCn = escapeHtml(formatChineseDate(input.adjustDate, false) || '(调价日期)')
+  const signCn = escapeHtml(formatChineseDate(input.adjustDate, true) || '(调价日期)')
+  const cashText = escapeHtml(formatPriceText(input.price))
+  const billText = escapeHtml(formatPriceText(input.billPrice))
+  const sealSrc = SEAL_SRC
+
+  return `<div class="price-letter-doc" style="font-family: SimSun, '宋体', serif; color: #1e293b; line-height: 1.85; font-size: 16px; padding: 8px 12px; max-width: 100%; overflow-x: hidden; box-sizing: border-box;">
+  <h1 style="text-align: center; font-size: 28px; font-weight: 700; margin: 8px 0 28px; letter-spacing: 0.2em;">调价函</h1>
+  <p style="margin: 0 0 16px; text-align: left;">致${customer}:</p>
+  <p style="margin: 0 0 16px; text-indent: 2em; text-align: justify;">首先感谢贵公司对我司${product}供应工作的大力支持,自${year}年度市场原材料价格波动。本着互惠互利,安全及质量第一的原则,我公司经商讨决定自${adjustCn}起供给贵司的${product}调整为:${cashText}元/吨(现金价),${billText}元/吨(汇票价),如在执行过程中有价格波动,我公司将及时进行调整,非常感谢!</p>
+  <div style="height: 72px;"></div>
+  <div style="display: flex; justify-content: flex-end; max-width: 100%;">
+    <div style="position: relative; text-align: center; min-width: 220px; max-width: 100%; padding: 24px 12px 8px; box-sizing: border-box;">
+      <img src="${sealSrc}" alt="印章" style="position: absolute; left: 50%; top: 42%; transform: translate(-50%, calc(-50% - 30px)); width: 160px; max-width: 100%; height: auto; opacity: 0.92; mix-blend-mode: multiply; pointer-events: none; z-index: 2;" />
+      <div style="position: relative; z-index: 1; line-height: 1.7;">
+        <div>安庆市山峡化工有限公司</div>
+        <div>${signCn}</div>
+      </div>
+    </div>
+  </div>
+</div>`
+}
+
+/** 字段是否齐全,可生成默认正文 */
+export function canBuildSendPriceLetterHtml(input: Partial<PriceLetterTemplateInput>): boolean {
+  const customer = String(input.customerName || '').trim()
+  const product = String(input.productName || '').trim()
+  const adjustDate = String(input.adjustDate || '').trim()
+  return !!(
+    customer &&
+    product &&
+    adjustDate &&
+    hasValidPrice(input.price) &&
+    hasValidPrice(input.billPrice)
+  )
+}

+ 1 - 0
web-admin/src/utils/router/menu-icon.ts

@@ -108,6 +108,7 @@ export const SIDEBAR_MENU_ICON_BY_CODE: Record<string, string> = {
   'crm:opportunity:list': 'ri:trophy-line',
   'crm:quote:list': 'ri:file-text-line',
   'crm:contract:list': 'ri:file-paper-2-line',
+  'crm:priceLetter:list': 'ri:file-list-3-line',
   'crm:customer:list': 'ri:team-line',
   'crm:customerPool:list': 'ri:global-line',
   'db:dashboard:list': 'ri:dashboard-line',

+ 115 - 0
web-admin/src/views/crm/price-letter/components/VoidDialog.vue

@@ -0,0 +1,115 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    title="作废调价函"
+    width="560px"
+    align-center
+    append-to-body
+    destroy-on-close
+    class="dialog-form-layout"
+    modal-class="order-module-dialog-mask"
+    :close-on-click-modal="false"
+  >
+    <el-alert type="warning" :closable="false" style="margin-bottom: 16px">
+      <template #default>
+        <div><strong>注意:</strong>调价函作废后不可恢复,仅保留档案,请谨慎操作。</div>
+      </template>
+    </el-alert>
+
+    <el-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      label-position="top"
+      scroll-to-error
+      :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
+    >
+      <div class="form-grid-2">
+        <el-form-item label="作废原因" prop="invalidReason" class="form-col-span-2">
+          <el-input
+            v-model="form.invalidReason"
+            type="textarea"
+            :rows="3"
+            placeholder="请填写作废原因"
+            maxlength="200"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="详细说明" prop="remark" class="form-col-span-2">
+          <el-input
+            v-model="form.remark"
+            type="textarea"
+            :rows="3"
+            placeholder="可选,将写入备注"
+            maxlength="500"
+            show-word-limit
+          />
+        </el-form-item>
+      </div>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="dialogVisible = false">
+        <el-icon><Close /></el-icon>取消
+      </el-button>
+      <el-button type="danger" :loading="submitLoading" @click="handleConfirm">
+        <el-icon><CircleClose /></el-icon>确认作废
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { Close, CircleClose } from '@element-plus/icons-vue'
+  import { fetchVoidPriceLetter } from '@/api/crm/price-letter'
+  import { $modal } from '@/utils/modal'
+
+  const emit = defineEmits(['success'])
+
+  const dialogVisible = ref(false)
+  const submitLoading = ref(false)
+  const letterId = ref('')
+  const formRef = ref()
+  const form = reactive({
+    invalidReason: '',
+    remark: ''
+  })
+
+  const rules = {
+    invalidReason: [{ required: true, message: '请填写作废原因', trigger: 'blur' }]
+  }
+
+  function open(row: { id: string }) {
+    letterId.value = row.id
+    form.invalidReason = ''
+    form.remark = ''
+    dialogVisible.value = true
+  }
+
+  async function handleConfirm() {
+    const ok = await formRef.value?.validate().catch(() => false)
+    if (!ok) return
+    try {
+      await $modal.confirm('确认作废该调价函?作废后不可恢复。')
+    } catch {
+      return
+    }
+    submitLoading.value = true
+    try {
+      await fetchVoidPriceLetter({
+        id: letterId.value,
+        invalidReason: form.invalidReason,
+        remark: form.remark || undefined
+      })
+      $modal.msgSuccess('作废成功')
+      dialogVisible.value = false
+      emit('success')
+    } catch (e: any) {
+      $modal.msgError(e?.message || '作废失败')
+    } finally {
+      submitLoading.value = false
+    }
+  }
+
+  defineExpose({ open })
+</script>

+ 1210 - 0
web-admin/src/views/crm/price-letter/index.vue

@@ -0,0 +1,1210 @@
+<!-- 调价函管理 P01 -->
+<!-- eslint-disable vue/no-v-model-argument -->
+<template>
+  <div class="page-container">
+    <section class="panel">
+      <div class="panel-top">
+        <h2 class="title-bar-heading">调价函管理</h2>
+      </div>
+
+      <el-tabs v-model="activeView" class="price-letter-tabs" @tab-change="handleTabChange">
+        <el-tab-pane label="全部" name="all" />
+        <el-tab-pane label="已作废" name="invalid" />
+      </el-tabs>
+
+      <div class="filter-block">
+        <el-form :model="searchForm" class="filter-form" @submit.prevent>
+          <div class="filter-grid">
+            <el-form-item label="类型">
+              <el-select v-model="searchForm.letterType" placeholder="全部" clearable>
+                <el-option
+                  v-for="opt in dictStore.getOptions('crm_price_letter_type')"
+                  :key="opt.value"
+                  :label="opt.label"
+                  :value="opt.value"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="客户名称">
+              <el-input
+                v-model="searchForm.customerName"
+                placeholder="客户名称"
+                clearable
+                @keyup.enter="handleSearch"
+              />
+            </el-form-item>
+            <el-form-item label="产品名称">
+              <el-input
+                v-model="searchForm.productName"
+                placeholder="产品名称"
+                clearable
+                @keyup.enter="handleSearch"
+              />
+            </el-form-item>
+            <el-form-item label="调价日期">
+              <el-date-picker
+                v-model="adjustDateRange"
+                type="daterange"
+                value-format="YYYY-MM-DD"
+                start-placeholder="开始"
+                end-placeholder="结束"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </div>
+          <div class="filter-actions-row">
+            <span class="filter-hint">独立档案,不关联合同/报价单,不回写产品价格。</span>
+            <div class="filter-actions-end">
+              <el-button class="btn-ghost" :loading="loading" @click="handleReset">
+                <el-icon><RefreshLeft /></el-icon>重置
+              </el-button>
+              <el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
+                <el-icon><Search /></el-icon>搜索
+              </el-button>
+            </div>
+          </div>
+        </el-form>
+      </div>
+
+      <div class="list-toolbar">
+        <div class="list-toolbar-row">
+          <div class="list-toolbar-selected">
+            已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
+          </div>
+          <el-button
+            v-if="hasPriceLetterPerm('add')"
+            class="btn-main"
+            type="primary"
+            @click="handleAdd"
+          >
+            <el-icon><Plus /></el-icon>新增调价函
+          </el-button>
+          <el-button
+            v-if="hasPriceLetterPerm('edit')"
+            class="btn-ghost"
+            :disabled="!canEditSelected"
+            @click="handleToolbarEdit"
+          >
+            <el-icon><Edit /></el-icon>编辑
+          </el-button>
+          <el-button
+            v-if="hasPriceLetterPerm('void')"
+            class="btn-ghost"
+            type="danger"
+            :disabled="!canVoidSelected"
+            @click="handleToolbarVoid"
+          >
+            <el-icon><CircleClose /></el-icon>作废
+          </el-button>
+          <el-button
+            v-if="hasPriceLetterPerm('print')"
+            class="btn-ghost"
+            :disabled="!canPrintSelected"
+            @click="handleToolbarPrint"
+          >
+            <el-icon><Printer /></el-icon>打印
+          </el-button>
+          <el-button
+            v-if="hasPriceLetterPerm('print')"
+            class="btn-ghost"
+            :disabled="!canPrintSelected || pdfLoading"
+            :loading="pdfLoading"
+            @click="handleToolbarExportPdf"
+          >
+            <el-icon><Download /></el-icon>导出PDF
+          </el-button>
+          <ListToolbarEndActions
+            :loading="loading"
+            :column-defs="listColumnDefs"
+            v-model:visible-columns="visibleColumns"
+            v-model:check-all="checkAll"
+            :is-indeterminate="isIndeterminate"
+            @refresh="getList"
+            @reset-columns="resetToDefault"
+          />
+        </div>
+      </div>
+
+      <div class="list-body list-wrap">
+        <el-table
+          class="list-table"
+          :data="tableData"
+          v-loading="loading"
+          stripe
+          row-key="id"
+          @selection-change="handleSelectionChange"
+          @row-dblclick="handleView"
+        >
+          <el-table-column type="selection" min-width="55" align="center" header-align="center" />
+          <el-table-column
+            type="index"
+            label="序号"
+            min-width="68"
+            align="center"
+            header-align="center"
+            :index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
+          />
+          <el-table-column
+            v-if="isColumnVisible('customerName')"
+            prop="customerName"
+            label="供应商/客户"
+            min-width="160"
+            align="left"
+            header-align="center"
+            show-overflow-tooltip
+          >
+            <template #default="{ row }">
+              <el-link type="primary" @click.stop="handleView(row)">{{ row.customerName || '-' }}</el-link>
+            </template>
+          </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('productName')"
+            prop="productName"
+            label="产品名称"
+            min-width="140"
+            align="left"
+            header-align="center"
+            show-overflow-tooltip
+          />
+          <el-table-column
+            v-if="isColumnVisible('letterType')"
+            prop="letterType"
+            label="类型"
+            min-width="120"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <el-tag :type="letterTypeTag(row.letterType)" size="small">
+                {{ letterTypeLabel(row.letterType) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('price')"
+            prop="price"
+            label="现金价格(元)"
+            min-width="120"
+            align="right"
+            header-align="center"
+          >
+            <template #default="{ row }">{{ formatPrice(row.price) }}</template>
+          </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('billPrice')"
+            prop="billPrice"
+            label="汇票价格(元)"
+            min-width="120"
+            align="right"
+            header-align="center"
+          >
+            <template #default="{ row }">{{ formatPrice(row.billPrice) }}</template>
+          </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('adjustDate')"
+            prop="adjustDate"
+            label="调价日期"
+            min-width="120"
+            align="center"
+            header-align="center"
+          />
+          <el-table-column
+            v-if="isColumnVisible('status')"
+            prop="status"
+            label="状态"
+            min-width="100"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <el-tag :type="row.status === 'invalid' ? 'danger' : 'success'" size="small">
+                {{ row.status === 'invalid' ? '已作废' : '正常' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('createTime')"
+            prop="createTime"
+            label="创建时间"
+            min-width="170"
+            align="center"
+            header-align="center"
+          />
+        </el-table>
+
+        <div class="pagination list-pager-bar">
+          <el-pagination
+            v-model:current-page="pagination.page"
+            v-model:page-size="pagination.pageSize"
+            :total="pagination.total"
+            :page-sizes="[10, 20, 50, 100]"
+            layout="total, sizes, prev, pager, next, jumper"
+            background
+            @size-change="getList"
+            @current-change="getList"
+          />
+        </div>
+      </div>
+    </section>
+
+    <!-- 新增/编辑:接受=单栏;发出=左右分栏(右侧富文本) -->
+    <el-dialog
+      v-model="formDialogVisible"
+      :title="isEdit ? '编辑调价函' : '新增调价函'"
+      :width="formData.letterType === 'send' ? '1380px' : '720px'"
+      align-center
+      destroy-on-close
+      class="dialog-form-layout price-letter-form-dialog"
+      modal-class="order-module-dialog-mask"
+      :close-on-click-modal="false"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="formRules"
+        label-position="top"
+        scroll-to-error
+        :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
+        class="price-letter-form"
+        :class="{ 'is-split': formData.letterType === 'send' }"
+      >
+        <div class="price-letter-form-left">
+          <el-form-item label="类型" prop="letterType">
+            <el-radio-group v-model="formData.letterType" :disabled="isEdit">
+              <el-radio value="receive">接受调价函</el-radio>
+              <el-radio value="send">发出调价函</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="调价日期" prop="adjustDate">
+            <el-date-picker
+              v-model="formData.adjustDate"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="请选择调价日期"
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item label="供应商名称(客户)" prop="customerId">
+            <el-input v-model="formData.customerName" placeholder="请选择客户" readonly>
+              <template #append>
+                <el-button @click="customerSelectorVisible = true">
+                  <el-icon><Search /></el-icon>
+                </el-button>
+              </template>
+            </el-input>
+          </el-form-item>
+          <el-form-item label="产品名称" prop="productId">
+            <el-select
+              v-model="formData.productId"
+              filterable
+              remote
+              clearable
+              placeholder="请选择产品"
+              :remote-method="searchProducts"
+              :loading="productLoading"
+              style="width: 100%"
+              @change="handleProductChange"
+            >
+              <el-option
+                v-for="p in productOptions"
+                :key="p.id"
+                :label="p.label || p.productName"
+                :value="p.id"
+              />
+            </el-select>
+          </el-form-item>
+          <div class="price-letter-price-row">
+            <el-form-item label="调整后现金价格(元)" prop="price">
+              <el-input-number
+                v-model="formData.price"
+                :min="0"
+                :precision="2"
+                :step="0.01"
+                controls-position="right"
+                style="width: 100%"
+              />
+            </el-form-item>
+            <el-form-item label="调整后汇票价格(元)" prop="billPrice">
+              <el-input-number
+                v-model="formData.billPrice"
+                :min="0"
+                :precision="2"
+                :step="0.01"
+                controls-position="right"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </div>
+          <el-form-item
+            :label="formData.letterType === 'receive' ? '调价函附件' : '附件(可选,如盖章扫描件)'"
+            prop="attachmentUrls"
+          >
+            <AttachmentUploadField
+              v-model="formData.attachmentUrls"
+              :limit="5"
+              folder="crm/price-letter"
+              hint="支持 PDF、图片等,最多 5 个"
+            />
+          </el-form-item>
+          <el-form-item label="备注" prop="remark">
+            <el-input
+              v-model="formData.remark"
+              type="textarea"
+              :rows="2"
+              maxlength="500"
+              show-word-limit
+              placeholder="选填"
+            />
+          </el-form-item>
+        </div>
+        <div v-if="formData.letterType === 'send'" class="price-letter-form-right">
+          <div class="price-letter-form-right-head">
+            <span>调价函内容(富文本)</span>
+            <span class="price-letter-form-right-tip">字段齐全后自动生成,可自由修改</span>
+          </div>
+          <el-form-item prop="content" class="price-letter-content-field" label-width="0">
+            <TinyMceEditor :model-value="formData.content" :height="520" @update:model-value="onContentUpdate" />
+          </el-form-item>
+        </div>
+      </el-form>
+      <template #footer>
+        <el-button
+          v-if="formData.letterType === 'send' && hasPriceLetterPerm('print')"
+          :loading="pdfLoading"
+          :disabled="!formData.content?.trim()"
+          @click="handleFormExportPdf"
+        >
+          <el-icon><Download /></el-icon>导出PDF
+        </el-button>
+        <el-button @click="formDialogVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button type="primary" :loading="submitLoading" @click="submitForm">
+          <el-icon><Check /></el-icon>确定
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <SystemViewDialog
+      v-model:visible="detailDialogVisible"
+      dialog-class="crm-price-letter-view-dialog"
+      :title="detailData?.customerName || '调价函详情'"
+      :icon="Document"
+      :code="detailData?.adjustDate"
+      code-label="调价日期"
+      :sections="viewSections"
+      dense
+      width="960px"
+      modal-class="order-module-dialog-mask"
+      append-to-body
+    >
+      <template #hero-actions>
+        <el-button
+          v-if="hasPriceLetterPerm('edit') && detailData?.status !== 'invalid'"
+          type="primary"
+          @click="handleDetailEdit"
+        >
+          <el-icon><Edit /></el-icon>编辑
+        </el-button>
+        <el-button
+          v-if="hasPriceLetterPerm('print') && detailData?.letterType === 'send' && detailData?.status !== 'invalid'"
+          @click="handlePrint(detailData)"
+        >
+          <el-icon><Printer /></el-icon>打印
+        </el-button>
+        <el-button
+          v-if="hasPriceLetterPerm('print') && detailData?.letterType === 'send'"
+          :loading="pdfLoading"
+          @click="handleExportPdf(detailData)"
+        >
+          <el-icon><Download /></el-icon>导出PDF
+        </el-button>
+        <el-button
+          v-if="hasPriceLetterPerm('void') && detailData?.status !== 'invalid'"
+          type="danger"
+          @click="voidDialogRef?.open(detailData)"
+        >
+          <el-icon><CircleClose /></el-icon>作废
+        </el-button>
+      </template>
+      <template #attachments>
+        <AttachmentUploadField
+          v-if="detailData?.attachmentUrls"
+          :model-value="detailData.attachmentUrls"
+          disabled
+          compact
+        />
+        <p v-else class="view-empty-hint">暂无附件</p>
+      </template>
+      <template #contentHtml>
+        <div
+          v-if="detailData?.content"
+          class="price-letter-content-preview"
+          v-html="detailData.content"
+        />
+        <p v-else class="view-empty-hint">暂无富文本内容</p>
+      </template>
+    </SystemViewDialog>
+
+    <CustomerSelector
+      :visible="customerSelectorVisible"
+      @update:visible="customerSelectorVisible = $event"
+      @confirm="handleCustomerConfirm"
+    />
+
+    <VoidDialog ref="voidDialogRef" @success="getList" />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import {
+    Plus,
+    Edit,
+    Search,
+    RefreshLeft,
+    Close,
+    Check,
+    CircleClose,
+    Printer,
+    Document,
+    Download
+  } from '@element-plus/icons-vue'
+  import html2canvas from 'html2canvas'
+  import jsPDF from 'jspdf'
+  import CustomerSelector from '@/components/CustomerSelector.vue'
+  import TinyMceEditor from '@/components/TinyMceEditor/index.vue'
+  import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
+  import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
+  import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
+  import { crmField, crmLongField, crmTagField } from '@/utils/crm/view-fields'
+  import {
+    buildSendPriceLetterHtml,
+    canBuildSendPriceLetterHtml,
+    prepareSealImagesForRasterExport
+  } from '@/utils/crm/price-letter-template'
+  import { useDictStore } from '@/store/modules/dict'
+  import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
+  import { formatMoneyDisplay } from '@/utils/money-format'
+  import { $modal } from '@/utils/modal'
+  import { usePriceLetterPerm } from '@/hooks/business/usePriceLetterPerm'
+  import { fetchProductOptions } from '@/api/crm/product'
+  import { fetchCustomerDetail } from '@/api/crm/customer'
+  import {
+    fetchPriceLetterList,
+    fetchPriceLetterDetail,
+    fetchCreatePriceLetter,
+    fetchUpdatePriceLetter
+  } from '@/api/crm/price-letter'
+  import VoidDialog from './components/VoidDialog.vue'
+
+  const dictStore = useDictStore()
+  const { loadPerms, hasPriceLetterPerm } = usePriceLetterPerm()
+
+  const loading = ref(false)
+  const tableData = ref<any[]>([])
+  const selectedRows = ref<any[]>([])
+  const activeView = ref('all')
+  const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
+  const searchForm = reactive({
+    letterType: '',
+    customerName: '',
+    productName: ''
+  })
+  const adjustDateRange = ref<string[] | null>(null)
+
+  const listColumnDefs = [
+    { prop: 'customerName', label: '供应商/客户' },
+    { prop: 'productName', label: '产品名称' },
+    { prop: 'letterType', label: '类型' },
+    { prop: 'price', label: '现金价格(元)' },
+    { prop: 'billPrice', label: '汇票价格(元)' },
+    { prop: 'adjustDate', label: '调价日期' },
+    { prop: 'status', label: '状态' },
+    { prop: 'createTime', label: '创建时间' }
+  ]
+  const {
+    visibleColumns,
+    checkAll,
+    isIndeterminate,
+    isColumnVisible,
+    resetToDefault
+  } = usePersistedListColumns('crm-price-letter-list-columns', listColumnDefs)
+
+  const formDialogVisible = ref(false)
+  const isEdit = ref(false)
+  const submitLoading = ref(false)
+  const pdfLoading = ref(false)
+  const formRef = ref()
+  /** 新增时用户是否改过富文本;为 true 后字段变更不再覆盖正文 */
+  const contentTouched = ref(false)
+  /** 程序写入正文时,避免误判为用户编辑 */
+  let applyingTemplate = false
+  const formData = reactive({
+    id: '',
+    letterType: 'receive',
+    adjustDate: '',
+    customerId: '',
+    customerName: '',
+    productId: '',
+    productName: '',
+    price: undefined as number | undefined,
+    billPrice: undefined as number | undefined,
+    content: '',
+    attachmentUrls: '',
+    remark: ''
+  })
+
+  const formRules = computed(() => {
+    const base: Record<string, any> = {
+      letterType: [{ required: true, message: '请选择类型', trigger: 'change' }],
+      adjustDate: [{ required: true, message: '请选择调价日期', trigger: 'change' }],
+      customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
+      productId: [{ required: true, message: '请选择产品', trigger: 'change' }],
+      price: [{ required: true, message: '请填写调整后现金价格', trigger: 'blur' }],
+      billPrice: [{ required: true, message: '请填写调整后汇票价格', trigger: 'blur' }]
+    }
+    if (formData.letterType === 'receive') {
+      base.attachmentUrls = [
+        {
+          validator: (_r: any, v: string, cb: (e?: Error) => void) => {
+            if (!v || !String(v).trim()) cb(new Error('接受调价函须上传附件'))
+            else cb()
+          },
+          trigger: 'change'
+        }
+      ]
+    }
+    return base
+  })
+
+  const productOptions = ref<any[]>([])
+  const productLoading = ref(false)
+  const customerSelectorVisible = ref(false)
+
+  const detailDialogVisible = ref(false)
+  const detailData = ref<any>(null)
+  const voidDialogRef = ref<InstanceType<typeof VoidDialog>>()
+
+  const canEditSelected = computed(
+    () => selectedRows.value.length === 1 && selectedRows.value[0]?.status !== 'invalid'
+  )
+  const canVoidSelected = computed(
+    () => selectedRows.value.length === 1 && selectedRows.value[0]?.status !== 'invalid'
+  )
+  const canPrintSelected = computed(
+    () =>
+      selectedRows.value.length === 1 &&
+      selectedRows.value[0]?.letterType === 'send' &&
+      selectedRows.value[0]?.status !== 'invalid'
+  )
+
+  const viewSections = computed<ViewSection[]>(() => {
+    const d = detailData.value
+    if (!d) return []
+    return [
+      {
+        title: '基本信息',
+        fields: [
+          crmTagField(
+            '类型',
+            letterTypeLabel(d.letterType),
+            letterTypeTag(d.letterType)
+          ),
+          crmTagField('状态', d.status === 'invalid' ? '已作废' : '正常', d.status === 'invalid' ? 'danger' : 'success'),
+          crmField('供应商/客户', d.customerName),
+          crmField('产品名称', d.productName),
+          crmField('调整后现金价格', formatPrice(d.price)),
+          crmField('调整后汇票价格', formatPrice(d.billPrice)),
+          crmField('调价日期', d.adjustDate),
+          crmLongField('备注', d.remark)
+        ]
+      },
+      {
+        title: '调价函附件',
+        fields: [],
+        slot: 'attachments'
+      },
+      ...(d.letterType === 'send'
+        ? [
+            {
+              title: '富文本内容',
+              fields: [],
+              slot: 'contentHtml'
+            } as ViewSection
+          ]
+        : []),
+      ...(d.status === 'invalid'
+        ? [
+            {
+              title: '作废信息',
+              fields: [
+                crmField('作废原因', d.invalidReason),
+                crmField('作废时间', d.invalidTime)
+              ]
+            } as ViewSection
+          ]
+        : [])
+    ]
+  })
+
+  function letterTypeLabel(v: string) {
+    return dictStore.getLabel('crm_price_letter_type', v) || (v === 'send' ? '发出调价函' : '接受调价函')
+  }
+  function letterTypeTag(v: string) {
+    return v === 'send' ? 'success' : 'warning'
+  }
+  function formatPrice(value: unknown) {
+    if (value === null || value === undefined || value === '') return '-'
+    const num = Number(value)
+    if (Number.isNaN(num)) return '-'
+    return `¥${formatMoneyDisplay(num)}`
+  }
+
+  async function getList() {
+    loading.value = true
+    try {
+      const params: Record<string, any> = {
+        page: pagination.page,
+        pageSize: pagination.pageSize,
+        letterType: searchForm.letterType || undefined,
+        customerName: searchForm.customerName || undefined,
+        productName: searchForm.productName || undefined
+      }
+      if (activeView.value === 'invalid') {
+        params.status = 'invalid'
+      } else {
+        params.excludeStatus = 'invalid'
+      }
+      if (adjustDateRange.value?.length === 2) {
+        params.adjustDateFrom = adjustDateRange.value[0]
+        params.adjustDateTo = adjustDateRange.value[1]
+      }
+      const res: any = await fetchPriceLetterList(params)
+      tableData.value = res?.list || []
+      pagination.total = res?.total || 0
+    } catch (e: any) {
+      $modal.msgError(e?.message || '加载失败')
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function handleSearch() {
+    pagination.page = 1
+    getList()
+  }
+  function handleReset() {
+    searchForm.letterType = ''
+    searchForm.customerName = ''
+    searchForm.productName = ''
+    adjustDateRange.value = null
+    handleSearch()
+  }
+  function handleTabChange() {
+    pagination.page = 1
+    selectedRows.value = []
+    getList()
+  }
+  function handleSelectionChange(rows: any[]) {
+    selectedRows.value = rows
+  }
+
+  function resetForm() {
+    formData.id = ''
+    formData.letterType = 'receive'
+    formData.adjustDate = ''
+    formData.customerId = ''
+    formData.customerName = ''
+    formData.productId = ''
+    formData.productName = ''
+    formData.price = undefined
+    formData.billPrice = undefined
+    formData.content = ''
+    formData.attachmentUrls = ''
+    formData.remark = ''
+    contentTouched.value = false
+  }
+
+  function tryApplyDefaultContent() {
+    if (isEdit.value || contentTouched.value) return
+    if (formData.letterType !== 'send') return
+    if (
+      !canBuildSendPriceLetterHtml({
+        customerName: formData.customerName,
+        productName: formData.productName,
+        adjustDate: formData.adjustDate,
+        price: formData.price,
+        billPrice: formData.billPrice
+      })
+    ) {
+      return
+    }
+    applyingTemplate = true
+    formData.content = buildSendPriceLetterHtml({
+      customerName: formData.customerName,
+      productName: formData.productName,
+      adjustDate: formData.adjustDate,
+      price: formData.price as number,
+      billPrice: formData.billPrice as number
+    })
+    nextTick(() => {
+      applyingTemplate = false
+    })
+  }
+
+  function onContentUpdate(html: string) {
+    formData.content = html
+    if (!applyingTemplate && !isEdit.value) {
+      contentTouched.value = true
+    }
+  }
+
+  watch(
+    () =>
+      [
+        formData.letterType,
+        formData.customerName,
+        formData.productName,
+        formData.adjustDate,
+        formData.price,
+        formData.billPrice
+      ] as const,
+    () => {
+      if (formData.letterType === 'receive') return
+      tryApplyDefaultContent()
+    }
+  )
+
+  function handleAdd() {
+    isEdit.value = false
+    resetForm()
+    formDialogVisible.value = true
+  }
+
+  async function openEdit(row: any) {
+    isEdit.value = true
+    resetForm()
+    contentTouched.value = true
+    const res: any = await fetchPriceLetterDetail({ id: row.id })
+    Object.assign(formData, {
+      id: res.id,
+      letterType: res.letterType,
+      adjustDate: res.adjustDate,
+      customerId: res.customerId,
+      customerName: res.customerName,
+      productId: res.productId,
+      productName: res.productName,
+      price: res.price != null ? Number(res.price) : undefined,
+      billPrice: res.billPrice != null ? Number(res.billPrice) : undefined,
+      content: res.content || '',
+      attachmentUrls: res.attachmentUrls || '',
+      remark: res.remark || ''
+    })
+    if (res.productId) {
+      productOptions.value = [
+        { id: res.productId, productName: res.productName, label: res.productName }
+      ]
+    }
+    formDialogVisible.value = true
+  }
+
+  function handleToolbarEdit() {
+    if (!canEditSelected.value) return
+    openEdit(selectedRows.value[0])
+  }
+  function handleToolbarVoid() {
+    if (!canVoidSelected.value) return
+    voidDialogRef.value?.open(selectedRows.value[0])
+  }
+
+  async function handleView(row: any) {
+    const res: any = await fetchPriceLetterDetail({ id: row.id })
+    detailData.value = res
+    detailDialogVisible.value = true
+  }
+  function handleDetailEdit() {
+    detailDialogVisible.value = false
+    if (detailData.value) openEdit(detailData.value)
+  }
+
+  function handleCustomerConfirm(customer: any) {
+    if (!customer) return
+    formData.customerId = customer.id
+    formData.customerName = customer.customerName || ''
+  }
+
+  async function searchProducts(keyword: string) {
+    productLoading.value = true
+    try {
+      const res: any = await fetchProductOptions({ keyword: keyword || undefined })
+      productOptions.value = Array.isArray(res) ? res : res?.list || []
+    } finally {
+      productLoading.value = false
+    }
+  }
+  function handleProductChange(id: string) {
+    const p = productOptions.value.find((x) => x.id === id)
+    formData.productName = p?.productName || p?.label || ''
+  }
+
+  function handleToolbarPrint() {
+    if (!canPrintSelected.value) return
+    handlePrint(selectedRows.value[0])
+  }
+  function handleToolbarExportPdf() {
+    if (!canPrintSelected.value) return
+    handleExportPdf(selectedRows.value[0])
+  }
+
+  function handleFormExportPdf() {
+    if (formData.letterType !== 'send') {
+      $modal.msgWarning('仅发出调价函支持导出 PDF')
+      return
+    }
+    handleExportPdf({
+      id: formData.id || undefined,
+      letterType: 'send',
+      content: formData.content,
+      customerId: formData.customerId,
+      customerName: formData.customerName,
+      productName: formData.productName,
+      adjustDate: formData.adjustDate
+    })
+  }
+
+  async function submitForm() {
+    const ok = await formRef.value?.validate().catch(() => false)
+    if (!ok) {
+      ElMessageBox.alert('请完善必填项后再提交', '校验未通过', { type: 'warning' })
+      return
+    }
+    if (formData.letterType === 'send') {
+      const hasContent = !!String(formData.content || '')
+        .replace(/<[^>]+>/g, '')
+        .replace(/&nbsp;/g, ' ')
+        .trim()
+      const hasAtt = !!String(formData.attachmentUrls || '').trim()
+      if (!hasContent && !hasAtt) {
+        $modal.msgWarning('发出调价函须填写富文本或上传附件')
+        return
+      }
+    }
+    submitLoading.value = true
+    try {
+      const payload = {
+        letterType: formData.letterType,
+        adjustDate: formData.adjustDate,
+        customerId: formData.customerId,
+        customerName: formData.customerName,
+        productId: formData.productId,
+        productName: formData.productName,
+        price: formData.price,
+        billPrice: formData.billPrice,
+        content: formData.letterType === 'send' ? formData.content : '',
+        attachmentUrls: formData.attachmentUrls || '',
+        remark: formData.remark || ''
+      }
+      if (isEdit.value) {
+        await fetchUpdatePriceLetter({ id: formData.id, ...payload })
+        $modal.msgSuccess('更新成功')
+      } else {
+        await fetchCreatePriceLetter(payload)
+        $modal.msgSuccess('创建成功')
+      }
+      formDialogVisible.value = false
+      getList()
+    } catch (e: any) {
+      $modal.msgError(e?.message || '保存失败')
+    } finally {
+      submitLoading.value = false
+    }
+  }
+
+  async function resolveSendLetterContent(row: any) {
+    if (!row || row.letterType !== 'send') {
+      $modal.msgWarning('仅发出调价函支持该操作')
+      return null
+    }
+    let data = row
+    if ((!String(row.content || '').trim() || !row.customerName) && row.id) {
+      try {
+        data = await fetchPriceLetterDetail({ id: row.id })
+      } catch (e: any) {
+        $modal.msgError(e?.message || '加载调价函内容失败')
+        return null
+      }
+    }
+    const html = data?.content || ''
+    if (!html.trim()) {
+      $modal.msgWarning('暂无富文本内容')
+      return null
+    }
+    return { data, html }
+  }
+
+  async function handlePrint(row: any) {
+    const resolved = await resolveSendLetterContent(row)
+    if (!resolved) return
+    const { html } = resolved
+
+    const prep = document.createElement('div')
+    prep.style.position = 'absolute'
+    prep.style.left = '-9999px'
+    prep.innerHTML = html
+    document.body.appendChild(prep)
+    try {
+      await prepareSealImagesForRasterExport(prep)
+    } catch {
+      /* 印刷前抠图失败则仍用原 HTML */
+    }
+    const preparedHtml = prep.innerHTML
+    document.body.removeChild(prep)
+
+    const win = window.open('', '_blank')
+    if (!win) {
+      $modal.msgError('无法打开打印窗口,请检查浏览器弹窗拦截')
+      return
+    }
+    win.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>打印调价函</title>
+<style>
+body{font-family:SimSun,'宋体',serif;color:#1e293b;padding:32px;line-height:1.85;font-size:16px}
+img{max-width:100%}
+@media print{body{padding:16px}}
+</style></head><body>
+${preparedHtml}
+</body></html>`)
+    win.document.close()
+    setTimeout(() => {
+      win.focus()
+      win.print()
+    }, 400)
+  }
+
+  async function handleExportPdf(row: any) {
+    const resolved = await resolveSendLetterContent(row)
+    if (!resolved) return
+    const { data, html } = resolved
+    pdfLoading.value = true
+    try {
+      const tempDiv = document.createElement('div')
+      tempDiv.style.position = 'absolute'
+      tempDiv.style.left = '-9999px'
+      tempDiv.style.top = '0'
+      tempDiv.style.width = '794px'
+      tempDiv.style.background = '#ffffff'
+      tempDiv.style.padding = '32px 40px'
+      tempDiv.innerHTML = html
+      document.body.appendChild(tempDiv)
+
+      // 先抠白底印章再等图片就绪(html2canvas 无法还原 mix-blend-mode)
+      await prepareSealImagesForRasterExport(tempDiv)
+
+      await new Promise<void>((resolve) => {
+        const imgs = Array.from(tempDiv.querySelectorAll('img'))
+        if (!imgs.length) {
+          resolve()
+          return
+        }
+        let left = imgs.length
+        imgs.forEach((img) => {
+          if (img.complete) {
+            left -= 1
+            if (left <= 0) resolve()
+          } else {
+            img.onload = img.onerror = () => {
+              left -= 1
+              if (left <= 0) resolve()
+            }
+          }
+        })
+        setTimeout(() => resolve(), 2000)
+      })
+
+      const canvas = await html2canvas(tempDiv, {
+        scale: 2,
+        useCORS: true,
+        allowTaint: true,
+        backgroundColor: '#ffffff'
+      })
+      document.body.removeChild(tempDiv)
+
+      const imgWidth = 210
+      const pageHeight = 297
+      const imgHeight = (canvas.height * imgWidth) / canvas.width
+      const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
+      const imgData = canvas.toDataURL('image/jpeg', 0.95)
+      if (imgHeight <= pageHeight) {
+        pdf.addImage(imgData, 'JPEG', 0, 0, imgWidth, imgHeight)
+      } else {
+        let heightLeft = imgHeight
+        let position = 0
+        pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight)
+        heightLeft -= pageHeight
+        while (heightLeft > 0) {
+          position = -(imgHeight - heightLeft)
+          pdf.addPage()
+          pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight)
+          heightLeft -= pageHeight
+        }
+      }
+      let customerShort = String(data.customerName || '客户').trim()
+      if (data.customerId) {
+        try {
+          const cust: any = await fetchCustomerDetail({ id: data.customerId })
+          const sn = String(cust?.shortName || '').trim()
+          if (sn) customerShort = sn
+        } catch {
+          /* 简称拉取失败时回退客户名称 */
+        }
+      }
+      const productPart = String(data.productName || '产品').trim() || '产品'
+      const datePart = String(data.adjustDate || '').trim() || '日期'
+      const name = `调价函_${customerShort}_${productPart}_${datePart}`.replace(
+        /[\\/:*?"<>|]/g,
+        '_'
+      )
+      pdf.save(`${name}.pdf`)
+      $modal.msgSuccess('PDF 已导出')
+    } catch (e: any) {
+      $modal.msgError(e?.message || '导出 PDF 失败')
+    } finally {
+      pdfLoading.value = false
+    }
+  }
+
+  onMounted(async () => {
+    await loadPerms()
+    getList()
+    searchProducts('')
+  })
+</script>
+
+<style scoped lang="scss">
+  .price-letter-price-row {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 12px 16px;
+  }
+
+  .price-letter-tabs {
+    margin: 0 0 8px;
+  }
+
+  .price-letter-form {
+    &.is-split {
+      display: flex;
+      gap: 20px;
+      align-items: stretch;
+      min-height: 560px;
+    }
+  }
+
+  .price-letter-form-left {
+    flex: 0 0 380px;
+    max-width: 100%;
+    overflow: auto;
+  }
+
+  .price-letter-form.is-split .price-letter-form-left {
+    flex: 0 0 380px;
+    border-right: 1px solid var(--border, #d9e8e2);
+    padding-right: 16px;
+  }
+
+  .price-letter-form:not(.is-split) .price-letter-form-left {
+    width: 100%;
+  }
+
+  .price-letter-form-right {
+    flex: 1;
+    min-width: 0;
+    max-width: 100%;
+    display: flex;
+    flex-direction: column;
+    overflow-x: hidden;
+  }
+
+  .price-letter-form-right-head {
+    display: flex;
+    align-items: baseline;
+    gap: 10px;
+    margin-bottom: 8px;
+    font-size: 14px;
+    font-weight: 600;
+    color: var(--mint-text, #1e293b);
+  }
+
+  .price-letter-form-right-tip {
+    font-size: 12px;
+    font-weight: 400;
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  .price-letter-content-field {
+    width: 100%;
+    max-width: 100%;
+    margin-bottom: 0;
+    flex: 1;
+    overflow-x: hidden;
+
+    :deep(.el-form-item__content) {
+      display: block;
+      width: 100%;
+      max-width: 100%;
+      overflow-x: hidden;
+    }
+
+    :deep(.tinymce-editor-container) {
+      width: 100% !important;
+      max-width: 100%;
+      overflow-x: hidden;
+    }
+
+    :deep(.tox-tinymce) {
+      width: 100% !important;
+      max-width: 100%;
+      box-sizing: border-box;
+      overflow-x: hidden !important;
+    }
+
+    :deep(.tox-editor-header),
+    :deep(.tox-toolbar),
+    :deep(.tox-toolbar__primary) {
+      max-width: 100%;
+      flex-wrap: wrap;
+    }
+
+    :deep(.tox-edit-area),
+    :deep(.tox-edit-area__iframe) {
+      max-width: 100%;
+    }
+  }
+
+  .price-letter-content-preview {
+    border: 1px solid var(--border, #d9e8e2);
+    border-radius: 8px;
+    padding: 12px;
+    background: #fff;
+    /* 由查看弹窗正文统一滚动,避免与外层双纵向滚动条 */
+    max-height: none;
+    overflow: visible;
+  }
+
+  .view-empty-hint {
+    margin: 0;
+    color: var(--mint-text-muted, #64748b);
+    font-size: 13px;
+  }
+</style>
+
+<style lang="scss">
+  .price-letter-form-dialog.el-dialog {
+    max-width: calc(100vw - 48px);
+
+    .el-dialog__body {
+      max-height: calc(100vh - 140px);
+      overflow: auto;
+      overflow-x: hidden;
+    }
+  }
+</style>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است