From ae91fbc63e740beebea1cdb5968bf8934a2be292 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 2 Sep 2026 11:40:54 +0800 Subject: [PATCH] feat(backend): support saveMode draft/formal and drafting follow gates Draft skips business validators and writes drafting; formal promotes drafting to pending_arrange; wait-notice/delete/force allow drafting. Co-authored-by: Cursor --- backend/app/api/pl/plan.py | 103 ++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index 67e0ce5da..427f3073f 100644 --- a/backend/app/api/pl/plan.py +++ b/backend/app/api/pl/plan.py @@ -140,7 +140,10 @@ from app.services.pl_plan_service import ( set_plan_erp_entered, sync_transport_prefill_for_plan, update_plan_product_certs, + assert_can_save_as_draft, + resolve_follow_status_on_save, validate_create_full, + validate_draft_min_fields, validate_hazard_cert_ready_for_transport, validate_plan_party_contracts, ) @@ -1097,10 +1100,36 @@ def create_plan_full( ) -> PlanWithProducts: """创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)""" tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal" + is_draft = save_mode == "draft" multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries) qty_warnings: list[str] = [] + load_body = None + delivery_bodies: list = [] + supplier_contracts: list[PlanContractLink] = [] + demander_contracts: list[PlanContractLink] = [] - if multi_stop: + if is_draft: + validate_draft_min_fields( + belongs_company=payload.belongs_company, + create_date=payload.create_date, + ) + if multi_stop and payload.load is not None: + try: + load_body, delivery_bodies = validate_plan_stops_write( + db, + tenant_no, + payload.load, + payload.deliveries, + payload.products, + ) + qty_warnings = collect_stop_qty_warnings( + payload.products, load_body, delivery_bodies + ) + except HTTPException: + load_body, delivery_bodies = None, [] + multi_stop = False + elif multi_stop: load_body, delivery_bodies = validate_plan_stops_write( db, tenant_no, @@ -1109,8 +1138,6 @@ def create_plan_full( payload.products, ) qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies) - supplier_contracts: list[PlanContractLink] = [] - demander_contracts: list[PlanContractLink] = [] else: validate_create_full(payload) supplier_contracts, demander_contracts = validate_plan_party_contracts( @@ -1142,7 +1169,9 @@ def create_plan_full( plan_date=payload.plan_date, belongs_company=payload.belongs_company, plan_type=plan_type, - follow_status="pending_arrange", + follow_status=resolve_follow_status_on_save( + save_mode=save_mode, current=None, is_create=True + ), transport_status="pending", archive_status="unarchived", complete_status="no", @@ -1466,18 +1495,34 @@ def update_plan( if plan.follow_status == "cancelled": raise HTTPException(status_code=400, detail="已作废的计划不能修改") + save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal" + is_draft = save_mode == "draft" + if is_draft: + assert_can_save_as_draft(plan.follow_status) + validate_draft_min_fields( + belongs_company=( + payload.belongs_company + if payload.belongs_company is not None + else plan.belongs_company + ), + create_date=( + payload.create_date if payload.create_date is not None else plan.create_date + ), + ) + multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries) qty_warnings: list[str] = [] load_body: Optional[PlanLoadBody] = None delivery_bodies: list[PlanDeliveryBody] = [] - # 更新主表字段(跟进状态仅由业务动作接口维护,忽略客户端写入) + # 更新主表字段(跟进状态仅由业务动作 / saveMode 决议维护,忽略客户端 follow_status) update_data = payload.model_dump( exclude_unset=True, exclude={ "products", "transport", "follow_status", + "save_mode", "supplier_contracts", "demander_contracts", "load", @@ -1501,7 +1546,32 @@ def update_plan( customer_id = plan.customer_id customer_name = plan.customer_name - if multi_stop: + if is_draft: + supplier_contracts: list[PlanContractLink] = [] + demander_contracts: list[PlanContractLink] = [] + if multi_stop and payload.load is not None: + try: + load_body, delivery_bodies = validate_plan_stops_write( + db, + tenant_no, + payload.load, + payload.deliveries, + payload.products, + ) + products_for_warn = payload.products if payload.products is not None else None + qty_warnings = collect_stop_qty_warnings( + products_for_warn, load_body, delivery_bodies + ) + sync_plan_legacy_from_stops(plan, load_body, delivery_bodies) + except HTTPException: + # 跳过段更新,保留库内原段 + load_body, delivery_bodies = None, [] + multi_stop = False + if not multi_stop: + supplier_contracts, demander_contracts = load_plan_contracts_grouped( + db, tenant_no, [id] + ).get(id, ([], [])) + elif multi_stop: load_body, delivery_bodies = validate_plan_stops_write( db, tenant_no, @@ -1512,8 +1582,8 @@ def update_plan( products_for_warn = payload.products if payload.products is not None else None qty_warnings = collect_stop_qty_warnings(products_for_warn, load_body, delivery_bodies) sync_plan_legacy_from_stops(plan, load_body, delivery_bodies) - supplier_contracts: list[PlanContractLink] = [] - demander_contracts: list[PlanContractLink] = [] + supplier_contracts = [] + demander_contracts = [] else: supplier_contracts_in = ( payload.supplier_contracts @@ -1677,6 +1747,11 @@ def update_plan( ) plan.update_by = user.id + plan.follow_status = resolve_follow_status_on_save( + save_mode=save_mode, + current=plan.follow_status, + is_create=False, + ) dibang_date_warnings = sync_linked_dibang_dates_for_plan( db, tenant_no=tenant_no, @@ -1932,8 +2007,8 @@ def mark_plan_wait_notice( raise HTTPException(status_code=400, detail="已取消的计划不能标记等通知") if plan.archive_status == "archived": raise HTTPException(status_code=400, detail="已归档的计划不能标记等通知") - if plan.follow_status != "pending_arrange": - raise HTTPException(status_code=400, detail="仅待安排状态可标记为等通知") + if plan.follow_status not in ("pending_arrange", "drafting"): + raise HTTPException(status_code=400, detail="仅待安排或草稿中状态可标记为等通知") plan.follow_status = "wait_notice" plan.update_by = user.id @@ -1966,7 +2041,7 @@ def cancel_plan_wait_notice( FORCE_FOLLOW_STATUS_PERM = "pl:plan:force-follow-status" ALLOWED_FORCE_FOLLOW_STATUSES = frozenset( - {"wait_notice", "pending_arrange", "arranged", "completed", "cancelled"} + {"drafting", "wait_notice", "pending_arrange", "arranged", "completed", "cancelled"} ) @@ -1987,7 +2062,7 @@ def force_plan_follow_status( if next_status not in ALLOWED_FORCE_FOLLOW_STATUSES: raise HTTPException( status_code=400, - detail="跟进状态仅支持:等通知/待安排/已安排/已完成/已取消", + detail="跟进状态仅支持:草稿中/等通知/待安排/已安排/已完成/已取消", ) tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) @@ -2484,8 +2559,8 @@ def delete_plan( tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) plan = _get_plan(db, tenant_no, id, user) - if plan.follow_status not in ("pending_arrange", "wait_notice"): - raise HTTPException(status_code=400, detail="只能删除待安排或等通知且未安排运输的计划") + if plan.follow_status not in ("pending_arrange", "wait_notice", "drafting"): + raise HTTPException(status_code=400, detail="只能删除待安排、草稿中或等通知且未安排运输的计划") if plan.transport_status != "pending": raise HTTPException(status_code=400, detail="已安排运输的计划不能删除") if plan.archive_status == "archived":