diff --git a/backend/app/services/pl_plan_stop_service.py b/backend/app/services/pl_plan_stop_service.py index 16b0a48b9..49da7d5d3 100644 --- a/backend/app/services/pl_plan_stop_service.py +++ b/backend/app/services/pl_plan_stop_service.py @@ -687,12 +687,13 @@ def sync_plan_legacy_from_stops( plan.destination_notes = first.notes -def _soft_delete_plan_stops( +def _soft_delete_load_products_and_stop_contracts( db: Session, tenant_no: str, plan_id: str, user_id: Optional[str], ) -> None: + """软删装货产品与装/送货段合同;送货段本体由 upsert 保留主键,不在此全量软删。""" now = datetime.now() load_ids = [ row.id @@ -710,28 +711,6 @@ def _soft_delete_plan_stops( PlPlanLoadProduct.del_flag == 0, ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) - delivery_ids = [ - row.id - for row in db.query(PlPlanDelivery) - .filter( - PlPlanDelivery.plan_id == plan_id, - PlPlanDelivery.tenant_no == tenant_no, - PlPlanDelivery.del_flag == 0, - ) - .all() - ] - if delivery_ids: - db.query(PlPlanDeliveryProduct).filter( - PlPlanDeliveryProduct.delivery_id.in_(delivery_ids), - PlPlanDeliveryProduct.del_flag == 0, - ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) - - db.query(PlPlanDelivery).filter( - PlPlanDelivery.plan_id == plan_id, - PlPlanDelivery.tenant_no == tenant_no, - PlPlanDelivery.del_flag == 0, - ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) - db.query(PlPlanContract).filter( PlPlanContract.plan_id == plan_id, PlPlanContract.tenant_no == tenant_no, @@ -742,6 +721,35 @@ def _soft_delete_plan_stops( ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) +def _soft_delete_delivery_products( + db: Session, + delivery_ids: list[str], + user_id: Optional[str], + now: datetime, +) -> None: + if not delivery_ids: + return + db.query(PlPlanDeliveryProduct).filter( + PlPlanDeliveryProduct.delivery_id.in_(delivery_ids), + PlPlanDeliveryProduct.del_flag == 0, + ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) + + +def resolve_delivery_id_for_upsert( + *, + plan_id: str, + payload_id: Optional[str], + existing_by_id: dict, +) -> tuple[str, Optional[object]]: + pid = (payload_id or "").strip() + if not pid: + return generate_uuid(), None + row = existing_by_id.get(pid) + if row is None or getattr(row, "plan_id", None) != plan_id: + raise HTTPException(status_code=422, detail=f"送货段 id 无效或不属于本计划:{pid}") + return pid, row + + def _write_stop_contracts( db: Session, tenant_no: str, @@ -884,7 +892,7 @@ def replace_plan_stops( dept_id: Optional[str], plan_products: Optional[list] = None, ) -> None: - _soft_delete_plan_stops(db, tenant_no, plan_id, user_id) + _soft_delete_load_products_and_stop_contracts(db, tenant_no, plan_id, user_id) now = datetime.now() load_id = _resolve_or_create_plan_load( @@ -940,6 +948,17 @@ def replace_plan_stops( contract_order, ) + existing_deliveries = ( + db.query(PlPlanDelivery) + .filter( + PlPlanDelivery.plan_id == plan_id, + PlPlanDelivery.tenant_no == tenant_no, + ) + .all() + ) + existing_by_id = {row.id: row for row in existing_deliveries} + kept_ids: set[str] = set() + for delivery in deliveries: generate_dibang, dibang_direction = normalize_stop_dibang_fields( delivery.customer_name, @@ -948,32 +967,57 @@ def replace_plan_stops( delivery.dibang_direction, ) - delivery_id = generate_uuid() - db.add( - PlPlanDelivery( - id=delivery_id, - plan_id=plan_id, - seq=delivery.seq or 1, - customer_id=delivery.customer_id, - customer_name=delivery.customer_name, - address=delivery.address, - lng=delivery.lng, - lat=delivery.lat, - delivery_time=delivery.delivery_time, - contact=delivery.contact, - notes=delivery.notes, - generate_dibang=generate_dibang, - dibang_direction=dibang_direction, - tenant_no=tenant_no, - remark=delivery.remark, - create_by=user_id, - create_dept_id=dept_id or "", - create_time=now, - update_by=user_id, - update_time=now, - del_flag=0, - ) + delivery_id, existing = resolve_delivery_id_for_upsert( + plan_id=plan_id, + payload_id=delivery.id, + existing_by_id=existing_by_id, ) + kept_ids.add(delivery_id) + + if existing is not None: + existing.seq = delivery.seq or 1 + existing.customer_id = delivery.customer_id + existing.customer_name = delivery.customer_name + existing.address = delivery.address + existing.lng = delivery.lng + existing.lat = delivery.lat + existing.delivery_time = delivery.delivery_time + existing.contact = delivery.contact + existing.notes = delivery.notes + existing.generate_dibang = generate_dibang + existing.dibang_direction = dibang_direction + existing.remark = delivery.remark + existing.del_flag = 0 + existing.update_by = user_id + existing.update_time = now + else: + db.add( + PlPlanDelivery( + id=delivery_id, + plan_id=plan_id, + seq=delivery.seq or 1, + customer_id=delivery.customer_id, + customer_name=delivery.customer_name, + address=delivery.address, + lng=delivery.lng, + lat=delivery.lat, + delivery_time=delivery.delivery_time, + contact=delivery.contact, + notes=delivery.notes, + generate_dibang=generate_dibang, + dibang_direction=dibang_direction, + tenant_no=tenant_no, + remark=delivery.remark, + create_by=user_id, + create_dept_id=dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + ) + + _soft_delete_delivery_products(db, [delivery_id], user_id, now) for idx, prod in enumerate(delivery.products or []): plan_product_id = _resolve_plan_product_id(prod, plan_product_rows) @@ -1014,3 +1058,12 @@ def replace_plan_stops( dept_id, contract_order, ) + + removed_ids = [row.id for row in existing_deliveries if row.id not in kept_ids and row.del_flag == 0] + if removed_ids: + _soft_delete_delivery_products(db, removed_ids, user_id, now) + db.query(PlPlanDelivery).filter( + PlPlanDelivery.id.in_(removed_ids), + PlPlanDelivery.tenant_no == tenant_no, + PlPlanDelivery.del_flag == 0, + ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) diff --git a/backend/tests/test_pl_plan_stop_upsert.py b/backend/tests/test_pl_plan_stop_upsert.py new file mode 100644 index 000000000..100308351 --- /dev/null +++ b/backend/tests/test_pl_plan_stop_upsert.py @@ -0,0 +1,43 @@ +"""replace_plan_stops 应按 payload.id upsert 送货段,而非每次换新 UUID。""" +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from app.services.pl_plan_stop_service import resolve_delivery_id_for_upsert + + +def test_upsert_delivery_rejects_foreign_plan_id(): + with pytest.raises(HTTPException) as ei: + resolve_delivery_id_for_upsert( + plan_id="plan-1", + payload_id="del-other-plan", + existing_by_id={}, + ) + assert ei.value.status_code == 422 + + +def test_upsert_delivery_keeps_payload_id(): + existing = MagicMock() + existing.id = "del-keep" + existing.plan_id = "plan-1" + new_id, row = resolve_delivery_id_for_upsert( + plan_id="plan-1", + payload_id="del-keep", + existing_by_id={"del-keep": existing}, + ) + assert new_id == "del-keep" + assert row is existing + + +def test_upsert_delivery_generates_id_when_missing(monkeypatch): + from app.services import pl_plan_stop_service as svc + + monkeypatch.setattr(svc, "generate_uuid", lambda: "new-del") + new_id, row = resolve_delivery_id_for_upsert( + plan_id="plan-1", + payload_id=None, + existing_by_id={}, + ) + assert new_id == "new-del" + assert row is None