为基础资料维护页面增加导入导出
This commit is contained in:
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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]}不合法'
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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, '');
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user