安全:隔离主数据并封闭单条删除

This commit is contained in:
焦龙言
2026-09-22 18:16:30 +08:00
parent af5428d0e3
commit b6f606d9f2
13 changed files with 1382 additions and 1131 deletions
@@ -1,14 +1,14 @@
from datetime import datetime
from typing import Any, Literal
from typing import Any
from pydantic import BaseModel
from sqlalchemy import ColumnElement, Select, and_, delete, func, or_, select, update
from sqlalchemy import ColumnElement, Select, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
@@ -19,18 +19,7 @@ from module_business.entity.do.master_do import (
BizUserWarehouse,
BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundItem,
BizInboundOrder,
BizPalletEvent,
BizPalletInventory,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
from module_business.entity.do.operation_do import BizPalletInventory
from module_business.entity.vo.master_vo import (
CustomerModel,
CustomerPageQueryModel,
@@ -47,54 +36,6 @@ from module_business.entity.vo.master_vo import (
)
from utils.page_util import PageUtil
MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse']
_REFERENCE_COLUMNS: dict[MasterResource, tuple[tuple[InstrumentedAttribute, ...], ...]] = {
'strain': ((BizFillingRecord.strain_id,),),
'customer': ((BizCustomerSite.customer_id,),),
'site': (
(BizProductionLine.site_id,),
(BizUserSite.site_id,),
(BizShipmentOrder.target_site_id,),
(BizUsageRecord.site_id,),
(BizReturnOrder.source_site_id,),
(BizPalletInventory.site_id,),
(BizPalletEvent.before_site_id, BizPalletEvent.after_site_id),
),
'line': (
(BizUsageRecord.line_id,),
(BizPalletInventory.current_line_id,),
(BizPalletEvent.before_line_id, BizPalletEvent.after_line_id),
),
'warehouse': (
(BizUserWarehouse.warehouse_id,),
(BizInboundOrder.warehouse_id,),
(BizFillingRecord.warehouse_id,),
(BizShipmentOrder.source_warehouse_id,),
(BizReturnOrder.target_warehouse_id,),
(BizPalletInventory.warehouse_id,),
(BizPalletEvent.before_warehouse_id, BizPalletEvent.after_warehouse_id),
),
}
_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,
BizShipmentItem.pallet_id,
BizUsageRecord.pallet_id,
BizReturnItem.pallet_id,
BizPalletEvent.pallet_id,
)
class MasterDao:
"""菌种、客户、现场、产线和仓库主数据数据库操作层。"""
@@ -120,10 +61,11 @@ class MasterDao:
model: type[Any],
id_column: InstrumentedAttribute,
resource_id: int,
scope_predicate: ColumnElement[bool],
*,
for_update: bool = False,
) -> Any | None:
statement = select(model).where(id_column == resource_id)
statement = select(model).where(id_column == resource_id, scope_predicate)
if for_update:
statement = statement.with_for_update().execution_options(populate_existing=True)
result = await db.execute(statement)
@@ -146,8 +88,10 @@ class MasterDao:
model: type[Any],
id_field: str,
page_object: BaseModel,
test_batch_id: int | None,
) -> Any:
payload = page_object.model_dump(exclude={id_field, 'can_remove'})
payload = page_object.model_dump(exclude={id_field, 'can_remove', 'test_batch_id'})
payload['test_batch_id'] = test_batch_id
record = model(**payload)
db.add(record)
await db.flush()
@@ -160,63 +104,31 @@ class MasterDao:
id_column: InstrumentedAttribute,
id_field: str,
page_object: BaseModel,
scope: TestBatchScope,
) -> None:
payload = page_object.model_dump(
exclude_unset=True,
exclude={'can_remove', 'create_by', 'create_time'},
exclude={'can_remove', 'create_by', 'create_time', 'test_batch_id'},
)
resource_id = payload.pop(id_field)
await db.execute(update(model).where(id_column == resource_id).values(**payload))
@staticmethod
async def _delete(
db: AsyncSession,
model: type[Any],
id_column: InstrumentedAttribute,
resource_id: int,
) -> 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
await db.execute(
update(model)
.where(
id_column == resource_id,
batch_scope_condition(model.test_batch_id, scope),
)
.values(**payload)
)
@classmethod
async def list_strain(
cls,
db: AsyncSession,
query_object: StrainPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
conditions = [batch_scope_condition(BizStrain.test_batch_id, scope)]
if query_object.strain_code:
conditions.append(BizStrain.strain_code.like(f'%{query_object.strain_code}%'))
if query_object.strain_name:
@@ -231,6 +143,7 @@ class MasterDao:
cls,
db: AsyncSession,
strain_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizStrain | None:
@@ -239,6 +152,7 @@ class MasterDao:
BizStrain,
BizStrain.strain_id,
strain_id,
batch_scope_condition(BizStrain.test_batch_id, scope),
for_update=for_update,
)
@@ -247,25 +161,22 @@ class MasterDao:
return await cls._find(db, BizStrain, BizStrain.strain_code, strain_code)
@classmethod
async def add_strain(cls, db: AsyncSession, strain: StrainModel) -> BizStrain:
return await cls._add(db, BizStrain, 'strain_id', strain)
async def add_strain(cls, db: AsyncSession, strain: StrainModel, test_batch_id: int | None) -> BizStrain:
return await cls._add(db, BizStrain, 'strain_id', strain, test_batch_id)
@classmethod
async def update_strain(cls, db: AsyncSession, strain: StrainModel) -> None:
await cls._update(db, BizStrain, BizStrain.strain_id, 'strain_id', strain)
@classmethod
async def delete_strain(cls, db: AsyncSession, strain_id: int) -> None:
await cls._delete(db, BizStrain, BizStrain.strain_id, strain_id)
async def update_strain(cls, db: AsyncSession, strain: StrainModel, scope: TestBatchScope) -> None:
await cls._update(db, BizStrain, BizStrain.strain_id, 'strain_id', strain, scope)
@classmethod
async def list_customer(
cls,
db: AsyncSession,
query_object: CustomerPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
conditions = [batch_scope_condition(BizCustomer.test_batch_id, scope)]
if query_object.customer_code:
conditions.append(BizCustomer.customer_code.like(f'%{query_object.customer_code}%'))
if query_object.customer_name:
@@ -276,33 +187,44 @@ class MasterDao:
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_customer(cls, db: AsyncSession, customer_id: int) -> BizCustomer | None:
return await cls._get(db, BizCustomer, BizCustomer.customer_id, customer_id)
async def get_customer(
cls,
db: AsyncSession,
customer_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizCustomer | None:
return await cls._get(
db,
BizCustomer,
BizCustomer.customer_id,
customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
for_update=for_update,
)
@classmethod
async def find_customer_by_code(cls, db: AsyncSession, customer_code: str) -> BizCustomer | None:
return await cls._find(db, BizCustomer, BizCustomer.customer_code, customer_code)
@classmethod
async def add_customer(cls, db: AsyncSession, customer: CustomerModel) -> BizCustomer:
return await cls._add(db, BizCustomer, 'customer_id', customer)
async def add_customer(cls, db: AsyncSession, customer: CustomerModel, test_batch_id: int | None) -> BizCustomer:
return await cls._add(db, BizCustomer, 'customer_id', customer, test_batch_id)
@classmethod
async def update_customer(cls, db: AsyncSession, customer: CustomerModel) -> None:
await cls._update(db, BizCustomer, BizCustomer.customer_id, 'customer_id', customer)
@classmethod
async def delete_customer(cls, db: AsyncSession, customer_id: int) -> None:
await cls._delete(db, BizCustomer, BizCustomer.customer_id, customer_id)
async def update_customer(cls, db: AsyncSession, customer: CustomerModel, scope: TestBatchScope) -> None:
await cls._update(db, BizCustomer, BizCustomer.customer_id, 'customer_id', customer, scope)
@classmethod
async def list_site(
cls,
db: AsyncSession,
query_object: SitePageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope)]
if query_object.customer_id:
conditions.append(BizCustomerSite.customer_id == query_object.customer_id)
if query_object.site_code:
@@ -319,6 +241,7 @@ class MasterDao:
cls,
db: AsyncSession,
site_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizCustomerSite | None:
@@ -327,6 +250,7 @@ class MasterDao:
BizCustomerSite,
BizCustomerSite.site_id,
site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
for_update=for_update,
)
@@ -346,25 +270,22 @@ class MasterDao:
)
@classmethod
async def add_site(cls, db: AsyncSession, site: SiteModel) -> BizCustomerSite:
return await cls._add(db, BizCustomerSite, 'site_id', site)
async def add_site(cls, db: AsyncSession, site: SiteModel, test_batch_id: int | None) -> BizCustomerSite:
return await cls._add(db, BizCustomerSite, 'site_id', site, test_batch_id)
@classmethod
async def update_site(cls, db: AsyncSession, site: SiteModel) -> None:
await cls._update(db, BizCustomerSite, BizCustomerSite.site_id, 'site_id', site)
@classmethod
async def delete_site(cls, db: AsyncSession, site_id: int) -> None:
await cls._delete(db, BizCustomerSite, BizCustomerSite.site_id, site_id)
async def update_site(cls, db: AsyncSession, site: SiteModel, scope: TestBatchScope) -> None:
await cls._update(db, BizCustomerSite, BizCustomerSite.site_id, 'site_id', site, scope)
@classmethod
async def list_line(
cls,
db: AsyncSession,
query_object: LinePageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
conditions = [batch_scope_condition(BizProductionLine.test_batch_id, scope)]
if query_object.site_id:
conditions.append(BizProductionLine.site_id == query_object.site_id)
if query_object.line_code:
@@ -381,6 +302,7 @@ class MasterDao:
cls,
db: AsyncSession,
line_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizProductionLine | None:
@@ -389,6 +311,7 @@ class MasterDao:
BizProductionLine,
BizProductionLine.line_id,
line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
for_update=for_update,
)
@@ -408,25 +331,22 @@ class MasterDao:
)
@classmethod
async def add_line(cls, db: AsyncSession, line: LineModel) -> BizProductionLine:
return await cls._add(db, BizProductionLine, 'line_id', line)
async def add_line(cls, db: AsyncSession, line: LineModel, test_batch_id: int | None) -> BizProductionLine:
return await cls._add(db, BizProductionLine, 'line_id', line, test_batch_id)
@classmethod
async def update_line(cls, db: AsyncSession, line: LineModel) -> None:
await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line)
@classmethod
async def delete_line(cls, db: AsyncSession, line_id: int) -> None:
await cls._delete(db, BizProductionLine, BizProductionLine.line_id, line_id)
async def update_line(cls, db: AsyncSession, line: LineModel, scope: TestBatchScope) -> None:
await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line, scope)
@classmethod
async def list_warehouse(
cls,
db: AsyncSession,
query_object: WarehousePageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
conditions = [batch_scope_condition(BizWarehouse.test_batch_id, scope)]
if query_object.warehouse_code:
conditions.append(BizWarehouse.warehouse_code.like(f'%{query_object.warehouse_code}%'))
if query_object.warehouse_name:
@@ -441,6 +361,7 @@ class MasterDao:
cls,
db: AsyncSession,
warehouse_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizWarehouse | None:
@@ -449,6 +370,7 @@ class MasterDao:
BizWarehouse,
BizWarehouse.warehouse_id,
warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
for_update=for_update,
)
@@ -457,25 +379,22 @@ class MasterDao:
return await cls._find(db, BizWarehouse, BizWarehouse.warehouse_code, warehouse_code)
@classmethod
async def add_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> BizWarehouse:
return await cls._add(db, BizWarehouse, 'warehouse_id', warehouse)
async def add_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel, test_batch_id: int | None) -> BizWarehouse:
return await cls._add(db, BizWarehouse, 'warehouse_id', warehouse, test_batch_id)
@classmethod
async def update_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> None:
await cls._update(db, BizWarehouse, BizWarehouse.warehouse_id, 'warehouse_id', warehouse)
@classmethod
async def delete_warehouse(cls, db: AsyncSession, warehouse_id: int) -> None:
await cls._delete(db, BizWarehouse, BizWarehouse.warehouse_id, warehouse_id)
async def update_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel, scope: TestBatchScope) -> None:
await cls._update(db, BizWarehouse, BizWarehouse.warehouse_id, 'warehouse_id', warehouse, scope)
@classmethod
async def list_pallet(
cls,
db: AsyncSession,
query_object: PalletPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)]
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.asset_status:
@@ -488,45 +407,18 @@ class MasterDao:
cls,
db: AsyncSession,
pallet_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizPallet | None:
statement = select(BizPallet).where(BizPallet.pallet_id == pallet_id)
if for_update:
statement = statement.with_for_update()
result = await db.execute(statement)
return result.scalar_one_or_none()
@classmethod
async def get_pallet_removability(
cls,
db: AsyncSession,
pallet_ids: list[int],
) -> dict[int, bool]:
"""批量判断托盘是否仍为未入库且没有任何业务引用。"""
removability = dict.fromkeys(pallet_ids, False)
if not pallet_ids:
return removability
has_unstocked_inventory = (
select(BizPalletInventory.pallet_id)
.where(
BizPalletInventory.pallet_id == BizPallet.pallet_id,
BizPalletInventory.flow_state == PalletFlowState.UNSTOCKED.value,
)
.exists()
)
has_no_business_references = tuple(
~select(column).where(column == BizPallet.pallet_id).exists() for column in _PALLET_REMOVAL_BLOCKING_COLUMNS
)
statement = select(
return await cls._get(
db,
BizPallet,
BizPallet.pallet_id,
and_(has_unstocked_inventory, *has_no_business_references).label('can_remove'),
).where(BizPallet.pallet_id.in_(pallet_ids))
result = await db.execute(statement)
for pallet_id, can_remove in result.all():
removability[pallet_id] = bool(can_remove)
return removability
pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
for_update=for_update,
)
@classmethod
async def find_pallet_by_code(cls, db: AsyncSession, pallet_code: str) -> BizPallet | None:
@@ -543,12 +435,16 @@ class MasterDao:
cls,
db: AsyncSession,
pallet: PalletEditModel,
scope: TestBatchScope,
update_by: str,
update_time: datetime,
) -> None:
await db.execute(
update(BizPallet)
.where(BizPallet.pallet_id == pallet.pallet_id)
.where(
BizPallet.pallet_id == pallet.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
)
.values(
capacity_packages=pallet.capacity_packages,
asset_status=pallet.asset_status,
@@ -558,21 +454,21 @@ class MasterDao:
)
)
@classmethod
async def delete_pallet(cls, db: AsyncSession, pallet_id: int) -> None:
await cls._delete(db, BizPallet, BizPallet.pallet_id, pallet_id)
@classmethod
async def get_pallet_inventory(
cls,
db: AsyncSession,
pallet_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizPalletInventory | None:
statement = select(BizPalletInventory).where(BizPalletInventory.pallet_id == pallet_id)
statement = select(BizPalletInventory).where(
BizPalletInventory.pallet_id == pallet_id,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
if for_update:
statement = statement.with_for_update()
statement = statement.with_for_update().execution_options(populate_existing=True)
result = await db.execute(statement)
return result.scalar_one_or_none()
@@ -586,17 +482,6 @@ class MasterDao:
await db.flush()
return inventory
@classmethod
async def delete_pallet_inventory(cls, db: AsyncSession, pallet_id: int) -> None:
await cls._delete(db, BizPalletInventory, BizPalletInventory.pallet_id, pallet_id)
@classmethod
async def count_pallet_events(cls, db: AsyncSession, pallet_id: int) -> int:
result = await db.execute(
select(func.count('*')).select_from(BizPalletEvent).where(BizPalletEvent.pallet_id == pallet_id)
)
return result.scalar() or 0
@classmethod
async def list_assignable_users(cls, db: AsyncSession) -> list[Any]:
result = await db.execute(
@@ -622,31 +507,35 @@ class MasterDao:
return list(result.all())
@classmethod
async def list_warehouse_assigned_user_ids(cls, db: AsyncSession, warehouse_id: int) -> list[int]:
result = await db.execute(select(BizUserWarehouse.user_id).where(BizUserWarehouse.warehouse_id == warehouse_id))
return list(result.scalars().all())
@classmethod
async def list_site_assigned_user_ids(cls, db: AsyncSession, site_id: int) -> list[int]:
result = await db.execute(select(BizUserSite.user_id).where(BizUserSite.site_id == site_id))
return list(result.scalars().all())
@classmethod
async def count_references(
async def list_warehouse_assigned_user_ids(
cls,
db: AsyncSession,
resource_type: MasterResource,
resource_id: int,
) -> int:
"""统计主数据的所有直接外键引用;同表的多个位置字段按一行计数。"""
references = _REFERENCE_COLUMNS.get(resource_type)
if references is None:
raise ValueError(f'不支持的主数据类型:{resource_type}')
warehouse_id: int,
scope: TestBatchScope,
) -> list[int]:
result = await db.execute(
select(BizUserWarehouse.user_id)
.join(BizWarehouse, BizWarehouse.warehouse_id == BizUserWarehouse.warehouse_id)
.where(
BizUserWarehouse.warehouse_id == warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
)
)
return list(result.scalars().all())
total = 0
for columns in references:
predicates = [column == resource_id for column in columns]
predicate = predicates[0] if len(predicates) == 1 else or_(*predicates)
result = await db.execute(select(func.count('*')).select_from(columns[0].class_).where(predicate))
total += result.scalar() or 0
return total
@classmethod
async def list_site_assigned_user_ids(
cls,
db: AsyncSession,
site_id: int,
scope: TestBatchScope,
) -> list[int]:
result = await db.execute(
select(BizUserSite.user_id)
.join(BizCustomerSite, BizCustomerSite.site_id == BizUserSite.site_id)
.where(
BizUserSite.site_id == site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
)
)
return list(result.scalars().all())