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:
1 parent
3011cb9dae
commit
81b1cb9b1d
10 files changed
+1227
-11
No files matched your search
@@ -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>计划数量 > 可安排 → <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(沿用安排硬拦)
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user