diff --git a/shierfeng-fastapi-backend/module_business/controller/master_excel_controller.py b/shierfeng-fastapi-backend/module_business/controller/master_excel_controller.py new file mode 100644 index 0000000..d2e5065 --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/controller/master_excel_controller.py @@ -0,0 +1,455 @@ +# 基础资料导入导出。七个资源共用服务,路由权限必须保持字面量,供契约扫描。 +from typing import Annotated + +from fastapi import File, Form, Query, Request, Response, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.log_annotation import Log +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import ResponseBaseModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dependency.test_batch_dependency import TestBatchScopeDependency +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_vo import ( + CustomerPageQueryModel, + LinePageQueryModel, + PalletPageQueryModel, + SitePageQueryModel, + StrainPageQueryModel, + TunnelPageQueryModel, + WarehousePageQueryModel, +) +from module_business.service.master_excel_service import MasterExcelResource, MasterExcelService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +_EXCEL_RESPONSES = { + 200: { + 'description': '流式返回 excel 文件', + 'content': {'application/octet-stream': {}}, + } +} + +strain_excel_controller = APIRouterPro( + prefix='/master/strain', + order_num=110, + tags=['基础资料-菌种管理'], + dependencies=[PreAuthDependency()], +) +customer_excel_controller = APIRouterPro( + prefix='/master/customer', + order_num=111, + tags=['基础资料-客户管理'], + dependencies=[PreAuthDependency()], +) +site_excel_controller = APIRouterPro( + prefix='/master/site', + order_num=112, + tags=['基础资料-客户现场管理'], + dependencies=[PreAuthDependency()], +) +line_excel_controller = APIRouterPro( + prefix='/master/line', + order_num=113, + tags=['基础资料-产线管理'], + dependencies=[PreAuthDependency()], +) +warehouse_excel_controller = APIRouterPro( + prefix='/master/warehouse', + order_num=114, + tags=['基础资料-仓库管理'], + dependencies=[PreAuthDependency()], +) +pallet_excel_controller = APIRouterPro( + prefix='/master/pallet', + order_num=115, + tags=['基础资料-托盘管理'], + dependencies=[PreAuthDependency()], +) +tunnel_excel_controller = APIRouterPro( + prefix='/master/tunnel', + order_num=116, + tags=['基础资料-隧道管理'], + dependencies=[PreAuthDependency()], +) + + +async def _import_excel( + resource: MasterExcelResource, + file: UploadFile, + update_support: bool, + db: AsyncSession, + current_user: CurrentUserModel, + scope: TestBatchScope, +) -> Response: + result = await MasterExcelService.import_resource( + db, resource, file, update_support, current_user, scope + ) + logger.info(result.message) + return ResponseUtil.success(msg=result.message) + + +async def _export_excel( + resource: MasterExcelResource, + query: BaseModel, + db: AsyncSession, + scope: TestBatchScope, +) -> Response: + binary = await MasterExcelService.export_resource(db, resource, query, scope) + logger.info('导出成功') + return ResponseUtil.streaming(data=bytes2file_response(binary)) + + +def _template_excel(resource: MasterExcelResource) -> Response: + logger.info('获取成功') + return ResponseUtil.streaming(data=bytes2file_response(MasterExcelService.build_template(resource))) + + +@strain_excel_controller.post( + '/importData', + summary='批量导入菌种接口', + description='用于按模板批量导入菌种', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('master:strain:import')], +) +@Log(title='菌种管理', business_type=BusinessType.IMPORT) +async def import_master_strain_excel( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], +) -> Response: + return await _import_excel('strain', file, update_support, query_db, current_user, scope) + + +@strain_excel_controller.post( + '/importTemplate', + summary='获取菌种导入模板接口', + description='用于下载菌种导入模板', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:strain:import')], +) +async def export_master_strain_template(request: Request) -> Response: + return _template_excel('strain') + + +@strain_excel_controller.post( + '/export', + summary='导出菌种列表接口', + description='用于按当前筛选条件导出全部菌种', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:strain:export')], +) +@Log(title='菌种管理', business_type=BusinessType.EXPORT) +async def export_master_strain_excel( + request: Request, + strain_page_query: Annotated[StrainPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + return await _export_excel('strain', strain_page_query, query_db, scope) + + +@customer_excel_controller.post( + '/importData', + summary='批量导入客户接口', + description='用于按模板批量导入客户', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('master:customer:import')], +) +@Log(title='客户管理', business_type=BusinessType.IMPORT) +async def import_master_customer_excel( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], +) -> Response: + return await _import_excel('customer', file, update_support, query_db, current_user, scope) + + +@customer_excel_controller.post( + '/importTemplate', + summary='获取客户导入模板接口', + description='用于下载客户导入模板', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:customer:import')], +) +async def export_master_customer_template(request: Request) -> Response: + return _template_excel('customer') + + +@customer_excel_controller.post( + '/export', + summary='导出客户列表接口', + description='用于按当前筛选条件导出全部客户', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:customer:export')], +) +@Log(title='客户管理', business_type=BusinessType.EXPORT) +async def export_master_customer_excel( + request: Request, + customer_page_query: Annotated[CustomerPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + return await _export_excel('customer', customer_page_query, query_db, scope) + + +@site_excel_controller.post( + '/importData', + summary='批量导入客户现场接口', + description='用于按模板批量导入客户现场', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('master:site:import')], +) +@Log(title='客户现场管理', business_type=BusinessType.IMPORT) +async def import_master_site_excel( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], +) -> Response: + return await _import_excel('site', file, update_support, query_db, current_user, scope) + + +@site_excel_controller.post( + '/importTemplate', + summary='获取客户现场导入模板接口', + description='用于下载客户现场导入模板', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:site:import')], +) +async def export_master_site_template(request: Request) -> Response: + return _template_excel('site') + + +@site_excel_controller.post( + '/export', + summary='导出客户现场列表接口', + description='用于按当前筛选条件导出全部客户现场', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:site:export')], +) +@Log(title='客户现场管理', business_type=BusinessType.EXPORT) +async def export_master_site_excel( + request: Request, + site_page_query: Annotated[SitePageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + return await _export_excel('site', site_page_query, query_db, scope) + + +@line_excel_controller.post( + '/importData', + summary='批量导入产线接口', + description='用于按模板批量导入产线', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('master:line:import')], +) +@Log(title='产线管理', business_type=BusinessType.IMPORT) +async def import_master_line_excel( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], +) -> Response: + return await _import_excel('line', file, update_support, query_db, current_user, scope) + + +@line_excel_controller.post( + '/importTemplate', + summary='获取产线导入模板接口', + description='用于下载产线导入模板', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:line:import')], +) +async def export_master_line_template(request: Request) -> Response: + return _template_excel('line') + + +@line_excel_controller.post( + '/export', + summary='导出产线列表接口', + description='用于按当前筛选条件导出全部产线', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:line:export')], +) +@Log(title='产线管理', business_type=BusinessType.EXPORT) +async def export_master_line_excel( + request: Request, + line_page_query: Annotated[LinePageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + return await _export_excel('line', line_page_query, query_db, scope) + + +@warehouse_excel_controller.post( + '/importData', + summary='批量导入仓库接口', + description='用于按模板批量导入仓库', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('master:warehouse:import')], +) +@Log(title='仓库管理', business_type=BusinessType.IMPORT) +async def import_master_warehouse_excel( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], +) -> Response: + return await _import_excel('warehouse', file, update_support, query_db, current_user, scope) + + +@warehouse_excel_controller.post( + '/importTemplate', + summary='获取仓库导入模板接口', + description='用于下载仓库导入模板', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:warehouse:import')], +) +async def export_master_warehouse_template(request: Request) -> Response: + return _template_excel('warehouse') + + +@warehouse_excel_controller.post( + '/export', + summary='导出仓库列表接口', + description='用于按当前筛选条件导出全部仓库', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:warehouse:export')], +) +@Log(title='仓库管理', business_type=BusinessType.EXPORT) +async def export_master_warehouse_excel( + request: Request, + warehouse_page_query: Annotated[WarehousePageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + return await _export_excel('warehouse', warehouse_page_query, query_db, scope) + + +@pallet_excel_controller.post( + '/importData', + summary='批量导入托盘接口', + description='用于按模板批量导入托盘', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('master:pallet:import')], +) +@Log(title='托盘管理', business_type=BusinessType.IMPORT) +async def import_master_pallet_excel( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], +) -> Response: + return await _import_excel('pallet', file, update_support, query_db, current_user, scope) + + +@pallet_excel_controller.post( + '/importTemplate', + summary='获取托盘导入模板接口', + description='用于下载托盘导入模板', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:pallet:import')], +) +async def export_master_pallet_template(request: Request) -> Response: + return _template_excel('pallet') + + +@pallet_excel_controller.post( + '/export', + summary='导出托盘列表接口', + description='用于按当前筛选条件导出全部托盘', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:pallet:export')], +) +@Log(title='托盘管理', business_type=BusinessType.EXPORT) +async def export_master_pallet_excel( + request: Request, + pallet_page_query: Annotated[PalletPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + return await _export_excel('pallet', pallet_page_query, query_db, scope) + + +@tunnel_excel_controller.post( + '/importData', + summary='批量导入隧道接口', + description='用于按模板批量导入隧道', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('master:tunnel:import')], +) +@Log(title='隧道管理', business_type=BusinessType.IMPORT) +async def import_master_tunnel_excel( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], +) -> Response: + return await _import_excel('tunnel', file, update_support, query_db, current_user, scope) + + +@tunnel_excel_controller.post( + '/importTemplate', + summary='获取隧道导入模板接口', + description='用于下载隧道导入模板', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:tunnel:import')], +) +async def export_master_tunnel_template(request: Request) -> Response: + return _template_excel('tunnel') + + +@tunnel_excel_controller.post( + '/export', + summary='导出隧道列表接口', + description='用于按当前筛选条件导出全部隧道', + response_class=StreamingResponse, + responses=_EXCEL_RESPONSES, + dependencies=[UserInterfaceAuthDependency('master:tunnel:export')], +) +@Log(title='隧道管理', business_type=BusinessType.EXPORT) +async def export_master_tunnel_excel( + request: Request, + tunnel_page_query: Annotated[TunnelPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + return await _export_excel('tunnel', tunnel_page_query, query_db, scope) diff --git a/shierfeng-fastapi-backend/module_business/dao/master_dao.py b/shierfeng-fastapi-backend/module_business/dao/master_dao.py index c618a74..25fb2cd 100644 --- a/shierfeng-fastapi-backend/module_business/dao/master_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/master_dao.py @@ -568,6 +568,70 @@ class MasterDao: await db.flush() return inventory + @classmethod + async def list_sites_by_code( + cls, + db: AsyncSession, + site_code: str, + scope: TestBatchScope, + ) -> list[BizCustomerSite]: + """按现场编码列出当前数据范围内的现场,用于导入时消除重名。""" + return await cls._list_by_code(db, BizCustomerSite, BizCustomerSite.site_code, site_code, scope) + + @classmethod + async def list_lines_by_code( + cls, + db: AsyncSession, + line_code: str, + scope: TestBatchScope, + ) -> list[BizProductionLine]: + """按产线编码列出当前数据范围内的产线,用于判断能否变更所属现场。""" + return await cls._list_by_code(db, BizProductionLine, BizProductionLine.line_code, line_code, scope) + + @classmethod + async def list_tunnels_by_code( + cls, + db: AsyncSession, + tunnel_code: str, + scope: TestBatchScope, + ) -> list[BizTunnel]: + """按隧道编号列出当前数据范围内的隧道,用于判断能否变更所属现场。""" + return await cls._list_by_code(db, BizTunnel, BizTunnel.tunnel_code, tunnel_code, scope) + + @classmethod + async def map_customer_codes(cls, db: AsyncSession, customer_ids: set[int]) -> dict[int, str]: + """批量把客户 ID 转成客户编码,供导出模板列使用。""" + if not customer_ids: + return {} + result = await db.execute( + select(BizCustomer.customer_id, BizCustomer.customer_code).where(BizCustomer.customer_id.in_(customer_ids)) + ) + return {int(customer_id): customer_code for customer_id, customer_code in result.all()} + + @classmethod + async def map_site_codes(cls, db: AsyncSession, site_ids: set[int]) -> dict[int, str]: + """批量把现场 ID 转成现场编码,供产线和隧道导出使用。""" + if not site_ids: + return {} + result = await db.execute( + select(BizCustomerSite.site_id, BizCustomerSite.site_code).where(BizCustomerSite.site_id.in_(site_ids)) + ) + return {int(site_id): site_code for site_id, site_code in result.all()} + + @classmethod + async def _list_by_code( + cls, + db: AsyncSession, + model: type[Any], + code_column: InstrumentedAttribute, + code: str, + scope: TestBatchScope, + ) -> list[Any]: + result = await db.execute( + select(model).where(code_column == code, batch_scope_condition(model.test_batch_id, scope)) + ) + return list(result.scalars().all()) + @classmethod async def list_assignable_users(cls, db: AsyncSession) -> list[Any]: result = await db.execute( diff --git a/shierfeng-fastapi-backend/module_business/service/master_excel_service.py b/shierfeng-fastapi-backend/module_business/service/master_excel_service.py new file mode 100644 index 0000000..023fb67 --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/service/master_excel_service.py @@ -0,0 +1,731 @@ +import io +from dataclasses import dataclass +from datetime import datetime +from typing import Literal + +from fastapi import UploadFile +from openpyxl import load_workbook +from openpyxl.styles import Alignment, PatternFill +from openpyxl.utils import get_column_letter +from pydantic import BaseModel, ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import CrudResponseModel +from exceptions.exception import PermissionException, ServiceException +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dao.master_dao import MasterDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_vo import ( + CustomerModel, + LineModel, + SiteModel, + StrainModel, + TunnelModel, + WarehouseModel, +) +from module_business.service.master_service import MasterResource, MasterService +from module_business.service.pallet_service import PalletService +from utils.excel_util import ExcelUtil + +MasterExcelResource = Literal['strain', 'customer', 'site', 'line', 'warehouse', 'tunnel', 'pallet'] +ImportOutcome = Literal['add', 'update', 'skip'] +_MAX_IMPORT_BYTES = 5 * 1024 * 1024 +_MAX_IMPORT_ROWS = 5000 +_STATUS_TO_VALUE = {'正常': '0', '停用': '1'} +_VALUE_TO_STATUS = {'0': '正常', '1': '停用'} + + +@dataclass(frozen=True) +class ExcelSpec: + """一个基础资料资源的导入导出列定义。""" + + label: str + headers: tuple[str, ...] + code_header: str + code_attr: str + name_header: str | None = None + name_attr: str | None = None + address_header: str | None = None + address_attr: str | None = None + parent_header: str | None = None + parent_kind: Literal['customer', 'site'] | None = None + parent_attr: str | None = None + status_attr: str = 'status' + id_attr: str = '' + code_max: int = 64 + name_max: int = 100 + + +_SPECS: dict[MasterExcelResource, ExcelSpec] = { + 'strain': ExcelSpec( + label='菌种', + headers=('菌种编码', '菌种名称', '状态', '备注'), + code_header='菌种编码', + code_attr='strain_code', + name_header='菌种名称', + name_attr='strain_name', + id_attr='strain_id', + ), + 'customer': ExcelSpec( + label='客户', + headers=('客户编码', '客户名称', '状态', '备注'), + code_header='客户编码', + code_attr='customer_code', + name_header='客户名称', + name_attr='customer_name', + id_attr='customer_id', + ), + 'site': ExcelSpec( + label='客户现场', + headers=('客户编码', '现场编码', '现场名称', '现场地址', '状态', '备注'), + code_header='现场编码', + code_attr='site_code', + name_header='现场名称', + name_attr='site_name', + address_header='现场地址', + address_attr='address', + parent_header='客户编码', + parent_kind='customer', + parent_attr='customer_id', + id_attr='site_id', + ), + 'line': ExcelSpec( + label='产线', + headers=('现场编码', '产线编码', '产线名称', '状态', '备注'), + code_header='产线编码', + code_attr='line_code', + name_header='产线名称', + name_attr='line_name', + parent_header='现场编码', + parent_kind='site', + parent_attr='site_id', + id_attr='line_id', + ), + 'warehouse': ExcelSpec( + label='仓库', + headers=('仓库编码', '仓库名称', '仓库地址', '状态', '备注'), + code_header='仓库编码', + code_attr='warehouse_code', + name_header='仓库名称', + name_attr='warehouse_name', + address_header='仓库地址', + address_attr='address', + id_attr='warehouse_id', + ), + 'pallet': ExcelSpec( + label='托盘', + headers=('托盘编号', '状态', '备注'), + code_header='托盘编号', + code_attr='pallet_code', + status_attr='asset_status', + id_attr='pallet_id', + ), + 'tunnel': ExcelSpec( + label='隧道', + headers=('现场编码', '隧道编号', '隧道名称', '状态', '备注'), + code_header='隧道编号', + code_attr='tunnel_code', + name_header='隧道名称', + name_attr='tunnel_name', + parent_header='现场编码', + parent_kind='site', + parent_attr='site_id', + id_attr='tunnel_id', + ), +} +_LISTERS = { + 'strain': MasterService.list_strain, + 'customer': MasterService.list_customer, + 'site': MasterService.list_site, + 'line': MasterService.list_line, + 'warehouse': MasterService.list_warehouse, + 'tunnel': MasterService.list_tunnel, + 'pallet': PalletService.list_pallet, +} + + +@dataclass(frozen=True) +class ParsedRow: + """保留 Excel 行号的一行导入数据。""" + + excel_row: int + values: dict[str, str] + + +class MasterExcelService: + """基础资料 Excel 导入导出。七个资源共用列配置,一次导入只提交一次事务。""" + + @classmethod + def build_template(cls, resource: MasterExcelResource) -> bytes: + """生成只含业务列和状态下拉的导入模板。""" + spec = _SPECS[resource] + binary = ExcelUtil.get_excel_template( + header_list=list(spec.headers), + selector_header_list=['状态'], + option_list=[{'状态': ['正常', '停用']}], + ) + return cls._format_text_columns(binary, len(spec.headers)) + + @classmethod + async def export_resource( + cls, + db: AsyncSession, + resource: MasterExcelResource, + query: BaseModel, + scope: TestBatchScope, + ) -> bytes: + """按当前筛选条件导出全部命中行,列与导入模板一致。""" + spec = _SPECS[resource] + records = await _LISTERS[resource](db, query, scope, is_page=False) + if not isinstance(records, list): + raise ServiceException(message='导出数据格式错误') + customer_codes: dict[int, str] = {} + site_codes: dict[int, str] = {} + if spec.parent_kind == 'customer': + customer_codes = await MasterDao.map_customer_codes( + db, {int(getattr(record, 'customer_id')) for record in records} + ) + elif spec.parent_kind == 'site': + site_codes = await MasterDao.map_site_codes(db, {int(getattr(record, 'site_id')) for record in records}) + data_rows = [ + cls._export_row(spec, record, customer_codes, site_codes) + for record in records + ] + return cls._fill_template(cls.build_template(resource), data_rows) + + @classmethod + async def import_resource( + cls, + db: AsyncSession, + resource: MasterExcelResource, + file: UploadFile, + update_support: bool, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + """导入一个资源。文件内编码重复或文件不可读时整批失败,单行业务错误只跳过该行。""" + parsed_rows = await cls._read_upload(resource, file) + if not parsed_rows: + return CrudResponseModel(is_success=True, message=cls._summary(0, 0, 0, [])) + + added = 0 + updated = 0 + skipped = 0 + details: list[str] = [] + try: + if resource == 'pallet': + await PalletService._prepare_test_write(db, current_user, scope) + else: + await MasterService._prepare_test_write(db, current_user, scope) + for row in parsed_rows: + try: + outcome, detail = await cls._apply_row( + db, resource, row, update_support, current_user, scope + ) + except ServiceException as exc: + details.append(f'第{row.excel_row}行:{exc.message or "导入失败"}') + continue + if outcome == 'add': + added += 1 + elif outcome == 'update': + updated += 1 + else: + skipped += 1 + if detail: + details.append(f'第{row.excel_row}行:{detail}') + if added or updated: + await db.commit() + else: + await db.rollback() + except (ServiceException, PermissionException): + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise ServiceException(message='导入失败,数据已全部回滚') from exc + return CrudResponseModel(is_success=True, message=cls._summary(added, updated, skipped, details)) + + @classmethod + async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]: + spec = _SPECS[resource] + contents = await file.read() + await file.close() + if not contents: + raise ServiceException(message='导入文件不能为空') + if len(contents) > _MAX_IMPORT_BYTES: + raise ServiceException(message='导入文件不能超过5MB') + try: + headers, rows = ExcelUtil.read_text_table(contents) + except ValueError as exc: + raise ServiceException(message=str(exc)) from exc + if len(headers) != len(set(headers)) or set(headers) != set(spec.headers): + raise ServiceException(message=f'导入表头不匹配,应为:{"、".join(spec.headers)}') + if len(rows) > _MAX_IMPORT_ROWS: + raise ServiceException(message=f'一次最多导入{_MAX_IMPORT_ROWS}行') + parsed = [ParsedRow(excel_row=excel_row, values=values) for excel_row, values in rows] + cls._reject_duplicate_keys(spec, parsed) + return parsed + + @staticmethod + def _reject_duplicate_keys(spec: ExcelSpec, rows: list[ParsedRow]) -> None: + seen: set[tuple[str, ...]] = set() + duplicates: list[str] = [] + for row in rows: + identity = _identity(spec, row.values) + if identity is None or identity in seen: + if identity is not None: + duplicates.append(_identity_text(spec, row.values)) + continue + seen.add(identity) + if duplicates: + unique_names = list(dict.fromkeys(duplicates)) + raise ServiceException(message=f'导入文件存在重复编码:{"、".join(unique_names)}') + + @classmethod + async def _apply_row( + cls, + db: AsyncSession, + resource: MasterExcelResource, + row: ParsedRow, + update_support: bool, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> tuple[ImportOutcome, str]: + spec = _SPECS[resource] + if resource == 'pallet': + code = _required(row.values.get(spec.code_header, ''), spec.code_header, spec.code_max) + status = _status(row.values.get('状态', '')) + remark = _optional(row.values.get('备注', ''), '备注', 500) + outcome = await PalletService.stage_imported_pallet( + db, + pallet_code=code, + asset_status=status, + remark=remark, + update_support=update_support, + current_user=current_user, + scope=scope, + ) + return outcome, f'托盘编号{code}已存在,已跳过' if outcome == 'skip' else '' + if resource in {'strain', 'customer', 'warehouse'}: + return await cls._apply_global_row(db, resource, row, update_support, current_user, scope) + if resource == 'site': + return await cls._apply_site_row(db, row, update_support, current_user, scope) + return await cls._apply_child_row(db, resource, row, update_support, current_user, scope) + + @classmethod + async def _apply_global_row( + cls, + db: AsyncSession, + resource: Literal['strain', 'customer', 'warehouse'], + row: ParsedRow, + update_support: bool, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> tuple[ImportOutcome, str]: + spec = _SPECS[resource] + code = _required(row.values.get(spec.code_header, ''), spec.code_header, spec.code_max) + name = _required(row.values.get(spec.name_header or '', ''), spec.name_header or '名称', spec.name_max) + status = _status(row.values.get('状态', '')) + remark = _optional(row.values.get('备注', ''), '备注', 500) + address = ( + _optional(row.values.get(spec.address_header, ''), spec.address_header or '地址', 500) + if spec.address_header + else None + ) + found = await getattr(MasterDao, f'find_{resource}_by_code')(db, code) + if found is None: + await cls._save_master( + db, + resource, + existing=None, + parent_id=None, + code=code, + name=name, + status=status, + remark=remark, + address=address, + current_user=current_user, + scope=scope, + ) + return 'add', '' + current = await getattr(MasterDao, f'get_{resource}')( + db, + getattr(found, spec.id_attr), + scope, + for_update=scope.mode == 'TEST', + ) + if current is None: + raise ServiceException(message=f'{spec.code_header}{code}已存在于其他数据范围') + if not update_support: + return 'skip', f'{spec.code_header}{code}已存在,已跳过' + await cls._save_master( + db, + resource, + existing=current, + parent_id=None, + code=getattr(current, spec.code_attr), + name=name, + status=status, + remark=remark, + address=address, + current_user=current_user, + scope=scope, + ) + return 'update', '' + + @classmethod + async def _apply_site_row( + cls, + db: AsyncSession, + row: ParsedRow, + update_support: bool, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> tuple[ImportOutcome, str]: + spec = _SPECS['site'] + customer_code = _required(row.values.get('客户编码', ''), '客户编码', 64) + site_code = _required(row.values.get(spec.code_header, ''), spec.code_header, spec.code_max) + name = _required(row.values.get(spec.name_header or '', ''), spec.name_header or '名称', spec.name_max) + status = _status(row.values.get('状态', '')) + remark = _optional(row.values.get('备注', ''), '备注', 500) + address = _optional(row.values.get('现场地址', ''), '现场地址', 500) + customer = await cls._require_customer(db, customer_code, scope) + found = await MasterDao.find_site_by_code(db, site_code, customer.customer_id) + if found is not None: + current = await MasterDao.get_site(db, found.site_id, scope, for_update=scope.mode == 'TEST') + if current is None: + raise ServiceException(message=f'{spec.code_header}{site_code}已存在于其他数据范围') + if not update_support: + return 'skip', f'客户{customer_code}的{spec.code_header}{site_code}已存在,已跳过' + await cls._save_master( + db, + 'site', + existing=current, + parent_id=customer.customer_id, + code=current.site_code, + name=name, + status=status, + remark=remark, + address=address, + current_user=current_user, + scope=scope, + ) + return 'update', '' + + if update_support: + matches = await MasterDao.list_sites_by_code(db, site_code, scope) + if len(matches) > 1: + raise ServiceException(message=f'{spec.code_header}{site_code}不唯一,无法更新上级') + if len(matches) == 1: + current = await MasterDao.get_site(db, matches[0].site_id, scope, for_update=scope.mode == 'TEST') + if current is None: + raise ServiceException(message=f'{spec.code_header}{site_code}已存在于其他数据范围') + await cls._save_master( + db, + 'site', + existing=current, + parent_id=customer.customer_id, + code=current.site_code, + name=name, + status=status, + remark=remark, + address=address, + current_user=current_user, + scope=scope, + ) + return 'update', '' + await cls._save_master( + db, + 'site', + existing=None, + parent_id=customer.customer_id, + code=site_code, + name=name, + status=status, + remark=remark, + address=address, + current_user=current_user, + scope=scope, + ) + return 'add', '' + + @classmethod + async def _apply_child_row( + cls, + db: AsyncSession, + resource: Literal['line', 'tunnel'], + row: ParsedRow, + update_support: bool, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> tuple[ImportOutcome, str]: + spec = _SPECS[resource] + site_code = _required(row.values.get('现场编码', ''), '现场编码', 64) + code = _required(row.values.get(spec.code_header, ''), spec.code_header, spec.code_max) + name = _required(row.values.get(spec.name_header or '', ''), spec.name_header or '名称', spec.name_max) + status = _status(row.values.get('状态', '')) + remark = _optional(row.values.get('备注', ''), '备注', 500) + site = await cls._require_site(db, site_code, scope) + found = await getattr(MasterDao, f'find_{resource}_by_code')(db, code, site.site_id) + if found is not None: + current = await getattr(MasterDao, f'get_{resource}')( + db, getattr(found, spec.id_attr), scope, for_update=scope.mode == 'TEST' + ) + if current is None: + raise ServiceException(message=f'{spec.code_header}{code}已存在于其他数据范围') + if not update_support: + return 'skip', f'现场{site_code}的{spec.code_header}{code}已存在,已跳过' + await cls._save_master( + db, + resource, + existing=current, + parent_id=site.site_id, + code=getattr(current, spec.code_attr), + name=name, + status=status, + remark=remark, + address=None, + current_user=current_user, + scope=scope, + ) + return 'update', '' + + if update_support: + matches = await getattr(MasterDao, f'list_{resource}s_by_code')(db, code, scope) + if len(matches) > 1: + raise ServiceException(message=f'{spec.code_header}{code}不唯一,无法更新上级') + if len(matches) == 1: + current = await getattr(MasterDao, f'get_{resource}')( + db, getattr(matches[0], spec.id_attr), scope, for_update=scope.mode == 'TEST' + ) + if current is None: + raise ServiceException(message=f'{spec.code_header}{code}已存在于其他数据范围') + await cls._save_master( + db, + resource, + existing=current, + parent_id=site.site_id, + code=getattr(current, spec.code_attr), + name=name, + status=status, + remark=remark, + address=None, + current_user=current_user, + scope=scope, + ) + return 'update', '' + await cls._save_master( + db, + resource, + existing=None, + parent_id=site.site_id, + code=code, + name=name, + status=status, + remark=remark, + address=None, + current_user=current_user, + scope=scope, + ) + return 'add', '' + + @classmethod + async def _save_master( + cls, + db: AsyncSession, + resource: MasterResource, + *, + existing: object | None, + parent_id: int | None, + code: str, + name: str, + status: Literal['0', '1'], + remark: str | None, + address: str | None, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> None: + spec = _SPECS[resource] + now = datetime.now() + user_name = current_user.user.user_name + payload: dict[str, object] = { + spec.code_attr: code, + 'status': status, + 'remark': remark, + 'update_by': user_name, + 'update_time': now, + } + if spec.name_attr: + payload[spec.name_attr] = name + if spec.address_attr: + payload[spec.address_attr] = address + if spec.parent_attr: + payload[spec.parent_attr] = parent_id + if existing is None: + payload['create_by'] = user_name + payload['create_time'] = now + else: + payload[spec.id_attr] = getattr(existing, spec.id_attr) + try: + model = _MODEL_TYPES[resource](**payload) + except ValidationError as exc: + raise ServiceException(message=_validation_message(exc)) from exc + await MasterService._validate_parent(db, resource, model, scope) + current_id = None if existing is None else getattr(existing, spec.id_attr) + await MasterService._validate_unique(db, resource, model, current_id) + if existing is None: + await getattr(MasterDao, f'add_{resource}')(db, model, scope.batch_id) + return + await getattr(MasterDao, f'update_{resource}')(db, model, scope) + + @staticmethod + async def _require_customer(db: AsyncSession, customer_code: str, scope: TestBatchScope): + found = await MasterDao.find_customer_by_code(db, customer_code) + if found is None: + raise ServiceException(message=f'目标客户{customer_code}不存在') + customer = await MasterDao.get_customer(db, found.customer_id, scope) + if customer is None: + raise ServiceException(message=f'目标客户{customer_code}不属于当前数据模式或测试批次') + if customer.status != '0': + raise ServiceException(message=f'目标客户{customer_code}已停用') + return customer + + @staticmethod + async def _require_site(db: AsyncSession, site_code: str, scope: TestBatchScope): + matches = await MasterDao.list_sites_by_code(db, site_code, scope) + if not matches: + raise ServiceException(message=f'目标客户现场{site_code}不存在') + if len(matches) > 1: + raise ServiceException(message=f'现场编码{site_code}不唯一,无法确定目标客户现场') + site = matches[0] + if site.status != '0': + raise ServiceException(message=f'目标客户现场{site_code}已停用') + return site + + @staticmethod + def _export_row( + spec: ExcelSpec, + record: BaseModel, + customer_codes: dict[int, str], + site_codes: dict[int, str], + ) -> list[str]: + values: dict[str, str] = {} + if spec.parent_kind == 'customer': + customer_id = int(getattr(record, 'customer_id')) + customer_code = customer_codes.get(customer_id) + if not customer_code: + raise ServiceException(message=f'客户{customer_id}不存在,无法导出') + values[spec.parent_header or ''] = customer_code + elif spec.parent_kind == 'site': + site_id = int(getattr(record, 'site_id')) + site_code = site_codes.get(site_id) + if not site_code: + raise ServiceException(message=f'客户现场{site_id}不存在,无法导出') + values[spec.parent_header or ''] = site_code + values[spec.code_header] = str(getattr(record, spec.code_attr)) + if spec.name_header and spec.name_attr: + values[spec.name_header] = str(getattr(record, spec.name_attr)) + if spec.address_header and spec.address_attr: + values[spec.address_header] = str(getattr(record, spec.address_attr) or '') + status_value = str(getattr(record, spec.status_attr)) + values['状态'] = _VALUE_TO_STATUS.get(status_value, status_value) + values['备注'] = str(getattr(record, 'remark') or '') + return [values.get(header, '') for header in spec.headers] + + @staticmethod + def _summary(added: int, updated: int, skipped: int, details: list[str]) -> str: + summary = f'导入完成:新增{added}条,更新{updated}条,跳过{skipped}条' + if not details: + return summary + return summary + '\n' + '\n'.join(details) + + @staticmethod + def _format_text_columns(binary: bytes, column_count: int) -> bytes: + workbook = load_workbook(io.BytesIO(binary)) + worksheet = workbook.active + header_fill = PatternFill(start_color='ABABAB', end_color='ABABAB', fill_type='solid') + for column_index in range(1, column_count + 1): + column_letter = get_column_letter(column_index) + header = worksheet.cell(1, column_index) + header.fill = header_fill + header.alignment = Alignment(horizontal='center') + header.number_format = '@' + worksheet.column_dimensions[column_letter].number_format = '@' + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + @staticmethod + def _fill_template(template: bytes, data_rows: list[list[str]]) -> bytes: + if not data_rows: + return template + workbook = load_workbook(io.BytesIO(template)) + worksheet = workbook.active + for data in data_rows: + worksheet.append(data) + for column_index, _value in enumerate(data, start=1): + cell = worksheet.cell(worksheet.max_row, column_index) + if cell.value is not None: + cell.value = str(cell.value) + cell.number_format = '@' + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +_MODEL_TYPES: dict[MasterResource, type[BaseModel]] = { + 'strain': StrainModel, + 'customer': CustomerModel, + 'site': SiteModel, + 'line': LineModel, + 'warehouse': WarehouseModel, + 'tunnel': TunnelModel, +} + + +def _required(value: str, label: str, max_length: int) -> str: + if not value: + raise ServiceException(message=f'{label}不能为空') + if len(value) > max_length: + raise ServiceException(message=f'{label}长度不能超过{max_length}') + return value + + +def _optional(value: str, label: str, max_length: int) -> str | None: + if not value: + return None + if len(value) > max_length: + raise ServiceException(message=f'{label}长度不能超过{max_length}') + return value + + +def _status(value: str) -> Literal['0', '1']: + mapped = _STATUS_TO_VALUE.get(value) + if mapped is None: + raise ServiceException(message='状态只能填写正常或停用') + return mapped + + +def _identity(spec: ExcelSpec, values: dict[str, str]) -> tuple[str, ...] | None: + code = values.get(spec.code_header, '') + if spec.parent_header: + parent = values.get(spec.parent_header, '') + if not code or not parent: + return None + return (parent.casefold(), code.casefold()) + if not code: + return None + return (code.casefold(),) + + +def _identity_text(spec: ExcelSpec, values: dict[str, str]) -> str: + code = values.get(spec.code_header, '') + if spec.parent_header: + return f'{values.get(spec.parent_header, "")}/{code}' + return code + + +def _validation_message(exc: ValidationError) -> str: + error = exc.errors()[0] + location = error.get('loc') or ('字段',) + return f'{location[-1]}不合法' diff --git a/shierfeng-fastapi-backend/module_business/service/pallet_service.py b/shierfeng-fastapi-backend/module_business/service/pallet_service.py index d7bd302..1c951d5 100644 --- a/shierfeng-fastapi-backend/module_business/service/pallet_service.py +++ b/shierfeng-fastapi-backend/module_business/service/pallet_service.py @@ -187,6 +187,95 @@ class PalletService: await db.rollback() raise + @classmethod + async def stage_imported_pallet( + cls, + db: AsyncSession, + *, + pallet_code: str, + asset_status: Literal['0', '1'], + remark: str | None, + update_support: bool, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> Literal['add', 'update', 'skip']: + """写入一条托盘导入。不提交事务,容量固定 100,且不改编号和二维码。""" + existing = await MasterDao.find_pallet_by_code(db, pallet_code) + if existing is None: + return await cls._stage_new_pallet( + db, + pallet_code=pallet_code, + asset_status=asset_status, + remark=remark, + current_user=current_user, + scope=scope, + ) + + current = await MasterDao.get_pallet(db, existing.pallet_id, scope, for_update=scope.mode == 'TEST') + if current is None: + raise ServiceException(message=f'托盘编号{pallet_code}已存在于其他数据范围') + if not update_support: + return 'skip' + + _current_pallet, _inventory = await cls._lock_pallet_inventory(db, current.pallet_id, scope) + edit_model = PalletEditModel( + pallet_id=current.pallet_id, + capacity_packages=current.capacity_packages, + asset_status=asset_status, + remark=remark, + ) + if current.capacity_packages == 100 and edit_model.capacity_packages != 100: + raise ServiceException(message='新规格托盘容量固定为 100 包') + if current.capacity_packages != edit_model.capacity_packages: + raise ServiceException(message='历史托盘容量不允许修改') + if edit_model.capacity_packages < _inventory.remaining_packages: + raise ServiceException(message='托盘容量不能小于当前剩余包数') + await MasterDao.update_pallet( + db, + edit_model, + scope, + update_by=current_user.user.user_name, + update_time=datetime.now(), + ) + return 'update' + + @classmethod + async def _stage_new_pallet( + cls, + db: AsyncSession, + *, + pallet_code: str, + asset_status: Literal['0', '1'], + remark: str | None, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> Literal['add']: + current_time = datetime.now() + user_name = current_user.user.user_name + pallet_record = BizPallet( + test_batch_id=scope.batch_id, + pallet_code=pallet_code, + qr_token=secrets.token_urlsafe(32), + capacity_packages=100, + asset_status=asset_status, + remark=remark, + create_by=user_name, + create_time=current_time, + update_by=user_name, + update_time=current_time, + ) + pallet_record = await MasterDao.add_pallet(db, pallet_record) + inventory = BizPalletInventory( + pallet_id=pallet_record.pallet_id, + test_batch_id=scope.batch_id, + flow_state='UNSTOCKED', + remaining_packages=0, + version=0, + updated_at=current_time, + ) + await MasterDao.add_pallet_inventory(db, inventory) + return 'add' + @classmethod async def remove_pallet( cls, diff --git a/shierfeng-fastapi-backend/sql/patches/20261009_master_excel_mysql.sql b/shierfeng-fastapi-backend/sql/patches/20261009_master_excel_mysql.sql new file mode 100644 index 0000000..34d46d0 --- /dev/null +++ b/shierfeng-fastapi-backend/sql/patches/20261009_master_excel_mysql.sql @@ -0,0 +1,75 @@ +-- 基础资料导入导出:MySQL 8 单次菜单增量。 +-- 执行前必须停写并完成备份。由 DBA 审核后在明确选定的目标库单次执行。 +-- 本脚本仅随代码交付,不在应用启动时执行;本次开发未连接数据库、未运行 SQL。 +-- 权限只新增 sys_menu,不写 sys_role_menu;上线后由管理员按现场职责手动授权。 + +DELIMITER $$ +CREATE PROCEDURE migrate_master_excel_20261009() +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + + IF DATABASE() IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Select the target database before migration'; + END IF; + IF SUBSTRING_INDEX(VERSION(), '.', 1) <> '8' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MySQL 8 is required'; + END IF; + IF EXISTS ( + SELECT 1 FROM sys_menu + WHERE menu_id BETWEEN 1120 AND 1133 + OR perms IN ( + 'master:strain:import', 'master:strain:export', + 'master:customer:import', 'master:customer:export', + 'master:site:import', 'master:site:export', + 'master:line:import', 'master:line:export', + 'master:warehouse:import', 'master:warehouse:export', + 'master:pallet:import', 'master:pallet:export', + 'master:tunnel:import', 'master:tunnel:export' + ) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Master excel menu ids or permissions already exist'; + END IF; + IF EXISTS (SELECT 1 FROM sys_role_menu WHERE menu_id BETWEEN 1120 AND 1133) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Orphan role grants for master excel menus must be reviewed first'; + END IF; + IF (SELECT COUNT(*) FROM sys_menu + WHERE (menu_id = 502 AND perms = 'master:strain:list' AND menu_type = 'C') + OR (menu_id = 503 AND perms = 'master:customer:list' AND menu_type = 'C') + OR (menu_id = 504 AND perms = 'master:site:list' AND menu_type = 'C') + OR (menu_id = 505 AND perms = 'master:line:list' AND menu_type = 'C') + OR (menu_id = 506 AND perms = 'master:warehouse:list' AND menu_type = 'C') + OR (menu_id = 507 AND perms = 'master:pallet:list' AND menu_type = 'C') + OR (menu_id = 520 AND perms = 'master:tunnel:list' AND menu_type = 'C')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Master parent menus do not match the expected contract'; + END IF; + + START TRANSACTION; + INSERT INTO sys_menu ( + menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, + create_time, update_by, update_time, remark + ) VALUES + (1120, '菌种导入', 502, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:import', '#', 'admin', SYSDATE(), '', NULL, ''), + (1121, '菌种导出', 502, 5, '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:export', '#', 'admin', SYSDATE(), '', NULL, ''), + (1122, '客户导入', 503, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:import', '#', 'admin', SYSDATE(), '', NULL, ''), + (1123, '客户导出', 503, 5, '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:export', '#', 'admin', SYSDATE(), '', NULL, ''), + (1124, '现场导入', 504, 6, '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:import', '#', 'admin', SYSDATE(), '', NULL, ''), + (1125, '现场导出', 504, 7, '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:export', '#', 'admin', SYSDATE(), '', NULL, ''), + (1126, '产线导入', 505, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:import', '#', 'admin', SYSDATE(), '', NULL, ''), + (1127, '产线导出', 505, 5, '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:export', '#', 'admin', SYSDATE(), '', NULL, ''), + (1128, '仓库导入', 506, 6, '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:import', '#', 'admin', SYSDATE(), '', NULL, ''), + (1129, '仓库导出', 506, 7, '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:export', '#', 'admin', SYSDATE(), '', NULL, ''), + (1130, '托盘导入', 507, 6, '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:import', '#', 'admin', SYSDATE(), '', NULL, ''), + (1131, '托盘导出', 507, 7, '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:export', '#', 'admin', SYSDATE(), '', NULL, ''), + (1132, '隧道导入', 520, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:import', '#', 'admin', SYSDATE(), '', NULL, ''), + (1133, '隧道导出', 520, 5, '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:export', '#', 'admin', SYSDATE(), '', NULL, ''); + COMMIT; +END$$ +DELIMITER ; + +CALL migrate_master_excel_20261009(); +DROP PROCEDURE migrate_master_excel_20261009; diff --git a/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql b/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql index a327a33..6752ce5 100644 --- a/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql +++ b/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql @@ -347,6 +347,20 @@ insert into sys_menu values('1091', '托盘打印', '507', '5', '', '', '', '', insert into sys_menu values('1116', '隧道查询', '520', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:query', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1117', '隧道新增', '520', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:add', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1118', '隧道修改', '520', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1120', '菌种导入', '502', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1121', '菌种导出', '502', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1122', '客户导入', '503', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1123', '客户导出', '503', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1124', '现场导入', '504', '6', '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1125', '现场导出', '504', '7', '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1126', '产线导入', '505', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1127', '产线导出', '505', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1128', '仓库导入', '506', '6', '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1129', '仓库导出', '506', '7', '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1130', '托盘导入', '507', '6', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1131', '托盘导出', '507', '7', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1132', '隧道导入', '520', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1133', '隧道导出', '520', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:export', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1092', '入库查询', '508', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:query', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1093', '入库新增', '508', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:add', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1094', '入库修改', '508', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:edit', '#', 'admin', sysdate(), '', null, ''); diff --git a/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py index 77df721..a079468 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py @@ -29,6 +29,7 @@ CONTROLLER_NAMES = ( 'test_batch', 'dashboard', 'tunnel', + 'master_excel', ) TARGET_PREFIXES = ( '/master', @@ -85,10 +86,24 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset( 'master:pallet:add', 'master:pallet:edit', 'master:pallet:print', + 'master:strain:import', + 'master:strain:export', + 'master:customer:import', + 'master:customer:export', + 'master:site:import', + 'master:site:export', + 'master:line:import', + 'master:line:export', + 'master:warehouse:import', + 'master:warehouse:export', + 'master:pallet:import', + 'master:pallet:export', 'master:tunnel:query', 'master:tunnel:add', 'master:tunnel:edit', 'master:tunnel:remove', + 'master:tunnel:import', + 'master:tunnel:export', 'business:inbound:query', 'business:inbound:add', 'business:inbound:edit', @@ -142,6 +157,7 @@ VUE_PAGE_PATHS = ( 'src/views/report/consumption/index.vue', 'src/views/report/return/index.vue', 'src/views/report/palletTrace/index.vue', + 'src/components/MasterExcelActions/index.vue', ) PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report|system):[a-z]+:[a-z]+') UNGUARDED_AUTHENTICATED_ROUTES = { @@ -196,6 +212,16 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]: ('/master/tunnel/{tunnel_ids}', 'DELETE'): 'master:tunnel:remove', } ) + for resource in ('strain', 'customer', 'site', 'line', 'warehouse', 'pallet', 'tunnel'): + root = f'/master/{resource}' + permission_root = f'master:{resource}' + expected.update( + { + (f'{root}/importData', 'POST'): f'{permission_root}:import', + (f'{root}/importTemplate', 'POST'): f'{permission_root}:import', + (f'{root}/export', 'POST'): f'{permission_root}:export', + } + ) expected.update( { @@ -323,7 +349,7 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]: def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None: expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values()) - test_case.assertEqual(98, expected.total()) + test_case.assertEqual(119, expected.total()) test_case.assertEqual(expected, Counter(literals)) test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected)) @@ -475,7 +501,7 @@ class ApiPermissionContractTest(unittest.TestCase): } self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations) - self.assertEqual(100, len(actual_operations)) + self.assertEqual(121, len(actual_operations)) openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/')) app_ai_paths = sorted( @@ -529,9 +555,9 @@ class ApiPermissionContractTest(unittest.TestCase): self.assertEqual([], malformed_calls) self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS)) - self.assertEqual(48, len(EXPECTED_BUTTON_PERMISSIONS)) + self.assertEqual(62, len(EXPECTED_BUTTON_PERMISSIONS)) self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS) - self.assertEqual(67, len(EXPECTED_PERMISSIONS)) + self.assertEqual(81, len(EXPECTED_PERMISSIONS)) _assert_controller_permission_literals(self, literals) self.assertEqual( { @@ -541,6 +567,8 @@ class ApiPermissionContractTest(unittest.TestCase): 'close', 'confirm', 'edit', + 'export', + 'import', 'list', 'print', 'purge', @@ -554,7 +582,7 @@ class ApiPermissionContractTest(unittest.TestCase): def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None: literals = list(EXPECTED_ROUTE_PERMISSIONS.values()) - self.assertEqual(98, len(literals)) + self.assertEqual(119, len(literals)) literals.append(literals[0]) with self.assertRaises(AssertionError): @@ -572,8 +600,8 @@ class ApiPermissionContractTest(unittest.TestCase): def test_vue_pages_have_exact_guarded_permission_contract(self) -> None: actual_permissions = set() - self.assertEqual(18, len(VUE_PAGE_PATHS)) - self.assertEqual(18, len(set(VUE_PAGE_PATHS))) + self.assertEqual(19, len(VUE_PAGE_PATHS)) + self.assertEqual(19, len(set(VUE_PAGE_PATHS))) for relative_path in VUE_PAGE_PATHS: source = (FRONTEND_ROOT / relative_path).read_text(encoding='utf-8') guarded = _extract_guarded_vue_permissions(source) @@ -758,7 +786,7 @@ class ApiPermissionContractTest(unittest.TestCase): for method in route.methods & WRITE_METHODS: actual_write_routes[(route.path_format, method)] = route - self.assertEqual(46, len(expected_write_permissions)) + self.assertEqual(67, len(expected_write_permissions)) self.assertEqual(set(expected_write_permissions), set(actual_write_routes)) for key, expected_permission in expected_write_permissions.items(): with self.subTest(operation=key): diff --git a/shierfeng-fastapi-backend/tests/module_business/test_master_excel_service.py b/shierfeng-fastapi-backend/tests/module_business/test_master_excel_service.py new file mode 100644 index 0000000..2b41bf9 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_master_excel_service.py @@ -0,0 +1,320 @@ +import io +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from openpyxl import Workbook, load_workbook +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from exceptions.exception import PermissionException, ServiceException +from module_business.dao.master_dao import MasterDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.do.master_do import BizPallet +from module_business.entity.do.operation_do import BizPalletInventory +from module_business.entity.vo.master_vo import StrainModel, StrainPageQueryModel +from module_business.service.master_excel_service import MasterExcelService, _LISTERS +from module_business.service.master_service import MasterService +from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope + + +class _Upload: + def __init__(self, content: bytes) -> None: + self.content = content + self.closed = False + + async def read(self) -> bytes: + return self.content + + async def close(self) -> None: + self.closed = True + + +def workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes: + workbook = Workbook() + worksheet = workbook.active + worksheet.append(headers) + for row in rows: + worksheet.append(row) + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +def sheet_rows(binary: bytes) -> list[tuple[object, ...]]: + workbook = load_workbook(io.BytesIO(binary), data_only=False) + try: + return [tuple(cell.value for cell in row) for row in workbook.active.iter_rows()] + finally: + workbook.close() + + +def compile_sql(statement: object) -> str: + return ' '.join(str(statement.compile()).lower().split()) + + +class MasterExcelServiceTest(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.db = AsyncMock(spec=AsyncSession) + self.admin = make_admin() + self.scope = TestBatchScope.formal() + self.executed: list[object] = [] + + async def execute(statement: object) -> None: + self.executed.append(statement) + + self.db.execute = execute + + async def _import(self, resource: str, headers: list[str], rows: list[list[object]], *, update_support: bool = False, scope=None, user=None): + return await MasterExcelService.import_resource( + self.db, + resource, # type: ignore[arg-type] + _Upload(workbook_bytes(headers, rows)), + update_support, + user or self.admin, + scope or self.scope, + ) + + async def test_add_skip_update_and_other_scope(self) -> None: + headers = ['菌种编码', '菌种名称', '状态', '备注'] + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)), patch.object( + MasterDao, 'add_strain', new=AsyncMock() + ) as add_strain: + added = await self._import('strain', headers, [[1001, '香菇', '正常', '首批']]) + self.assertIn('新增1条', added.message) + saved = add_strain.await_args.args[1] + self.assertEqual('1001', saved.strain_code) + self.assertEqual('香菇', saved.strain_name) + self.assertEqual('0', saved.status) + self.assertIsNone(add_strain.await_args.args[2]) + self.db.commit.assert_awaited_once() + + self.db.commit.reset_mock() + existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None) + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object( + MasterDao, 'get_strain', new=AsyncMock(return_value=existing) + ), patch.object(MasterDao, 'update_strain', new=AsyncMock()) as update_strain: + skipped = await self._import('strain', headers, [['S001', '香菇', '正常', '']]) + self.assertIn('跳过1条', skipped.message) + self.assertIn('已跳过', skipped.message) + update_strain.assert_not_awaited() + self.db.rollback.assert_awaited() + self.db.commit.assert_not_awaited() + + self.db.rollback.reset_mock() + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object( + MasterDao, 'get_strain', new=AsyncMock(return_value=existing) + ), patch.object(MasterDao, 'update_strain', new=AsyncMock()) as update_strain: + updated = await self._import('strain', headers, [['S001', '新香菇', '停用', '改备注']], update_support=True) + self.assertIn('更新1条', updated.message) + model = update_strain.await_args.args[1] + self.assertEqual(3, model.strain_id) + self.assertEqual('S001', model.strain_code) + self.assertEqual('新香菇', model.strain_name) + self.assertEqual('1', model.status) + self.db.commit.assert_awaited_once() + + self.db.commit.reset_mock() + outside = SimpleNamespace(strain_id=9, strain_code='S009', test_batch_id=4) + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=outside)), patch.object( + MasterDao, 'get_strain', new=AsyncMock(return_value=None) + ), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain: + rejected = await self._import('strain', headers, [['S009', '越界', '正常', '']]) + self.assertIn('已存在于其他数据范围', rejected.message) + add_strain.assert_not_awaited() + self.db.commit.assert_not_awaited() + self.db.rollback.assert_awaited() + + async def test_reordered_headers_and_row_error_still_commit_success_rows(self) -> None: + headers = ['备注', '状态', '菌种名称', '菌种编码'] + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)), patch.object( + MasterDao, 'add_strain', new=AsyncMock() + ) as add_strain: + result = await self._import('strain', headers, [['备注', '正常', '香菇', 'S001'], ['坏', '启用', '平菇', 'S002']]) + self.assertEqual(1, add_strain.await_count) + self.assertIn('新增1条', result.message) + self.assertIn('第3行', result.message) + self.assertIn('状态只能填写正常或停用', result.message) + self.db.commit.assert_awaited_once() + + async def test_duplicate_identity_rejects_whole_file_before_write(self) -> None: + headers = ['菌种编码', '菌种名称', '状态', '备注'] + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock()) as find_strain: + with self.assertRaises(ServiceException) as caught: + await self._import('strain', headers, [['S001', '甲', '正常', ''], ['s001', '乙', '停用', '']]) + self.assertIn('重复编码', caught.exception.message) + find_strain.assert_not_awaited() + self.db.commit.assert_not_awaited() + self.db.rollback.assert_not_awaited() + + async def test_header_mismatch_empty_file_and_size_limit(self) -> None: + with self.assertRaises(ServiceException) as mismatch: + await self._import('strain', ['菌种编码', '多余'], [['S001', '甲']]) + self.assertIn('表头不匹配', mismatch.exception.message) + + with self.assertRaises(ServiceException) as empty: + await MasterExcelService.import_resource( + self.db, 'strain', _Upload(b''), False, self.admin, self.scope + ) + self.assertEqual('导入文件不能为空', empty.exception.message) + + with self.assertRaises(ServiceException) as oversized: + await MasterExcelService.import_resource( + self.db, 'strain', _Upload(b'x' * (5 * 1024 * 1024 + 1)), False, self.admin, self.scope + ) + self.assertIn('5MB', oversized.exception.message) + self.db.commit.assert_not_awaited() + + async def test_missing_parent_and_ambiguous_site_code_are_row_errors(self) -> None: + site_headers = ['客户编码', '现场编码', '现场名称', '现场地址', '状态', '备注'] + with patch.object(MasterDao, 'find_customer_by_code', new=AsyncMock(return_value=None)), patch.object( + MasterDao, 'add_site', new=AsyncMock() + ) as add_site: + missing = await self._import('site', site_headers, [['C404', 'SITE1', '现场', '地址', '正常', '']]) + self.assertIn('目标客户C404不存在', missing.message) + add_site.assert_not_awaited() + + line_headers = ['现场编码', '产线编码', '产线名称', '状态', '备注'] + sites = [ + SimpleNamespace(site_id=1, site_code='SITE1', status='0', test_batch_id=None), + SimpleNamespace(site_id=2, site_code='SITE1', status='0', test_batch_id=None), + ] + with patch.object(MasterDao, 'list_sites_by_code', new=AsyncMock(return_value=sites)), patch.object( + MasterDao, 'add_line', new=AsyncMock() + ) as add_line: + ambiguous = await self._import('line', line_headers, [['SITE1', 'L001', '一线', '正常', '']]) + self.assertIn('现场编码SITE1不唯一', ambiguous.message) + add_line.assert_not_awaited() + self.db.commit.assert_not_awaited() + + async def test_database_error_rolls_back_and_permission_error_is_not_wrapped(self) -> None: + headers = ['菌种编码', '菌种名称', '状态', '备注'] + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)), patch.object( + MasterDao, 'add_strain', new=AsyncMock(side_effect=IntegrityError('statement', {}, RuntimeError('duplicate'))) + ): + with self.assertRaises(ServiceException) as caught: + await self._import('strain', headers, [['S001', '香菇', '正常', '']]) + self.assertEqual('导入失败,数据已全部回滚', caught.exception.message) + self.db.rollback.assert_awaited() + self.db.commit.assert_not_awaited() + + self.db.rollback.reset_mock() + with self.assertRaises(PermissionException) as denied: + await self._import( + 'strain', + headers, + [['S001', '香菇', '正常', '']], + scope=make_test_scope(9), + user=make_current_user(admin=False), + ) + self.assertEqual('仅超级管理员可执行测试批次管理操作', denied.exception.message) + self.db.rollback.assert_awaited() + + async def test_import_uses_current_test_batch(self) -> None: + headers = ['菌种编码', '菌种名称', '状态', '备注'] + scope = make_test_scope(9) + with patch.object(MasterService, '_prepare_test_write', new=AsyncMock()) as prepare, patch.object( + MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None) + ), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain: + await self._import('strain', headers, [['S001', '香菇', '正常', '']], scope=scope, user=self.admin) + prepare.assert_awaited_once() + self.assertIs(scope, prepare.await_args.args[2]) + self.assertEqual(9, add_strain.await_args.args[2]) + + async def test_new_pallet_gets_fixed_capacity_qr_and_empty_inventory(self) -> None: + async def flush() -> None: + for call in self.db.add.call_args_list: + record = call.args[0] + if isinstance(record, BizPallet) and record.pallet_id is None: + record.pallet_id = 15 + + self.db.flush = flush + with patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)): + result = await self._import('pallet', ['托盘编号', '状态', '备注'], [['P001', '停用', '新盘']]) + added = [call.args[0] for call in self.db.add.call_args_list] + pallet = next(record for record in added if isinstance(record, BizPallet)) + inventory = next(record for record in added if isinstance(record, BizPalletInventory)) + self.assertEqual(100, pallet.capacity_packages) + self.assertEqual('1', pallet.asset_status) + self.assertTrue(pallet.qr_token) + self.assertNotEqual('P001', pallet.qr_token) + self.assertEqual(15, inventory.pallet_id) + self.assertEqual(0, inventory.remaining_packages) + self.assertEqual('UNSTOCKED', inventory.flow_state) + self.assertIsNone(inventory.test_batch_id) + self.assertIn('新增1条', result.message) + self.db.commit.assert_awaited_once() + + async def test_pallet_update_keeps_capacity_and_qr_token(self) -> None: + pallet = SimpleNamespace( + pallet_id=5, + pallet_code='P001', + qr_token='qr-keep', + capacity_packages=80, + asset_status='0', + remark='旧', + test_batch_id=None, + ) + inventory = SimpleNamespace(pallet_id=5, remaining_packages=3, test_batch_id=None) + with patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=pallet)), patch.object( + MasterDao, 'get_pallet', new=AsyncMock(return_value=pallet) + ), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)): + result = await self._import( + 'pallet', ['托盘编号', '状态', '备注'], [['P001', '停用', '新备注']], update_support=True + ) + self.assertIn('更新1条', result.message) + self.assertEqual('qr-keep', pallet.qr_token) + self.assertEqual(80, pallet.capacity_packages) + sql = compile_sql(self.executed[-1]) + self.assertNotIn('qr_token', sql) + self.assertNotIn('pallet_code', sql) + self.assertIn('capacity_packages', sql) + self.assertIn('asset_status', sql) + self.db.commit.assert_awaited_once() + + async def test_tunnel_update_sql_does_not_touch_current_trace(self) -> None: + site = SimpleNamespace(site_id=2, site_code='SITE1', status='0', test_batch_id=None) + tunnel = SimpleNamespace(tunnel_id=8, tunnel_code='T001', site_id=2, status='0', test_batch_id=None) + with patch.object(MasterDao, 'list_sites_by_code', new=AsyncMock(return_value=[site])), patch.object( + MasterDao, 'find_tunnel_by_code', new=AsyncMock(return_value=tunnel) + ), patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=tunnel)), patch.object( + MasterDao, 'get_site', new=AsyncMock(return_value=site) + ): + result = await self._import( + 'tunnel', + ['现场编码', '隧道编号', '隧道名称', '状态', '备注'], + [['SITE1', 'T001', '新隧道', '停用', '改备注']], + update_support=True, + ) + self.assertIn('更新1条', result.message) + sql = compile_sql(self.executed[-1]) + self.assertNotIn('current_trace_no', sql) + self.assertNotIn('current_trace_started_at', sql) + self.assertIn('tunnel_name', sql) + self.assertIn('status', sql) + self.db.commit.assert_awaited_once() + + async def test_export_uses_chinese_status_and_empty_result_keeps_headers(self) -> None: + rows = [ + StrainModel(strainId=1, strainCode='S001', strainName='香菇', status='0', remark='甲'), + StrainModel(strainId=2, strainCode='S002', strainName='平菇', status='1', remark=None), + ] + query = StrainPageQueryModel() + list_strain = AsyncMock(return_value=rows) + with patch.dict(_LISTERS, {'strain': list_strain}): + binary = await MasterExcelService.export_resource(self.db, 'strain', query, self.scope) + self.assertFalse(list_strain.await_args.kwargs['is_page']) + self.assertEqual( + [ + ('菌种编码', '菌种名称', '状态', '备注'), + ('S001', '香菇', '正常', '甲'), + ('S002', '平菇', '停用', None), + ], + [row[:4] for row in sheet_rows(binary)], + ) + + with patch.dict(_LISTERS, {'strain': AsyncMock(return_value=[])}): + empty = await MasterExcelService.export_resource(self.db, 'strain', query, self.scope) + exported = sheet_rows(empty) + self.assertEqual([('菌种编码', '菌种名称', '状态', '备注')], [row[:4] for row in exported]) + self.assertEqual(1, len(exported)) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py index 4b76a3f..0e66930 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py @@ -11,6 +11,7 @@ MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_ BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql' TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql' SHIPMENT_RECEIPT_PATCH_PATH = SQL_ROOT / 'patches/20261008_shipment_receipt_mysql.sql' +MASTER_EXCEL_PATCH_PATH = SQL_ROOT / 'patches/20261009_master_excel_mysql.sql' SYS_MENU_VALUE_COUNT = 20 SYS_MENU_PERMS_INDEX = 13 SYS_MENU_ICON_INDEX = 14 @@ -798,7 +799,27 @@ LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | { # 同样在菜单旧快照生成后加入收货权限,避免改变历史迁移的权限清单。 EXPECTED_BUTTONS[1119] = ('收货核对', 510, 6, 'business:shipment:receive') EXPECTED_BUSINESS_MENU_FIELDS[1119] = (510, '收货核对', '', '', 6, 'business:shipment:receive', '') -EXPECTED_BUTTON_PERMISSIONS |= {'business:shipment:receive'} +MASTER_EXCEL_BUTTONS = { + 1120: ('菌种导入', 502, 4, 'master:strain:import'), + 1121: ('菌种导出', 502, 5, 'master:strain:export'), + 1122: ('客户导入', 503, 4, 'master:customer:import'), + 1123: ('客户导出', 503, 5, 'master:customer:export'), + 1124: ('现场导入', 504, 6, 'master:site:import'), + 1125: ('现场导出', 504, 7, 'master:site:export'), + 1126: ('产线导入', 505, 4, 'master:line:import'), + 1127: ('产线导出', 505, 5, 'master:line:export'), + 1128: ('仓库导入', 506, 6, 'master:warehouse:import'), + 1129: ('仓库导出', 506, 7, 'master:warehouse:export'), + 1130: ('托盘导入', 507, 6, 'master:pallet:import'), + 1131: ('托盘导出', 507, 7, 'master:pallet:export'), + 1132: ('隧道导入', 520, 4, 'master:tunnel:import'), + 1133: ('隧道导出', 520, 5, 'master:tunnel:export'), +} +for menu_id, spec in MASTER_EXCEL_BUTTONS.items(): + EXPECTED_BUTTONS[menu_id] = spec + name, parent_id, order_num, permission = spec + EXPECTED_BUSINESS_MENU_FIELDS[menu_id] = (parent_id, name, '', '', order_num, permission, '') +EXPECTED_BUTTON_PERMISSIONS |= {'business:shipment:receive', *(spec[3] for spec in MASTER_EXCEL_BUTTONS.values())} EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS EXPECTED_TOP_LEVEL_MENU_POSITIONS = { 121: ('基础资料维护', 0, 1), @@ -842,6 +863,7 @@ BUSINESS_MENU_IDS = ( | {1103} | set(range(1104, 1109)) | set(range(1109, 1119)) + | set(range(1120, 1134)) ) TEST_BATCH_MENU_IDS = {519, 1109, 1110, 1111, 1112, 1113, 1114, 1115} MYSQL_MENU_STABLE_COLUMNS = ( @@ -1508,6 +1530,45 @@ class ShipmentReceiptSqlContractTest(unittest.TestCase): self.assertNotIn(1119, LEGACY_EXPECTED_BUSINESS_MENU_FIELDS) +class MasterExcelMenuPatchContractTest(unittest.TestCase): + def patch_sql(self) -> str: + self.assertTrue(MASTER_EXCEL_PATCH_PATH.is_file(), '缺少基础资料导入导出菜单迁移脚本') + return MASTER_EXCEL_PATCH_PATH.read_text(encoding='utf-8') + + def test_patch_only_inserts_master_excel_menus_without_role_grants(self) -> None: + raw = self.patch_sql() + stripped = _strip_sql_comments(raw).lower() + inserts = re.findall(r'\binsert\s+into\s+(\w+)', stripped) + self.assertEqual(['sys_menu'], inserts) + self.assertNotRegex(stripped, r'\b(?:alter\s+table|delete\s+from|replace\s+into|truncate\s+table|update\s+\w+)\b') + self.assertNotRegex(stripped, r'\b(?:insert\s+into|update|delete\s+from)\s+sys_role_menu\b') + self.assertIn('不写 sys_role_menu', raw) + guard = stripped.split('start transaction', maxsplit=1)[0] + self.assertIn('menu_id between 1120 and 1133', guard) + self.assertIn('perms in (', guard) + self.assertIn('from sys_role_menu where menu_id between 1120 and 1133', guard) + self.assertIn("signal sqlstate '45000'", guard) + self.assertIn('resignal', guard) + self.assertLess(stripped.index("signal sqlstate '45000'"), stripped.index('start transaction')) + + rows = _insert_rows(raw, 'sys_menu') + self.assertEqual(list(MASTER_EXCEL_BUTTONS), [int(row[0]) for row in rows]) + for row in rows: + menu_id = int(row[0]) + name, parent_id, order_num, permission = MASTER_EXCEL_BUTTONS[menu_id] + self.assertEqual(SYS_MENU_VALUE_COUNT, len(row), menu_id) + self.assertEqual([str(menu_id), name, str(parent_id), str(order_num)], row[:4]) + self.assertEqual(permission, row[SYS_MENU_PERMS_INDEX]) + self.assertEqual( + ('', '', '', '', '1', '0', 'F', '0', '0'), + tuple(row[index] for index in (4, 5, 6, 7, 8, 9, 10, 11, 12)), + ) + + def test_patch_does_not_change_legacy_menu_snapshot(self) -> None: + self.assertTrue(MASTER_EXCEL_BUTTONS.keys().isdisjoint(LEGACY_EXPECTED_BUSINESS_MENU_FIELDS)) + self.assertTrue({spec[3] for spec in MASTER_EXCEL_BUTTONS.values()}.isdisjoint(LEGACY_EXPECTED_PERMISSIONS)) + + class SqlContractTest(unittest.TestCase): def test_test_batch_initializers_and_patches_match_contract(self) -> None: scripts = ( @@ -1615,10 +1676,10 @@ class SqlContractTest(unittest.TestCase): def test_initializers_define_fixed_business_menus_and_permissions(self) -> None: self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS)) - self.assertEqual(47, len(EXPECTED_BUTTON_PERMISSIONS)) + self.assertEqual(61, len(EXPECTED_BUTTON_PERMISSIONS)) self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS) - self.assertEqual(66, len(EXPECTED_PERMISSIONS)) - self.assertEqual(69, len(EXPECTED_BUSINESS_MENU_FIELDS)) + self.assertEqual(80, len(EXPECTED_PERMISSIONS)) + self.assertEqual(83, len(EXPECTED_BUSINESS_MENU_FIELDS)) for filename in INITIALIZERS: with self.subTest(filename=filename): sql = (SQL_ROOT / filename).read_text(encoding='utf-8') diff --git a/shierfeng-fastapi-backend/utils/excel_util.py b/shierfeng-fastapi-backend/utils/excel_util.py index 07168d1..db52968 100644 --- a/shierfeng-fastapi-backend/utils/excel_util.py +++ b/shierfeng-fastapi-backend/utils/excel_util.py @@ -102,3 +102,47 @@ class ExcelUtil: excel_data = file.getvalue() return excel_data + + @classmethod + def read_text_table(cls, contents: bytes) -> tuple[list[str], list[tuple[int, dict[str, str]]]]: + """按文本读取首个工作表,保留 Excel 行号,避免编码被当成数字。""" + from openpyxl import load_workbook + + try: + workbook = load_workbook(io.BytesIO(contents), data_only=False, read_only=True) + except Exception as exc: + raise ValueError('导入文件无法读取,请上传 xls 或 xlsx 模板') from exc + try: + worksheet = workbook.active + rows = worksheet.iter_rows(values_only=True) + try: + header_row = next(rows) + except StopIteration as exc: + raise ValueError('导入文件没有表头') from exc + headers = [cls._cell_text(cell) for cell in header_row] + while headers and headers[-1] == '': + headers.pop() + parsed_rows: list[tuple[int, dict[str, str]]] = [] + for offset, row in enumerate(rows, start=2): + values = [cls._cell_text(row[index] if index < len(row) else None) for index in range(len(headers))] + if not any(values): + continue + parsed_rows.append((offset, dict(zip(headers, values, strict=True)))) + return headers, parsed_rows + finally: + workbook.close() + + @staticmethod + def _cell_text(value: object) -> str: + """把单元格归一成去掉首尾空白的文本。""" + if value is None: + return '' + if isinstance(value, bool): + return '1' if value else '0' + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if value.is_integer(): + return str(int(value)) + return format(value, 'f').rstrip('0').rstrip('.') + return str(value).strip() diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 63c17e6..5922ea5 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -442,6 +442,20 @@ const expectedButtonPermissions = [ 'master:pallet:add', 'master:pallet:edit', 'master:pallet:print', + 'master:strain:import', + 'master:strain:export', + 'master:customer:import', + 'master:customer:export', + 'master:site:import', + 'master:site:export', + 'master:line:import', + 'master:line:export', + 'master:warehouse:import', + 'master:warehouse:export', + 'master:pallet:import', + 'master:pallet:export', + 'master:tunnel:import', + 'master:tunnel:export', 'business:inbound:query', 'business:inbound:add', 'business:inbound:edit', @@ -1221,8 +1235,8 @@ test('窄屏模式控件仍保留正式退出与测试批次切换入口', async test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => { assert.equal(expectedPagePermissions.length, 17) - assert.equal(expectedButtonPermissions.length, 38) - assert.equal(new Set(expectedPermissions).size, 55) + assert.equal(expectedButtonPermissions.length, 52) + assert.equal(new Set(expectedPermissions).size, 69) const relativePaths = [ ...Object.values(masterPageContracts), ...Object.values(businessPageContracts), @@ -1231,7 +1245,7 @@ test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async assert.equal(relativePaths.length, 17) const actualPermissions = [] - for (const relativePath of relativePaths) { + for (const relativePath of [...relativePaths, 'src/components/MasterExcelActions/index.vue']) { const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') const guarded = extractGuardedVuePermissions(source) assert.ok(guarded.length > 0, `${relativePath} 缺少真实权限门禁`) @@ -1840,6 +1854,16 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除 assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`) assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/) assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/) + assert.match( + masterToolbar, + new RegExp(``), + `${resource} 必须复用基础资料导入导出组件` + ) + assert.match( + masterToolbar, + /<\/template>\s*\s* { + const source = await readFile(path.join(frontendRoot, 'src/views/master/tunnel/index.vue'), 'utf8') + const masterToolbar = source.match(/]*class="[^"]*\bmaster-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || '' + assert.match( + masterToolbar, + // + ) + assert.match(masterToolbar, /<\/template>\s*\s*\s*(?:导入|导出)\s*<\/el-button>/) + assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/) +}) + test('五个业务页面逐页满足 API、精确权限、列表和交互基础契约', async (t) => { for (const [resource, contract] of Object.entries(businessPageContracts)) { await t.test(contract.relativePath, async () => { diff --git a/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue b/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue new file mode 100644 index 0000000..2fda332 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue @@ -0,0 +1,193 @@ + + + 导入 + + + 导出 + + + 导入 + + + 导出 + + + 导入 + + + 导出 + + + 导入 + + + 导出 + + + 导入 + + + 导出 + + + 导入 + + + 导出 + + + 导入 + + + 导出 + + + + + + 将文件拖到此处,或点击上传 + + + + 是否更新已经存在的{{ resourceLabel }}数据 + + 仅允许导入xls、xlsx格式文件。 + 下载模板 + + + + + + + + + + diff --git a/shierfeng-fastapi-frontend/src/main.js b/shierfeng-fastapi-frontend/src/main.js index aee437f..8a9bcad 100644 --- a/shierfeng-fastapi-frontend/src/main.js +++ b/shierfeng-fastapi-frontend/src/main.js @@ -43,6 +43,7 @@ import ImageUpload from "@/components/ImageUpload" import ImagePreview from "@/components/ImagePreview" // 字典标签组件 import DictTag from '@/components/DictTag' +import MasterExcelActions from '@/components/MasterExcelActions' const app = createApp(App) @@ -65,6 +66,7 @@ app.component('ImageUpload', ImageUpload) app.component('ImagePreview', ImagePreview) app.component('RightToolbar', RightToolbar) app.component('Editor', Editor) +app.component('MasterExcelActions', MasterExcelActions) app.use(router) app.use(store) diff --git a/shierfeng-fastapi-frontend/src/views/master/customer/index.vue b/shierfeng-fastapi-frontend/src/views/master/customer/index.vue index 0e8dc7d..1b8f8de 100644 --- a/shierfeng-fastapi-frontend/src/views/master/customer/index.vue +++ b/shierfeng-fastapi-frontend/src/views/master/customer/index.vue @@ -32,6 +32,7 @@ 纳入测试批次 + diff --git a/shierfeng-fastapi-frontend/src/views/master/line/index.vue b/shierfeng-fastapi-frontend/src/views/master/line/index.vue index 3c14b81..0f4537b 100644 --- a/shierfeng-fastapi-frontend/src/views/master/line/index.vue +++ b/shierfeng-fastapi-frontend/src/views/master/line/index.vue @@ -35,6 +35,7 @@ 纳入测试批次 + diff --git a/shierfeng-fastapi-frontend/src/views/master/pallet/index.vue b/shierfeng-fastapi-frontend/src/views/master/pallet/index.vue index cb7d6e2..0f61653 100644 --- a/shierfeng-fastapi-frontend/src/views/master/pallet/index.vue +++ b/shierfeng-fastapi-frontend/src/views/master/pallet/index.vue @@ -29,6 +29,7 @@ 纳入测试批次 + diff --git a/shierfeng-fastapi-frontend/src/views/master/site/index.vue b/shierfeng-fastapi-frontend/src/views/master/site/index.vue index 51c9808..9bf7d44 100644 --- a/shierfeng-fastapi-frontend/src/views/master/site/index.vue +++ b/shierfeng-fastapi-frontend/src/views/master/site/index.vue @@ -36,6 +36,7 @@ 纳入测试批次 + diff --git a/shierfeng-fastapi-frontend/src/views/master/strain/index.vue b/shierfeng-fastapi-frontend/src/views/master/strain/index.vue index 0d0cf50..354c1b0 100644 --- a/shierfeng-fastapi-frontend/src/views/master/strain/index.vue +++ b/shierfeng-fastapi-frontend/src/views/master/strain/index.vue @@ -39,6 +39,7 @@ 纳入测试批次 + diff --git a/shierfeng-fastapi-frontend/src/views/master/tunnel/index.vue b/shierfeng-fastapi-frontend/src/views/master/tunnel/index.vue index 5590dc3..d9ab220 100644 --- a/shierfeng-fastapi-frontend/src/views/master/tunnel/index.vue +++ b/shierfeng-fastapi-frontend/src/views/master/tunnel/index.vue @@ -35,6 +35,7 @@ 纳入测试批次 + diff --git a/shierfeng-fastapi-frontend/src/views/master/warehouse/index.vue b/shierfeng-fastapi-frontend/src/views/master/warehouse/index.vue index 4928d97..b5e752c 100644 --- a/shierfeng-fastapi-frontend/src/views/master/warehouse/index.vue +++ b/shierfeng-fastapi-frontend/src/views/master/warehouse/index.vue @@ -33,6 +33,7 @@ 纳入测试批次 +