修复:限定幂等键精确业务域
This commit is contained in:
@@ -431,15 +431,16 @@ class OperationDao:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
idempotency_key: str,
|
||||
site_ids: list[int] | None = None,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
source_site_id: int,
|
||||
target_warehouse_id: int,
|
||||
) -> BizReturnOrder | None:
|
||||
return await cls._get_by_key(
|
||||
db,
|
||||
BizReturnOrder,
|
||||
BizReturnOrder.request_idempotency_key,
|
||||
idempotency_key,
|
||||
*cls._return_scope_conditions(site_ids, warehouse_ids),
|
||||
BizReturnOrder.source_site_id == source_site_id,
|
||||
BizReturnOrder.target_warehouse_id == target_warehouse_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -447,15 +448,16 @@ class OperationDao:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
idempotency_key: str,
|
||||
site_ids: list[int] | None = None,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
source_site_id: int,
|
||||
target_warehouse_id: int,
|
||||
) -> BizReturnOrder | None:
|
||||
return await cls._get_by_key(
|
||||
db,
|
||||
BizReturnOrder,
|
||||
BizReturnOrder.confirm_idempotency_key,
|
||||
idempotency_key,
|
||||
*cls._return_scope_conditions(site_ids, warehouse_ids),
|
||||
BizReturnOrder.source_site_id == source_site_id,
|
||||
BizReturnOrder.target_warehouse_id == target_warehouse_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -12,6 +12,11 @@ class BizInboundOrder(Base):
|
||||
|
||||
__tablename__ = 'biz_inbound_order'
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
'warehouse_id',
|
||||
'confirm_idempotency_key',
|
||||
name='uq_biz_inbound_order_warehouse_confirm_idempotency',
|
||||
),
|
||||
CheckConstraint("status IN ('DRAFT', 'CONFIRMED')", name='ck_biz_inbound_order_status'),
|
||||
{'comment': '首次入库单'},
|
||||
)
|
||||
@@ -25,7 +30,7 @@ class BizInboundOrder(Base):
|
||||
comment='仓库ID',
|
||||
)
|
||||
status = Column(String(32), nullable=False, server_default='DRAFT', comment='单据状态')
|
||||
confirm_idempotency_key = Column(String(64), nullable=True, unique=True, comment='确认幂等键')
|
||||
confirm_idempotency_key = Column(String(64), nullable=True, comment='确认幂等键')
|
||||
confirm_request_hash = Column(String(64), nullable=True, comment='确认请求摘要')
|
||||
confirmed_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='确认人ID')
|
||||
confirmed_at = Column(DateTime, nullable=True, comment='确认时间')
|
||||
@@ -73,6 +78,11 @@ class BizFillingRecord(Base):
|
||||
|
||||
__tablename__ = 'biz_filling_record'
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
'warehouse_id',
|
||||
'idempotency_key',
|
||||
name='uq_biz_filling_record_warehouse_idempotency',
|
||||
),
|
||||
UniqueConstraint('pallet_id', 'active_slot', name='uq_biz_filling_record_pallet_active_slot'),
|
||||
CheckConstraint('initial_packages > 0', name='ck_biz_filling_record_initial_packages'),
|
||||
CheckConstraint(
|
||||
@@ -99,7 +109,7 @@ class BizFillingRecord(Base):
|
||||
initial_packages = Column(Integer, nullable=False, comment='初始包数')
|
||||
active_slot = Column(String(16), nullable=True, comment='活动装填槽')
|
||||
closed_at = Column(DateTime, nullable=True, comment='装填周期关闭时间')
|
||||
idempotency_key = Column(String(64), nullable=False, unique=True, comment='幂等键')
|
||||
idempotency_key = Column(String(64), nullable=False, comment='幂等键')
|
||||
request_hash = Column(String(64), nullable=False, comment='请求摘要')
|
||||
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
|
||||
operated_at = Column(DateTime, nullable=False, comment='操作时间')
|
||||
@@ -120,6 +130,12 @@ class BizShipmentOrder(Base):
|
||||
|
||||
__tablename__ = 'biz_shipment_order'
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
'source_warehouse_id',
|
||||
'target_site_id',
|
||||
'confirm_idempotency_key',
|
||||
name='uq_biz_shipment_order_route_confirm_idempotency',
|
||||
),
|
||||
CheckConstraint("status IN ('DRAFT', 'CONFIRMED')", name='ck_biz_shipment_order_status'),
|
||||
{'comment': '整托发货单'},
|
||||
)
|
||||
@@ -139,7 +155,7 @@ class BizShipmentOrder(Base):
|
||||
comment='目标现场ID',
|
||||
)
|
||||
status = Column(String(32), nullable=False, server_default='DRAFT', comment='单据状态')
|
||||
confirm_idempotency_key = Column(String(64), nullable=True, unique=True, comment='确认幂等键')
|
||||
confirm_idempotency_key = Column(String(64), nullable=True, comment='确认幂等键')
|
||||
confirm_request_hash = Column(String(64), nullable=True, comment='确认请求摘要')
|
||||
confirmed_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='确认人ID')
|
||||
confirmed_at = Column(DateTime, nullable=True, comment='确认时间')
|
||||
@@ -198,6 +214,7 @@ class BizUsageRecord(Base):
|
||||
|
||||
__tablename__ = 'biz_usage_record'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('site_id', 'idempotency_key', name='uq_biz_usage_record_site_idempotency'),
|
||||
CheckConstraint('used_packages > 0', name='ck_biz_usage_record_used_packages'),
|
||||
CheckConstraint(
|
||||
'remaining_packages_after >= 0',
|
||||
@@ -219,7 +236,7 @@ class BizUsageRecord(Base):
|
||||
line_id = Column(BigInteger, ForeignKey('biz_production_line.line_id'), nullable=False, comment='产线ID')
|
||||
used_packages = Column(Integer, nullable=False, comment='本次使用包数')
|
||||
remaining_packages_after = Column(Integer, nullable=False, comment='使用后剩余包数')
|
||||
idempotency_key = Column(String(64), nullable=False, unique=True, comment='幂等键')
|
||||
idempotency_key = Column(String(64), nullable=False, comment='幂等键')
|
||||
request_hash = Column(String(64), nullable=False, comment='请求摘要')
|
||||
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
|
||||
occurred_at = Column(DateTime, nullable=False, comment='发生时间')
|
||||
@@ -230,6 +247,18 @@ class BizReturnOrder(Base):
|
||||
|
||||
__tablename__ = 'biz_return_order'
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
'request_idempotency_key',
|
||||
name='uq_biz_return_order_route_request_idempotency',
|
||||
),
|
||||
UniqueConstraint(
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
'confirm_idempotency_key',
|
||||
name='uq_biz_return_order_route_confirm_idempotency',
|
||||
),
|
||||
CheckConstraint("status IN ('REQUESTED', 'CONFIRMED')", name='ck_biz_return_order_status'),
|
||||
{'comment': '托盘退回单'},
|
||||
)
|
||||
@@ -249,9 +278,9 @@ class BizReturnOrder(Base):
|
||||
comment='目标仓库ID',
|
||||
)
|
||||
status = Column(String(32), nullable=False, server_default='REQUESTED', comment='退回状态')
|
||||
request_idempotency_key = Column(String(64), nullable=False, unique=True, comment='申请幂等键')
|
||||
request_idempotency_key = Column(String(64), nullable=False, comment='申请幂等键')
|
||||
request_hash = Column(String(64), nullable=False, comment='申请请求摘要')
|
||||
confirm_idempotency_key = Column(String(64), nullable=True, unique=True, comment='确认幂等键')
|
||||
confirm_idempotency_key = Column(String(64), nullable=True, comment='确认幂等键')
|
||||
confirm_request_hash = Column(String(64), nullable=True, comment='确认请求摘要')
|
||||
requested_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='申请人ID')
|
||||
requested_at = Column(DateTime, nullable=False, comment='申请时间')
|
||||
|
||||
@@ -300,7 +300,7 @@ class FillingService:
|
||||
recovered = await OperationDao.get_filling_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
warehouse_ids=[page_object.warehouse_id],
|
||||
)
|
||||
if recovered is None:
|
||||
raise ServiceException(message='装填失败,数据冲突')
|
||||
@@ -333,7 +333,7 @@ class FillingService:
|
||||
existing = await OperationDao.get_filling_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
warehouse_ids=[page_object.warehouse_id],
|
||||
)
|
||||
if existing is not None:
|
||||
await AccessService.assert_warehouse_access(
|
||||
|
||||
@@ -513,10 +513,17 @@ class InboundService:
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
current = await OperationDao.get_inbound_by_id(
|
||||
query_db,
|
||||
inbound_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if current is None:
|
||||
cls._raise_inbound_not_accessible(current_user)
|
||||
recovered = await OperationDao.get_inbound_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
warehouse_ids=[current.warehouse_id],
|
||||
)
|
||||
if recovered is None:
|
||||
raise ServiceException(message='确认入库失败,幂等冲突')
|
||||
@@ -575,7 +582,7 @@ class InboundService:
|
||||
existing = await OperationDao.get_inbound_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
warehouse_ids=[inbound.warehouse_id],
|
||||
)
|
||||
if existing is not None:
|
||||
await AccessService.assert_warehouse_access(
|
||||
|
||||
@@ -586,11 +586,13 @@ class ReturnService:
|
||||
try:
|
||||
site_ids = await cls._site_scope(query_db, current_user)
|
||||
request_warehouse_ids = None if site_ids is None else []
|
||||
recovered = None
|
||||
if site_ids is None or page_object.source_site_id in site_ids:
|
||||
recovered = await OperationDao.get_return_by_request_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=site_ids,
|
||||
warehouse_ids=request_warehouse_ids,
|
||||
source_site_id=page_object.source_site_id,
|
||||
target_warehouse_id=page_object.target_warehouse_id,
|
||||
)
|
||||
if recovered is None or not cls._is_exact_request_result(recovered, page_object, request_hash):
|
||||
raise ServiceException(message='申请退回失败,幂等冲突')
|
||||
@@ -640,8 +642,8 @@ class ReturnService:
|
||||
existing = await OperationDao.get_return_by_request_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=preflight_site_ids,
|
||||
warehouse_ids=request_warehouse_ids,
|
||||
source_site_id=page_object.source_site_id,
|
||||
target_warehouse_id=page_object.target_warehouse_id,
|
||||
)
|
||||
if existing is not None and not cls._is_exact_request_result(existing, page_object, request_hash):
|
||||
raise ServiceException(message='申请退回失败,幂等键已被其他请求使用')
|
||||
@@ -681,12 +683,11 @@ class ReturnService:
|
||||
fresh_site_ids = await cls._site_scope(query_db, current_user)
|
||||
if fresh_site_ids is not None and page_object.source_site_id not in fresh_site_ids:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
fresh_warehouse_ids = None if fresh_site_ids is None else []
|
||||
fresh_existing = await OperationDao.get_return_by_request_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=fresh_site_ids,
|
||||
warehouse_ids=fresh_warehouse_ids,
|
||||
source_site_id=page_object.source_site_id,
|
||||
target_warehouse_id=page_object.target_warehouse_id,
|
||||
)
|
||||
if fresh_existing is not None:
|
||||
if not cls._is_exact_request_result(fresh_existing, page_object, request_hash):
|
||||
@@ -905,11 +906,19 @@ class ReturnService:
|
||||
try:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
confirm_site_ids = None if warehouse_ids is None else []
|
||||
route = await OperationDao.get_return_by_id(
|
||||
query_db,
|
||||
return_id,
|
||||
site_ids=confirm_site_ids,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if route is None:
|
||||
cls._raise_return_not_accessible(current_user)
|
||||
recovered = await OperationDao.get_return_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=confirm_site_ids,
|
||||
warehouse_ids=warehouse_ids,
|
||||
source_site_id=route.source_site_id,
|
||||
target_warehouse_id=route.target_warehouse_id,
|
||||
)
|
||||
if recovered is None:
|
||||
raise ServiceException(message='确认退回失败,幂等冲突')
|
||||
@@ -955,20 +964,6 @@ class ReturnService:
|
||||
request_hash = cls._build_confirm_request_hash(return_id, page_object)
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
confirm_site_ids = None if warehouse_ids is None else []
|
||||
existing = await OperationDao.get_return_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=confirm_site_ids,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if existing is not None and not cls._is_exact_confirm_result(
|
||||
existing,
|
||||
return_id,
|
||||
page_object,
|
||||
request_hash,
|
||||
):
|
||||
raise ServiceException(message='确认退回失败,幂等键已被其他请求使用')
|
||||
|
||||
return_order = await OperationDao.get_return_by_id(
|
||||
query_db,
|
||||
return_id,
|
||||
@@ -985,6 +980,19 @@ class ReturnService:
|
||||
)
|
||||
except PermissionException:
|
||||
cls._raise_return_not_accessible(current_user)
|
||||
existing = await OperationDao.get_return_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
source_site_id=return_order.source_site_id,
|
||||
target_warehouse_id=return_order.target_warehouse_id,
|
||||
)
|
||||
if existing is not None and not cls._is_exact_confirm_result(
|
||||
existing,
|
||||
return_id,
|
||||
page_object,
|
||||
request_hash,
|
||||
):
|
||||
raise ServiceException(message='确认退回失败,幂等键已被其他请求使用')
|
||||
|
||||
await query_db.rollback()
|
||||
locked = await OperationDao.get_return_for_update(
|
||||
|
||||
@@ -750,11 +750,19 @@ class ShipmentService:
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
current = await OperationDao.get_shipment_by_id(
|
||||
query_db,
|
||||
shipment_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if current is None:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
recovered = await OperationDao.get_shipment_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
warehouse_ids=[current.source_warehouse_id],
|
||||
site_ids=[current.target_site_id],
|
||||
)
|
||||
if recovered is None:
|
||||
raise ServiceException(message='确认发货失败,幂等冲突')
|
||||
@@ -815,8 +823,8 @@ class ShipmentService:
|
||||
existing = await OperationDao.get_shipment_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
warehouse_ids=[shipment.source_warehouse_id],
|
||||
site_ids=[shipment.target_site_id],
|
||||
)
|
||||
if existing is not None and not cls._is_exact_confirmed(
|
||||
existing,
|
||||
|
||||
@@ -179,7 +179,7 @@ class UsageService:
|
||||
recovered = await OperationDao.get_usage_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=site_ids,
|
||||
site_ids=[expected_site_id],
|
||||
)
|
||||
if recovered is None:
|
||||
raise ServiceException(message='使用失败,数据冲突')
|
||||
@@ -351,10 +351,17 @@ class UsageService:
|
||||
request_hash = cls._build_request_hash(page_object)
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
preflight_site_ids = await cls._site_scope(query_db, current_user)
|
||||
# 产线归属是请求内稳定路由;锁事务仍以库存中的实际现场复核。
|
||||
preflight_line = await MasterDao.get_line(query_db, page_object.line_id)
|
||||
preflight_site_id = preflight_line.site_id if preflight_line is not None else None
|
||||
existing = None
|
||||
if preflight_site_id is not None and (
|
||||
preflight_site_ids is None or preflight_site_id in preflight_site_ids
|
||||
):
|
||||
existing = await OperationDao.get_usage_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=preflight_site_ids,
|
||||
site_ids=[preflight_site_id],
|
||||
)
|
||||
if existing is not None:
|
||||
if not cls._is_exact_idempotent_result(existing, page_object, request_hash):
|
||||
@@ -390,6 +397,8 @@ class UsageService:
|
||||
expected_site_id = inventory.site_id
|
||||
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
||||
locked_site_ids = await cls._site_scope(query_db, current_user)
|
||||
if locked_site_ids is not None and inventory.site_id not in locked_site_ids:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
|
||||
locked_pallets = await InventoryDao.get_pallets_by_ids(
|
||||
query_db,
|
||||
@@ -410,7 +419,7 @@ class UsageService:
|
||||
locked_existing = await OperationDao.get_usage_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
site_ids=locked_site_ids,
|
||||
site_ids=[inventory.site_id],
|
||||
)
|
||||
if locked_existing is not None:
|
||||
if locked_existing.site_id != inventory.site_id or not cls._is_exact_idempotent_result(
|
||||
|
||||
@@ -148,7 +148,7 @@ create table if not exists biz_inbound_order (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (inbound_id),
|
||||
constraint uq_biz_inbound_order_no unique (inbound_no),
|
||||
constraint uq_biz_inbound_order_confirm_idempotency unique (confirm_idempotency_key),
|
||||
constraint uq_biz_inbound_order_warehouse_confirm_idempotency unique (warehouse_id, confirm_idempotency_key),
|
||||
constraint fk_biz_inbound_order_warehouse foreign key (warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_inbound_order_confirmed_by foreign key (confirmed_by) references sys_user (user_id),
|
||||
constraint ck_biz_inbound_order_status check (status in ('DRAFT', 'CONFIRMED'))
|
||||
@@ -192,7 +192,7 @@ create table if not exists biz_filling_record (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (filling_id),
|
||||
constraint uq_biz_filling_record_no unique (filling_no),
|
||||
constraint uq_biz_filling_record_idempotency unique (idempotency_key),
|
||||
constraint uq_biz_filling_record_warehouse_idempotency unique (warehouse_id, idempotency_key),
|
||||
constraint uq_biz_filling_record_pallet_active_slot unique (pallet_id, active_slot),
|
||||
constraint fk_biz_filling_record_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
|
||||
constraint fk_biz_filling_record_warehouse foreign key (warehouse_id) references biz_warehouse (warehouse_id),
|
||||
@@ -220,7 +220,7 @@ create table if not exists biz_shipment_order (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (shipment_id),
|
||||
constraint uq_biz_shipment_order_no unique (shipment_no),
|
||||
constraint uq_biz_shipment_order_confirm_idempotency unique (confirm_idempotency_key),
|
||||
constraint uq_biz_shipment_order_route_confirm_idempotency unique (source_warehouse_id, target_site_id, confirm_idempotency_key),
|
||||
constraint fk_biz_shipment_order_warehouse foreign key (source_warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_shipment_order_site foreign key (target_site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_shipment_order_confirmed_by foreign key (confirmed_by) references sys_user (user_id),
|
||||
@@ -260,7 +260,7 @@ create table if not exists biz_usage_record (
|
||||
occurred_at datetime not null comment '发生时间',
|
||||
primary key (usage_id),
|
||||
constraint uq_biz_usage_record_no unique (usage_no),
|
||||
constraint uq_biz_usage_record_idempotency unique (idempotency_key),
|
||||
constraint uq_biz_usage_record_site_idempotency unique (site_id, idempotency_key),
|
||||
constraint fk_biz_usage_record_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
|
||||
constraint fk_biz_usage_record_filling foreign key (active_filling_id) references biz_filling_record (filling_id),
|
||||
constraint fk_biz_usage_record_site foreign key (site_id) references biz_customer_site (site_id),
|
||||
@@ -292,8 +292,8 @@ create table if not exists biz_return_order (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (return_id),
|
||||
constraint uq_biz_return_order_no unique (return_no),
|
||||
constraint uq_biz_return_order_request_idempotency unique (request_idempotency_key),
|
||||
constraint uq_biz_return_order_confirm_idempotency unique (confirm_idempotency_key),
|
||||
constraint uq_biz_return_order_route_request_idempotency unique (source_site_id, target_warehouse_id, request_idempotency_key),
|
||||
constraint uq_biz_return_order_route_confirm_idempotency unique (source_site_id, target_warehouse_id, confirm_idempotency_key),
|
||||
constraint fk_biz_return_order_site foreign key (source_site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_return_order_warehouse foreign key (target_warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_return_order_requested_by foreign key (requested_by) references sys_user (user_id),
|
||||
|
||||
@@ -1211,7 +1211,7 @@ create table biz_inbound_order (
|
||||
remark varchar(500) default null,
|
||||
primary key (inbound_id),
|
||||
constraint uq_biz_inbound_order_no unique (inbound_no),
|
||||
constraint uq_biz_inbound_order_confirm_key unique (confirm_idempotency_key),
|
||||
constraint uq_biz_inbound_order_warehouse_confirm_idempotency unique (warehouse_id, confirm_idempotency_key),
|
||||
constraint ck_biz_inbound_order_status check (status in ('DRAFT', 'CONFIRMED')),
|
||||
constraint fk_biz_inbound_order_warehouse foreign key (warehouse_id) references biz_warehouse(warehouse_id),
|
||||
constraint fk_biz_inbound_order_confirmer foreign key (confirmed_by) references sys_user(user_id)
|
||||
@@ -1253,7 +1253,7 @@ create table biz_filling_record (
|
||||
remark varchar(500) default null,
|
||||
primary key (filling_id),
|
||||
constraint uq_biz_filling_record_no unique (filling_no),
|
||||
constraint uq_biz_filling_record_idempotency unique (idempotency_key),
|
||||
constraint uq_biz_filling_record_warehouse_idempotency unique (warehouse_id, idempotency_key),
|
||||
constraint uq_biz_filling_record_pallet_active_slot unique (pallet_id, active_slot),
|
||||
constraint ck_biz_filling_record_initial_packages check (initial_packages > 0),
|
||||
constraint ck_biz_filling_record_active_slot check (active_slot is null or active_slot = 'ACTIVE'),
|
||||
@@ -1280,7 +1280,7 @@ create table biz_shipment_order (
|
||||
remark varchar(500) default null,
|
||||
primary key (shipment_id),
|
||||
constraint uq_biz_shipment_order_no unique (shipment_no),
|
||||
constraint uq_biz_shipment_order_confirm_key unique (confirm_idempotency_key),
|
||||
constraint uq_biz_shipment_order_route_confirm_idempotency unique (source_warehouse_id, target_site_id, confirm_idempotency_key),
|
||||
constraint ck_biz_shipment_order_status check (status in ('DRAFT', 'CONFIRMED')),
|
||||
constraint fk_biz_shipment_order_warehouse foreign key (source_warehouse_id) references biz_warehouse(warehouse_id),
|
||||
constraint fk_biz_shipment_order_site foreign key (target_site_id) references biz_customer_site(site_id),
|
||||
@@ -1318,7 +1318,7 @@ create table biz_usage_record (
|
||||
occurred_at timestamp without time zone not null,
|
||||
primary key (usage_id),
|
||||
constraint uq_biz_usage_record_no unique (usage_no),
|
||||
constraint uq_biz_usage_record_idempotency unique (idempotency_key),
|
||||
constraint uq_biz_usage_record_site_idempotency unique (site_id, idempotency_key),
|
||||
constraint ck_biz_usage_record_used_packages check (used_packages > 0),
|
||||
constraint ck_biz_usage_record_remaining_packages_after check (remaining_packages_after >= 0),
|
||||
constraint fk_biz_usage_record_pallet foreign key (pallet_id) references biz_pallet(pallet_id),
|
||||
@@ -1349,8 +1349,8 @@ create table biz_return_order (
|
||||
remark varchar(500) default null,
|
||||
primary key (return_id),
|
||||
constraint uq_biz_return_order_no unique (return_no),
|
||||
constraint uq_biz_return_order_request_key unique (request_idempotency_key),
|
||||
constraint uq_biz_return_order_confirm_key unique (confirm_idempotency_key),
|
||||
constraint uq_biz_return_order_route_request_idempotency unique (source_site_id, target_warehouse_id, request_idempotency_key),
|
||||
constraint uq_biz_return_order_route_confirm_idempotency unique (source_site_id, target_warehouse_id, confirm_idempotency_key),
|
||||
constraint ck_biz_return_order_status check (status in ('REQUESTED', 'CONFIRMED')),
|
||||
constraint fk_biz_return_order_site foreign key (source_site_id) references biz_customer_site(site_id),
|
||||
constraint fk_biz_return_order_warehouse foreign key (target_warehouse_id) references biz_warehouse(warehouse_id),
|
||||
|
||||
@@ -955,7 +955,7 @@ create table biz_inbound_order (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (inbound_id),
|
||||
constraint uq_biz_inbound_order_no unique (inbound_no),
|
||||
constraint uq_biz_inbound_order_confirm_idempotency unique (confirm_idempotency_key),
|
||||
constraint uq_biz_inbound_order_warehouse_confirm_idempotency unique (warehouse_id, confirm_idempotency_key),
|
||||
constraint fk_biz_inbound_order_warehouse foreign key (warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_inbound_order_confirmed_by foreign key (confirmed_by) references sys_user (user_id),
|
||||
constraint ck_biz_inbound_order_status check (status in ('DRAFT', 'CONFIRMED'))
|
||||
@@ -999,7 +999,7 @@ create table biz_filling_record (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (filling_id),
|
||||
constraint uq_biz_filling_record_no unique (filling_no),
|
||||
constraint uq_biz_filling_record_idempotency unique (idempotency_key),
|
||||
constraint uq_biz_filling_record_warehouse_idempotency unique (warehouse_id, idempotency_key),
|
||||
constraint uq_biz_filling_record_pallet_active_slot unique (pallet_id, active_slot),
|
||||
constraint fk_biz_filling_record_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
|
||||
constraint fk_biz_filling_record_warehouse foreign key (warehouse_id) references biz_warehouse (warehouse_id),
|
||||
@@ -1027,7 +1027,7 @@ create table biz_shipment_order (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (shipment_id),
|
||||
constraint uq_biz_shipment_order_no unique (shipment_no),
|
||||
constraint uq_biz_shipment_order_confirm_idempotency unique (confirm_idempotency_key),
|
||||
constraint uq_biz_shipment_order_route_confirm_idempotency unique (source_warehouse_id, target_site_id, confirm_idempotency_key),
|
||||
constraint fk_biz_shipment_order_warehouse foreign key (source_warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_shipment_order_site foreign key (target_site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_shipment_order_confirmed_by foreign key (confirmed_by) references sys_user (user_id),
|
||||
@@ -1067,7 +1067,7 @@ create table biz_usage_record (
|
||||
occurred_at datetime not null comment '发生时间',
|
||||
primary key (usage_id),
|
||||
constraint uq_biz_usage_record_no unique (usage_no),
|
||||
constraint uq_biz_usage_record_idempotency unique (idempotency_key),
|
||||
constraint uq_biz_usage_record_site_idempotency unique (site_id, idempotency_key),
|
||||
constraint fk_biz_usage_record_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
|
||||
constraint fk_biz_usage_record_filling foreign key (active_filling_id) references biz_filling_record (filling_id),
|
||||
constraint fk_biz_usage_record_site foreign key (site_id) references biz_customer_site (site_id),
|
||||
@@ -1099,8 +1099,8 @@ create table biz_return_order (
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (return_id),
|
||||
constraint uq_biz_return_order_no unique (return_no),
|
||||
constraint uq_biz_return_order_request_idempotency unique (request_idempotency_key),
|
||||
constraint uq_biz_return_order_confirm_idempotency unique (confirm_idempotency_key),
|
||||
constraint uq_biz_return_order_route_request_idempotency unique (source_site_id, target_warehouse_id, request_idempotency_key),
|
||||
constraint uq_biz_return_order_route_confirm_idempotency unique (source_site_id, target_warehouse_id, confirm_idempotency_key),
|
||||
constraint fk_biz_return_order_site foreign key (source_site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_return_order_warehouse foreign key (target_warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_return_order_requested_by foreign key (requested_by) references sys_user (user_id),
|
||||
|
||||
@@ -729,6 +729,25 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertEqual(31, result.result)
|
||||
|
||||
async def test_idempotency_lookup_uses_requested_warehouse_for_multi_scope_and_admin(self) -> None:
|
||||
for user, scope in ((self.user, [9, 7, 9]), (make_user(1), None)):
|
||||
with self.subTest(admin=bool(user.user and user.user.admin)):
|
||||
self.db.reset_mock()
|
||||
existing = make_filling()
|
||||
get_key = AsyncMock(return_value=existing)
|
||||
scope_read = AsyncMock(return_value=scope)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope_read),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=get_key),
|
||||
):
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, user)
|
||||
|
||||
get_key.assert_awaited_once_with(self.db, 'fill-key', warehouse_ids=[7])
|
||||
self.assertEqual(31, result.result)
|
||||
if user.user and user.user.admin:
|
||||
scope_read.assert_not_awaited()
|
||||
|
||||
async def test_same_idempotency_key_with_different_hash_is_rejected(self) -> None:
|
||||
existing = make_filling(request_hash='different-hash')
|
||||
with (
|
||||
@@ -913,7 +932,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
key_reads = AsyncMock(side_effect=[None, recovered])
|
||||
access = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 9])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=key_reads),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
@@ -926,6 +945,10 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual(2, key_reads.await_count)
|
||||
self.assertEqual(
|
||||
[{'warehouse_ids': [7]}, {'warehouse_ids': [7]}],
|
||||
[call.kwargs for call in key_reads.await_args_list],
|
||||
)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
access.assert_any_await(self.db, self.user, 7)
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.routing import APIRoute
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.exc import IntegrityError, MultipleResultsFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
||||
@@ -1407,6 +1407,65 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_same_key_retry_uses_exact_warehouse_when_user_can_access_duplicate_keys(self) -> None:
|
||||
target = make_inbound(
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=self.request_hash,
|
||||
)
|
||||
other_domain = make_inbound(
|
||||
inbound_id=12,
|
||||
warehouse_id=8,
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=InboundService._build_confirm_request_hash(12),
|
||||
)
|
||||
|
||||
async def read_key(
|
||||
_db: AsyncSession,
|
||||
_key: str,
|
||||
*,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
) -> BizInboundOrder | None:
|
||||
matches = [
|
||||
inbound
|
||||
for inbound in (target, other_domain)
|
||||
if warehouse_ids is None or inbound.warehouse_id in warehouse_ids
|
||||
]
|
||||
if len(matches) > 1:
|
||||
raise MultipleResultsFound('广域幂等键查询返回多条入库单')
|
||||
return matches[0] if matches else None
|
||||
|
||||
get_detail = AsyncMock(return_value=target)
|
||||
key_read = AsyncMock(side_effect=read_key)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
||||
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=target)),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
):
|
||||
result = await InboundService.confirm_inbound_services(
|
||||
self.db,
|
||||
11,
|
||||
self.command,
|
||||
self.user,
|
||||
)
|
||||
|
||||
self.assertTrue(result.is_success)
|
||||
get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[7, 8])
|
||||
key_read.assert_awaited_once_with(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
warehouse_ids=[7],
|
||||
)
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_same_key_retry_hides_document_when_current_mapping_is_revoked(self) -> None:
|
||||
confirmed = make_inbound(
|
||||
status='CONFIRMED',
|
||||
@@ -1569,6 +1628,76 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_integrity_recovery_uses_exact_warehouse_with_duplicate_visible_keys(self) -> None:
|
||||
initial = make_inbound(status='DRAFT')
|
||||
recovered = make_inbound(
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=self.request_hash,
|
||||
)
|
||||
other_domain = make_inbound(
|
||||
inbound_id=12,
|
||||
warehouse_id=8,
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=InboundService._build_confirm_request_hash(12),
|
||||
)
|
||||
read_count = 0
|
||||
|
||||
async def read_key(
|
||||
_db: AsyncSession,
|
||||
_key: str,
|
||||
*,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
) -> BizInboundOrder | None:
|
||||
nonlocal read_count
|
||||
read_count += 1
|
||||
if read_count == 1:
|
||||
return None
|
||||
matches = [
|
||||
inbound
|
||||
for inbound in (recovered, other_domain)
|
||||
if warehouse_ids is None or inbound.warehouse_id in warehouse_ids
|
||||
]
|
||||
if len(matches) > 1:
|
||||
raise MultipleResultsFound('广域幂等键恢复查询返回多条入库单')
|
||||
return matches[0] if matches else None
|
||||
|
||||
warehouse_scope = AsyncMock(side_effect=[[7, 8], [7, 8]])
|
||||
key_read = AsyncMock(side_effect=read_key)
|
||||
parent_lock = AsyncMock(side_effect=[integrity_error(), recovered])
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=initial)),
|
||||
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
):
|
||||
result = await InboundService.confirm_inbound_services(
|
||||
self.db,
|
||||
11,
|
||||
self.command,
|
||||
self.user,
|
||||
)
|
||||
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertEqual(2, warehouse_scope.await_count)
|
||||
self.assertEqual(2, parent_lock.await_count)
|
||||
self.assertEqual(
|
||||
[
|
||||
unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]),
|
||||
unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]),
|
||||
],
|
||||
key_read.await_args_list,
|
||||
)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_integrity_recovery_locks_parent_and_rechecks_revoked_current_mapping(self) -> None:
|
||||
inbound = make_inbound(pallet_ids=(2,))
|
||||
recovered = make_inbound(
|
||||
|
||||
@@ -141,14 +141,14 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
'get_return_by_request_idempotency_key': (
|
||||
'db',
|
||||
'idempotency_key',
|
||||
'site_ids',
|
||||
'warehouse_ids',
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
),
|
||||
'get_return_by_confirm_idempotency_key': (
|
||||
'db',
|
||||
'idempotency_key',
|
||||
'site_ids',
|
||||
'warehouse_ids',
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
),
|
||||
'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'),
|
||||
'add_inbound': ('db', 'inbound'),
|
||||
@@ -249,8 +249,6 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
self.assertIs(True, return_list_signature.parameters['is_page'].default)
|
||||
for method_name in (
|
||||
'get_return_for_update',
|
||||
'get_return_by_request_idempotency_key',
|
||||
'get_return_by_confirm_idempotency_key',
|
||||
'get_return_by_id',
|
||||
'get_return_list',
|
||||
):
|
||||
@@ -259,6 +257,15 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
self.assertIsNone(signature.parameters['site_ids'].default)
|
||||
self.assertIsNone(signature.parameters['warehouse_ids'].default)
|
||||
|
||||
for method_name in (
|
||||
'get_return_by_request_idempotency_key',
|
||||
'get_return_by_confirm_idempotency_key',
|
||||
):
|
||||
with self.subTest(method=method_name):
|
||||
signature = inspect.signature(getattr(OperationDao, method_name))
|
||||
self.assertIs(inspect.Parameter.empty, signature.parameters['source_site_id'].default)
|
||||
self.assertIs(inspect.Parameter.empty, signature.parameters['target_warehouse_id'].default)
|
||||
|
||||
def test_dao_source_never_controls_transaction_boundary(self) -> None:
|
||||
for module in (inventory_dao_module, operation_dao_module):
|
||||
with self.subTest(module=module.__name__):
|
||||
@@ -609,26 +616,30 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
|
||||
OperationDao.get_inbound_by_confirm_idempotency_key,
|
||||
BizInboundOrder,
|
||||
'confirm_idempotency_key',
|
||||
{},
|
||||
),
|
||||
(
|
||||
OperationDao.get_shipment_by_confirm_idempotency_key,
|
||||
BizShipmentOrder,
|
||||
'confirm_idempotency_key',
|
||||
{},
|
||||
),
|
||||
(OperationDao.get_filling_by_idempotency_key, BizFillingRecord, 'idempotency_key'),
|
||||
(OperationDao.get_usage_by_idempotency_key, BizUsageRecord, 'idempotency_key'),
|
||||
(OperationDao.get_filling_by_idempotency_key, BizFillingRecord, 'idempotency_key', {}),
|
||||
(OperationDao.get_usage_by_idempotency_key, BizUsageRecord, 'idempotency_key', {}),
|
||||
(
|
||||
OperationDao.get_return_by_request_idempotency_key,
|
||||
BizReturnOrder,
|
||||
'request_idempotency_key',
|
||||
{'source_site_id': 9, 'target_warehouse_id': 7},
|
||||
),
|
||||
(
|
||||
OperationDao.get_return_by_confirm_idempotency_key,
|
||||
BizReturnOrder,
|
||||
'confirm_idempotency_key',
|
||||
{'source_site_id': 9, 'target_warehouse_id': 7},
|
||||
),
|
||||
)
|
||||
for method, model, key_column in cases:
|
||||
for method, model, key_column, kwargs in cases:
|
||||
with self.subTest(method=method.__name__):
|
||||
self.db.reset_mock()
|
||||
record = SimpleNamespace()
|
||||
@@ -636,13 +647,17 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
|
||||
result.scalar_one_or_none.return_value = record
|
||||
self.db.execute.return_value = result
|
||||
|
||||
actual = await method(self.db, 'same-key')
|
||||
actual = await method(self.db, 'same-key', **kwargs)
|
||||
|
||||
self.assertIs(record, actual)
|
||||
statement = self.db.execute.await_args.args[0]
|
||||
sql = compile_sql(statement)
|
||||
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
|
||||
self.assertIn(f"{model.__tablename__}.{key_column} = 'same-key'", sql)
|
||||
if model is BizReturnOrder:
|
||||
self.assertIn('biz_return_order.source_site_id = 9', sql)
|
||||
self.assertIn('biz_return_order.target_warehouse_id = 7', sql)
|
||||
self.assertNotIn(' OR ', sql)
|
||||
self.assertNotIn('FOR UPDATE', sql)
|
||||
|
||||
async def test_inbound_confirm_key_pushes_warehouse_scope_into_sql(self) -> None:
|
||||
|
||||
@@ -862,27 +862,27 @@ EXPECTED_UNIQUE_SETS = {
|
||||
'biz_user_site': set(),
|
||||
'biz_inbound_order': {
|
||||
frozenset({'inbound_no'}),
|
||||
frozenset({'confirm_idempotency_key'}),
|
||||
frozenset({'warehouse_id', 'confirm_idempotency_key'}),
|
||||
},
|
||||
'biz_inbound_item': {frozenset({'inbound_id', 'pallet_id'})},
|
||||
'biz_filling_record': {
|
||||
frozenset({'filling_no'}),
|
||||
frozenset({'idempotency_key'}),
|
||||
frozenset({'warehouse_id', 'idempotency_key'}),
|
||||
frozenset({'pallet_id', 'active_slot'}),
|
||||
},
|
||||
'biz_shipment_order': {
|
||||
frozenset({'shipment_no'}),
|
||||
frozenset({'confirm_idempotency_key'}),
|
||||
frozenset({'source_warehouse_id', 'target_site_id', 'confirm_idempotency_key'}),
|
||||
},
|
||||
'biz_shipment_item': {frozenset({'shipment_id', 'pallet_id'})},
|
||||
'biz_usage_record': {
|
||||
frozenset({'usage_no'}),
|
||||
frozenset({'idempotency_key'}),
|
||||
frozenset({'site_id', 'idempotency_key'}),
|
||||
},
|
||||
'biz_return_order': {
|
||||
frozenset({'return_no'}),
|
||||
frozenset({'request_idempotency_key'}),
|
||||
frozenset({'confirm_idempotency_key'}),
|
||||
frozenset({'source_site_id', 'target_warehouse_id', 'request_idempotency_key'}),
|
||||
frozenset({'source_site_id', 'target_warehouse_id', 'confirm_idempotency_key'}),
|
||||
},
|
||||
'biz_return_item': {frozenset({'return_id', 'pallet_id'})},
|
||||
'biz_pallet_inventory': set(),
|
||||
|
||||
@@ -188,20 +188,21 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
OperationDao.get_return_by_request_idempotency_key: (
|
||||
'db',
|
||||
'idempotency_key',
|
||||
'site_ids',
|
||||
'warehouse_ids',
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
),
|
||||
OperationDao.get_return_by_confirm_idempotency_key: (
|
||||
'db',
|
||||
'idempotency_key',
|
||||
'site_ids',
|
||||
'warehouse_ids',
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
),
|
||||
OperationDao.get_return_by_id: ('db', 'return_id', 'site_ids', 'warehouse_ids'),
|
||||
}
|
||||
for method, parameters in expected.items():
|
||||
with self.subTest(method=method.__name__):
|
||||
self.assertEqual(parameters, tuple(inspect.signature(method).parameters))
|
||||
if 'site_ids' in parameters:
|
||||
self.assertIsNone(inspect.signature(method).parameters['site_ids'].default)
|
||||
self.assertIsNone(inspect.signature(method).parameters['warehouse_ids'].default)
|
||||
|
||||
@@ -278,24 +279,39 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn('biz_return_order.target_warehouse_id IN (7)', sql)
|
||||
self.assertIn(' OR ', sql)
|
||||
|
||||
async def test_request_key_read_is_scoped_and_never_locks_missing_unique_key(self) -> None:
|
||||
async def test_return_key_reads_use_exact_route_and_never_lock_missing_unique_key(self) -> None:
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = None
|
||||
self.db.execute.return_value = result
|
||||
|
||||
await OperationDao.get_return_by_request_idempotency_key(
|
||||
key_reads = (
|
||||
(OperationDao.get_return_by_request_idempotency_key, 'request-key', 'request_idempotency_key'),
|
||||
(OperationDao.get_return_by_confirm_idempotency_key, 'confirm-key', 'confirm_idempotency_key'),
|
||||
)
|
||||
for method, key, column in key_reads:
|
||||
for source_site_id, target_warehouse_id in ((9, 7), (9, 8), (10, 7)):
|
||||
with self.subTest(
|
||||
method=method.__name__,
|
||||
source_site_id=source_site_id,
|
||||
target_warehouse_id=target_warehouse_id,
|
||||
):
|
||||
self.db.reset_mock()
|
||||
self.db.execute.return_value = result
|
||||
await method(
|
||||
self.db,
|
||||
'request-key',
|
||||
site_ids=[9],
|
||||
warehouse_ids=[],
|
||||
key,
|
||||
source_site_id=source_site_id,
|
||||
target_warehouse_id=target_warehouse_id,
|
||||
)
|
||||
|
||||
statement = self.db.execute.await_args.args[0]
|
||||
self.assertTrue(statement.get_final_froms()[0].compare(BizReturnOrder.__table__))
|
||||
for dialect in (mysql.dialect(), postgresql.dialect()):
|
||||
sql = compile_sql(statement, dialect)
|
||||
self.assertIn("biz_return_order.request_idempotency_key = 'request-key'", sql)
|
||||
self.assertIn('biz_return_order.source_site_id IN (9)', sql)
|
||||
self.assertIn(f"biz_return_order.{column} = '{key}'", sql)
|
||||
self.assertIn(f'biz_return_order.source_site_id = {source_site_id}', sql)
|
||||
self.assertIn(f'biz_return_order.target_warehouse_id = {target_warehouse_id}', sql)
|
||||
self.assertNotIn(' OR ', sql)
|
||||
self.assertNotIn('FOR UPDATE', sql)
|
||||
|
||||
|
||||
@@ -1021,8 +1037,8 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(13, result.result)
|
||||
self.assertEqual(
|
||||
[
|
||||
{'site_ids': [9], 'warehouse_ids': []},
|
||||
{'site_ids': [9, 10], 'warehouse_ids': []},
|
||||
{'source_site_id': 9, 'target_warehouse_id': 7},
|
||||
{'source_site_id': 9, 'target_warehouse_id': 7},
|
||||
],
|
||||
[call.kwargs for call in key_read.await_args_list],
|
||||
)
|
||||
@@ -1276,7 +1292,10 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(2, scope_read.await_count)
|
||||
self.assertEqual(2, key_read.await_count)
|
||||
self.assertEqual(
|
||||
[{'site_ids': [9], 'warehouse_ids': []}, {'site_ids': [9], 'warehouse_ids': []}],
|
||||
[
|
||||
{'source_site_id': 9, 'target_warehouse_id': 7},
|
||||
{'source_site_id': 9, 'target_warehouse_id': 7},
|
||||
],
|
||||
[call.kwargs for call in key_read.await_args_list],
|
||||
)
|
||||
locks.assert_awaited_once_with(self.db, [2, 5])
|
||||
@@ -1447,7 +1466,41 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
inventory_locks.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_admin_request_idempotency_scope_is_unrestricted(self) -> None:
|
||||
async def test_request_integrity_recovery_key_lookup_uses_exact_route(self) -> None:
|
||||
request_hash = ReturnService._build_request_hash(self.page)
|
||||
exact = make_return_order(request_hash=request_hash)
|
||||
key_read = AsyncMock(return_value=exact)
|
||||
order_lock = AsyncMock(return_value=exact)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[10, 9])),
|
||||
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
|
||||
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
):
|
||||
result = await ReturnService._recover_request_integrity_error(
|
||||
self.db,
|
||||
self.page,
|
||||
request_hash,
|
||||
self.user,
|
||||
)
|
||||
|
||||
self.assertEqual(13, result.result)
|
||||
key_read.assert_awaited_once_with(
|
||||
self.db,
|
||||
'request-key',
|
||||
source_site_id=9,
|
||||
target_warehouse_id=7,
|
||||
)
|
||||
order_lock.assert_awaited_once_with(
|
||||
self.db,
|
||||
13,
|
||||
site_ids=[9, 10],
|
||||
warehouse_ids=[],
|
||||
)
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_admin_request_idempotency_lookup_still_uses_exact_route(self) -> None:
|
||||
admin = make_user(1)
|
||||
request_hash = ReturnService._build_request_hash(self.page)
|
||||
exact = make_return_order(request_hash=request_hash)
|
||||
@@ -1464,8 +1517,8 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
get_by_key.assert_awaited_once_with(
|
||||
self.db,
|
||||
'request-key',
|
||||
site_ids=None,
|
||||
warehouse_ids=None,
|
||||
source_site_id=9,
|
||||
target_warehouse_id=7,
|
||||
)
|
||||
|
||||
|
||||
@@ -1558,7 +1611,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual([None, None], [event.after_site_id for event in events])
|
||||
self.db.commit.assert_awaited_once()
|
||||
|
||||
async def test_confirm_preflights_idempotency_key_before_return_id(self) -> None:
|
||||
async def test_confirm_resolves_authorized_return_route_before_key_lookup(self) -> None:
|
||||
call_order: list[str] = []
|
||||
order = make_return_order()
|
||||
|
||||
@@ -1569,9 +1622,10 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
call_order.append('return')
|
||||
return order
|
||||
|
||||
key_read = AsyncMock(side_effect=get_by_key)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=AsyncMock(side_effect=get_by_key)),
|
||||
patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=key_read),
|
||||
patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(side_effect=get_by_id)),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=order)),
|
||||
@@ -1583,7 +1637,13 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
except ServiceException:
|
||||
pass
|
||||
|
||||
self.assertEqual(['key', 'return'], call_order[:2])
|
||||
self.assertEqual(['return', 'key'], call_order[:2])
|
||||
key_read.assert_awaited_once_with(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
source_site_id=9,
|
||||
target_warehouse_id=7,
|
||||
)
|
||||
|
||||
async def test_confirm_requires_exact_scan_set_before_inventory_locks(self) -> None:
|
||||
order = make_return_order()
|
||||
@@ -1688,7 +1748,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn('幂等键已被其他请求使用', raised.exception.message)
|
||||
conflict_db.rollback.assert_awaited()
|
||||
|
||||
async def test_admin_confirm_scope_is_unrestricted(self) -> None:
|
||||
async def test_admin_confirm_idempotency_lookup_still_uses_exact_route(self) -> None:
|
||||
admin = make_user(1)
|
||||
confirm_hash = ReturnService._build_confirm_request_hash(13, self.page)
|
||||
exact = make_return_order(
|
||||
@@ -1697,12 +1757,13 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
confirm_hash=confirm_hash,
|
||||
)
|
||||
get_order = AsyncMock(return_value=exact)
|
||||
get_by_key = AsyncMock(return_value=exact)
|
||||
with (
|
||||
patch.object(OperationDao, 'get_return_by_id', new=get_order),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_return_by_confirm_idempotency_key',
|
||||
new=AsyncMock(return_value=exact),
|
||||
new=get_by_key,
|
||||
),
|
||||
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
@@ -1717,6 +1778,60 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
site_ids=None,
|
||||
warehouse_ids=None,
|
||||
)
|
||||
get_by_key.assert_awaited_once_with(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
source_site_id=9,
|
||||
target_warehouse_id=7,
|
||||
)
|
||||
|
||||
async def test_confirm_integrity_recovery_resolves_route_before_exact_key_lookup(self) -> None:
|
||||
confirm_hash = ReturnService._build_confirm_request_hash(13, self.page)
|
||||
exact = make_return_order(
|
||||
status=ReturnStatus.CONFIRMED.value,
|
||||
confirm_key='confirm-key',
|
||||
confirm_hash=confirm_hash,
|
||||
)
|
||||
call_order: list[str] = []
|
||||
|
||||
async def read_route(*_args: Any, **_kwargs: Any) -> BizReturnOrder:
|
||||
call_order.append('return')
|
||||
return exact
|
||||
|
||||
async def read_key(*_args: Any, **_kwargs: Any) -> BizReturnOrder:
|
||||
call_order.append('key')
|
||||
return exact
|
||||
|
||||
route_read = AsyncMock(side_effect=read_route)
|
||||
key_read = AsyncMock(side_effect=read_key)
|
||||
order_lock = AsyncMock(return_value=exact)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[8, 7])),
|
||||
patch.object(OperationDao, 'get_return_by_id', new=route_read),
|
||||
patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=key_read),
|
||||
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
):
|
||||
result = await ReturnService._recover_confirm_integrity_error(
|
||||
self.db,
|
||||
13,
|
||||
self.page,
|
||||
confirm_hash,
|
||||
self.user,
|
||||
)
|
||||
|
||||
self.assertEqual(13, result.result)
|
||||
self.assertEqual(['return', 'key'], call_order)
|
||||
route_read.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7, 8])
|
||||
key_read.assert_awaited_once_with(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
source_site_id=9,
|
||||
target_warehouse_id=7,
|
||||
)
|
||||
order_lock.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7, 8])
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_confirm_rechecks_warehouse_mapping_before_business_locks(self) -> None:
|
||||
order = make_return_order()
|
||||
|
||||
@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.exc import IntegrityError, MultipleResultsFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
||||
@@ -2181,6 +2181,96 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
parent_lock.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_same_key_retry_uses_exact_route_when_user_can_access_duplicate_keys(self) -> None:
|
||||
target = make_shipment(
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=self.request_hash,
|
||||
)
|
||||
other_domain = make_shipment(
|
||||
13,
|
||||
source_warehouse_id=8,
|
||||
target_site_id=10,
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=ShipmentService._build_confirm_request_hash(13),
|
||||
)
|
||||
|
||||
async def read_key(
|
||||
_db: AsyncSession,
|
||||
_key: str,
|
||||
*,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
site_ids: list[int] | None = None,
|
||||
) -> BizShipmentOrder | None:
|
||||
matches = [
|
||||
shipment
|
||||
for shipment in (target, other_domain)
|
||||
if (warehouse_ids is None or shipment.source_warehouse_id in warehouse_ids)
|
||||
and (site_ids is None or shipment.target_site_id in site_ids)
|
||||
]
|
||||
if len(matches) > 1:
|
||||
raise MultipleResultsFound('广域幂等键查询返回多条发货单')
|
||||
return matches[0] if matches else None
|
||||
|
||||
get_detail = AsyncMock(return_value=target)
|
||||
key_read = AsyncMock(side_effect=read_key)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
|
||||
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
|
||||
patch.object(OperationDao, 'get_shipment_by_confirm_idempotency_key', new=key_read),
|
||||
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=target)),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(
|
||||
return_value=BizWarehouse(
|
||||
warehouse_id=7,
|
||||
warehouse_code='W-7',
|
||||
warehouse_name='七号仓',
|
||||
status='0',
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_site',
|
||||
new=AsyncMock(
|
||||
return_value=BizCustomerSite(
|
||||
site_id=9,
|
||||
customer_id=1,
|
||||
site_code='S-9',
|
||||
site_name='九号现场',
|
||||
status='0',
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
):
|
||||
result = await ShipmentService.confirm_shipment_services(
|
||||
self.db,
|
||||
12,
|
||||
self.command,
|
||||
self.user,
|
||||
)
|
||||
|
||||
self.assertTrue(result.is_success)
|
||||
get_detail.assert_awaited_once_with(
|
||||
self.db,
|
||||
12,
|
||||
warehouse_ids=[7, 8],
|
||||
site_ids=[9, 10],
|
||||
)
|
||||
key_read.assert_awaited_once_with(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
warehouse_ids=[7],
|
||||
site_ids=[9],
|
||||
)
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_integrity_recovery_nonexact_result_is_stable_conflict_not_success(self) -> None:
|
||||
initial = make_shipment()
|
||||
nonexact = make_shipment(
|
||||
@@ -2413,6 +2503,113 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_integrity_recovery_uses_exact_route_with_duplicate_visible_keys(self) -> None:
|
||||
initial = make_shipment(status='DRAFT')
|
||||
recovered = make_shipment(
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=self.request_hash,
|
||||
)
|
||||
other_domain = make_shipment(
|
||||
13,
|
||||
source_warehouse_id=8,
|
||||
target_site_id=10,
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=ShipmentService._build_confirm_request_hash(13),
|
||||
)
|
||||
read_count = 0
|
||||
|
||||
async def read_key(
|
||||
_db: AsyncSession,
|
||||
_key: str,
|
||||
*,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
site_ids: list[int] | None = None,
|
||||
) -> BizShipmentOrder | None:
|
||||
nonlocal read_count
|
||||
read_count += 1
|
||||
if read_count == 1:
|
||||
return None
|
||||
matches = [
|
||||
shipment
|
||||
for shipment in (recovered, other_domain)
|
||||
if (warehouse_ids is None or shipment.source_warehouse_id in warehouse_ids)
|
||||
and (site_ids is None or shipment.target_site_id in site_ids)
|
||||
]
|
||||
if len(matches) > 1:
|
||||
raise MultipleResultsFound('广域幂等键恢复查询返回多条发货单')
|
||||
return matches[0] if matches else None
|
||||
|
||||
warehouse_scope = AsyncMock(side_effect=[[7, 8], [7, 8]])
|
||||
site_scope = AsyncMock(side_effect=[[9, 10], [9, 10]])
|
||||
key_read = AsyncMock(side_effect=read_key)
|
||||
parent_lock = AsyncMock(side_effect=[integrity_error(), recovered])
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
|
||||
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
|
||||
patch.object(OperationDao, 'get_shipment_by_confirm_idempotency_key', new=key_read),
|
||||
patch.object(OperationDao, 'get_shipment_for_update', new=parent_lock),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(
|
||||
return_value=BizWarehouse(
|
||||
warehouse_id=7,
|
||||
warehouse_code='W-7',
|
||||
warehouse_name='七号仓',
|
||||
status='0',
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_site',
|
||||
new=AsyncMock(
|
||||
return_value=BizCustomerSite(
|
||||
site_id=9,
|
||||
customer_id=1,
|
||||
site_code='S-9',
|
||||
site_name='九号现场',
|
||||
status='0',
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
):
|
||||
result = await ShipmentService.confirm_shipment_services(
|
||||
self.db,
|
||||
12,
|
||||
self.command,
|
||||
self.user,
|
||||
)
|
||||
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertEqual(2, warehouse_scope.await_count)
|
||||
self.assertEqual(2, site_scope.await_count)
|
||||
self.assertEqual(2, parent_lock.await_count)
|
||||
self.assertEqual(
|
||||
[
|
||||
unittest.mock.call(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
warehouse_ids=[7],
|
||||
site_ids=[9],
|
||||
),
|
||||
unittest.mock.call(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
warehouse_ids=[7],
|
||||
site_ids=[9],
|
||||
),
|
||||
],
|
||||
key_read.await_args_list,
|
||||
)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_revoked_mapping_after_locks_hides_document_and_mutates_nothing(self) -> None:
|
||||
shipment = make_shipment(pallet_ids=(2,))
|
||||
inventory = make_inventory(2, version=4)
|
||||
|
||||
@@ -218,13 +218,24 @@ EXPECTED_UNIQUE_KEYS = {
|
||||
'biz_pallet': {('pallet_code',), ('qr_token',)},
|
||||
'biz_user_warehouse': set(),
|
||||
'biz_user_site': set(),
|
||||
'biz_inbound_order': {('inbound_no',), ('confirm_idempotency_key',)},
|
||||
'biz_inbound_order': {('inbound_no',), ('warehouse_id', 'confirm_idempotency_key')},
|
||||
'biz_inbound_item': {('inbound_id', 'pallet_id')},
|
||||
'biz_filling_record': {('filling_no',), ('idempotency_key',), ('pallet_id', 'active_slot')},
|
||||
'biz_shipment_order': {('shipment_no',), ('confirm_idempotency_key',)},
|
||||
'biz_filling_record': {
|
||||
('filling_no',),
|
||||
('warehouse_id', 'idempotency_key'),
|
||||
('pallet_id', 'active_slot'),
|
||||
},
|
||||
'biz_shipment_order': {
|
||||
('shipment_no',),
|
||||
('source_warehouse_id', 'target_site_id', 'confirm_idempotency_key'),
|
||||
},
|
||||
'biz_shipment_item': {('shipment_id', 'pallet_id')},
|
||||
'biz_usage_record': {('usage_no',), ('idempotency_key',)},
|
||||
'biz_return_order': {('return_no',), ('request_idempotency_key',), ('confirm_idempotency_key',)},
|
||||
'biz_usage_record': {('usage_no',), ('site_id', 'idempotency_key')},
|
||||
'biz_return_order': {
|
||||
('return_no',),
|
||||
('source_site_id', 'target_warehouse_id', 'request_idempotency_key'),
|
||||
('source_site_id', 'target_warehouse_id', 'confirm_idempotency_key'),
|
||||
},
|
||||
'biz_return_item': {('return_id', 'pallet_id')},
|
||||
'biz_pallet_inventory': set(),
|
||||
'biz_pallet_event': {('pallet_id', 'event_seq'), ('pallet_id', 'idempotency_key')},
|
||||
|
||||
@@ -732,8 +732,19 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
pallet_lock = AsyncMock(return_value=[selected_pallet])
|
||||
filling_lock = AsyncMock(return_value=selected_filling)
|
||||
site_lock = AsyncMock(return_value=selected_site)
|
||||
preflight_line_read = AsyncMock(return_value=selected_line)
|
||||
line_lock = AsyncMock(return_value=selected_line)
|
||||
|
||||
async def read_line(
|
||||
db: AsyncSession,
|
||||
line_id: int,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> BizProductionLine | None:
|
||||
if for_update:
|
||||
return await line_lock(db, line_id, for_update=True)
|
||||
return await preflight_line_read(db, line_id)
|
||||
|
||||
stack.enter_context(patch.object(AccessDao, 'list_user_site_ids', new=scope_read))
|
||||
stack.enter_context(patch.object(AccessService, 'assert_site_access', new=site_access))
|
||||
stack.enter_context(patch.object(OperationDao, 'get_usage_by_idempotency_key', new=usage_key_read))
|
||||
@@ -742,7 +753,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
stack.enter_context(patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock))
|
||||
stack.enter_context(patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock))
|
||||
stack.enter_context(patch.object(MasterDao, 'get_site', new=site_lock))
|
||||
stack.enter_context(patch.object(MasterDao, 'get_line', new=line_lock))
|
||||
stack.enter_context(patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=read_line)))
|
||||
stack.enter_context(patch.object(OperationDao, 'add_usage_record', new=usage_write))
|
||||
stack.enter_context(patch.object(OperationDao, 'update_filling_record', new=filling_write))
|
||||
stack.enter_context(patch.object(InventoryDao, 'add_event', new=event_write))
|
||||
@@ -757,6 +768,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
'pallet_lock': pallet_lock,
|
||||
'filling_lock': filling_lock,
|
||||
'site_lock': site_lock,
|
||||
'preflight_line_read': preflight_line_read,
|
||||
'line_lock': line_lock,
|
||||
'add_usage': usage_write,
|
||||
'update_filling': filling_write,
|
||||
@@ -877,6 +889,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(
|
||||
[
|
||||
'preflight-scope',
|
||||
'line-lock',
|
||||
'usage-preflight-read',
|
||||
'qr-snapshot-read',
|
||||
'rollback',
|
||||
@@ -1044,6 +1057,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.reset_mock()
|
||||
stack, mocks = self._patch_record_path(line=line)
|
||||
if line is None:
|
||||
mocks['preflight_line_read'].return_value = None
|
||||
mocks['line_lock'].return_value = None
|
||||
with stack, self.assertRaises(ServiceException) as raised:
|
||||
await UsageService.record_usage_services(self.db, self.page, self.user)
|
||||
@@ -1169,6 +1183,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_record_hides_missing_and_non_site_qr_but_admin_gets_precise_error(self) -> None:
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
|
||||
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
|
||||
self.assertRaises(PermissionException) as normal_missing,
|
||||
@@ -1179,6 +1194,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.db.reset_mock()
|
||||
with (
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
|
||||
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
|
||||
self.assertRaises(ServiceException) as admin_missing,
|
||||
@@ -1239,10 +1255,11 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
async def read_key(*_args: Any, **kwargs: Any) -> BizUsageRecord | None:
|
||||
nonlocal key_call_count
|
||||
key_call_count += 1
|
||||
actions.append('usage-preflight-read' if key_call_count == 1 else 'usage-fresh-read')
|
||||
if key_call_count == 1:
|
||||
is_locked_phase = 'inventory-lock' in actions
|
||||
actions.append('usage-fresh-read' if is_locked_phase else 'usage-preflight-read')
|
||||
if not is_locked_phase:
|
||||
return None
|
||||
return recovered if kwargs.get('site_ids') == scopes[1] else None
|
||||
return recovered if kwargs.get('site_ids') == [inventory.site_id] else None
|
||||
|
||||
async def scan_pallet(*_args: Any) -> BizPallet:
|
||||
actions.append('qr-snapshot-read')
|
||||
@@ -1301,6 +1318,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
access = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_usage_by_idempotency_key',
|
||||
@@ -1334,10 +1352,34 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
result.result.model_dump(by_alias=True),
|
||||
)
|
||||
|
||||
async def test_preflight_key_lookup_uses_line_site_for_multi_scope_and_admin(self) -> None:
|
||||
for user, scope in ((self.user, [10, 9, 10]), (make_user(1), None)):
|
||||
with self.subTest(admin=bool(user.user and user.user.admin)):
|
||||
self.db.reset_mock()
|
||||
existing = self._exact_usage()
|
||||
line_read = AsyncMock(return_value=make_line(site_id=9))
|
||||
get_key = AsyncMock(return_value=existing)
|
||||
scope_read = AsyncMock(return_value=scope)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=scope_read),
|
||||
patch.object(MasterDao, 'get_line', new=line_read),
|
||||
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=get_key),
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
):
|
||||
result = await UsageService.record_usage_services(self.db, self.page, user)
|
||||
|
||||
line_read.assert_awaited_once_with(self.db, 4)
|
||||
get_key.assert_awaited_once_with(self.db, 'usage-key', site_ids=[9])
|
||||
self.assertEqual(61, result.result.usage_id)
|
||||
if user.user and user.user.admin:
|
||||
scope_read.assert_not_awaited()
|
||||
|
||||
async def test_same_key_with_different_hash_is_stable_conflict(self) -> None:
|
||||
existing = self._exact_usage(request_hash='different-hash')
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
|
||||
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()) as inventory_lock,
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
@@ -1353,6 +1395,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
|
||||
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)),
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())),
|
||||
patch.object(AccessService, 'assert_site_access', new=access),
|
||||
@@ -1505,7 +1548,6 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(
|
||||
[
|
||||
'preflight-scope',
|
||||
'usage-preflight-read',
|
||||
'qr-snapshot-read',
|
||||
'preflight-rollback',
|
||||
'inventory-lock',
|
||||
@@ -1520,7 +1562,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
actions,
|
||||
)
|
||||
self.assertEqual(
|
||||
[{'site_ids': []}, {'site_ids': [9]}],
|
||||
[{'site_ids': [9]}],
|
||||
[call.kwargs for call in mocks['key_read'].await_args_list],
|
||||
)
|
||||
mocks['filling_lock'].assert_not_awaited()
|
||||
@@ -1702,11 +1744,11 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
self.fail('锁后命中的摘要或实际现场冲突不得返回成功')
|
||||
|
||||
self.assertEqual(
|
||||
[{'site_ids': [9, 10]}, {'site_ids': [9, 10]}],
|
||||
[{'site_ids': [9]}, {'site_ids': [9]}],
|
||||
[call.kwargs for call in key_read.await_args_list],
|
||||
)
|
||||
self.assertEqual(2, mocks['scope_read'].await_count)
|
||||
self.assertTrue(all(call.kwargs['site_ids'] == [9, 10] for call in key_read.await_args_list))
|
||||
self.assertTrue(all(call.kwargs['site_ids'] == [9] for call in key_read.await_args_list))
|
||||
mocks['site_lock'].assert_not_awaited()
|
||||
mocks['line_lock'].assert_not_awaited()
|
||||
mocks['add_usage'].assert_not_awaited()
|
||||
@@ -1717,7 +1759,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_integrity_error_recovers_only_fresh_scoped_exact_result(self) -> None:
|
||||
recovered = self._exact_usage()
|
||||
scope = AsyncMock(side_effect=[[9], [9], [9]])
|
||||
scope = AsyncMock(side_effect=[[10, 9], [10, 9], [10, 9]])
|
||||
key_read = AsyncMock(side_effect=[None, None, recovered])
|
||||
add_usage = AsyncMock(side_effect=integrity_error())
|
||||
stack, mocks = self.core._patch_record_path(
|
||||
|
||||
Reference in New Issue
Block a user