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 @@ + + + + + + Fresh Mint · 运输计划表单 · 可安排数量 Demo + + + + + + +
+
+

运输计划表单 · 可安排数量

+

+ 装货/送货产品区:可安排(绿)在计划数量(红)前;超可安排仅标红提示,仍可保存/安排(安排接口硬拦)。 + 问号点击弹窗展示合同数量、已安排、可安排,及已安排该产品的运输计划列表。 +

+
+ /#/pl/plan-module/plan + 可安排 · 绿 + 计划数量 · 红 · 超量仅提示 + ? · 点击弹窗 +
+
+ +
+
+

已确认

+
    +
  • 计划数量 > 可安排 → 仅标红,不拦保存/安排
  • +
  • 安排时仍走合同数量硬拦接口
  • +
  • 问号 → 弹窗(非气泡悬停)
  • +
+
+
+

口径

+
    +
  • 可安排 = 合同数量 − 已安排(编辑扣本计划旧量)
  • +
  • 按本段合同分别计算
  • +
  • 框架无固定量:可安排「—」,弹窗说明不限额
  • +
+
+
+ +
+
+

编辑运输计划 · YP20260830021

+ +
+
+
+
+ 装货信息 + 合同 HT-CG-2026-102 +
+
+
+
装货客户
云栋供应商
+
装货地址
安庆某某园区 A 库
+
装货时间
2026-08-31 09:00
+
+
+
+
产品名称
液碱 32%
+
别名
—
+
品牌
不限品牌
+
规格型号
—
+
单位
吨
+
+
+
+ 可安排数量 +
+ 50.000 + +
+
+
+ 计划数量 +
40.000
+
+
包装规格
槽罐
+
送货方式
送货上门
+
备注
—
+
+
+
+
+
产品名称
98% 硫酸(超量示意)
+
别名
—
+
品牌
—
+
规格型号
工业级
+
单位
吨
+
+
+
+ 可安排数量 +
+ 20.000 + +
+
+
+ 计划数量 +
30.000
+
已超过可安排数量,安排时将被拦截
+
+
包装规格
槽罐
+
送货方式
自提
+
备注
标红仍可暂存
+
+
+
+
+ +
+
+ 送货信息 #1 + 合同 HT-XS-2026-088 +
+
+
+
送货客户
某某贸易公司
+
送货地址
合肥某某仓储
+
送货时间
2026-08-31 16:00
+
+
+
+
产品名称
液碱 32%
+
别名
—
+
品牌
不限品牌
+
规格型号
—
+
单位
吨
+
+
+
+ 可安排数量 +
+ 140.000 + +
+
+
+ 计划数量 +
40.000
+
+
包装规格
槽罐
+
送货方式
送货上门
+
备注
—
+
+
+
+
+
+ +
+ +

+ 交互:点击「?」打开二级弹窗(无背景模糊)。超量行仅红色强调 + 小字提示,不禁止保存。 +

+
+ + + + + + + + + + + diff --git a/docs/superpowers/specs/2026-08-30-pl-plan-form-available-qty-design.md b/docs/superpowers/specs/2026-08-30-pl-plan-form-available-qty-design.md new file mode 100644 index 000000000..1a9c0280b --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-pl-plan-form-available-qty-design.md @@ -0,0 +1,56 @@ +# 运输计划表单 · 可安排数量 + +**日期:** 2026-08-30 +**状态:** 已确认(方案 2:专用查询接口) +**Demo:** `docs/fresh-mint-pl-plan-form-available-qty-demo.html` + +## 目标 + +新增/编辑运输计划弹窗中,装货/送货产品区在「计划数量」前展示「可安排数量」,并支持问号弹窗查看合同用量与已安排计划列表。 + +## 已确认决策 + +| 项 | 决策 | +|----|------| +| 颜色 | 可安排绿色;计划数量红色 | +| 超量 | 仅标红提示,仍可保存;安排接口硬拦 | +| 展示条件 | 有产品行即显示;无合同 →「—」,问号禁用并提示先选合同 | +| 问号 | 点击弹窗(非悬停气泡) | +| 计划编号 | 仅展示,不可点击 | +| 口径 | 可安排 = 合同数量 − 已安排(编辑时已安排不含本计划旧量) | +| 框架无固定量 | 可安排「—」;弹窗说明不限额 | +| 实现 | 方案 2:专用 API | + +## API + +`GET /transport/plan/contract-product-arrange-qty` + +Query:`contractId`、`productId`、可选 `excludePlanId`(当前编辑计划) + +响应: + +```text +{ + contractId, contractNo, customerName, productId, productName, + hasFixedQty: boolean, + contractQty?: number, + arrangedQty: number, // 不含 excludePlanId + availableQty?: number, // hasFixedQty 时 + plans: [{ planId, planNo, planDate, planQty, followStatus, transportStatus, archiveStatus }] +} +``` + +复用 `pl_contract_plan_qty` 汇总口径;`plans` 为计入已安排且本产品有量的计划(不含 exclude)。 + +## UI + +- `PlPlanStopProductTable`:可安排列 + 问号;计划数量超可安排加红样式/提示文案 +- Props:`contractId`、`excludePlanId` +- 装货/送货 section 传入本段首个合同 id;表单传入 `planId` 作为 exclude +- 弹窗:三格统计 + 计划表;无合同点问号 → Message 提示先选合同 + +## 非目标 + +- 输入硬拦超过可安排 +- 计划编号跳转查看 +- 独立预检 API(沿用安排硬拦) diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index ef0153bcd..efd7a7c5f 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -866,6 +866,29 @@ export interface PlanContractGapQuery { q?: string } +export interface ContractProductArrangeQtyPlanItem { + planId: string + planNo?: string + planDate?: string | null + planQty: number + followStatus?: string + transportStatus?: string + archiveStatus?: string +} + +export interface ContractProductArrangeQtyDetail { + contractId: string + contractNo?: string | null + customerName?: string | null + productId: string + productName?: string | null + hasFixedQty: boolean + contractQty?: number | null + arrangedQty: number + availableQty?: number | null + plans: ContractProductArrangeQtyPlanItem[] +} + export const planApi = { list: (params?: PlanQuery) => request.get('/transport/plan/', { params }), export: (data: Record) => @@ -875,6 +898,16 @@ export const planApi = { request.get<{ counts: Record }>('/transport/plan/count-by-contract', { params: { contractIds: contractIds.join(',') } }), + /** 表单可安排数量 / 问号弹窗 */ + getContractProductArrangeQty: (params: { + contractId: string + productId: string + excludePlanId?: string + }) => + request.get( + '/transport/plan/contract-product-arrange-qty', + { params } + ), create: (data: Partial) => request.post('/transport/plan/', data), createFull: (data: PlanCreateFullPayload) => request.post('/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 diff --git a/web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/index.vue b/web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/index.vue new file mode 100644 index 000000000..fdb5f50f8 --- /dev/null +++ b/web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/index.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/web-admin/src/components/business/pl-plan-delivery-section/index.vue b/web-admin/src/components/business/pl-plan-delivery-section/index.vue index 468247ff4..c27073a3f 100644 --- a/web-admin/src/components/business/pl-plan-delivery-section/index.vue +++ b/web-admin/src/components/business/pl-plan-delivery-section/index.vue @@ -201,6 +201,8 @@ :compact="compactProducts" :allow-remove="!readonly" :shared-readonly="true" + :contract-id="deliveryContractId(delivery)" + :exclude-plan-id="excludePlanId" @remove="(productIndex) => removeStopProduct(index, productIndex)" /> @@ -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>({}) const customerOptionsLoading = ref>({}) diff --git a/web-admin/src/components/business/pl-plan-form-dialog/index.vue b/web-admin/src/components/business/pl-plan-form-dialog/index.vue index be9908e35..2d741a5f2 100644 --- a/web-admin/src/components/business/pl-plan-form-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-form-dialog/index.vue @@ -191,6 +191,7 @@ ref="loadSectionRef" v-model="loadForm" compact-products + :exclude-plan-id="planId || ''" @open-customer-picker="openCustomerSelector('load')" @add-products="productSelectorVisible = true" /> @@ -230,6 +231,7 @@ compact-products :qty-unit-label="planQtyUnitLabel" :show-split-hint="showDeliverySplitHint" + :exclude-plan-id="planId || ''" @active-index-change="deliveryActiveIndex = $event" @open-customer-picker="openCustomerSelector('delivery', $event)" @sync-products="syncDeliveryProductsFromLoadManual" diff --git a/web-admin/src/components/business/pl-plan-load-section/index.vue b/web-admin/src/components/business/pl-plan-load-section/index.vue index 76b2cb3dc..3347c287a 100644 --- a/web-admin/src/components/business/pl-plan-load-section/index.vue +++ b/web-admin/src/components/business/pl-plan-load-section/index.vue @@ -145,6 +145,8 @@ :products="modelValue.products" :editable="!readonly" :compact="compactProducts" + :contract-id="stopContractId" + :exclude-plan-id="excludePlanId" @remove="removeStopProduct" /> @@ -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 diff --git a/web-admin/src/components/business/pl-plan-stop-product-table/index.vue b/web-admin/src/components/business/pl-plan-stop-product-table/index.vue index 1571b7825..59e743524 100644 --- a/web-admin/src/components/business/pl-plan-stop-product-table/index.vue +++ b/web-admin/src/components/business/pl-plan-stop-product-table/index.vue @@ -111,6 +111,21 @@
+
+ 可安排数量 +
+ {{ formatAvailableDisplay(row) }} + + ? + +
+
计划数量 {{ row.planQty ?? '—' }} +

+ 已超过可安排数量,安排时将被拦截 +

包装规格 @@ -246,6 +265,22 @@ + + + + +