Просмотр исходного кода

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 <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
ae91fbc63e
1 измененных файлов с 90 добавлено и 15 удалено
  1. 90 15
      backend/app/api/pl/plan.py

+ 90 - 15
backend/app/api/pl/plan.py

@@ -140,7 +140,10 @@ from app.services.pl_plan_service import (
     set_plan_erp_entered,
     set_plan_erp_entered,
     sync_transport_prefill_for_plan,
     sync_transport_prefill_for_plan,
     update_plan_product_certs,
     update_plan_product_certs,
+    assert_can_save_as_draft,
+    resolve_follow_status_on_save,
     validate_create_full,
     validate_create_full,
+    validate_draft_min_fields,
     validate_hazard_cert_ready_for_transport,
     validate_hazard_cert_ready_for_transport,
     validate_plan_party_contracts,
     validate_plan_party_contracts,
 )
 )
@@ -1097,10 +1100,36 @@ def create_plan_full(
 ) -> PlanWithProducts:
 ) -> PlanWithProducts:
     """创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)"""
     """创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)"""
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
     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)
     multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries)
     qty_warnings: list[str] = []
     qty_warnings: list[str] = []
-
-    if multi_stop:
+    load_body = None
+    delivery_bodies: list = []
+    supplier_contracts: list[PlanContractLink] = []
+    demander_contracts: list[PlanContractLink] = []
+
+    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(
         load_body, delivery_bodies = validate_plan_stops_write(
             db,
             db,
             tenant_no,
             tenant_no,
@@ -1109,8 +1138,6 @@ def create_plan_full(
             payload.products,
             payload.products,
         )
         )
         qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies)
         qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies)
-        supplier_contracts: list[PlanContractLink] = []
-        demander_contracts: list[PlanContractLink] = []
     else:
     else:
         validate_create_full(payload)
         validate_create_full(payload)
         supplier_contracts, demander_contracts = validate_plan_party_contracts(
         supplier_contracts, demander_contracts = validate_plan_party_contracts(
@@ -1142,7 +1169,9 @@ def create_plan_full(
         plan_date=payload.plan_date,
         plan_date=payload.plan_date,
         belongs_company=payload.belongs_company,
         belongs_company=payload.belongs_company,
         plan_type=plan_type,
         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",
         transport_status="pending",
         archive_status="unarchived",
         archive_status="unarchived",
         complete_status="no",
         complete_status="no",
@@ -1466,18 +1495,34 @@ def update_plan(
     if plan.follow_status == "cancelled":
     if plan.follow_status == "cancelled":
         raise HTTPException(status_code=400, detail="已作废的计划不能修改")
         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)
     multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries)
     qty_warnings: list[str] = []
     qty_warnings: list[str] = []
     load_body: Optional[PlanLoadBody] = None
     load_body: Optional[PlanLoadBody] = None
     delivery_bodies: list[PlanDeliveryBody] = []
     delivery_bodies: list[PlanDeliveryBody] = []
 
 
-    # 更新主表字段(跟进状态仅由业务动作接口维护,忽略客户端写入)
+    # 更新主表字段(跟进状态仅由业务动作 / saveMode 决议维护,忽略客户端 follow_status)
     update_data = payload.model_dump(
     update_data = payload.model_dump(
         exclude_unset=True,
         exclude_unset=True,
         exclude={
         exclude={
             "products",
             "products",
             "transport",
             "transport",
             "follow_status",
             "follow_status",
+            "save_mode",
             "supplier_contracts",
             "supplier_contracts",
             "demander_contracts",
             "demander_contracts",
             "load",
             "load",
@@ -1501,7 +1546,32 @@ def update_plan(
     customer_id = plan.customer_id
     customer_id = plan.customer_id
     customer_name = plan.customer_name
     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(
         load_body, delivery_bodies = validate_plan_stops_write(
             db,
             db,
             tenant_no,
             tenant_no,
@@ -1512,8 +1582,8 @@ def update_plan(
         products_for_warn = payload.products if payload.products is not None else None
         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)
         qty_warnings = collect_stop_qty_warnings(products_for_warn, load_body, delivery_bodies)
         sync_plan_legacy_from_stops(plan, 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:
     else:
         supplier_contracts_in = (
         supplier_contracts_in = (
             payload.supplier_contracts
             payload.supplier_contracts
@@ -1677,6 +1747,11 @@ def update_plan(
         )
         )
 
 
     plan.update_by = user.id
     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(
     dibang_date_warnings = sync_linked_dibang_dates_for_plan(
         db,
         db,
         tenant_no=tenant_no,
         tenant_no=tenant_no,
@@ -1932,8 +2007,8 @@ def mark_plan_wait_notice(
         raise HTTPException(status_code=400, detail="已取消的计划不能标记等通知")
         raise HTTPException(status_code=400, detail="已取消的计划不能标记等通知")
     if plan.archive_status == "archived":
     if plan.archive_status == "archived":
         raise HTTPException(status_code=400, detail="已归档的计划不能标记等通知")
         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.follow_status = "wait_notice"
     plan.update_by = user.id
     plan.update_by = user.id
@@ -1966,7 +2041,7 @@ def cancel_plan_wait_notice(
 
 
 FORCE_FOLLOW_STATUS_PERM = "pl:plan:force-follow-status"
 FORCE_FOLLOW_STATUS_PERM = "pl:plan:force-follow-status"
 ALLOWED_FORCE_FOLLOW_STATUSES = frozenset(
 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:
     if next_status not in ALLOWED_FORCE_FOLLOW_STATUSES:
         raise HTTPException(
         raise HTTPException(
             status_code=400,
             status_code=400,
-            detail="跟进状态仅支持:等通知/待安排/已安排/已完成/已取消",
+            detail="跟进状态仅支持:草稿中/等通知/待安排/已安排/已完成/已取消",
         )
         )
 
 
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
     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())
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
     plan = _get_plan(db, tenant_no, id, user)
     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":
     if plan.transport_status != "pending":
         raise HTTPException(status_code=400, detail="已安排运输的计划不能删除")
         raise HTTPException(status_code=400, detail="已安排运输的计划不能删除")
     if plan.archive_status == "archived":
     if plan.archive_status == "archived":