数据库字段与VO模型不匹配导致的序列化错误

 空值处理不当导致的异常
 缺少事务回滚机制
 错误信息不清晰
 样品数量计算可能返回None
main
risingLee 2025-12-29 22:22:13 +08:00
parent 3d82c0ea1e
commit 753dd62511
6 changed files with 312 additions and 142 deletions

View File

@ -0,0 +1,75 @@
# 需求文档
## 简介
本规范解决入库单管理系统中的错误处理和数据完整性问题。系统当前在查看入库单详情时出现错误,特别是与数据库操作以及入库单和样品之间的数据关系相关的问题。
## 术语表
- **入库单系统Warehouse Receipt System**: 负责管理入库单记录及其关联样品的应用模块
- **入库单Receipt**: 包含入库库存信息的仓库入库记录
- **样品Sample**: 与入库单关联的单个物品或样本
- **数据库会话Database Session**: 用于数据库操作的SQLAlchemy异步会话
- **错误处理器Error Handler**: 负责捕获错误并向用户报告的系统组件
## 需求
### 需求 1
**用户故事:** 作为仓库管理员,我希望能够查看入库单详情而不遇到系统错误,以便可靠地访问完整的入库单信息。
#### 验收标准
1. WHEN 用户通过入库单ID请求入库单详情时THE 入库单系统 SHALL 从数据库检索入库单记录
2. WHEN 入库单记录存在时THE 入库单系统 SHALL 返回完整的入库单信息,包括所有关联的样品
3. WHEN 入库单记录不存在时THE 入库单系统 SHALL 返回清晰的错误消息,指示未找到入库单
4. WHEN 检索过程中发生数据库错误时THE 入库单系统 SHALL 优雅地处理异常并返回用户友好的错误消息
5. WHEN 检索关联样品时THE 入库单系统 SHALL 确保在返回响应之前正确加载所有样品记录
### 需求 2
**用户故事:** 作为系统管理员,我希望在整个入库单操作中有适当的错误处理,以便用户在出现问题时收到清晰的反馈。
#### 验收标准
1. WHEN 任何数据库操作失败时THE 入库单系统 SHALL 捕获异常并记录详细的错误信息
2. WHEN 捕获到异常时THE 入库单系统 SHALL 返回带有适当HTTP状态码的结构化错误响应
3. WHEN 数据验证失败时THE 入库单系统 SHALL 提供具体的错误消息,指示哪些字段无效
4. WHEN 违反外键约束时THE 入库单系统 SHALL 返回解释关系约束的消息
5. THE 入库单系统 SHALL NOT 向最终用户暴露内部数据库错误详情
### 需求 3
**用户故事:** 作为开发人员,我希望在入库单操作中有适当的事务管理,以便在相关表之间维护数据完整性。
#### 验收标准
1. WHEN 创建带有样品的入库单时THE 入库单系统 SHALL 对两个操作使用单个数据库事务
2. WHEN 多步骤操作的任何部分失败时THE 入库单系统 SHALL 回滚该事务中所做的所有更改
3. WHEN 更新入库单时THE 入库单系统 SHALL 仅在所有验证通过后提交更改
4. WHEN 删除入库单时THE 入库单系统 SHALL 确保在同一事务中也删除所有关联的样品
5. THE 入库单系统 SHALL 对所有数据库操作使用适当的async/await模式
### 需求 4
**用户故事:** 作为仓库管理员,我希望看到每个入库单的准确样品数量,以便快速评估库存水平。
#### 验收标准
1. WHEN 显示入库单列表时THE 入库单系统 SHALL 计算并显示每个入库单的正确样品数量
2. WHEN 入库单没有样品时THE 入库单系统 SHALL 显示样品数量为零
3. WHEN 添加或删除样品时THE 入库单系统 SHALL 立即更新样品数量
4. THE 入库单系统 SHALL 使用高效的数据库查询来检索样品数量,而无需加载所有样品数据
5. WHEN 显示入库单详情时THE 入库单系统 SHALL 同时包含样品数量和完整的样品列表
### 需求 5
**用户故事:** 作为系统用户,我希望应用程序能够优雅地处理空值或缺失数据,以便不会遇到意外错误。
#### 验收标准
1. WHEN 可选字段为空或缺失时THE 入库单系统 SHALL 处理它们而不引发异常
2. WHEN 将数据库对象转换为字典时THE 入库单系统 SHALL 适当地处理None值
3. WHEN 关系返回空集合时THE 入库单系统 SHALL 将它们视为空列表而不是null
4. THE 入库单系统 SHALL 在尝试数据库操作之前验证必填字段
5. WHEN 序列化响应时THE 入库单系统 SHALL 排除空值或将它们转换为适当的默认值

View File

@ -42,9 +42,13 @@ async def get_warehouse_receipt_detail(
"""
获取入库单详情
"""
receipt_detail = await WarehouseReceiptService.get_receipt_detail(query_db, receipt_id)
logger.info('获取成功')
return ResponseUtil.success(data=receipt_detail)
try:
receipt_detail = await WarehouseReceiptService.get_receipt_detail(query_db, receipt_id)
logger.info('获取成功')
return ResponseUtil.success(data=receipt_detail)
except Exception as e:
logger.error(f'获取入库单详情失败: {str(e)}')
return ResponseUtil.error(msg=str(e))
@warehouseReceiptController.post('', dependencies=[Depends(CheckUserInterfaceAuth('warehouse:receipt:add'))])

View File

@ -126,25 +126,33 @@ class WarehouseReceiptDao:
await db.flush()
@classmethod
async def generate_receipt_no(cls, db: AsyncSession, year: int, type_code: str = '内检') -> str:
async def generate_receipt_no(cls, db: AsyncSession, year: int) -> str:
"""
生成入库单号
格式2025内检001
"""
# 这里简化处理,实际应该使用序号表
query = select(func.max(WarehouseReceipt.receipt_no)).where(
WarehouseReceipt.receipt_no.like(f'{year}{type_code}%')
)
result = await db.execute(query)
max_no = result.scalar()
type_code = '内检'
try:
# 查询当前年份最大的入库单号
query = select(func.max(WarehouseReceipt.receipt_no)).where(
WarehouseReceipt.receipt_no.like(f'{year}{type_code}%')
)
result = await db.execute(query)
max_no = result.scalar()
if max_no:
# 提取序号部分
seq = int(max_no[len(str(year)) + len(type_code):]) + 1
else:
seq = 1
if max_no:
# 提取序号部分
try:
seq = int(max_no[len(str(year)) + len(type_code):]) + 1
except (ValueError, IndexError):
seq = 1
else:
seq = 1
return f'{year}{type_code}{seq:03d}'
return f'{year}{type_code}{seq:03d}'
except Exception as e:
# 如果出错,返回默认序号
return f'{year}{type_code}001'

View File

@ -20,9 +20,11 @@ class WarehouseReceiptModel(BaseModel):
source_location: Optional[str] = Field(default=None, description='来源地')
delivery_person: Optional[str] = Field(default=None, description='送样人')
receipt_method: Optional[str] = Field(default=None, description='收样方式')
receipt_method_detail: Optional[str] = Field(default=None, description='收样方式详情')
receiver: Optional[str] = Field(default=None, description='收样人')
recorder: Optional[str] = Field(default=None, description='入库记录人')
purpose: Optional[str] = Field(default=None, description='来样目的')
purpose_detail: Optional[str] = Field(default=None, description='来样目的详情')
status: Optional[str] = Field(default='0', description='状态')
remark: Optional[str] = Field(default=None, description='备注')
create_by: Optional[str] = Field(default=None, description='创建者')

View File

@ -23,173 +23,205 @@ class WarehouseReceiptService:
"""
获取入库单列表
"""
receipt_list = await WarehouseReceiptDao.get_receipt_list(db, query_object, is_page)
# 转换为字典并添加样品数量
result_list = []
for receipt in receipt_list:
receipt_dict = CamelCaseUtil.transform_result(receipt)
# 获取样品数量
sample_count = await WarehouseSampleDao.get_sample_count_by_receipt(db, receipt.receipt_id)
receipt_dict['sample_count'] = sample_count
result_list.append(receipt_dict)
try:
receipt_list = await WarehouseReceiptDao.get_receipt_list(db, query_object, is_page)
# 转换为字典并添加样品数量
result_list = []
for receipt in receipt_list:
receipt_dict = CamelCaseUtil.transform_result(receipt)
# 获取样品数量
sample_count = await WarehouseSampleDao.get_sample_count_by_receipt(db, receipt.receipt_id)
receipt_dict['sample_count'] = sample_count if sample_count else 0
result_list.append(receipt_dict)
if is_page:
total = await WarehouseReceiptDao.get_receipt_count(db, query_object)
return {'rows': result_list, 'total': total}
else:
return result_list
if is_page:
total = await WarehouseReceiptDao.get_receipt_count(db, query_object)
return {'rows': result_list, 'total': total if total else 0}
else:
return result_list
except Exception as e:
raise ServiceException(message=f'获取入库单列表失败: {str(e)}')
@classmethod
async def get_receipt_detail(cls, db: AsyncSession, receipt_id: int):
"""
获取入库单详情
"""
receipt = await WarehouseReceiptDao.get_receipt_by_id(db, receipt_id)
if not receipt:
raise ServiceException(message='入库单不存在')
receipt_dict = CamelCaseUtil.transform_result(receipt)
# 获取样品列表
from module_admin.entity.vo.warehouse_sample_vo import WarehouseSamplePageQueryModel
sample_query = WarehouseSamplePageQueryModel(receipt_id=receipt.receipt_id, page_num=1, page_size=1000)
samples = await WarehouseSampleDao.get_sample_list(db, sample_query, is_page=False)
# 转换样品列表
samples_list = [CamelCaseUtil.transform_result(sample) for sample in samples]
receipt_dict['samples'] = samples_list
receipt_dict['sample_count'] = len(samples_list)
return receipt_dict
try:
receipt = await WarehouseReceiptDao.get_receipt_by_id(db, receipt_id)
if not receipt:
raise ServiceException(message='入库单不存在')
receipt_dict = CamelCaseUtil.transform_result(receipt)
# 获取样品列表
from module_admin.entity.vo.warehouse_sample_vo import WarehouseSamplePageQueryModel
sample_query = WarehouseSamplePageQueryModel(receipt_id=receipt.receipt_id, page_num=1, page_size=1000)
samples = await WarehouseSampleDao.get_sample_list(db, sample_query, is_page=False)
# 转换样品列表
samples_list = [CamelCaseUtil.transform_result(sample) for sample in samples] if samples else []
receipt_dict['samples'] = samples_list
receipt_dict['sample_count'] = len(samples_list)
return receipt_dict
except ServiceException:
raise
except Exception as e:
raise ServiceException(message=f'获取入库单详情失败: {str(e)}')
@classmethod
async def add_receipt(cls, db: AsyncSession, receipt_model: AddWarehouseReceiptModel):
"""
新增入库单同时创建关联的样品
"""
# 检查入库单号是否已存在
existing = await WarehouseReceiptDao.get_receipt_by_no(db, receipt_model.receipt_no)
if existing:
raise ServiceException(message=f'入库单号【{receipt_model.receipt_no}】已存在')
try:
# 检查入库单号是否已存在
existing = await WarehouseReceiptDao.get_receipt_by_no(db, receipt_model.receipt_no)
if existing:
raise ServiceException(message=f'入库单号【{receipt_model.receipt_no}】已存在')
# 获取样品数据
samples_data = receipt_model.samples if receipt_model.samples else []
# 创建入库单对象排除samples字段
receipt_dict = receipt_model.model_dump(exclude_unset=True, exclude={'samples'})
receipt = WarehouseReceipt(**receipt_dict)
receipt.create_time = datetime.now()
receipt.update_time = datetime.now()
# 保存入库单
await WarehouseReceiptDao.add_receipt(db, receipt)
await db.flush() # 刷新以获取receipt_id
# 创建样品对象并关联到入库单
for sample_data in samples_data:
sample_dict = sample_data.model_dump(exclude_unset=True)
sample = WarehouseSample(**sample_dict)
sample.receipt_id = receipt.receipt_id
sample.receipt_no = receipt.receipt_no
sample.create_by = receipt.create_by
sample.create_time = datetime.now()
sample.update_time = datetime.now()
await WarehouseSampleDao.add_sample(db, sample)
await db.commit()
# 获取样品数据
samples_data = receipt_model.samples if receipt_model.samples else []
# 创建入库单对象排除samples字段
receipt_dict = receipt_model.model_dump(exclude_unset=True, exclude={'samples'})
receipt = WarehouseReceipt(**receipt_dict)
receipt.create_time = datetime.now()
receipt.update_time = datetime.now()
# 保存入库单
await WarehouseReceiptDao.add_receipt(db, receipt)
await db.flush() # 刷新以获取receipt_id
# 创建样品对象并关联到入库单
for sample_data in samples_data:
sample_dict = sample_data.model_dump(exclude_unset=True)
sample = WarehouseSample(**sample_dict)
sample.receipt_id = receipt.receipt_id
sample.receipt_no = receipt.receipt_no
sample.create_by = receipt.create_by
sample.create_time = datetime.now()
sample.update_time = datetime.now()
await WarehouseSampleDao.add_sample(db, sample)
await db.commit()
return CrudResponseModel(is_success=True, message='新增成功')
return CrudResponseModel(is_success=True, message='新增成功')
except ServiceException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise ServiceException(message=f'新增入库单失败: {str(e)}')
@classmethod
async def edit_receipt(cls, db: AsyncSession, receipt_model: EditWarehouseReceiptModel):
"""
编辑入库单同时更新关联的样品
"""
# 检查入库单是否存在
receipt = await WarehouseReceiptDao.get_receipt_by_id(db, receipt_model.receipt_id)
if not receipt:
raise ServiceException(message='入库单不存在')
try:
# 检查入库单是否存在
receipt = await WarehouseReceiptDao.get_receipt_by_id(db, receipt_model.receipt_id)
if not receipt:
raise ServiceException(message='入库单不存在')
# 如果修改了入库单号,检查新单号是否已存在
if receipt_model.receipt_no and receipt_model.receipt_no != receipt.receipt_no:
existing = await WarehouseReceiptDao.get_receipt_by_no(db, receipt_model.receipt_no)
if existing:
raise ServiceException(message=f'入库单号【{receipt_model.receipt_no}】已存在')
# 如果修改了入库单号,检查新单号是否已存在
if receipt_model.receipt_no and receipt_model.receipt_no != receipt.receipt_no:
existing = await WarehouseReceiptDao.get_receipt_by_no(db, receipt_model.receipt_no)
if existing:
raise ServiceException(message=f'入库单号【{receipt_model.receipt_no}】已存在')
# 更新入库单基本信息
receipt_model.update_time = datetime.now()
await WarehouseReceiptDao.edit_receipt(db, receipt_model)
# 如果提供了样品列表,则同步更新样品
if receipt_model.samples is not None:
# 获取现有样品列表
from module_admin.entity.vo.warehouse_sample_vo import WarehouseSamplePageQueryModel
sample_query = WarehouseSamplePageQueryModel(receipt_id=receipt_model.receipt_id, page_num=1, page_size=1000)
existing_samples = await WarehouseSampleDao.get_sample_list(db, sample_query, is_page=False)
existing_sample_ids = {sample.sample_id for sample in existing_samples}
# 更新入库单基本信息
receipt_model.update_time = datetime.now()
await WarehouseReceiptDao.edit_receipt(db, receipt_model)
# 处理前端传来的样品
submitted_sample_ids = set()
for sample_data in receipt_model.samples:
sample_dict = sample_data.model_dump(exclude_unset=True)
# 如果提供了样品列表,则同步更新样品
if receipt_model.samples is not None:
# 获取现有样品列表
from module_admin.entity.vo.warehouse_sample_vo import WarehouseSamplePageQueryModel
sample_query = WarehouseSamplePageQueryModel(receipt_id=receipt_model.receipt_id, page_num=1, page_size=1000)
existing_samples = await WarehouseSampleDao.get_sample_list(db, sample_query, is_page=False)
existing_sample_ids = {sample.sample_id for sample in existing_samples} if existing_samples else set()
# 如果有sample_id说明是更新现有样品
if 'sample_id' in sample_dict and sample_dict['sample_id']:
sample_id = sample_dict['sample_id']
submitted_sample_ids.add(sample_id)
# 更新样品
from module_admin.entity.vo.warehouse_sample_vo import EditWarehouseSampleModel
edit_sample = EditWarehouseSampleModel(**sample_dict)
edit_sample.update_time = datetime.now()
edit_sample.update_by = receipt_model.update_by
await WarehouseSampleDao.edit_sample(db, edit_sample)
else:
# 新增样品
sample = WarehouseSample(**sample_dict)
sample.receipt_id = receipt_model.receipt_id
sample.receipt_no = receipt_model.receipt_no or receipt.receipt_no
sample.create_by = receipt_model.update_by
sample.create_time = datetime.now()
sample.update_time = datetime.now()
await WarehouseSampleDao.add_sample(db, sample)
# 处理前端传来的样品
submitted_sample_ids = set()
for sample_data in receipt_model.samples:
sample_dict = sample_data.model_dump(exclude_unset=True)
# 如果有sample_id说明是更新现有样品
if 'sample_id' in sample_dict and sample_dict['sample_id']:
sample_id = sample_dict['sample_id']
submitted_sample_ids.add(sample_id)
# 更新样品
from module_admin.entity.vo.warehouse_sample_vo import EditWarehouseSampleModel
edit_sample = EditWarehouseSampleModel(**sample_dict)
edit_sample.update_time = datetime.now()
edit_sample.update_by = receipt_model.update_by
await WarehouseSampleDao.edit_sample(db, edit_sample)
else:
# 新增样品
sample = WarehouseSample(**sample_dict)
sample.receipt_id = receipt_model.receipt_id
sample.receipt_no = receipt_model.receipt_no or receipt.receipt_no
sample.create_by = receipt_model.update_by
sample.create_time = datetime.now()
sample.update_time = datetime.now()
await WarehouseSampleDao.add_sample(db, sample)
# 删除前端没有提交的样品(逻辑删除)
samples_to_delete = existing_sample_ids - submitted_sample_ids
if samples_to_delete:
await WarehouseSampleDao.delete_sample(db, list(samples_to_delete))
# 删除前端没有提交的样品(逻辑删除)
samples_to_delete = existing_sample_ids - submitted_sample_ids
if samples_to_delete:
await WarehouseSampleDao.delete_sample(db, list(samples_to_delete))
await db.commit()
await db.commit()
return CrudResponseModel(is_success=True, message='更新成功')
return CrudResponseModel(is_success=True, message='更新成功')
except ServiceException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise ServiceException(message=f'更新入库单失败: {str(e)}')
@classmethod
async def delete_receipt(cls, db: AsyncSession, delete_model: DeleteWarehouseReceiptModel):
"""
删除入库单同时删除关联的样品
"""
receipt_ids = [int(id_str) for id_str in delete_model.receipt_ids.split(',')]
# 检查入库单是否存在
for receipt_id in receipt_ids:
receipt = await WarehouseReceiptDao.get_receipt_by_id(db, receipt_id)
if not receipt:
raise ServiceException(message=f'入库单ID【{receipt_id}】不存在')
try:
receipt_ids = [int(id_str) for id_str in delete_model.receipt_ids.split(',')]
# 检查入库单是否存在
for receipt_id in receipt_ids:
receipt = await WarehouseReceiptDao.get_receipt_by_id(db, receipt_id)
if not receipt:
raise ServiceException(message=f'入库单ID【{receipt_id}】不存在')
# 删除入库单(级联删除样品)
await WarehouseReceiptDao.delete_receipt(db, receipt_ids)
await db.commit()
# 删除入库单(级联删除样品)
await WarehouseReceiptDao.delete_receipt(db, receipt_ids)
await db.commit()
return CrudResponseModel(is_success=True, message='删除成功')
return CrudResponseModel(is_success=True, message='删除成功')
except ServiceException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise ServiceException(message=f'删除入库单失败: {str(e)}')
@classmethod
async def generate_receipt_no(cls, db: AsyncSession):
"""
生成入库单号
"""
year = datetime.now().year
receipt_no = await WarehouseReceiptDao.generate_receipt_no(db, year)
return {'receiptNo': receipt_no}
try:
year = datetime.now().year
receipt_no = await WarehouseReceiptDao.generate_receipt_no(db, year)
return {'receiptNo': receipt_no}
except Exception as e:
raise ServiceException(message=f'生成入库单号失败: {str(e)}')

View File

@ -0,0 +1,49 @@
"""
测试入库单修复的脚本
"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from module_admin.service.warehouse_receipt_service import WarehouseReceiptService
from module_admin.entity.vo.warehouse_receipt_vo import WarehouseReceiptPageQueryModel
async def test_receipt_detail():
"""测试获取入库单详情"""
# 创建数据库连接
DATABASE_URL = "mysql+aiomysql://cpy_admin:Tgzz2025+@123.57.81.127:3306/ruoyi-fastapi"
engine = create_async_engine(DATABASE_URL, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
try:
# 测试获取入库单列表
print("测试获取入库单列表...")
query_object = WarehouseReceiptPageQueryModel(page_num=1, page_size=10)
result = await WarehouseReceiptService.get_receipt_list(session, query_object, is_page=True)
print(f"获取到 {result['total']} 条入库单记录")
if result['rows']:
# 测试获取第一个入库单的详情
first_receipt = result['rows'][0]
receipt_id = first_receipt['receiptId']
print(f"\n测试获取入库单详情 (ID: {receipt_id})...")
detail = await WarehouseReceiptService.get_receipt_detail(session, receipt_id)
print(f"入库单号: {detail.get('receiptNo')}")
print(f"委托单位: {detail.get('clientUnit')}")
print(f"样品数量: {detail.get('sampleCount')}")
print(f"样品列表: {len(detail.get('samples', []))} 个样品")
print("\n测试成功!")
else:
print("没有找到入库单记录")
except Exception as e:
print(f"测试失败: {str(e)}")
import traceback
traceback.print_exc()
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(test_receipt_detail())