diff --git a/shierfeng-fastapi-backend/module_business/dao/master_dao.py b/shierfeng-fastapi-backend/module_business/dao/master_dao.py index b39445c..24adefd 100644 --- a/shierfeng-fastapi-backend/module_business/dao/master_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/master_dao.py @@ -78,6 +78,14 @@ _REFERENCE_COLUMNS: dict[MasterResource, tuple[tuple[InstrumentedAttribute, ...] ), } +_MASTER_ID_COLUMNS: dict[MasterResource, InstrumentedAttribute] = { + 'strain': BizStrain.strain_id, + 'customer': BizCustomer.customer_id, + 'site': BizCustomerSite.site_id, + 'line': BizProductionLine.line_id, + 'warehouse': BizWarehouse.warehouse_id, +} + _PALLET_REMOVAL_BLOCKING_COLUMNS: tuple[InstrumentedAttribute, ...] = ( BizInboundItem.pallet_id, BizFillingRecord.pallet_id, @@ -139,7 +147,7 @@ class MasterDao: id_field: str, page_object: BaseModel, ) -> Any: - payload = page_object.model_dump(exclude={id_field}) + payload = page_object.model_dump(exclude={id_field, 'can_remove'}) record = model(**payload) db.add(record) await db.flush() @@ -155,7 +163,7 @@ class MasterDao: ) -> None: payload = page_object.model_dump( exclude_unset=True, - exclude={'create_by', 'create_time'}, + exclude={'can_remove', 'create_by', 'create_time'}, ) resource_id = payload.pop(id_field) await db.execute(update(model).where(id_column == resource_id).values(**payload)) @@ -169,6 +177,38 @@ class MasterDao: ) -> None: await db.execute(delete(model).where(id_column == resource_id)) + @classmethod + async def get_master_removability( + cls, + db: AsyncSession, + resource_type: MasterResource, + resource_ids: list[int], + ) -> dict[int, bool]: + """按删除约束批量判断五类主数据是否允许删除。""" + removability = dict.fromkeys(resource_ids, False) + if not resource_ids: + return removability + + id_column = _MASTER_ID_COLUMNS.get(resource_type) + references = _REFERENCE_COLUMNS.get(resource_type) + if id_column is None or references is None: + raise ValueError(f'不支持的主数据类型:{resource_type}') + + has_no_references = [] + for columns in references: + predicates = [column == id_column for column in columns] + predicate = predicates[0] if len(predicates) == 1 else or_(*predicates) + has_no_references.append(~select(columns[0]).where(predicate).exists()) + + statement = select( + id_column, + and_(*has_no_references).label('can_remove'), + ).where(id_column.in_(resource_ids)) + result = await db.execute(statement) + for resource_id, can_remove in result.all(): + removability[resource_id] = bool(can_remove) + return removability + @classmethod async def list_strain( cls, diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py index 2c2894b..996f39d 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py @@ -32,6 +32,11 @@ class StrainModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') + can_remove: bool | None = Field( + default=None, + description='是否允许删除', + json_schema_extra={'readOnly': True}, + ) class StrainPageQueryModel(BaseModel): @@ -86,6 +91,11 @@ class CustomerModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') + can_remove: bool | None = Field( + default=None, + description='是否允许删除', + json_schema_extra={'readOnly': True}, + ) class CustomerPageQueryModel(BaseModel): @@ -148,6 +158,11 @@ class SiteModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') + can_remove: bool | None = Field( + default=None, + description='是否允许删除', + json_schema_extra={'readOnly': True}, + ) class SitePageQueryModel(BaseModel): @@ -204,6 +219,11 @@ class LineModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') + can_remove: bool | None = Field( + default=None, + description='是否允许删除', + json_schema_extra={'readOnly': True}, + ) class LinePageQueryModel(BaseModel): @@ -260,6 +280,11 @@ class WarehouseModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') + can_remove: bool | None = Field( + default=None, + description='是否允许删除', + json_schema_extra={'readOnly': True}, + ) class WarehousePageQueryModel(BaseModel): @@ -386,7 +411,7 @@ class UserAssignModel(BaseModel): extra='forbid', ) - user_ids: list[PositiveId] = Field(min_length=1, description='用户ID列表') + user_ids: list[PositiveId] = Field(description='用户ID列表') _validate_unique_ids = field_validator('user_ids')(_ensure_unique_ids) diff --git a/shierfeng-fastapi-backend/module_business/service/master_service.py b/shierfeng-fastapi-backend/module_business/service/master_service.py index 26e067e..6f8917b 100644 --- a/shierfeng-fastapi-backend/module_business/service/master_service.py +++ b/shierfeng-fastapi-backend/module_business/service/master_service.py @@ -143,8 +143,24 @@ class MasterService: resource: MasterResource, query_object: BaseModel, is_page: bool, - ) -> PageModel | list[dict[str, Any]]: - return await getattr(MasterDao, f'list_{resource}')(db, query_object, is_page) + ) -> PageModel | list[BaseModel]: + result = await getattr(MasterDao, f'list_{resource}')(db, query_object, is_page) + config = _RESOURCE_CONFIG[resource] + records = result.rows if isinstance(result, PageModel) else result + models = [config['vo'].model_validate(record) for record in records] + id_field = config['id_field'] + removability = await MasterDao.get_master_removability( + db, + resource, + [getattr(model, id_field) for model in models], + ) + rows = [ + model.model_copy(update={'can_remove': removability.get(getattr(model, id_field), False)}) + for model in models + ] + if isinstance(result, PageModel): + return result.model_copy(update={'rows': rows}) + return rows @classmethod async def _detail(cls, db: AsyncSession, resource: MasterResource, resource_id: int) -> BaseModel: @@ -152,7 +168,9 @@ class MasterService: record = await getattr(MasterDao, f'get_{resource}')(db, resource_id) if record is None: raise ServiceException(message=f'{config["label"]}不存在') - return config['vo'].model_validate(record) + model = config['vo'].model_validate(record) + removability = await MasterDao.get_master_removability(db, resource, [resource_id]) + return model.model_copy(update={'can_remove': removability.get(resource_id, False)}) @classmethod async def _add( @@ -235,7 +253,7 @@ class MasterService: db: AsyncSession, query_object: StrainPageQueryModel, is_page: bool = True, - ) -> PageModel | list[dict[str, Any]]: + ) -> PageModel | list[StrainModel]: return await cls._list(db, 'strain', query_object, is_page) @classmethod @@ -264,7 +282,7 @@ class MasterService: db: AsyncSession, query_object: CustomerPageQueryModel, is_page: bool = True, - ) -> PageModel | list[dict[str, Any]]: + ) -> PageModel | list[CustomerModel]: return await cls._list(db, 'customer', query_object, is_page) @classmethod @@ -293,7 +311,7 @@ class MasterService: db: AsyncSession, query_object: SitePageQueryModel, is_page: bool = True, - ) -> PageModel | list[dict[str, Any]]: + ) -> PageModel | list[SiteModel]: return await cls._list(db, 'site', query_object, is_page) @classmethod @@ -322,7 +340,7 @@ class MasterService: db: AsyncSession, query_object: LinePageQueryModel, is_page: bool = True, - ) -> PageModel | list[dict[str, Any]]: + ) -> PageModel | list[LineModel]: return await cls._list(db, 'line', query_object, is_page) @classmethod @@ -351,7 +369,7 @@ class MasterService: db: AsyncSession, query_object: WarehousePageQueryModel, is_page: bool = True, - ) -> PageModel | list[dict[str, Any]]: + ) -> PageModel | list[WarehouseModel]: return await cls._list(db, 'warehouse', query_object, is_page) @classmethod diff --git a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py index 0c7c30e..61b2eed 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py @@ -108,6 +108,91 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))) ) + async def test_list_populates_true_and_false_removability_for_all_resources(self) -> None: + for resource, id_field in RESOURCE_IDS.items(): + rows = [make_models(1)[resource], make_models(2)[resource]] + page = PageModel(rows=rows, pageNum=1, pageSize=10, total=2, hasNext=False) + removability_mock = AsyncMock(return_value={1: True, 2: False}) + with ( + self.subTest(resource=resource), + patch.object(MasterDao, f'list_{resource}', new=AsyncMock(return_value=page)), + patch.object( + MasterDao, + 'get_master_removability', + new=removability_mock, + create=True, + ), + ): + result = await getattr(MasterService, f'list_{resource}')(self.db, MagicMock()) + + self.assertEqual( + [True, False], + [row.model_dump().get('can_remove') for row in result.rows], + ) + removability_mock.assert_awaited_once_with( + self.db, + resource, + [getattr(row, id_field) for row in rows], + ) + + async def test_detail_populates_true_and_false_removability_for_all_resources(self) -> None: + for resource in RESOURCE_IDS: + for can_remove in (True, False): + record = make_models(7)[resource] + removability_mock = AsyncMock(return_value={7: can_remove}) + with ( + self.subTest(resource=resource, can_remove=can_remove), + patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=record)), + patch.object( + MasterDao, + 'get_master_removability', + new=removability_mock, + create=True, + ), + ): + result = await getattr(MasterService, f'detail_{resource}')(self.db, 7) + + self.assertIs(can_remove, result.model_dump().get('can_remove')) + removability_mock.assert_awaited_once_with(self.db, resource, [7]) + + async def test_non_paginated_list_populates_removability_for_all_resources(self) -> None: + for resource, id_field in RESOURCE_IDS.items(): + records = [make_models(1)[resource], make_models(2)[resource]] + removability_mock = AsyncMock(return_value={1: False, 2: True}) + with ( + self.subTest(resource=resource), + patch.object(MasterDao, f'list_{resource}', new=AsyncMock(return_value=records)) as list_mock, + patch.object(MasterDao, 'get_master_removability', new=removability_mock), + ): + query = MagicMock() + result = await getattr(MasterService, f'list_{resource}')(self.db, query, is_page=False) + + self.assertIsInstance(result, list) + self.assertEqual( + [False, True], + [row.model_dump().get('can_remove') for row in result], + ) + list_mock.assert_awaited_once_with(self.db, query, False) + removability_mock.assert_awaited_once_with( + self.db, + resource, + [getattr(record, id_field) for record in records], + ) + + def test_removability_uses_camel_case_json_alias_for_all_resources(self) -> None: + for resource, model in make_models(1).items(): + with self.subTest(resource=resource): + response_model = model.model_copy(update={'can_remove': True}) + + payload = response_model.model_dump(by_alias=True) + + self.assertIs(True, payload.get('canRemove')) + self.assertNotIn('can_remove', payload) + self.assertIs( + True, + type(model).model_json_schema(by_alias=True)['properties']['canRemove'].get('readOnly'), + ) + async def test_duplicate_strain_code_is_rejected(self) -> None: with ( patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=object())), @@ -560,6 +645,33 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): self.db.commit.assert_not_awaited() self.db.rollback.assert_not_awaited() + async def test_add_and_update_exclude_read_only_removability_from_orm_payloads(self) -> None: + table_names = { + 'strain': 'biz_strain', + 'customer': 'biz_customer', + 'site': 'biz_customer_site', + 'line': 'biz_production_line', + 'warehouse': 'biz_warehouse', + } + for resource, base_model in make_models(7).items(): + payload = base_model.model_dump(by_alias=True) + payload['canRemove'] = False + try: + model = type(base_model).model_validate(payload) + except ValueError as exc: + self.fail(f'{resource} 缺少只读 canRemove 契约:{exc}') + + with self.subTest(resource=resource): + self.db.reset_mock() + record = await getattr(MasterDao, f'add_{resource}')(self.db, model) + self.assertNotIn('can_remove', record.__dict__) + + self.db.reset_mock() + await getattr(MasterDao, f'update_{resource}')(self.db, model) + update_sql = compile_sql(self.db.execute.await_args.args[0]) + self.assertIn(f'UPDATE {table_names[resource]} SET', update_sql) + self.assertNotIn('can_remove', update_sql) + async def test_update_and_delete_statements_use_primary_key_and_preserve_creation_audit(self) -> None: table_names = { 'strain': 'biz_strain', @@ -639,6 +751,51 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): self.assertIn(f'{table}.{column} = 9', sql) self.db.commit.assert_not_awaited() + async def test_master_removability_uses_the_same_reference_constraints_as_delete(self) -> None: + expected = { + 'strain': (('biz_filling_record', ('strain_id',)),), + 'customer': (('biz_customer_site', ('customer_id',)),), + 'site': ( + ('biz_production_line', ('site_id',)), + ('biz_user_site', ('site_id',)), + ('biz_shipment_order', ('target_site_id',)), + ('biz_usage_record', ('site_id',)), + ('biz_return_order', ('source_site_id',)), + ('biz_pallet_inventory', ('site_id',)), + ('biz_pallet_event', ('before_site_id', 'after_site_id')), + ), + 'line': ( + ('biz_usage_record', ('line_id',)), + ('biz_pallet_inventory', ('current_line_id',)), + ('biz_pallet_event', ('before_line_id', 'after_line_id')), + ), + 'warehouse': ( + ('biz_user_warehouse', ('warehouse_id',)), + ('biz_inbound_order', ('warehouse_id',)), + ('biz_filling_record', ('warehouse_id',)), + ('biz_shipment_order', ('source_warehouse_id',)), + ('biz_return_order', ('target_warehouse_id',)), + ('biz_pallet_inventory', ('warehouse_id',)), + ('biz_pallet_event', ('before_warehouse_id', 'after_warehouse_id')), + ), + } + method = getattr(MasterDao, 'get_master_removability', None) + self.assertIsNotNone(method) + + for resource, references in expected.items(): + result = MagicMock() + result.all.return_value = [(1, True), (2, False)] + self.db.execute = AsyncMock(return_value=result) + + removability = await method(self.db, resource, [1, 2, 3]) + + self.assertEqual({1: True, 2: False, 3: False}, removability) + sql = compile_sql(self.db.execute.await_args.args[0]) + for table, columns in references: + self.assertIn(f'FROM {table}', sql) + for column in columns: + self.assertIn(f'{table}.{column}', sql) + class TestMasterControllers(unittest.TestCase): ROUTERS = { diff --git a/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py b/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py index ef5bcc8..74da974 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py @@ -993,6 +993,51 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): self.db.rollback.assert_not_awaited() self.assertTrue(result.is_success) + async def test_assignment_put_allows_empty_complete_replacement(self) -> None: + try: + assignment = UserAssignModel(userIds=[]) + except ValidationError as exc: + self.fail(f'完整替换用户关联必须允许空数组:{exc}') + + for resource, resource_id in (('warehouse', 7), ('site', 9)): + with self.subTest(resource=resource): + self.db.reset_mock() + list_users_mock = AsyncMock(return_value=[]) + replace_mock = AsyncMock() + with ( + patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=SimpleNamespace())), + patch.object(MasterDao, 'list_assignable_users_by_ids', new=list_users_mock), + patch.object(AccessDao, f'replace_{resource}_users', new=replace_mock), + ): + result = await getattr(PalletService, f'assign_{resource}_users')( + self.db, + resource_id, + assignment, + ) + + list_users_mock.assert_awaited_once_with(self.db, []) + replace_mock.assert_awaited_once_with(self.db, resource_id, []) + self.db.commit.assert_awaited_once() + self.db.rollback.assert_not_awaited() + self.assertTrue(result.is_success) + + async def test_empty_replacement_deletes_links_without_adding_new_rows(self) -> None: + cases = ( + ('warehouse', 7, 'biz_user_warehouse', 'warehouse_id'), + ('site', 9, 'biz_user_site', 'site_id'), + ) + for resource, resource_id, table, id_field in cases: + with self.subTest(resource=resource): + self.db.reset_mock() + + await getattr(AccessDao, f'replace_{resource}_users')(self.db, resource_id, []) + + delete_sql = compile_sql(self.db.execute.await_args.args[0]) + self.assertIn(f'DELETE FROM {table}', delete_sql) + self.assertIn(f'{table}.{id_field} = {resource_id}', delete_sql) + self.db.add_all.assert_called_once_with([]) + self.db.flush.assert_awaited_once() + async def test_assignment_put_rejects_missing_disabled_or_deleted_user(self) -> None: replace = AsyncMock() assignment = UserAssignModel(userIds=[2, 5]) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py index ba07c54..88d4e06 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py @@ -81,6 +81,7 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', + 'can_remove', }, StrainPageQueryModel: {'page_num', 'page_size', 'strain_code', 'strain_name', 'status'}, DeleteStrainModel: {'strain_ids'}, @@ -94,6 +95,7 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', + 'can_remove', }, CustomerPageQueryModel: {'page_num', 'page_size', 'customer_code', 'customer_name', 'status'}, DeleteCustomerModel: {'customer_ids'}, @@ -109,6 +111,7 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', + 'can_remove', }, SitePageQueryModel: {'page_num', 'page_size', 'customer_id', 'site_code', 'site_name', 'status'}, DeleteSiteModel: {'site_ids'}, @@ -123,6 +126,7 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', + 'can_remove', }, LinePageQueryModel: {'page_num', 'page_size', 'site_id', 'line_code', 'line_name', 'status'}, DeleteLineModel: {'line_ids'}, @@ -137,6 +141,7 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', + 'can_remove', }, WarehousePageQueryModel: {'page_num', 'page_size', 'warehouse_code', 'warehouse_name', 'status'}, DeleteWarehouseModel: {'warehouse_ids'}, @@ -943,7 +948,6 @@ class BusinessVoContractTest(unittest.TestCase): (DeleteLineModel, {}, 'lineIds'), (DeleteWarehouseModel, {}, 'warehouseIds'), (DeletePalletModel, {}, 'palletIds'), - (UserAssignModel, {}, 'userIds'), (InboundCreateModel, {'warehouseId': 1}, 'palletIds'), (InboundUpdateModel, {'inboundId': 1, 'warehouseId': 1}, 'palletIds'), (ShipmentCreateModel, {'sourceWarehouseId': 1, 'targetSiteId': 2}, 'palletIds'), @@ -965,6 +969,16 @@ class BusinessVoContractTest(unittest.TestCase): with self.subTest(model=model_class.__name__, value=invalid_value), self.assertRaises(ValidationError): model_class.model_validate(payload | {field_name: invalid_value}) + def test_user_assignment_allows_empty_and_rejects_invalid_non_empty_ids(self) -> None: + try: + empty_assignment = UserAssignModel.model_validate({'userIds': []}) + except ValidationError as exc: + self.fail(f'完整替换用户关联必须允许空数组:{exc}') + self.assertEqual([], empty_assignment.user_ids) + for invalid_value in ([0], [1, 1]): + with self.subTest(value=invalid_value), self.assertRaises(ValidationError): + UserAssignModel.model_validate({'userIds': invalid_value}) + def test_pallet_edit_forbids_identity_fields(self) -> None: valid_payload = { 'palletId': 1,