补充主数据删除能力契约
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user