Quellcode durchsuchen

feat(pl): upsert plan product rows by id on update.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu vor 1 Monat
Ursprung
Commit
857067ec80

+ 37 - 22
backend/app/api/pl/plan.py

@@ -128,6 +128,7 @@ from app.services.pl_plan_service import (
     apply_plan_site_feedback,
     apply_vehicle_prefill_to_plan,
     assert_contract_selectable_for_plan,
+    apply_plan_product_fields,
     build_plan_product,
     calc_plan_total_amount,
     clear_draft_transport_for_plan,
@@ -1640,16 +1641,15 @@ def update_plan(
 
     plan_type = payload.plan_type or plan.plan_type
 
-    # 更新产品明细
+    # 更新产品明细(按 id upsert,保持 pl_plan_product.id 稳定)
     if payload.products is not None:
-        existing_products = (
+        existing_all = (
             db.query(PlPlanProduct)
-            .filter(
-                PlPlanProduct.plan_id == id,
-                PlPlanProduct.del_flag == 0,
-            )
+            .filter(PlPlanProduct.plan_id == id)
             .all()
         )
+        existing_all_by_id = {row.id: row for row in existing_all}
+        existing_products = [row for row in existing_all if row.del_flag == 0]
         old_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, existing_products)
         cert_by_product_id = {
             row.product_id: (
@@ -1660,11 +1660,7 @@ def update_plan(
             for row in existing_products
         }
 
-        db.query(PlPlanProduct).filter(
-            PlPlanProduct.plan_id == id,
-            PlPlanProduct.del_flag == 0,
-        ).update({"del_flag": 1})
-
+        kept_ids: set[str] = set()
         plan_products: list[PlPlanProduct] = []
         for idx, prod in enumerate(payload.products):
             is_toxic, is_explosive = resolve_hazard_flags_from_crm(
@@ -1689,17 +1685,36 @@ def update_plan(
                     "legal_use_cert_url": legal_use_cert,
                 }
             )
-            pp = build_plan_product(
-                tenant_no=tenant_no,
-                plan_id=id,
-                plan_type=plan_type,
-                prod=prod_payload,
-                order_num=idx,
-                user_id=user.id,
-                dept_id=user.dept_id,
-            )
-            db.add(pp)
-            plan_products.append(pp)
+            row_id = (prod.id or "").strip() or None
+            existing = existing_all_by_id.get(row_id) if row_id else None
+            if existing is not None:
+                apply_plan_product_fields(
+                    existing,
+                    plan_type=plan_type,
+                    prod=prod_payload,
+                    order_num=idx,
+                    user_id=user.id,
+                )
+                kept_ids.add(existing.id)
+                plan_products.append(existing)
+            else:
+                pp = build_plan_product(
+                    tenant_no=tenant_no,
+                    plan_id=id,
+                    plan_type=plan_type,
+                    prod=prod_payload,
+                    order_num=idx,
+                    user_id=user.id,
+                    dept_id=user.dept_id,
+                )
+                db.add(pp)
+                kept_ids.add(pp.id)
+                plan_products.append(pp)
+
+        for row in existing_products:
+            if row.id not in kept_ids:
+                row.del_flag = 1
+                row.update_by = user.id
 
         new_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, payload.products)
         if old_hazard_product_ids != new_hazard_product_ids:

+ 47 - 1
backend/app/services/pl_plan_service.py

@@ -111,12 +111,13 @@ def build_plan_product(
     order_num: int,
     user_id: str,
     dept_id: str,
+    product_row_id: Optional[str] = None,
 ) -> PlPlanProduct:
     qty = _product_quantity(prod)
     estimates = _estimate_qty_fields(plan_type, qty if qty else None)
     tax_price = prod.tax_price
     return PlPlanProduct(
-        id=generate_uuid(),
+        id=(product_row_id or "").strip() or generate_uuid(),
         tenant_no=tenant_no,
         plan_id=plan_id,
         product_id=prod.product_id,
@@ -152,6 +153,51 @@ def build_plan_product(
     )
 
 
+def apply_plan_product_fields(
+    row: PlPlanProduct,
+    *,
+    plan_type: str,
+    prod: PlanProductCreate,
+    order_num: int,
+    user_id: str,
+) -> None:
+    """就地更新已有计划产品行字段(保持 id),用于 update upsert。"""
+    qty = _product_quantity(prod)
+    estimates = _estimate_qty_fields(plan_type, qty if qty else None)
+    tax_price = prod.tax_price
+    row.product_id = prod.product_id
+    row.product_no = prod.product_no
+    row.product_name = prod.product_name
+    row.brand_mode = prod.brand_mode
+    row.brand_id = prod.brand_id
+    row.brand_name = prod.brand_name
+    row.model = prod.model
+    row.unit = prod.unit or "吨"
+    row.category_name = prod.category_name
+    row.package_spec = prod.package_spec
+    row.delivery_method = prod.delivery_method
+    row.quantity = qty if qty else None
+    row.actual_qty = Decimal(str(prod.actual_qty)) if prod.actual_qty is not None else None
+    row.tax_price = tax_price
+    row.tax_amount = prod.tax_amount
+    row.tax_subtotal = prod.tax_subtotal
+    row.is_explosive = prod.is_explosive
+    row.is_toxic = prod.is_toxic
+    row.purchase_cert_url = prod.purchase_cert_url
+    row.transport_cert_url = prod.transport_cert_url
+    row.legal_use_cert_url = prod.legal_use_cert_url
+    row.inbound_price = tax_price
+    row.outbound_price = tax_price
+    row.inbound_amount = prod.tax_subtotal
+    row.outbound_amount = prod.tax_subtotal
+    row.remark = prod.remark
+    row.order_num = order_num
+    row.update_by = user_id
+    row.del_flag = 0
+    for key, value in estimates.items():
+        setattr(row, key, value)
+
+
 def _apply_vehicle_snapshot_to_plan_transport(
     db: Session,
     *,

+ 42 - 0
backend/tests/test_pl_plan_product_upsert.py

@@ -0,0 +1,42 @@
+"""build_plan_product 应支持传入 product_row_id,编辑时保持 pl_plan_product.id 稳定。"""
+from app.schemas.pl.plan import PlanProductCreate
+from app.services import pl_plan_service as svc
+from app.services.pl_plan_service import build_plan_product
+
+
+def _minimal_prod(**kwargs) -> PlanProductCreate:
+    data = {
+        "product_id": "crm-p1",
+        "product_no": "P001",
+        "product_name": "甲醇",
+    }
+    data.update(kwargs)
+    return PlanProductCreate(**data)
+
+
+def test_build_plan_product_keeps_row_id():
+    result = build_plan_product(
+        tenant_no="t1",
+        plan_id="plan-1",
+        plan_type="outbound",
+        prod=_minimal_prod(),
+        order_num=0,
+        user_id="u1",
+        dept_id="d1",
+        product_row_id="pp-1",
+    )
+    assert result.id == "pp-1"
+
+
+def test_build_plan_product_generates_when_missing(monkeypatch):
+    monkeypatch.setattr(svc, "generate_uuid", lambda: "gen-1")
+    result = build_plan_product(
+        tenant_no="t1",
+        plan_id="plan-1",
+        plan_type="outbound",
+        prod=_minimal_prod(),
+        order_num=0,
+        user_id="u1",
+        dept_id="d1",
+    )
+    assert result.id == "gen-1"