diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index 62abb0cbb..67e0ce5da 100644 --- a/backend/app/api/pl/plan.py +++ b/backend/app/api/pl/plan.py @@ -83,7 +83,10 @@ from app.schemas.pl.transport import ( TransportIoOrderBrief, TransportSiteLinkRead, ) -from app.services.pl_dibang_bridge_service import revoke_regeneratable_dibang_for_transport +from app.services.pl_dibang_bridge_service import ( + revoke_regeneratable_dibang_for_transport, + sync_linked_dibang_dates_for_plan, +) from app.services.pl_inventory_link_read import build_transport_inventory_link_read from app.services.pl_io_order_service import get_active_io_order_for_plan, void_io_orders_for_plan from app.services.pl_internal_customer import plan_requires_site_link @@ -390,6 +393,11 @@ def list_plans( alias="hazardCertReady", description="0=未确认齐备 1=已确认齐备", ), + contract_id: Optional[str] = Query( + default=None, + alias="contractId", + description="CRM合同ID:返回关联该合同的运输计划(含 pl_plan_contract 与遗留主表 contract_id)", + ), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"), db: Session = Depends(get_db), @@ -401,6 +409,16 @@ def list_plans( PlPlan.del_flag == 0, ) + if contract_id and str(contract_id).strip(): + from app.services.pl_contract_plan_qty import plan_ids_linked_to_contract + + linked_ids = plan_ids_linked_to_contract( + db, tenant_no=tenant_no, contract_id=str(contract_id).strip() + ) + if not linked_ids: + return PlanListResponse(total=0, list=[]) + query = query.filter(PlPlan.id.in_(linked_ids)) + if q: q_kw = q.strip() if q_kw: @@ -794,6 +812,27 @@ def count_plans_by_contract( return {"counts": counts} +@router.get("/contract-product-arrange-qty") +def get_contract_product_arrange_qty( + contract_id: str = Query(..., alias="contractId"), + product_id: str = Query(..., alias="productId"), + exclude_plan_id: Optional[str] = Query(default=None, alias="excludePlanId"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> dict: + """表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。""" + from app.services.pl_contract_plan_qty import get_contract_product_arrange_qty_detail + + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + return get_contract_product_arrange_qty_detail( + db, + tenant_no=tenant_no, + contract_id=contract_id, + product_id=product_id, + exclude_plan_id=exclude_plan_id, + ) + + @router.get("/contract-gaps", response_model=PlanContractGapListResponse) def list_contract_gaps( page: int = Query(default=1, ge=1), @@ -1638,13 +1677,20 @@ def update_plan( ) plan.update_by = user.id + dibang_date_warnings = sync_linked_dibang_dates_for_plan( + db, + tenant_no=tenant_no, + plan=plan, + user_id=user.id, + dept_id=user.dept_id or "", + ) db.commit() db.refresh(plan) result = PlanWithProducts.model_validate(plan) result.products = plan_products result.supplier_contracts = supplier_contracts result.demander_contracts = demander_contracts - result.warnings = qty_warnings + result.warnings = [*qty_warnings, *dibang_date_warnings] stops = load_plan_stops_grouped(db, tenant_no, [id]).get(id, (None, [])) attach_plan_stops_to_item(result, plan, stops) return result diff --git a/backend/app/services/pl_contract_plan_qty.py b/backend/app/services/pl_contract_plan_qty.py index dab1a9bac..62d6a90da 100644 --- a/backend/app/services/pl_contract_plan_qty.py +++ b/backend/app/services/pl_contract_plan_qty.py @@ -708,6 +708,7 @@ def collect_plan_contract_qty_checks( stop_kind = _stop_kind_from_scope(scope) contract_type = (contract.contract_type or "").strip() contract_no = contract.contract_no or "" + customer_name = (contract.customer_name or "").strip() for pid, new_qty_raw in plan_qtys.items(): new_qty = _to_float(new_qty_raw) @@ -744,9 +745,12 @@ def collect_plan_contract_qty_checks( "contractId": cid, "contractNo": contract_no, "contractType": contract_type, + "customerName": customer_name, "stopKind": stop_kind, "productId": pid, "productName": cp.product_name or "", + "model": (cp.model or "").strip() or None, + "unit": (cp.unit or "").strip() or None, "thisPlanQty": evaluated.get("thisPlanQty", round(new_qty, 3)), "arrangedTotalQty": evaluated["arrangedTotalQty"], } @@ -760,6 +764,10 @@ def collect_plan_contract_qty_checks( "newPlanQty": evaluated["newPlanQty"], "effectiveTotal": evaluated["effectiveTotal"], "overflowQty": evaluated["overflowQty"], + # 与提醒表同列:剩余可为负,表示超量 + "remainingQty": round( + evaluated["contractQty"] - evaluated["effectiveTotal"], 3 + ), } ) elif kind == "remaining": @@ -812,3 +820,223 @@ def build_contract_qty_reminder_payload( if not reminders: return None return {"items": reminders} + + +def get_contract_product_arrange_qty_detail( + db: Session, + *, + tenant_no: str, + contract_id: str, + product_id: str, + exclude_plan_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + 表单「可安排数量」与问号弹窗数据。 + arrangedQty / availableQty 均不含 exclude_plan_id(若该计划已在已安排桶中)。 + """ + from app.models.crm_models import CrmContract, CrmContractProduct + + cid = (contract_id or "").strip() + pid = (product_id or "").strip() + exclude_id = (exclude_plan_id or "").strip() or None + empty = { + "contractId": cid, + "contractNo": None, + "customerName": None, + "productId": pid, + "productName": None, + "hasFixedQty": False, + "contractQty": None, + "arrangedQty": 0.0, + "availableQty": None, + "plans": [], + } + if not cid or not pid: + return empty + + contract = ( + db.query(CrmContract) + .filter( + CrmContract.id == cid, + CrmContract.tenant_no == tenant_no, + CrmContract.del_flag == 0, + ) + .first() + ) + if not contract: + return empty + + cp = ( + db.query(CrmContractProduct) + .filter( + CrmContractProduct.contract_id == cid, + CrmContractProduct.product_id == pid, + CrmContractProduct.tenant_no == tenant_no, + CrmContractProduct.del_flag == 0, + ) + .first() + ) + contract_type = (contract.contract_type or "").strip() + has_fixed = False + contract_qty: Optional[float] = None + if cp: + has_fixed = contract_product_has_fixed_qty( + contract_type=contract_type, + quantity=cp.quantity, + quantity_mode=cp.quantity_mode, + ) + if has_fixed: + contract_qty = round(_to_float(cp.quantity), 3) + + qty_map = aggregate_contract_product_plan_qtys( + db, tenant_no=tenant_no, contract_id=cid + ) + arranged_including = _to_float((qty_map.get(pid) or {}).get("arrangedTotalQty")) + + link_rows = ( + db.query(PlPlanContract) + .filter( + PlPlanContract.tenant_no == tenant_no, + PlPlanContract.del_flag == 0, + PlPlanContract.contract_id == cid, + ) + .all() + ) + link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id} + legacy_plans = ( + db.query(PlPlan) + .filter( + PlPlan.tenant_no == tenant_no, + PlPlan.del_flag == 0, + PlPlan.contract_id == cid, + ) + .all() + ) + legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids} + scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids) + plan_ids = list(scopes.keys()) + + exclude_qty = 0.0 + plans_out: list[Dict[str, Any]] = [] + + if plan_ids: + plans = ( + db.query(PlPlan) + .filter( + PlPlan.tenant_no == tenant_no, + PlPlan.del_flag == 0, + PlPlan.id.in_(plan_ids), + PlPlan.follow_status != "cancelled", + ) + .all() + ) + plan_by_id = {p.id: p for p in plans} + + loads = ( + db.query(PlPlanLoad) + .filter( + PlPlanLoad.tenant_no == tenant_no, + PlPlanLoad.del_flag == 0, + PlPlanLoad.plan_id.in_(plan_ids), + ) + .all() + ) + deliveries = ( + db.query(PlPlanDelivery) + .filter( + PlPlanDelivery.tenant_no == tenant_no, + PlPlanDelivery.del_flag == 0, + PlPlanDelivery.plan_id.in_(plan_ids), + ) + .all() + ) + load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set) + delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set) + for row in loads: + load_ids_by_plan[row.plan_id].add(row.id) + for row in deliveries: + delivery_ids_by_plan[row.plan_id].add(row.id) + + all_load_ids = {row.id for row in loads} + all_delivery_ids = {row.id for row in deliveries} + load_products_by_load: DefaultDict[str, list] = defaultdict(list) + if all_load_ids: + for prod in ( + db.query(PlPlanLoadProduct) + .filter( + PlPlanLoadProduct.tenant_no == tenant_no, + PlPlanLoadProduct.del_flag == 0, + PlPlanLoadProduct.load_id.in_(all_load_ids), + ) + .all() + ): + load_products_by_load[prod.load_id].append(prod) + delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list) + if all_delivery_ids: + for prod in ( + db.query(PlPlanDeliveryProduct) + .filter( + PlPlanDeliveryProduct.tenant_no == tenant_no, + PlPlanDeliveryProduct.del_flag == 0, + PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids), + ) + .all() + ): + delivery_products_by_delivery[prod.delivery_id].append(prod) + + for plan_id, scope in scopes.items(): + plan = plan_by_id.get(plan_id) + if not plan: + continue + bucket = _bucket_for_plan( + archive_status=plan.archive_status, + follow_status=plan.follow_status, + transport_status=plan.transport_status, + ) + if not bucket: + continue + qtys = sum_qtys_for_scope( + load_products_by_load=load_products_by_load, + delivery_products_by_delivery=delivery_products_by_delivery, + all_load_ids=load_ids_by_plan.get(plan_id, set()), + all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()), + scope=scope, + ) + pq = _to_float(qtys.get(pid)) + if pq <= 0: + continue + if exclude_id and str(plan_id) == exclude_id: + exclude_qty = pq + continue + plan_date = getattr(plan, "plan_date", None) + plans_out.append( + { + "planId": plan_id, + "planNo": plan.plan_no or "", + "planDate": plan_date.isoformat() if plan_date else None, + "planQty": round(pq, 3), + "followStatus": plan.follow_status, + "transportStatus": plan.transport_status, + "archiveStatus": plan.archive_status, + } + ) + + plans_out.sort(key=lambda x: (x.get("planDate") or "", x.get("planNo") or ""), reverse=True) + + arranged_qty = round(max(0.0, arranged_including - exclude_qty), 3) + available_qty = None + if has_fixed and contract_qty is not None: + available_qty = round(contract_qty - arranged_qty, 3) + + return { + "contractId": cid, + "contractNo": contract.contract_no or "", + "customerName": (contract.customer_name or "").strip() or None, + "productId": pid, + "productName": (cp.product_name if cp else None) or None, + "hasFixedQty": has_fixed, + "contractQty": contract_qty, + "arrangedQty": arranged_qty, + "availableQty": available_qty, + "plans": plans_out, + } diff --git a/docs/fresh-mint-pl-plan-form-available-qty-demo.html b/docs/fresh-mint-pl-plan-form-available-qty-demo.html new file mode 100644 index 000000000..84feaf0d8 --- /dev/null +++ b/docs/fresh-mint-pl-plan-form-available-qty-demo.html @@ -0,0 +1,432 @@ + + +
+ + ++ 装货/送货产品区:可安排(绿)在计划数量(红)前;超可安排仅标红提示,仍可保存/安排(安排接口硬拦)。 + 问号点击弹窗展示合同数量、已安排、可安排,及已安排该产品的运输计划列表。 +
+ ++ 交互:点击「?」打开二级弹窗(无背景模糊)。超量行仅红色强调 + 小字提示,不禁止保存。 +
+云栋供应商 - HT-CG-2026-102(装货)
+| 计划编号 | 计划日期 | 计划数量 | 状态 |
|---|---|---|---|
| YP20260812003 | 2026-08-12 | 80.000 | 已归档 |
| YP20260820008 | 2026-08-20 | 70.000 | 已安排 |
云栋供应商 - HT-CG-2026-102(装货)
+| 计划编号 | 计划日期 | 计划数量 | 状态 |
|---|---|---|---|
| YP20260810001 | 2026-08-10 | 80.000 | 已安排 |
某某贸易公司 - HT-XS-2026-088(送货)
+| 计划编号 | 计划日期 | 计划数量 | 状态 |
|---|---|---|---|
| YP20260815002 | 2026-08-15 | 40.000 | 已安排 |
{{ subtitle }}
+暂无已安排计划
++ 已超过可安排数量,安排时将被拦截 +
+ 已超过可安排数量 +