为基础资料维护页面增加导入导出

This commit is contained in:
2026-10-09 21:13:43 +08:00
parent a62803ccf0
commit a8af1f542b
20 changed files with 2134 additions and 15 deletions
@@ -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,