Explorar o código

fix: enforce atomic presign status and action transitions.

zlwu hai 3 semanas
pai
achega
3f40caa1cd

+ 11 - 1
backend/app/services/market_presign.py

@@ -37,6 +37,11 @@ def _dump_transfer_body(body: MarketPresignTransferBody) -> dict:
     return body.model_dump(by_alias=False, exclude_unset=True)
 
 
+def _reject_direct_conversion(fields: dict) -> None:
+    if str(fields.get('order_status') or '').strip() == 'converted':
+        raise ValueError('预签约订单必须通过转正式操作进入已转正式状态')
+
+
 def _assert_transfer_order_writable(order: BizPresignOrder) -> None:
     if (order.order_status or '').strip() not in ('', 'draft', 'active'):
         raise ValueError('当前状态的预签约订单不可操作转账记录')
@@ -266,6 +271,7 @@ def create_presign_order(
     user_info: dict,
 ) -> dict:
     fields = _dump_body(body)
+    _reject_direct_conversion(fields)
     sample_type_ids = fields.pop("sample_type_ids", None)
     transfers = fields.pop("transfers", None)
     if transfers and (
@@ -361,6 +367,7 @@ def update_presign_order(
         if (current.order_status or '').strip() == 'converted':
             raise ValueError('当前状态的预签约订单不可修改')
         fields = _dump_body(body)
+        _reject_direct_conversion(fields)
         sample_type_ids = fields.pop("sample_type_ids", None)
         fields.pop("transfers", None)
         resulting_region_id = fields.get("region_id", current.region_id)
@@ -402,13 +409,16 @@ def update_presign_order(
             fields=fields,
             sample_type_ids=sample_type_ids,
             create_dept_id=_current_dept_id(user_info),
+            commit=False,
         )
         row = assert_presign_access(
             db, row.id, ctx, user_info=user_info
         )
-        return presign_crud.order_to_dict(
+        result = presign_crud.order_to_dict(
             db, row, ctx.tenant_no, with_children=True
         )
+        db.commit()
+        return result
     except Exception:
         db.rollback()
         raise

+ 14 - 4
backend/app/services/market_support.py

@@ -200,7 +200,6 @@ def get_form_options(
     selected_region = str(region_id or '').strip()
     if selected_region and selected_region not in region_ids:
         raise PermissionError('所选地区不在当前用户权限范围内')
-    hospital_regions = [selected_region] if selected_region else region_ids
     regions = []
     if region_ids:
         regions = (
@@ -213,6 +212,12 @@ def get_form_options(
             .order_by(BizCollectionRegion.order_num.asc(), BizCollectionRegion.region_name.asc())
             .all()
         )
+    active_region_ids = [str(row.id) for row in regions]
+    if selected_region and selected_region not in active_region_ids:
+        raise PermissionError('所选地区不存在或已停用')
+    hospital_regions = (
+        [selected_region] if selected_region else active_region_ids
+    )
     hospitals = []
     staff_rows = []
     if hospital_regions:
@@ -702,12 +707,17 @@ def decorate_allowed_actions(
         )
         return data
     if module == 'presign' and detail:
+        parent_writable = status.lower() in {'', 'draft', 'active'}
         transfers = []
         for transfer in data.get('transfers') or []:
             transfer_data = dict(transfer)
-            transfer_data['allowedActions'] = allowed_actions(
-                'presign-transfer',
-                status=str(transfer_data.get('payStatus') or ''),
+            transfer_data['allowedActions'] = (
+                allowed_actions(
+                    'presign-transfer',
+                    status=str(transfer_data.get('payStatus') or ''),
+                )
+                if parent_writable
+                else []
             )
             transfers.append(transfer_data)
         data['transfers'] = transfers

+ 90 - 0
backend/tests/test_market_presign_api.py

@@ -539,6 +539,96 @@ def test_specialist_create_forces_current_sales_user_and_accepts_order_status():
     assert row.order_status == "void"
 
 
+def test_create_rejects_client_requested_converted_status_without_writing():
+    from app.schemas.market_mobile import MarketPresignOrderBody
+    from app.services.market_presign import create_presign_order
+
+    db = _session()
+    _add_master_data(db)
+
+    with pytest.raises(ValueError, match='转正式|converted'):
+        create_presign_order(
+            db,
+            body=MarketPresignOrderBody.model_validate(
+                {
+                    'motherName': '不可直转客户',
+                    'regionId': 'region-a',
+                    'hospitalId': 'hospital-a',
+                    'orderStatus': 'converted',
+                }
+            ),
+            ctx=_ctx('specialist'),
+            user_info=_user(),
+        )
+
+    assert db.query(BizPresignOrder).count() == 0
+
+
+@pytest.mark.parametrize('current_status', ['active', 'draft', 'void'])
+def test_update_rejects_client_requested_converted_status_without_writing(
+    current_status,
+):
+    from app.schemas.market_mobile import MarketPresignOrderBody
+    from app.services.market_presign import update_presign_order
+
+    db = _session()
+    _add_master_data(db)
+    order = _order('own', sales_user_id='u1')
+    order.order_status = current_status
+    order.remark = 'before'
+    db.add(order)
+    db.commit()
+
+    with pytest.raises(ValueError, match='转正式|converted'):
+        update_presign_order(
+            db,
+            order_id='own',
+            body=MarketPresignOrderBody.model_validate(
+                {'orderStatus': 'converted', 'remark': 'after'}
+            ),
+            ctx=_ctx('specialist'),
+            user_info=_user(),
+        )
+
+    db.expire_all()
+    unchanged = db.query(BizPresignOrder).filter_by(id='own').one()
+    assert unchanged.order_status == current_status
+    assert unchanged.remark == 'before'
+
+
+def test_update_rolls_back_when_response_serialization_fails(monkeypatch):
+    from app.crud import presign_order as presign_crud
+    from app.schemas.market_mobile import MarketPresignOrderBody
+    from app.services.market_presign import update_presign_order
+
+    db = _session()
+    _add_master_data(db)
+    order = _order('own', sales_user_id='u1')
+    order.remark = 'before'
+    db.add(order)
+    db.commit()
+
+    monkeypatch.setattr(
+        presign_crud,
+        'order_to_dict',
+        lambda *args, **kwargs: (_ for _ in ()).throw(
+            RuntimeError('serialize failed')
+        ),
+    )
+
+    with pytest.raises(RuntimeError, match='serialize failed'):
+        update_presign_order(
+            db,
+            order_id='own',
+            body=MarketPresignOrderBody(remark='after'),
+            ctx=_ctx('specialist'),
+            user_info=_user(),
+        )
+
+    db.expire_all()
+    assert db.query(BizPresignOrder).filter_by(id='own').one().remark == 'before'
+
+
 def test_create_rolls_back_order_children_and_transfers_when_data_scope_rejects():
     from app.schemas.market_mobile import MarketPresignOrderBody
     from app.services.market_presign import create_presign_order

+ 79 - 0
backend/tests/test_market_support_api.py

@@ -712,6 +712,85 @@ def test_presign_actions_include_delete_and_nested_transfer_actions():
         [],
     ]
 
+    for parent_status, parent_actions in (
+        ('converted', ['edit']),
+        ('void', ['edit', 'delete']),
+    ):
+        locked = decorate_allowed_actions(
+            'presign',
+            {
+                'orderStatus': parent_status,
+                'transfers': [
+                    {'id': 't1', 'payStatus': 'dfj'},
+                    {'id': 't2', 'payStatus': 'jfqrz'},
+                ],
+            },
+            scope_kind='specialist',
+            detail=True,
+        )
+        assert locked['allowedActions'] == parent_actions
+        assert [row['allowedActions'] for row in locked['transfers']] == [[], []]
+
+    unknown = decorate_allowed_actions(
+        'presign',
+        {
+            'orderStatus': 'legacy-locked',
+            'transfers': [{'id': 't1', 'payStatus': 'dfj'}],
+        },
+        scope_kind='specialist',
+        detail=True,
+    )
+    assert unknown['transfers'][0]['allowedActions'] == []
+
+
+def test_form_options_do_not_leak_children_of_soft_deleted_regions():
+    from app.services.market_support import get_form_options
+
+    db = _session()
+    _seed_options(db)
+    db.add_all(
+        [
+            BizCollectionRegion(
+                id='deleted-region',
+                region_name='已删除地区',
+                tenant_no='tenant-a',
+                create_dept_id='',
+                del_flag=1,
+            ),
+            BizCollectionHospital(
+                id='deleted-region-hospital',
+                region_id='deleted-region',
+                hospital_name='不应泄漏医院',
+                tenant_no='tenant-a',
+                create_dept_id='',
+                del_flag=0,
+            ),
+            BizCollectionRegionStaff(
+                id='deleted-region-staff',
+                region_id='deleted-region',
+                user_id='user-a',
+                is_leader=0,
+                tenant_no='tenant-a',
+                create_dept_id='',
+                del_flag=0,
+            ),
+        ]
+    )
+    db.commit()
+
+    out = get_form_options(db, module='presign', ctx=_ctx(), user_info=_user())
+
+    assert '已删除地区' not in str(out)
+    assert '不应泄漏医院' not in str(out)
+    with pytest.raises(PermissionError, match='地区'):
+        get_form_options(
+            db,
+            module='presign',
+            ctx=_ctx(),
+            user_info=_user(),
+            region_id='deleted-region',
+        )
+
 
 def test_decorate_allowed_actions_covers_nested_payment_transfers():
     from app.services.market_support import decorate_allowed_actions