迁移合同费用通用附件

1、删除旧费用附件 API、CRUD、模型和专用前端面板,统一改用通用附件能力。

2、调整合同费用接口为 study_id 语义,并补充合同、凭证、发票等附件类型的权限映射。

3、将合同费用新建和编辑改为抽屉流程,详情页与列表页复用同一编辑组件。

4、补充数据库迁移、附件列表、合同费用抽屉和权限场景测试,覆盖旧权限移除与新流程。
This commit is contained in:
Cheng Zhou
2026-06-04 11:07:13 +08:00
parent ab4f0d93ed
commit 720c98765f
36 changed files with 2576 additions and 1875 deletions
@@ -4,17 +4,49 @@ from datetime import date
from decimal import Decimal
import uuid
from starlette.requests import Request
from app.api.v1 import fees_contracts
from app.models.contract_fee import ContractFee
from app.schemas.contract_fee import ContractFeeCreate, ContractFeeRead, ContractFeeUpdate
def _request_with_query(query: str) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/api/v1/fees/contracts",
"query_string": query.encode(),
"headers": [],
}
)
def test_contract_fee_list_query_accepts_legacy_project_params():
"""合同费用列表应兼容旧 projectId/centerId 查询参数。"""
study_id = uuid.uuid4()
center_id = uuid.uuid4()
resolver = getattr(fees_contracts, "_resolve_contract_fee_list_query", None)
assert resolver is not None
resolved_study_id, resolved_center_id = resolver(
_request_with_query(f"projectId={study_id}&centerId={center_id}"),
None,
None,
)
assert resolved_study_id == study_id
assert resolved_center_id == center_id
def test_contract_fee_schema_includes_contract_basic_fields():
"""合同费用应包含合同基础信息字段。"""
project_id = uuid.uuid4()
study_id = uuid.uuid4()
center_id = uuid.uuid4()
payload = ContractFeeCreate(
project_id=project_id,
study_id=study_id,
center_id=center_id,
contract_no="CT-001",
signed_date=date(2026, 5, 27),
@@ -28,6 +60,8 @@ def test_contract_fee_schema_includes_contract_basic_fields():
assert payload.signed_date == date(2026, 5, 27)
assert payload.currency == "CNY"
assert payload.remark == "首版合同"
assert payload.study_id == study_id
assert "project_id" not in ContractFeeCreate.model_fields
update_payload = ContractFeeUpdate(contract_no="CT-002", currency="USD", remark="变更合同信息")
assert update_payload.model_dump(exclude_unset=True) == {
@@ -38,6 +72,8 @@ def test_contract_fee_schema_includes_contract_basic_fields():
for field in ("contract_no", "signed_date", "currency", "remark"):
assert field in ContractFeeRead.model_fields
assert "study_id" in ContractFeeRead.model_fields
assert "project_id" not in ContractFeeRead.model_fields
def test_contract_fee_model_includes_contract_basic_columns():
@@ -23,6 +23,16 @@ class _FaqItem:
self.study_id = study_id
class _ContractFee:
def __init__(self, study_id: uuid.UUID | None):
self.study_id = study_id
class _ContractPayment:
def __init__(self, contract_fee_id: uuid.UUID):
self.contract_fee_id = contract_fee_id
def _make_request(*, method: str = "POST", path: str = "/", query_string: bytes = b"", json_body: bytes = b"", path_params=None):
async def receive():
return {"type": "http.request", "body": json_body, "more_body": False}
@@ -80,6 +90,64 @@ async def test_require_study_not_locked_resolves_study_id_from_category_path(mon
assert study.id == study_id
@pytest.mark.asyncio
async def test_require_study_not_locked_resolves_study_id_from_contract_path(monkeypatch):
study_id = uuid.uuid4()
contract_id = uuid.uuid4()
async def fake_get_contract(_db, requested_contract_id):
assert requested_contract_id == contract_id
return _ContractFee(study_id)
async def fake_get_study(_db, requested_study_id):
return _Study(requested_study_id, is_locked=False)
monkeypatch.setattr("app.crud.contract_fee.get_contract_fee", fake_get_contract)
monkeypatch.setattr("app.crud.study.get", fake_get_study)
dependency = deps.require_study_not_locked()
request = _make_request(
path=f"/api/v1/fees/contracts/{contract_id}",
path_params={"contract_id": str(contract_id)},
)
study = await dependency(request=request, db=object())
assert study.id == study_id
@pytest.mark.asyncio
async def test_require_study_not_locked_resolves_study_id_from_contract_payment_path(monkeypatch):
study_id = uuid.uuid4()
contract_id = uuid.uuid4()
payment_id = uuid.uuid4()
async def fake_get_payment(_db, requested_payment_id):
assert requested_payment_id == payment_id
return _ContractPayment(contract_id)
async def fake_get_contract(_db, requested_contract_id):
assert requested_contract_id == contract_id
return _ContractFee(study_id)
async def fake_get_study(_db, requested_study_id):
return _Study(requested_study_id, is_locked=False)
monkeypatch.setattr("app.crud.contract_fee_payment.get_payment", fake_get_payment)
monkeypatch.setattr("app.crud.contract_fee.get_contract_fee", fake_get_contract)
monkeypatch.setattr("app.crud.study.get", fake_get_study)
dependency = deps.require_study_not_locked()
request = _make_request(
path=f"/api/v1/fees/payments/{payment_id}",
path_params={"payment_id": str(payment_id)},
)
study = await dependency(request=request, db=object())
assert study.id == study_id
@pytest.mark.asyncio
async def test_require_study_not_locked_rejects_locked_study_resolved_from_item_path(monkeypatch):
study_id = uuid.uuid4()