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>
This commit is contained in:
zlwuandCursor committed 2026-08-30 17:41:36 +08:00
1 parent 3011cb9dae
commit 81b1cb9b1d
10 files changed
+1227 -11

No files matched your search

+48 -2
View File
@@ -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
@@ -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,
}
@@ -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>
@@ -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
View File
@@ -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
@@ -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>
@@ -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>>({})
@@ -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"
@@ -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
@@ -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;