diff --git a/.kiro/specs/warehouse-receipt-error-fix/requirements.md b/.kiro/specs/warehouse-receipt-error-fix/requirements.md new file mode 100644 index 0000000..4f4edba --- /dev/null +++ b/.kiro/specs/warehouse-receipt-error-fix/requirements.md @@ -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 排除空值或将它们转换为适当的默认值 diff --git a/ruoyi-fastapi-backend/module_admin/controller/warehouse_receipt_controller.py b/ruoyi-fastapi-backend/module_admin/controller/warehouse_receipt_controller.py index 4b74c0a..30942fc 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/warehouse_receipt_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/warehouse_receipt_controller.py @@ -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'))]) diff --git a/ruoyi-fastapi-backend/module_admin/dao/warehouse_receipt_dao.py b/ruoyi-fastapi-backend/module_admin/dao/warehouse_receipt_dao.py index a10d5f0..bbdb081 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/warehouse_receipt_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/warehouse_receipt_dao.py @@ -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' diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/warehouse_receipt_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/warehouse_receipt_vo.py index f3b539d..04e81d9 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/warehouse_receipt_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/warehouse_receipt_vo.py @@ -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='创建者') diff --git a/ruoyi-fastapi-backend/module_admin/service/warehouse_receipt_service.py b/ruoyi-fastapi-backend/module_admin/service/warehouse_receipt_service.py index 11a6d10..e551ae8 100644 --- a/ruoyi-fastapi-backend/module_admin/service/warehouse_receipt_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/warehouse_receipt_service.py @@ -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)}') diff --git a/test_warehouse_receipt_fix.py b/test_warehouse_receipt_fix.py new file mode 100644 index 0000000..96ff102 --- /dev/null +++ b/test_warehouse_receipt_fix.py @@ -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())