diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index c07219f4e..49dc9c441 100644 --- a/backend/app/api/pl/plan.py +++ b/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: diff --git a/backend/app/services/pl_plan_service.py b/backend/app/services/pl_plan_service.py index 10ed7d184..071061c71 100644 --- a/backend/app/services/pl_plan_service.py +++ b/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, *, diff --git a/backend/tests/test_pl_plan_product_upsert.py b/backend/tests/test_pl_plan_product_upsert.py new file mode 100644 index 000000000..605853b90 --- /dev/null +++ b/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"