Browse Source

feat(pl): show available contract qty before plan qty in stop products

Add contract-product-arrange-qty API and form UI with green available qty,
red plan qty over-hint, and click-through dialog for usage and plan list.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 months ago
parent
commit
81b1cb9b1d

+ 48 - 2
backend/app/api/pl/plan.py

@@ -83,7 +83,10 @@ from app.schemas.pl.transport import (
     TransportIoOrderBrief,
     TransportSiteLinkRead,
 )
-from app.services.pl_dibang_bridge_service import revoke_regeneratable_dibang_for_transport
+from app.services.pl_dibang_bridge_service import (
+    revoke_regeneratable_dibang_for_transport,
+    sync_linked_dibang_dates_for_plan,
+)
 from app.services.pl_inventory_link_read import build_transport_inventory_link_read
 from app.services.pl_io_order_service import get_active_io_order_for_plan, void_io_orders_for_plan
 from app.services.pl_internal_customer import plan_requires_site_link
@@ -390,6 +393,11 @@ def list_plans(
         alias="hazardCertReady",
         description="0=未确认齐备 1=已确认齐备",
     ),
+    contract_id: Optional[str] = Query(
+        default=None,
+        alias="contractId",
+        description="CRM合同ID:返回关联该合同的运输计划(含 pl_plan_contract 与遗留主表 contract_id)",
+    ),
     page: int = Query(default=1, ge=1),
     page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
     db: Session = Depends(get_db),
@@ -401,6 +409,16 @@ def list_plans(
         PlPlan.del_flag == 0,
     )
 
+    if contract_id and str(contract_id).strip():
+        from app.services.pl_contract_plan_qty import plan_ids_linked_to_contract
+
+        linked_ids = plan_ids_linked_to_contract(
+            db, tenant_no=tenant_no, contract_id=str(contract_id).strip()
+        )
+        if not linked_ids:
+            return PlanListResponse(total=0, list=[])
+        query = query.filter(PlPlan.id.in_(linked_ids))
+
     if q:
         q_kw = q.strip()
         if q_kw:
@@ -794,6 +812,27 @@ def count_plans_by_contract(
     return {"counts": counts}
 
 
+@router.get("/contract-product-arrange-qty")
+def get_contract_product_arrange_qty(
+    contract_id: str = Query(..., alias="contractId"),
+    product_id: str = Query(..., alias="productId"),
+    exclude_plan_id: Optional[str] = Query(default=None, alias="excludePlanId"),
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> dict:
+    """表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。"""
+    from app.services.pl_contract_plan_qty import get_contract_product_arrange_qty_detail
+
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    return get_contract_product_arrange_qty_detail(
+        db,
+        tenant_no=tenant_no,
+        contract_id=contract_id,
+        product_id=product_id,
+        exclude_plan_id=exclude_plan_id,
+    )
+
+
 @router.get("/contract-gaps", response_model=PlanContractGapListResponse)
 def list_contract_gaps(
     page: int = Query(default=1, ge=1),
@@ -1638,13 +1677,20 @@ def update_plan(
         )
 
     plan.update_by = user.id
+    dibang_date_warnings = sync_linked_dibang_dates_for_plan(
+        db,
+        tenant_no=tenant_no,
+        plan=plan,
+        user_id=user.id,
+        dept_id=user.dept_id or "",
+    )
     db.commit()
     db.refresh(plan)
     result = PlanWithProducts.model_validate(plan)
     result.products = plan_products
     result.supplier_contracts = supplier_contracts
     result.demander_contracts = demander_contracts
-    result.warnings = qty_warnings
+    result.warnings = [*qty_warnings, *dibang_date_warnings]
     stops = load_plan_stops_grouped(db, tenant_no, [id]).get(id, (None, []))
     attach_plan_stops_to_item(result, plan, stops)
     return result

+ 228 - 0
backend/app/services/pl_contract_plan_qty.py

@@ -708,6 +708,7 @@ def collect_plan_contract_qty_checks(
         stop_kind = _stop_kind_from_scope(scope)
         contract_type = (contract.contract_type or "").strip()
         contract_no = contract.contract_no or ""
+        customer_name = (contract.customer_name or "").strip()
 
         for pid, new_qty_raw in plan_qtys.items():
             new_qty = _to_float(new_qty_raw)
@@ -744,9 +745,12 @@ def collect_plan_contract_qty_checks(
                 "contractId": cid,
                 "contractNo": contract_no,
                 "contractType": contract_type,
+                "customerName": customer_name,
                 "stopKind": stop_kind,
                 "productId": pid,
                 "productName": cp.product_name or "",
+                "model": (cp.model or "").strip() or None,
+                "unit": (cp.unit or "").strip() or None,
                 "thisPlanQty": evaluated.get("thisPlanQty", round(new_qty, 3)),
                 "arrangedTotalQty": evaluated["arrangedTotalQty"],
             }
@@ -760,6 +764,10 @@ def collect_plan_contract_qty_checks(
                         "newPlanQty": evaluated["newPlanQty"],
                         "effectiveTotal": evaluated["effectiveTotal"],
                         "overflowQty": evaluated["overflowQty"],
+                        # 与提醒表同列:剩余可为负,表示超量
+                        "remainingQty": round(
+                            evaluated["contractQty"] - evaluated["effectiveTotal"], 3
+                        ),
                     }
                 )
             elif kind == "remaining":
@@ -812,3 +820,223 @@ def build_contract_qty_reminder_payload(
     if not reminders:
         return None
     return {"items": reminders}
+
+
+def get_contract_product_arrange_qty_detail(
+    db: Session,
+    *,
+    tenant_no: str,
+    contract_id: str,
+    product_id: str,
+    exclude_plan_id: Optional[str] = None,
+) -> Dict[str, Any]:
+    """
+    表单「可安排数量」与问号弹窗数据。
+    arrangedQty / availableQty 均不含 exclude_plan_id(若该计划已在已安排桶中)。
+    """
+    from app.models.crm_models import CrmContract, CrmContractProduct
+
+    cid = (contract_id or "").strip()
+    pid = (product_id or "").strip()
+    exclude_id = (exclude_plan_id or "").strip() or None
+    empty = {
+        "contractId": cid,
+        "contractNo": None,
+        "customerName": None,
+        "productId": pid,
+        "productName": None,
+        "hasFixedQty": False,
+        "contractQty": None,
+        "arrangedQty": 0.0,
+        "availableQty": None,
+        "plans": [],
+    }
+    if not cid or not pid:
+        return empty
+
+    contract = (
+        db.query(CrmContract)
+        .filter(
+            CrmContract.id == cid,
+            CrmContract.tenant_no == tenant_no,
+            CrmContract.del_flag == 0,
+        )
+        .first()
+    )
+    if not contract:
+        return empty
+
+    cp = (
+        db.query(CrmContractProduct)
+        .filter(
+            CrmContractProduct.contract_id == cid,
+            CrmContractProduct.product_id == pid,
+            CrmContractProduct.tenant_no == tenant_no,
+            CrmContractProduct.del_flag == 0,
+        )
+        .first()
+    )
+    contract_type = (contract.contract_type or "").strip()
+    has_fixed = False
+    contract_qty: Optional[float] = None
+    if cp:
+        has_fixed = contract_product_has_fixed_qty(
+            contract_type=contract_type,
+            quantity=cp.quantity,
+            quantity_mode=cp.quantity_mode,
+        )
+        if has_fixed:
+            contract_qty = round(_to_float(cp.quantity), 3)
+
+    qty_map = aggregate_contract_product_plan_qtys(
+        db, tenant_no=tenant_no, contract_id=cid
+    )
+    arranged_including = _to_float((qty_map.get(pid) or {}).get("arrangedTotalQty"))
+
+    link_rows = (
+        db.query(PlPlanContract)
+        .filter(
+            PlPlanContract.tenant_no == tenant_no,
+            PlPlanContract.del_flag == 0,
+            PlPlanContract.contract_id == cid,
+        )
+        .all()
+    )
+    link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
+    legacy_plans = (
+        db.query(PlPlan)
+        .filter(
+            PlPlan.tenant_no == tenant_no,
+            PlPlan.del_flag == 0,
+            PlPlan.contract_id == cid,
+        )
+        .all()
+    )
+    legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
+    scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
+    plan_ids = list(scopes.keys())
+
+    exclude_qty = 0.0
+    plans_out: list[Dict[str, Any]] = []
+
+    if plan_ids:
+        plans = (
+            db.query(PlPlan)
+            .filter(
+                PlPlan.tenant_no == tenant_no,
+                PlPlan.del_flag == 0,
+                PlPlan.id.in_(plan_ids),
+                PlPlan.follow_status != "cancelled",
+            )
+            .all()
+        )
+        plan_by_id = {p.id: p for p in plans}
+
+        loads = (
+            db.query(PlPlanLoad)
+            .filter(
+                PlPlanLoad.tenant_no == tenant_no,
+                PlPlanLoad.del_flag == 0,
+                PlPlanLoad.plan_id.in_(plan_ids),
+            )
+            .all()
+        )
+        deliveries = (
+            db.query(PlPlanDelivery)
+            .filter(
+                PlPlanDelivery.tenant_no == tenant_no,
+                PlPlanDelivery.del_flag == 0,
+                PlPlanDelivery.plan_id.in_(plan_ids),
+            )
+            .all()
+        )
+        load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
+        delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
+        for row in loads:
+            load_ids_by_plan[row.plan_id].add(row.id)
+        for row in deliveries:
+            delivery_ids_by_plan[row.plan_id].add(row.id)
+
+        all_load_ids = {row.id for row in loads}
+        all_delivery_ids = {row.id for row in deliveries}
+        load_products_by_load: DefaultDict[str, list] = defaultdict(list)
+        if all_load_ids:
+            for prod in (
+                db.query(PlPlanLoadProduct)
+                .filter(
+                    PlPlanLoadProduct.tenant_no == tenant_no,
+                    PlPlanLoadProduct.del_flag == 0,
+                    PlPlanLoadProduct.load_id.in_(all_load_ids),
+                )
+                .all()
+            ):
+                load_products_by_load[prod.load_id].append(prod)
+        delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
+        if all_delivery_ids:
+            for prod in (
+                db.query(PlPlanDeliveryProduct)
+                .filter(
+                    PlPlanDeliveryProduct.tenant_no == tenant_no,
+                    PlPlanDeliveryProduct.del_flag == 0,
+                    PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
+                )
+                .all()
+            ):
+                delivery_products_by_delivery[prod.delivery_id].append(prod)
+
+        for plan_id, scope in scopes.items():
+            plan = plan_by_id.get(plan_id)
+            if not plan:
+                continue
+            bucket = _bucket_for_plan(
+                archive_status=plan.archive_status,
+                follow_status=plan.follow_status,
+                transport_status=plan.transport_status,
+            )
+            if not bucket:
+                continue
+            qtys = sum_qtys_for_scope(
+                load_products_by_load=load_products_by_load,
+                delivery_products_by_delivery=delivery_products_by_delivery,
+                all_load_ids=load_ids_by_plan.get(plan_id, set()),
+                all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()),
+                scope=scope,
+            )
+            pq = _to_float(qtys.get(pid))
+            if pq <= 0:
+                continue
+            if exclude_id and str(plan_id) == exclude_id:
+                exclude_qty = pq
+                continue
+            plan_date = getattr(plan, "plan_date", None)
+            plans_out.append(
+                {
+                    "planId": plan_id,
+                    "planNo": plan.plan_no or "",
+                    "planDate": plan_date.isoformat() if plan_date else None,
+                    "planQty": round(pq, 3),
+                    "followStatus": plan.follow_status,
+                    "transportStatus": plan.transport_status,
+                    "archiveStatus": plan.archive_status,
+                }
+            )
+
+        plans_out.sort(key=lambda x: (x.get("planDate") or "", x.get("planNo") or ""), reverse=True)
+
+    arranged_qty = round(max(0.0, arranged_including - exclude_qty), 3)
+    available_qty = None
+    if has_fixed and contract_qty is not None:
+        available_qty = round(contract_qty - arranged_qty, 3)
+
+    return {
+        "contractId": cid,
+        "contractNo": contract.contract_no or "",
+        "customerName": (contract.customer_name or "").strip() or None,
+        "productId": pid,
+        "productName": (cp.product_name if cp else None) or None,
+        "hasFixedQty": has_fixed,
+        "contractQty": contract_qty,
+        "arrangedQty": arranged_qty,
+        "availableQty": available_qty,
+        "plans": plans_out,
+    }

+ 432 - 0
docs/fresh-mint-pl-plan-form-available-qty-demo.html

@@ -0,0 +1,432 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Fresh Mint · 运输计划表单 · 可安排数量 Demo</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #1e293b;
+      --text-muted: #64748b;
+      --border: #d9e8e2;
+      --surface: #ffffff;
+      --bg-page: #f1f5f9;
+      --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+      --shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.14);
+      --radius: 10px;
+      --qty-plan: #dc2626;
+      --qty-avail: #059669;
+      --load-bg: #ecfdf5;
+      --delivery-bg: #f0f9ff;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0; min-height: 100vh;
+      font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
+      font-size: 14px; line-height: 1.5; color: var(--text); background: var(--bg-page);
+    }
+    .demo-page { max-width: 1100px; margin: 0 auto; padding: 28px 24px 80px; }
+    .demo-head h1 { margin: 0 0 8px; font-size: 22px; font-weight: 700; }
+    .demo-head h1 span { color: var(--primary); }
+    .demo-head p { margin: 0; color: var(--text-muted); max-width: 900px; }
+    .meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
+    .pill {
+      display: inline-flex; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
+      background: var(--primary-bg); color: var(--primary-dark); border: 1px solid #a7f3d0;
+    }
+    .pill.muted { background: #f1f5f9; color: var(--text-muted); border-color: #e2e8f0; }
+    .pill.red { background: #fef2f2; color: var(--qty-plan); border-color: #fecaca; }
+    .pill.green { background: var(--primary-bg); color: var(--qty-avail); border-color: #a7f3d0; }
+
+    .brief { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
+    @media (max-width: 800px) { .brief { grid-template-columns: 1fr; } }
+    .card {
+      background: var(--surface); border: 1px solid var(--border);
+      border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
+    }
+    .card h2 {
+      margin: 0 0 8px; font-size: 14px; font-weight: 600;
+      padding-left: 10px; border-left: 3px solid var(--primary); color: var(--primary-dark);
+    }
+    .card ul { margin: 0; padding-left: 18px; color: var(--text-muted); font-size: 13px; }
+    .card li { margin: 4px 0; }
+    .card li strong { color: var(--text); }
+
+    .dialog-shell {
+      background: var(--surface); border: 1px solid var(--border);
+      border-radius: 12px; box-shadow: var(--shadow-lg); overflow: hidden;
+    }
+    .dialog-header {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 14px 20px; border-bottom: 1px solid var(--border);
+    }
+    .dialog-header h3 { margin: 0; font-size: 16px; font-weight: 600; }
+    .dialog-body { padding: 16px 20px 20px; max-height: 70vh; overflow: auto; }
+    .dialog-footer {
+      display: flex; justify-content: flex-end; gap: 8px;
+      padding: 12px 20px; border-top: 1px solid var(--border);
+    }
+    .btn {
+      appearance: none; display: inline-flex; align-items: center; gap: 6px;
+      height: 32px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 500;
+      border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer;
+    }
+    .btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
+
+    .section { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 14px; overflow: hidden; }
+    .section-head {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 10px 14px; font-weight: 600; font-size: 13px;
+    }
+    .section.load .section-head { background: var(--load-bg); color: var(--primary-dark); }
+    .section.delivery .section-head { background: var(--delivery-bg); color: #0369a1; }
+    .section-body { padding: 12px 14px 14px; }
+    .kv { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 12px; }
+    .kv .label { font-size: 11px; color: var(--text-muted); }
+    .kv .value { font-size: 13px; font-weight: 500; }
+
+    .product-card {
+      border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; background: #fff; margin-bottom: 10px;
+    }
+    .row1, .row2 { display: grid; gap: 10px; align-items: end; }
+    .row1 { grid-template-columns: 1.4fr 1fr 1fr 1fr 0.7fr; margin-bottom: 10px; }
+    .row2 { grid-template-columns: 1.1fr 1.1fr 1fr 1fr 1fr; }
+    @media (max-width: 900px) { .row1, .row2 { grid-template-columns: 1fr 1fr; } }
+    .field .flabel { display: block; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
+    .field .fval {
+      height: 36px; display: flex; align-items: center; padding: 0 10px;
+      border: 1px solid var(--border); border-radius: 8px; background: #f8fafc; font-size: 13px;
+    }
+    .field .fval.input {
+      background: #fff; justify-content: flex-end; font-variant-numeric: tabular-nums; font-weight: 600;
+    }
+    .field .fval.input.plan {
+      color: var(--qty-plan); border-color: #fecaca; background: #fff5f5;
+    }
+    .field .fval.input.plan.is-over { box-shadow: 0 0 0 1px var(--qty-plan); }
+    .over-tip { margin-top: 4px; font-size: 11px; color: var(--qty-plan); }
+
+    .avail-wrap { display: flex; align-items: center; gap: 6px; height: 36px; }
+    .avail-num { font-size: 15px; font-weight: 700; color: var(--qty-avail); font-variant-numeric: tabular-nums; }
+    .help-btn {
+      width: 20px; height: 20px; border-radius: 50%;
+      border: 1px solid #a7f3d0; background: var(--primary-bg);
+      color: var(--primary-dark); font-size: 12px; font-weight: 700;
+      cursor: pointer; display: inline-flex; align-items: center; justify-content: center; padding: 0;
+    }
+    .help-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
+
+    .mask {
+      position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45);
+      display: none; align-items: center; justify-content: center;
+      padding: 24px; z-index: 50; backdrop-filter: none;
+    }
+    .mask.show { display: flex; }
+    .qty-dialog {
+      width: min(640px, 100%); background: #fff; border-radius: 12px;
+      border: 1px solid var(--border); box-shadow: var(--shadow-lg); overflow: hidden;
+    }
+    .qty-dialog-header {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 14px 18px; border-bottom: 1px solid var(--border);
+    }
+    .qty-dialog-header h4 { margin: 0; font-size: 15px; font-weight: 600; color: var(--primary-dark); }
+    .qty-dialog-body { padding: 16px 18px; max-height: 60vh; overflow: auto; }
+    .qty-dialog-footer {
+      display: flex; justify-content: flex-end; gap: 8px;
+      padding: 12px 18px; border-top: 1px solid var(--border);
+    }
+    .stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 12px; }
+    .stat { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; }
+    .stat .k { font-size: 11px; color: var(--text-muted); }
+    .stat .v { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
+    .stat .v.green { color: var(--qty-avail); }
+    .sub { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
+    .qty-dialog table { width: 100%; border-collapse: collapse; font-size: 13px; }
+    .qty-dialog th, .qty-dialog td { border: 1px solid var(--border); padding: 8px 10px; text-align: left; }
+    .qty-dialog th { background: #f8fafc; font-weight: 600; }
+    .qty-dialog td.num { text-align: right; font-variant-numeric: tabular-nums; }
+    .qty-dialog a { color: var(--primary-dark); text-decoration: none; font-weight: 500; }
+
+    .legend {
+      margin-top: 14px; padding: 12px 14px; border-radius: 10px;
+      background: #fff; border: 1px dashed #a7f3d0; font-size: 13px; color: var(--text-muted);
+    }
+    .legend strong { color: var(--primary-dark); }
+  </style>
+</head>
+<body>
+  <div class="demo-page">
+    <header class="demo-head">
+      <h1>运输计划表单 · <span>可安排数量</span></h1>
+      <p>
+        装货/送货产品区:可安排(绿)在计划数量(红)前;超可安排仅标红提示,仍可保存/安排(安排接口硬拦)。
+        问号<strong>点击弹窗</strong>展示合同数量、已安排、可安排,及已安排该产品的运输计划列表。
+      </p>
+      <div class="meta">
+        <span class="pill muted">/#/pl/plan-module/plan</span>
+        <span class="pill green">可安排 · 绿</span>
+        <span class="pill red">计划数量 · 红 · 超量仅提示</span>
+        <span class="pill">? · 点击弹窗</span>
+      </div>
+    </header>
+
+    <div class="brief">
+      <div class="card">
+        <h2>已确认</h2>
+        <ul>
+          <li>计划数量 &gt; 可安排 → <strong>仅标红</strong>,不拦保存/安排</li>
+          <li>安排时仍走合同数量硬拦接口</li>
+          <li>问号 → <strong>弹窗</strong>(非气泡悬停)</li>
+        </ul>
+      </div>
+      <div class="card">
+        <h2>口径</h2>
+        <ul>
+          <li>可安排 = 合同数量 − 已安排(编辑扣本计划旧量)</li>
+          <li>按本段合同分别计算</li>
+          <li>框架无固定量:可安排「—」,弹窗说明不限额</li>
+        </ul>
+      </div>
+    </div>
+
+    <div class="dialog-shell">
+      <div class="dialog-header">
+        <h3>编辑运输计划 · YP20260830021</h3>
+        <button type="button" class="btn" tabindex="-1">×</button>
+      </div>
+      <div class="dialog-body">
+        <section class="section load">
+          <div class="section-head">
+            <span>装货信息</span>
+            <span style="font-weight:500;font-size:12px;">合同 HT-CG-2026-102</span>
+          </div>
+          <div class="section-body">
+            <div class="kv">
+              <div><div class="label">装货客户</div><div class="value">云栋供应商</div></div>
+              <div><div class="label">装货地址</div><div class="value">安庆某某园区 A 库</div></div>
+              <div><div class="label">装货时间</div><div class="value">2026-08-31 09:00</div></div>
+            </div>
+            <article class="product-card">
+              <div class="row1">
+                <div class="field"><span class="flabel">产品名称</span><div class="fval">液碱 32%</div></div>
+                <div class="field"><span class="flabel">别名</span><div class="fval">—</div></div>
+                <div class="field"><span class="flabel">品牌</span><div class="fval">不限品牌</div></div>
+                <div class="field"><span class="flabel">规格型号</span><div class="fval">—</div></div>
+                <div class="field"><span class="flabel">单位</span><div class="fval">吨</div></div>
+              </div>
+              <div class="row2">
+                <div class="field">
+                  <span class="flabel">可安排数量</span>
+                  <div class="avail-wrap">
+                    <span class="avail-num">50.000</span>
+                    <button type="button" class="help-btn" data-open="dlg-load" title="查看合同用量">?</button>
+                  </div>
+                </div>
+                <div class="field">
+                  <span class="flabel">计划数量</span>
+                  <div class="fval input plan">40.000</div>
+                </div>
+                <div class="field"><span class="flabel">包装规格</span><div class="fval">槽罐</div></div>
+                <div class="field"><span class="flabel">送货方式</span><div class="fval">送货上门</div></div>
+                <div class="field"><span class="flabel">备注</span><div class="fval">—</div></div>
+              </div>
+            </article>
+            <article class="product-card">
+              <div class="row1">
+                <div class="field"><span class="flabel">产品名称</span><div class="fval">98% 硫酸(超量示意)</div></div>
+                <div class="field"><span class="flabel">别名</span><div class="fval">—</div></div>
+                <div class="field"><span class="flabel">品牌</span><div class="fval">—</div></div>
+                <div class="field"><span class="flabel">规格型号</span><div class="fval">工业级</div></div>
+                <div class="field"><span class="flabel">单位</span><div class="fval">吨</div></div>
+              </div>
+              <div class="row2">
+                <div class="field">
+                  <span class="flabel">可安排数量</span>
+                  <div class="avail-wrap">
+                    <span class="avail-num">20.000</span>
+                    <button type="button" class="help-btn" data-open="dlg-over" title="查看合同用量">?</button>
+                  </div>
+                </div>
+                <div class="field">
+                  <span class="flabel">计划数量</span>
+                  <div class="fval input plan is-over">30.000</div>
+                  <div class="over-tip">已超过可安排数量,安排时将被拦截</div>
+                </div>
+                <div class="field"><span class="flabel">包装规格</span><div class="fval">槽罐</div></div>
+                <div class="field"><span class="flabel">送货方式</span><div class="fval">自提</div></div>
+                <div class="field"><span class="flabel">备注</span><div class="fval">标红仍可暂存</div></div>
+              </div>
+            </article>
+          </div>
+        </section>
+
+        <section class="section delivery">
+          <div class="section-head">
+            <span>送货信息 #1</span>
+            <span style="font-weight:500;font-size:12px;">合同 HT-XS-2026-088</span>
+          </div>
+          <div class="section-body">
+            <div class="kv">
+              <div><div class="label">送货客户</div><div class="value">某某贸易公司</div></div>
+              <div><div class="label">送货地址</div><div class="value">合肥某某仓储</div></div>
+              <div><div class="label">送货时间</div><div class="value">2026-08-31 16:00</div></div>
+            </div>
+            <article class="product-card">
+              <div class="row1">
+                <div class="field"><span class="flabel">产品名称</span><div class="fval">液碱 32%</div></div>
+                <div class="field"><span class="flabel">别名</span><div class="fval">—</div></div>
+                <div class="field"><span class="flabel">品牌</span><div class="fval">不限品牌</div></div>
+                <div class="field"><span class="flabel">规格型号</span><div class="fval">—</div></div>
+                <div class="field"><span class="flabel">单位</span><div class="fval">吨</div></div>
+              </div>
+              <div class="row2">
+                <div class="field">
+                  <span class="flabel">可安排数量</span>
+                  <div class="avail-wrap">
+                    <span class="avail-num">140.000</span>
+                    <button type="button" class="help-btn" data-open="dlg-del" title="查看合同用量">?</button>
+                  </div>
+                </div>
+                <div class="field">
+                  <span class="flabel">计划数量</span>
+                  <div class="fval input plan">40.000</div>
+                </div>
+                <div class="field"><span class="flabel">包装规格</span><div class="fval">槽罐</div></div>
+                <div class="field"><span class="flabel">送货方式</span><div class="fval">送货上门</div></div>
+                <div class="field"><span class="flabel">备注</span><div class="fval">—</div></div>
+              </div>
+            </article>
+          </div>
+        </section>
+      </div>
+      <div class="dialog-footer">
+        <button type="button" class="btn">✕ 取消</button>
+        <button type="button" class="btn primary">✓ 保存并安排</button>
+      </div>
+    </div>
+
+    <p class="legend">
+      <strong>交互:</strong>点击「?」打开二级弹窗(无背景模糊)。超量行仅红色强调 + 小字提示,不禁止保存。
+    </p>
+  </div>
+
+  <!-- 用量明细弹窗:装货正常 -->
+  <div class="mask" id="dlg-load" role="dialog" aria-modal="true">
+    <div class="qty-dialog">
+      <div class="qty-dialog-header">
+        <h4>合同用量 · 液碱 32%</h4>
+        <button type="button" class="btn" data-close>×</button>
+      </div>
+      <div class="qty-dialog-body">
+        <p style="margin:0 0 10px;font-size:13px;color:var(--text-muted);">云栋供应商 - HT-CG-2026-102(装货)</p>
+        <div class="stats">
+          <div class="stat"><div class="k">合同数量</div><div class="v">200.000</div></div>
+          <div class="stat"><div class="k">已安排</div><div class="v">150.000</div></div>
+          <div class="stat"><div class="k">可安排</div><div class="v green">50.000</div></div>
+        </div>
+        <div class="sub">已安排本产品的运输计划</div>
+        <table>
+          <thead>
+            <tr><th>计划编号</th><th>计划日期</th><th class="num">计划数量</th><th>状态</th></tr>
+          </thead>
+          <tbody>
+            <tr><td><a href="javascript:;">YP20260812003</a></td><td>2026-08-12</td><td class="num">80.000</td><td>已归档</td></tr>
+            <tr><td><a href="javascript:;">YP20260820008</a></td><td>2026-08-20</td><td class="num">70.000</td><td>已安排</td></tr>
+          </tbody>
+        </table>
+      </div>
+      <div class="qty-dialog-footer">
+        <button type="button" class="btn primary" data-close>✓ 知道了</button>
+      </div>
+    </div>
+  </div>
+
+  <div class="mask" id="dlg-over" role="dialog" aria-modal="true">
+    <div class="qty-dialog">
+      <div class="qty-dialog-header">
+        <h4>合同用量 · 98% 硫酸</h4>
+        <button type="button" class="btn" data-close>×</button>
+      </div>
+      <div class="qty-dialog-body">
+        <p style="margin:0 0 10px;font-size:13px;color:var(--text-muted);">云栋供应商 - HT-CG-2026-102(装货)</p>
+        <div class="stats">
+          <div class="stat"><div class="k">合同数量</div><div class="v">100.000</div></div>
+          <div class="stat"><div class="k">已安排</div><div class="v">80.000</div></div>
+          <div class="stat"><div class="k">可安排</div><div class="v green">20.000</div></div>
+        </div>
+        <div class="sub">已安排本产品的运输计划</div>
+        <table>
+          <thead>
+            <tr><th>计划编号</th><th>计划日期</th><th class="num">计划数量</th><th>状态</th></tr>
+          </thead>
+          <tbody>
+            <tr><td><a href="javascript:;">YP20260810001</a></td><td>2026-08-10</td><td class="num">80.000</td><td>已安排</td></tr>
+          </tbody>
+        </table>
+      </div>
+      <div class="qty-dialog-footer">
+        <button type="button" class="btn primary" data-close>✓ 知道了</button>
+      </div>
+    </div>
+  </div>
+
+  <div class="mask" id="dlg-del" role="dialog" aria-modal="true">
+    <div class="qty-dialog">
+      <div class="qty-dialog-header">
+        <h4>合同用量 · 液碱 32%</h4>
+        <button type="button" class="btn" data-close>×</button>
+      </div>
+      <div class="qty-dialog-body">
+        <p style="margin:0 0 10px;font-size:13px;color:var(--text-muted);">某某贸易公司 - HT-XS-2026-088(送货)</p>
+        <div class="stats">
+          <div class="stat"><div class="k">合同数量</div><div class="v">180.000</div></div>
+          <div class="stat"><div class="k">已安排</div><div class="v">40.000</div></div>
+          <div class="stat"><div class="k">可安排</div><div class="v green">140.000</div></div>
+        </div>
+        <div class="sub">已安排本产品的运输计划</div>
+        <table>
+          <thead>
+            <tr><th>计划编号</th><th>计划日期</th><th class="num">计划数量</th><th>状态</th></tr>
+          </thead>
+          <tbody>
+            <tr><td><a href="javascript:;">YP20260815002</a></td><td>2026-08-15</td><td class="num">40.000</td><td>已安排</td></tr>
+          </tbody>
+        </table>
+      </div>
+      <div class="qty-dialog-footer">
+        <button type="button" class="btn primary" data-close>✓ 知道了</button>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    (function () {
+      function openMask(id) {
+        var el = document.getElementById(id)
+        if (el) el.classList.add('show')
+      }
+      function closeAll() {
+        document.querySelectorAll('.mask').forEach(function (m) { m.classList.remove('show') })
+      }
+      document.querySelectorAll('[data-open]').forEach(function (btn) {
+        btn.addEventListener('click', function () { openMask(btn.getAttribute('data-open')) })
+      })
+      document.querySelectorAll('[data-close]').forEach(function (btn) {
+        btn.addEventListener('click', closeAll)
+      })
+      document.querySelectorAll('.mask').forEach(function (mask) {
+        mask.addEventListener('click', function (e) {
+          if (e.target === mask) closeAll()
+        })
+      })
+    })()
+  </script>
+</body>
+</html>

+ 56 - 0
docs/superpowers/specs/2026-08-30-pl-plan-form-available-qty-design.md

@@ -0,0 +1,56 @@
+# 运输计划表单 · 可安排数量
+
+**日期:** 2026-08-30  
+**状态:** 已确认(方案 2:专用查询接口)  
+**Demo:** `docs/fresh-mint-pl-plan-form-available-qty-demo.html`
+
+## 目标
+
+新增/编辑运输计划弹窗中,装货/送货产品区在「计划数量」前展示「可安排数量」,并支持问号弹窗查看合同用量与已安排计划列表。
+
+## 已确认决策
+
+| 项 | 决策 |
+|----|------|
+| 颜色 | 可安排绿色;计划数量红色 |
+| 超量 | 仅标红提示,仍可保存;安排接口硬拦 |
+| 展示条件 | 有产品行即显示;无合同 →「—」,问号禁用并提示先选合同 |
+| 问号 | 点击弹窗(非悬停气泡) |
+| 计划编号 | 仅展示,不可点击 |
+| 口径 | 可安排 = 合同数量 − 已安排(编辑时已安排不含本计划旧量) |
+| 框架无固定量 | 可安排「—」;弹窗说明不限额 |
+| 实现 | 方案 2:专用 API |
+
+## API
+
+`GET /transport/plan/contract-product-arrange-qty`
+
+Query:`contractId`、`productId`、可选 `excludePlanId`(当前编辑计划)
+
+响应:
+
+```text
+{
+  contractId, contractNo, customerName, productId, productName,
+  hasFixedQty: boolean,
+  contractQty?: number,
+  arrangedQty: number,   // 不含 excludePlanId
+  availableQty?: number, // hasFixedQty 时
+  plans: [{ planId, planNo, planDate, planQty, followStatus, transportStatus, archiveStatus }]
+}
+```
+
+复用 `pl_contract_plan_qty` 汇总口径;`plans` 为计入已安排且本产品有量的计划(不含 exclude)。
+
+## UI
+
+- `PlPlanStopProductTable`:可安排列 + 问号;计划数量超可安排加红样式/提示文案
+- Props:`contractId`、`excludePlanId`
+- 装货/送货 section 传入本段首个合同 id;表单传入 `planId` 作为 exclude
+- 弹窗:三格统计 + 计划表;无合同点问号 → Message 提示先选合同
+
+## 非目标
+
+- 输入硬拦超过可安排
+- 计划编号跳转查看
+- 独立预检 API(沿用安排硬拦)

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

@@ -866,6 +866,29 @@ export interface PlanContractGapQuery {
   q?: string
 }
 
+export interface ContractProductArrangeQtyPlanItem {
+  planId: string
+  planNo?: string
+  planDate?: string | null
+  planQty: number
+  followStatus?: string
+  transportStatus?: string
+  archiveStatus?: string
+}
+
+export interface ContractProductArrangeQtyDetail {
+  contractId: string
+  contractNo?: string | null
+  customerName?: string | null
+  productId: string
+  productName?: string | null
+  hasFixedQty: boolean
+  contractQty?: number | null
+  arrangedQty: number
+  availableQty?: number | null
+  plans: ContractProductArrangeQtyPlanItem[]
+}
+
 export const planApi = {
   list: (params?: PlanQuery) => request.get<PlanListResponse>('/transport/plan/', { params }),
   export: (data: Record<string, any>) =>
@@ -875,6 +898,16 @@ export const planApi = {
     request.get<{ counts: Record<string, number> }>('/transport/plan/count-by-contract', {
       params: { contractIds: contractIds.join(',') }
     }),
+  /** 表单可安排数量 / 问号弹窗 */
+  getContractProductArrangeQty: (params: {
+    contractId: string
+    productId: string
+    excludePlanId?: string
+  }) =>
+    request.get<ContractProductArrangeQtyDetail>(
+      '/transport/plan/contract-product-arrange-qty',
+      { params }
+    ),
   create: (data: Partial<Plan>) => request.post<Plan>('/transport/plan/', data),
   createFull: (data: PlanCreateFullPayload) =>
     request.post<Plan>('/transport/plan/create-full', data),
@@ -1108,9 +1141,12 @@ export interface ContractQtyReminderItem {
   contractId: string
   contractNo?: string
   contractType?: string
+  customerName?: string
   stopKind: string
   productId: string
   productName?: string
+  model?: string
+  unit?: string
   mode: 'remaining' | 'framework_cumulative' | string
   contractQty?: number
   arrangedTotalQty: number

+ 183 - 0
web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/index.vue

@@ -0,0 +1,183 @@
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    class="dialog-form-layout pl-contract-product-arrange-qty-dialog"
+    :title="dialogTitle"
+    width="640px"
+    align-center
+    destroy-on-close
+    append-to-body
+    modal-class="order-module-dialog-mask"
+    @update:model-value="emit('update:modelValue', $event)"
+  >
+    <div v-loading="loading" class="arrange-qty-body">
+      <p v-if="subtitle" class="arrange-qty-sub">{{ subtitle }}</p>
+      <div class="arrange-qty-stats">
+        <div class="arrange-qty-stat">
+          <div class="k">合同数量</div>
+          <div class="v">{{ contractQtyText }}</div>
+        </div>
+        <div class="arrange-qty-stat">
+          <div class="k">已安排</div>
+          <div class="v">{{ formatQty(detail?.arrangedQty) }}</div>
+        </div>
+        <div class="arrange-qty-stat">
+          <div class="k">可安排</div>
+          <div class="v is-avail">{{ availableQtyText }}</div>
+        </div>
+      </div>
+      <div class="arrange-qty-list-title">已安排本产品的运输计划</div>
+      <el-table
+        v-if="(detail?.plans || []).length"
+        :data="detail?.plans || []"
+        class="list-table"
+        stripe
+        size="small"
+        max-height="280"
+      >
+        <el-table-column prop="planNo" label="计划编号" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="planDate" label="计划日期" min-width="110" />
+        <el-table-column label="计划数量" min-width="100" align="right">
+          <template #default="{ row }">{{ formatQty(row.planQty) }}</template>
+        </el-table-column>
+        <el-table-column label="状态" min-width="100">
+          <template #default="{ row }">{{ statusLabel(row) }}</template>
+        </el-table-column>
+      </el-table>
+      <p v-else class="arrange-qty-empty">暂无已安排计划</p>
+    </div>
+    <template #footer>
+      <el-button type="primary" @click="emit('update:modelValue', false)">
+        <el-icon><Check /></el-icon>知道了
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { Check } from '@element-plus/icons-vue'
+  import { planApi, type ContractProductArrangeQtyDetail } from '@/api/pl'
+  import { pickBxData } from '@/utils/http/bx'
+
+  const props = defineProps<{
+    modelValue: boolean
+    contractId?: string
+    productId?: string
+    productName?: string
+    excludePlanId?: string
+  }>()
+
+  const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
+
+  const loading = ref(false)
+  const detail = ref<ContractProductArrangeQtyDetail | null>(null)
+
+  const dialogTitle = computed(
+    () => `合同用量 · ${props.productName || detail.value?.productName || '产品'}`
+  )
+  const subtitle = computed(() => {
+    const d = detail.value
+    if (!d) return ''
+    const parts = [d.customerName, d.contractNo].filter(Boolean)
+    return parts.join(' - ')
+  })
+  const contractQtyText = computed(() => {
+    if (!detail.value) return '—'
+    if (!detail.value.hasFixedQty) return '按客户需求'
+    return formatQty(detail.value.contractQty)
+  })
+  const availableQtyText = computed(() => {
+    if (!detail.value) return '—'
+    if (!detail.value.hasFixedQty) return '不限额'
+    return formatQty(detail.value.availableQty)
+  })
+
+  function formatQty(n: number | null | undefined) {
+    if (n == null || Number.isNaN(Number(n))) return '—'
+    return Number(n).toFixed(3)
+  }
+
+  function statusLabel(row: { archiveStatus?: string; followStatus?: string; transportStatus?: string }) {
+    const archive = (row.archiveStatus || '').trim()
+    if (archive === 'archived') return '已归档'
+    const follow = (row.followStatus || '').trim()
+    if (follow === 'completed') return '已完成'
+    if (follow === 'arranged' || row.transportStatus === 'arranged') return '已安排'
+    return follow || row.transportStatus || '—'
+  }
+
+  async function loadDetail() {
+    const contractId = String(props.contractId || '').trim()
+    const productId = String(props.productId || '').trim()
+    if (!contractId || !productId) {
+      detail.value = null
+      return
+    }
+    loading.value = true
+    try {
+      const res = await planApi.getContractProductArrangeQty({
+        contractId,
+        productId,
+        excludePlanId: props.excludePlanId || undefined
+      })
+      detail.value = pickBxData<ContractProductArrangeQtyDetail>(res) || null
+    } catch {
+      detail.value = null
+    } finally {
+      loading.value = false
+    }
+  }
+
+  watch(
+    () => [props.modelValue, props.contractId, props.productId, props.excludePlanId] as const,
+    ([visible]) => {
+      if (visible) void loadDetail()
+    }
+  )
+</script>
+
+<style scoped lang="scss">
+  .arrange-qty-body {
+    min-height: 120px;
+  }
+  .arrange-qty-sub {
+    margin: 0 0 12px;
+    font-size: 13px;
+    color: var(--mint-text-muted, #64748b);
+  }
+  .arrange-qty-stats {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 8px;
+    margin-bottom: 14px;
+  }
+  .arrange-qty-stat {
+    padding: 10px;
+    border-radius: 8px;
+    border: 1px solid var(--el-border-color-lighter, #e2e8f0);
+    background: #f8fafc;
+    .k {
+      font-size: 11px;
+      color: var(--mint-text-muted, #64748b);
+    }
+    .v {
+      margin-top: 2px;
+      font-size: 15px;
+      font-weight: 700;
+      font-variant-numeric: tabular-nums;
+      &.is-avail {
+        color: #059669;
+      }
+    }
+  }
+  .arrange-qty-list-title {
+    margin: 0 0 8px;
+    font-size: 13px;
+    font-weight: 600;
+  }
+  .arrange-qty-empty {
+    margin: 0;
+    font-size: 13px;
+    color: var(--mint-text-muted, #64748b);
+  }
+</style>

+ 9 - 1
web-admin/src/components/business/pl-plan-delivery-section/index.vue

@@ -201,6 +201,8 @@
         :compact="compactProducts"
         :allow-remove="!readonly"
         :shared-readonly="true"
+        :contract-id="deliveryContractId(delivery)"
+        :exclude-plan-id="excludePlanId"
         @remove="(productIndex) => removeStopProduct(index, productIndex)"
       />
     </article>
@@ -234,13 +236,15 @@
       qtyUnitLabel?: string
       /** 多送货段时提示拆分数量 */
       showSplitHint?: boolean
+      excludePlanId?: string
     }>(),
     {
       tabMode: true,
       readonly: false,
       compactProducts: true,
       qtyUnitLabel: '',
-      showSplitHint: false
+      showSplitHint: false,
+      excludePlanId: ''
     }
   )
 
@@ -259,6 +263,10 @@
   }
 
   const activeIndex = ref(0)
+
+  function deliveryContractId(delivery: PlanDeliveryForm) {
+    return String(delivery.contracts?.[0]?.contractId || '').trim()
+  }
   const customerOptionsByIndex = ref<Record<number, CustomerOptionRow[]>>({})
   const customerOptionsLoading = ref<Record<number, boolean>>({})
 

+ 2 - 0
web-admin/src/components/business/pl-plan-form-dialog/index.vue

@@ -191,6 +191,7 @@
                 ref="loadSectionRef"
                 v-model="loadForm"
                 compact-products
+                :exclude-plan-id="planId || ''"
                 @open-customer-picker="openCustomerSelector('load')"
                 @add-products="productSelectorVisible = true"
               />
@@ -230,6 +231,7 @@
                 compact-products
                 :qty-unit-label="planQtyUnitLabel"
                 :show-split-hint="showDeliverySplitHint"
+                :exclude-plan-id="planId || ''"
                 @active-index-change="deliveryActiveIndex = $event"
                 @open-customer-picker="openCustomerSelector('delivery', $event)"
                 @sync-products="syncDeliveryProductsFromLoadManual"

+ 11 - 1
web-admin/src/components/business/pl-plan-load-section/index.vue

@@ -145,6 +145,8 @@
       :products="modelValue.products"
       :editable="!readonly"
       :compact="compactProducts"
+      :contract-id="stopContractId"
+      :exclude-plan-id="excludePlanId"
       @remove="removeStopProduct"
     />
   </div>
@@ -168,10 +170,13 @@
       modelValue: PlanLoadForm
       readonly?: boolean
       compactProducts?: boolean
+      /** 编辑计划时排除本计划占用 */
+      excludePlanId?: string
     }>(),
     {
       readonly: false,
-      compactProducts: true
+      compactProducts: true,
+      excludePlanId: ''
     }
   )
 
@@ -193,6 +198,11 @@
 
   const isInternal = computed(() => isPlInternalCustomerName(props.modelValue.customerName))
 
+  const stopContractId = computed(() => {
+    const id = String(props.modelValue.contracts?.[0]?.contractId || '').trim()
+    return id
+  })
+
   /** 非内部客户须先选装货合同才能添加产品;内部客户不限制 */
   const addProductsDisabled = computed(() => {
     if (isInternal.value) return false

+ 222 - 7
web-admin/src/components/business/pl-plan-stop-product-table/index.vue

@@ -111,6 +111,21 @@
             </div>
           </div>
           <div class="pl-stop-product-dual-item__row2">
+            <div class="pl-stop-product-dual-item__field">
+              <span class="pl-stop-product-dual-item__label">可安排数量</span>
+              <div class="pl-stop-avail-wrap">
+                <span class="pl-stop-avail-num">{{ formatAvailableDisplay(row) }}</span>
+                <el-button
+                  class="pl-stop-avail-help"
+                  :disabled="!canOpenArrangeQty(row)"
+                  circle
+                  size="small"
+                  @click="openArrangeQtyDialog(row)"
+                >
+                  ?
+                </el-button>
+              </div>
+            </div>
             <div class="pl-stop-product-dual-item__field">
               <span class="pl-stop-product-dual-item__label">计划数量</span>
               <el-input-number
@@ -120,13 +135,17 @@
                 :precision="3"
                 :controls="false"
                 class="pl-plan-product-cell-control pl-stop-product-dual-qty"
+                :class="{ 'is-over-available': isPlanQtyOverAvailable(row) }"
               />
               <span
                 v-else
-                class="pl-stop-product-dual-item__readonly pl-stop-product-dual-item__qty-text"
+                class="pl-stop-product-dual-item__readonly pl-stop-product-dual-item__qty-text pl-stop-plan-qty"
               >
                 {{ row.planQty ?? '—' }}
               </span>
+              <p v-if="editable && isPlanQtyOverAvailable(row)" class="pl-stop-over-tip">
+                已超过可安排数量,安排时将被拦截
+              </p>
             </div>
             <div class="pl-stop-product-dual-item__field">
               <span class="pl-stop-product-dual-item__label">包装规格</span>
@@ -246,6 +265,22 @@
             </template>
           </template>
         </el-table-column>
+        <el-table-column label="可安排数量" min-width="120" align="right">
+          <template #default="{ row }">
+            <div class="pl-stop-avail-wrap pl-stop-avail-wrap--table">
+              <span class="pl-stop-avail-num">{{ formatAvailableDisplay(row) }}</span>
+              <el-button
+                class="pl-stop-avail-help"
+                :disabled="!canOpenArrangeQty(row)"
+                circle
+                size="small"
+                @click="openArrangeQtyDialog(row)"
+              >
+                ?
+              </el-button>
+            </div>
+          </template>
+        </el-table-column>
         <el-table-column label="计划数量" min-width="110" align="right">
           <template #default="{ row }">
             <el-input-number
@@ -255,8 +290,12 @@
               :precision="3"
               :controls="false"
               style="width: 100%"
+              :class="{ 'is-over-available': isPlanQtyOverAvailable(row) }"
             />
-            <span v-else>{{ row.planQty ?? '—' }}</span>
+            <span v-else class="pl-stop-plan-qty">{{ row.planQty ?? '—' }}</span>
+            <p v-if="editable && isPlanQtyOverAvailable(row)" class="pl-stop-over-tip">
+              已超过可安排数量
+            </p>
           </template>
         </el-table-column>
         <el-table-column
@@ -358,11 +397,20 @@
       :mode="aliasBrandManageMode"
       @saved="handleAliasBrandManaged"
     />
+
+    <PlContractProductArrangeQtyDialog
+      v-model="arrangeQtyDialogVisible"
+      :contract-id="arrangeQtyContractId"
+      :product-id="arrangeQtyProductId"
+      :product-name="arrangeQtyProductName"
+      :exclude-plan-id="excludePlanId"
+    />
   </div>
 </template>
 
 <script setup lang="ts">
   import { Delete, Plus } from '@element-plus/icons-vue'
+  import { planApi, type ContractProductArrangeQtyDetail } from '@/api/pl'
   import type { PlanStopProductFormRow } from '@/utils/pl-plan-stop-form'
   import {
     formatStopArchiveAmount,
@@ -378,9 +426,11 @@
   } from '@/utils/crm-product-brand'
   import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
   import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
+  import PlContractProductArrangeQtyDialog from '@/components/business/pl-contract-product-arrange-qty-dialog/index.vue'
   import DictTag from '@/components/DictTag/index.vue'
   import DictSelect from '@/components/DictSelect/index.vue'
   import { useDictStore } from '@/store/modules/dict'
+  import { pickBxData } from '@/utils/http/bx'
 
   const props = withDefaults(
     defineProps<{
@@ -398,6 +448,10 @@
       showStopArchivePrice?: boolean
       /** 与 showStopActualQty / showStopArchivePrice 配合 */
       stopArchiveMode?: 'load' | 'delivery'
+      /** 本段合同 id(无则展示 —) */
+      contractId?: string
+      /** 当前编辑计划 id,计算可安排时排除 */
+      excludePlanId?: string
     }>(),
     {
       compact: false,
@@ -405,7 +459,9 @@
       sharedReadonly: false,
       showStopActualQty: false,
       showStopArchivePrice: false,
-      stopArchiveMode: 'load'
+      stopArchiveMode: 'load',
+      contractId: '',
+      excludePlanId: ''
     }
   )
 
@@ -425,6 +481,94 @@
   const aliasBrandManageProductName = ref('')
   const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
 
+  const arrangeQtyCache = ref<Record<string, ContractProductArrangeQtyDetail | null>>({})
+  const arrangeQtyDialogVisible = ref(false)
+  const arrangeQtyContractId = ref('')
+  const arrangeQtyProductId = ref('')
+  const arrangeQtyProductName = ref('')
+
+  function cacheKey(productId: string) {
+    return `${props.contractId || ''}::${productId}::${props.excludePlanId || ''}`
+  }
+
+  async function ensureArrangeQty(productId: string) {
+    const cid = String(props.contractId || '').trim()
+    const pid = String(productId || '').trim()
+    if (!cid || !pid) return null
+    const key = cacheKey(pid)
+    if (key in arrangeQtyCache.value) return arrangeQtyCache.value[key]
+    try {
+      const res = await planApi.getContractProductArrangeQty({
+        contractId: cid,
+        productId: pid,
+        excludePlanId: props.excludePlanId || undefined
+      })
+      const data = pickBxData<ContractProductArrangeQtyDetail>(res) || null
+      arrangeQtyCache.value = { ...arrangeQtyCache.value, [key]: data }
+      return data
+    } catch {
+      arrangeQtyCache.value = { ...arrangeQtyCache.value, [key]: null }
+      return null
+    }
+  }
+
+  function formatAvailableDisplay(row: PlanStopProductFormRow) {
+    const cid = String(props.contractId || '').trim()
+    if (!cid) return '—'
+    const pid = String(row.productId || '').trim()
+    if (!pid) return '—'
+    const data = arrangeQtyCache.value[cacheKey(pid)]
+    if (!data) return '…'
+    if (!data.hasFixedQty) return '—'
+    if (data.availableQty == null) return '—'
+    return Number(data.availableQty).toFixed(3)
+  }
+
+  function isPlanQtyOverAvailable(row: PlanStopProductFormRow) {
+    const cid = String(props.contractId || '').trim()
+    const pid = String(row.productId || '').trim()
+    if (!cid || !pid) return false
+    const data = arrangeQtyCache.value[cacheKey(pid)]
+    if (!data?.hasFixedQty || data.availableQty == null) return false
+    const planQty = Number(row.planQty)
+    if (!Number.isFinite(planQty)) return false
+    return planQty > Number(data.availableQty) + 1e-9
+  }
+
+  function canOpenArrangeQty(row: PlanStopProductFormRow) {
+    return Boolean(String(props.contractId || '').trim() && String(row.productId || '').trim())
+  }
+
+  function openArrangeQtyDialog(row: PlanStopProductFormRow) {
+    if (!String(props.contractId || '').trim()) {
+      ElMessage.warning('请先选择合同')
+      return
+    }
+    if (!row.productId) return
+    arrangeQtyContractId.value = String(props.contractId)
+    arrangeQtyProductId.value = String(row.productId)
+    arrangeQtyProductName.value = String(row.productName || row.standardProductName || '')
+    arrangeQtyDialogVisible.value = true
+  }
+
+  watch(
+    () =>
+      [
+        props.contractId,
+        props.excludePlanId,
+        (props.products || []).map((p) => p.productId).join(',')
+      ] as const,
+    () => {
+      const cid = String(props.contractId || '').trim()
+      if (!cid) return
+      for (const row of props.products || []) {
+        const pid = String(row.productId || '').trim()
+        if (pid) void ensureArrangeQty(pid)
+      }
+    },
+    { immediate: true }
+  )
+
   function handleRemove(index: number) {
     emit('remove', index)
   }
@@ -497,8 +641,63 @@
     color: #64748b;
   }
 
-  .pl-plan-product-cell-control {
-    width: 100%;
+  .pl-stop-avail-wrap {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    min-height: 36px;
+  }
+
+  .pl-stop-avail-wrap--table {
+    justify-content: flex-end;
+  }
+
+  .pl-stop-avail-num {
+    font-size: 14px;
+    font-weight: 700;
+    color: #059669;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .pl-stop-avail-help {
+    width: 22px !important;
+    height: 22px !important;
+    min-height: 22px !important;
+    padding: 0 !important;
+    font-size: 12px;
+    font-weight: 700;
+    color: #059669;
+    border-color: #a7f3d0;
+    background: #ecfdf5;
+  }
+
+  .pl-stop-plan-qty {
+    color: #dc2626;
+    font-weight: 600;
+  }
+
+  .pl-stop-over-tip {
+    margin: 4px 0 0;
+    font-size: 11px;
+    line-height: 1.3;
+    color: #dc2626;
+  }
+
+  :deep(.pl-stop-product-dual-qty.is-over-available),
+  :deep(.el-input-number.is-over-available) {
+    .el-input__wrapper {
+      box-shadow: 0 0 0 1px #dc2626 inset;
+      background: #fff5f5;
+    }
+    .el-input__inner {
+      color: #dc2626;
+      font-weight: 600;
+    }
+  }
+
+  :deep(.pl-stop-product-dual-qty .el-input__inner) {
+    color: #dc2626;
+    font-weight: 600;
   }
 
   .pl-stop-product-dual-list {
@@ -571,12 +770,13 @@
   .pl-stop-product-dual-item__label {
     font-size: 10px;
     font-weight: 600;
-    color: #64748b;
+    color: #000;
     line-height: 1.2;
   }
 
   .pl-stop-product-dual-item__readonly {
     font-size: 12px;
+    font-weight: 600;
     color: #1e293b;
     word-break: break-all;
     line-height: 1.4;
@@ -610,7 +810,7 @@
 
     :deep(.el-input__inner) {
       text-align: left;
-      font-weight: 600;
+      font-weight: 600 !important;
     }
   }
 
@@ -622,6 +822,21 @@
     }
   }
 
+  /* 产品区可编辑控件:字段值加粗(覆盖 dialog-form-layout 默认 400) */
+  .pl-stop-product-dual-item__field {
+    :deep(.el-input__inner),
+    :deep(.el-textarea__inner),
+    :deep(.el-select__selected-item),
+    :deep(.el-select__placeholder:not(.is-transparent)),
+    :deep(.el-select__wrapper) {
+      font-weight: 600 !important;
+    }
+
+    :deep(.pl-product-name-hazard__name) {
+      font-weight: 600;
+    }
+  }
+
   .line-meta-cell {
     display: flex;
     align-items: center;