从0到1构建MES系统14-ESOP

302 阅读9分钟

今天我们来讲一下ESOP功能设计

本文将深入探讨电子标准作业程序(ESOP)系统的核心功能设计,通过UniApp移动端与Web管理端的WebSocket实时交互,实现制造业现场作业指导的高效管理。

1. 概述

电子标准作业程序(ESOP) 是制造业数字化转型的关键系统,它将传统的纸质作业指导书电子化,实现:

  1. 标准化作业:确保所有操作人员遵循统一标准
  2. 实时更新:作业指导书变更即时同步到生产线
  3. 多媒体支持:集成图文、视频等富媒体指导内容
  4. 操作追溯:记录文档查看与操作历史 相比传统作业指导方式,ESOP系统可提升生产效率15-30%,减少操作错误率40-60%,是智能制造的核心基础设施。

2、系统架构设计

Pasted image 20250805225015.png 技术栈说明

  • 移动终端:UniApp(Android 8.0+)
  • 后端:Spring Boot 2.7 + Spring WebSocket
  • Web管理端:React 18 + Ant Design 5.x
  • 协议:WebSocket(STOMP子协议)
  • 存储:MinIO分布式文件存储、本地存储
  • 数据库:MySQL 8.0

3. 数据库设计

Pasted image 20250805225204.png

sop_attachment(sop上传文件)

列名类型是否为空主键注释
idbigint文档附件ID
document_idbigint
parent_idbigint
storagevarchar(128)上传类型
file_namevarchar(1024)文件名
file_urlvarchar(1024)文件路径
file_typevarchar(256)文件类型
file_sizebigint文件大小
pageint页码
serial_numberint图片序号
remarkvarchar(128)
create_bybigint
create_timedatetime
update_bybigint
update_timedatetime

sop_document_manage(文档集管理)

列名类型是否为空主键默认值注释
idbigint unsigned文档集ID
codevarchar(64)文档编码
namevarchar(64)文档名称
typevarchar(64)文档类型
statusvarchar(64)''状态
remarkvarchar(500)''备注
create_bybigint创建者
create_timedatetime创建时间
update_bybigint更新者
update_timedatetime更新时间

sop_document_product(关联产品)

列名类型是否为空主键默认值注释
idbigint关联ID
manage_idbigint文档集ID
material_idbigint物料产品ID
remarkvarchar(500)''备注
create_bybigint创建者
create_timedatetime创建时间
update_bybigint更新者
update_timedatetime更新时间

sop_document(文档管理)

列名类型是否为空主键默认值注释
idbigint文档ID
manage_idbigint文档集ID
attachment_idbigint附件ID
start_indexint开始页码
end_indexint结束页码
remarkvarchar(500)''备注
create_bybigint创建者
create_timedatetime创建时间
update_bybigint更新者
update_timedatetime更新时间

sop_send_record(推送记录)

列名类型是否为空主键默认值注释
idbigint逻辑ID
manage_idbigint文档ID
document_idbigint文档集ID
material_idbigint物料ID
file_namevarchar(255)文件名
file_urlvarchar(255)文件地址
file_typevarchar(64)文件类型
file_sizeint文件大小(KB)
line_idbigint产线ID
station_idbigint工位ID
process_idbigint工序ID
terminal_idbigint终端ID
ipvarchar(64)终端IP
remarkvarchar(500)备注
statusvarchar(2)状态
create_bybigint创建者
create_timedatetime创建时间
update_bybigint更新者
update_timedatetime更新时间

sop_terminal(显示终端)

列名类型是否为空主键默认值注释
idbigint终端ID
codevarchar(64)终端编码
namevarchar(255)终端名称
workshop_idbigint车间ID
line_idbigint产线ID
station_idbigint工位ID
ipvarchar(64)终端IP
reportingvarchar(8)是否报工设备Y|N
statusvarchar(32)状态
remarkvarchar(500)备注
create_bybigint创建者
create_timedatetime创建时间
update_bybigint更新者
update_timedatetime更新时间

4. 功能设计

4.1. 文档管理模块

文档集管理

Pasted image 20250805230148.png 新增文档集

Pasted image 20250805230214.png 这里允许设置在终端显示文档的页码范围。

上传文档

Pasted image 20250805230238.png 添加关联产品

Pasted image 20250805230546.png

这里需要把【文件上传】功能详细说一下: 如果上传的是word、Excel、PPT、PDF,后端会自动识别,把文档的每一页转换成图片的形式,附件的parent_id为该文档的附件ID。由于转换文件转换的时间会比较久,在后端使用异步的方式去处理。 以下是文件上传的代码片段,如有需要请查看gitee上的完整代码。

@Async  
@Transactional  
public void processFile(SopAttachment sopAttachment, MultipartFile file) {  
    String fileType = sopAttachment.getFileType();  
    if (!Arrays.asList("pdf", "pptx").contains(fileType)) {  
        return;  
    }  
  
    ImageConverter imageConverter = ImageConverterFactory.getConverter(fileType);  
  
    List<byte[]> imageBytes;  
    try {  
        imageBytes = imageConverter.convertToImages(file.getBytes());  
    } catch (IOException exc) {  
        log.error("转换图片失败:", exc);  
        throw new RuntimeException(exc);  
    }  
  
    log.info("转换图片上传开始");  
  
    for (int i = 0; i < imageBytes.size(); i++) {  
        String fileName = this.getFileName(sopAttachment.getFileName()) + "page_" + i + ".png";  
  
        log.info("上传图片{}开始", fileName);  
        MockMultipartFile mockMultipartFile = new MockMultipartFile("file", fileName, "image/png", imageBytes.get(i));  
        String filePath = fileServiceContext.uploadFile(mockMultipartFile, "sop");  
  
        SopAttachment convertAttachment = new SopAttachment();  
        convertAttachment.setParentId(sopAttachment.getId());  
        convertAttachment.setFileName(fileName);  
        convertAttachment.setFileUrl(filePath);  
        convertAttachment.setFileType("png");  
        convertAttachment.setStorage(fileProperties.getStrategy());  
        convertAttachment.setPage(i);  
        convertAttachment.setSerialNumber(i);  
  
        super.save(convertAttachment); // 事务内提交  
  
    }  
  
    log.info("转换图片上传结束");  
}

文件预览 文件预览功能使用了开源项目 kkFileView kkFileView为文件文档在线预览解决方案,该项目使用流行的spring boot搭建,易上手和部署,基本支持主流办公文档的在线预览,如doc,docx,xls,xlsx,ppt,pptx,pdf,txt,zip,rar,图片,视频,音频等等。

官网及文档 地址:kkview.cn

4.2 文件发送

只用在【文档集】是发布 #状态 才能执行发送操作。

Pasted image 20250805231951.png 勾选要推送的文档集,点击工具栏中的【推送文件】按钮。

Pasted image 20250805232048.png 左侧为选中文档集下的文档信息,右侧是设备信息。

  1. 选择车间、产线点击【查询】按钮
  2. 选中文件列表中的文件,使用拖拽的方式把文件拖入到要显示的设备看片中。
  3. 点击【确定】

Pasted image 20250805232337.png 如上图,设备卡片中显示是已经拖放进去的文档信息,允许点击删除按钮删除。 以下是文件上传的代码片段,如有需要请查看gitee上的完整代码。

@Override  
@Transactional(rollbackFor = Exception.class)  
public void sendMultipleDoc(List<SopSendRecord> sendRecords) {  
    //系统配置  
    Map<String, Object> sysConfig = getSysConfig();  
  
    // 1. 按 IP 分组  
    Map<String, List<SopSendRecord>> groupedByIP = sendRecords.stream()  
            .collect(Collectors.groupingBy(SopSendRecord::getIp));  
  
    // 2. 之前的推送记录失效  
    groupedByIP.forEach((key, value) -> {  
        sopSendRecordService.invalidByIp(key);  
    });  
  
    groupedByIP.forEach((key, recordList) -> {  
        Map<String, Object> sendReocrdMap = new HashMap<>();  
        sendReocrdMap.put("sysConfig", sysConfig);  
  
        //文件列表  
        List<Map<String, Object>> docInfoList = new ArrayList<>();  
        recordList.forEach(sopSendRecord -> {  
            SopDocument sopDocument = sopDocumentService.getById(sopSendRecord.getDocumentId());  
  
            if (sopDocument != null) {  
                //文档信息  
                Long attachmentId = sopDocument.getAttachmentId();  
                SopAttachment sopAttachment = sopAttachmentService.getById(attachmentId);  
                if (sopAttachment != null) {  
                    // 更新记录  
                    sopSendRecord.setFileUrl(sopAttachment.getFileUrl());  
  
                    if (videoExtensions.contains(sopAttachment.getFileType()) || imageExtensions.contains(sopAttachment.getFileType())) {  
                        Map<String, Object> documentMap = document2Map(sopAttachment);  
                        docInfoList.add(documentMap);  
                    } else {  
                        List<SopAttachment> imagesAttachments = sopAttachmentService.getByParentId(sopDocument.getAttachmentId());  
  
                        Integer startIndex = sopDocument.getStartIndex();  
                        Integer endIndex = sopDocument.getEndIndex();  
  
                        // 处理 startIndex 为空的情况,默认为 1                        startIndex = (startIndex != null) ? startIndex - 1 : 0;  
                        // 处理 endIndex 为空的情况,默认为 list.size()                        endIndex = (endIndex != null) ? endIndex : imagesAttachments.size();  
                        // **确保索引合法**  
                        startIndex = Math.max(0, startIndex);  // 防止负数  
                        endIndex = Math.min(imagesAttachments.size(), endIndex);  // 防止超出 list.size()  
                        List<SopAttachment> subList = imagesAttachments.subList(startIndex, endIndex);  
                        subList.forEach(attachment -> {  
                            Map<String, Object> showDocMap = document2Map(attachment);  
                            docInfoList.add(showDocMap);  
                        });  
                    }  
                }  
            }  
        });  
        sendReocrdMap.put("docList", docInfoList);  
  
        ObjectMapper mapper = new ObjectMapper();  
        try {  
            WebSocketUsers.sendMessageToUser(key, mapper.writeValueAsString(sendReocrdMap));  
        } catch (JsonProcessingException e) {  
            throw new BizException("发送文档到设备终端失败!");  
        }  
    });  
  
    sopSendRecordService.saveBatch(sendRecords);  
}

4.2 Websocket配置

由于篇幅问题,这里只能简单讲一下重点,详细代码可以查看源码,路径如下:src/main/java/com/hgyc/mom/mes/websocket

开发Websocket对外开发接口

@Slf4j  
@Component  
@ServerEndpoint("/ws/esop/document/{ip}")  
public class WebSocketServer {  
  
    private final ConnectionManager connectionManager = WebSocketContext.getBean(ConnectionManager.class);  
    private final MessageHandler messageHandler = WebSocketContext.getBean(MessageHandler.class);  
  
    @OnOpen  
    public void onOpen(Session session, @PathParam("ip") String ip) {  
        connectionManager.handleOpen(session, ip);  
    }  
  
    @OnClose  
    public void onClose(Session session) {  
        connectionManager.handleClose(session);  
    }  
  
    @OnError  
    public void onError(Session session, Throwable throwable) {  
        connectionManager.handleError(session, throwable);  
    }  
  
    @OnMessage  
    public void onMessage(String message, Session session) {  
        messageHandler.handleMessage(message, session);  
    }  
}

配置Websocket

@Configuration  
public class WebSocketConfig  
{  
    @Bean  
    public ServerEndpointExporter serverEndpointExporter()  
    {  
        return new ServerEndpointExporter();  
    }  
}

事件处理类

@Slf4j  
@Service  
public class ConnectionManager {  
  
    private final SopTerminalService sopTerminalService;  
    private static final int MAX_CONNECTIONS = 1000;  
    private static final Semaphore socketSemaphore = new Semaphore(1000);  
  
    public ConnectionManager(SopTerminalService sopTerminalService) {  
        this.sopTerminalService = sopTerminalService;  
    }  
  
    public void handleOpen(Session session, String ip) {  
        if (!StringUtils.isNotNull(ip) || "undefined".equals(ip)) {  
            close(session, "无效IP");  
            return;  
        }  
  
        boolean acquired = SemaphoreUtils.tryAcquire(socketSemaphore);  
        if (!acquired) {  
            WebSocketUsers.sendMessage(session, "连接数超限:" + MAX_CONNECTIONS);  
            close(session, "连接数超限");  
            return;  
        }  
  
        try {  
            WebSocketUsers.put(ip, session);  
            WebSocketUsers.sendMessage(session, "连接成功");  
            sopTerminalService.updateTerminalByIP(ip, SopTerminalStatusEnum.ON_LINE);  
        } catch (Exception e) {  
            log.error("WebSocket 建立连接失败", e);  
            close(session, "连接失败");  
        } finally {  
            if (!WebSocketUsers.contains(ip)) {  
                SemaphoreUtils.release(socketSemaphore);  
            }  
        }  
    }  
  
    public void handleClose(Session session) {  
        String ip = WebSocketUsers.getKeyBySession(session);  
        WebSocketUsers.remove(session);  
        SemaphoreUtils.release(socketSemaphore);  
  
        // 更新设备状态为离线  
        if (ip != null) {  
            sopTerminalService.updateTerminalByIP(ip, SopTerminalStatusEnum.OFF_LINE);  
        }  
    }  
  
    public void handleError(Session session, Throwable throwable) {  
        String ip = WebSocketUsers.getKeyBySession(session);  
  
        log.error("WebSocket 出现异常", throwable);  
        WebSocketUsers.remove(session);  
        SemaphoreUtils.release(socketSemaphore);  
  
        if (ip != null) {  
            sopTerminalService.updateTerminalByIP(ip, SopTerminalStatusEnum.OFF_LINE);  
        }  
  
        try {  
            if (session.isOpen()) session.close();  
        } catch (Exception ignored) {}  
    }  
  
    private void close(Session session, String reason) {  
        try {  
            if (session.isOpen()) session.close();  
        } catch (Exception ignored) {}  
        log.warn("连接关闭: {}, 原因: {}", session.getId(), reason);  
    }  
}

4.3 显示终端管理

Pasted image 20250805233609.png 这里可以看到状态列,如果设备已经通过Websocket连接上,这里会显示 #在线

新增设备

Pasted image 20250805233723.png 这里需要填写设备的 【车间/产线/工位 】、【终端IP】信息,IP是不允许重复。

4.4 移动设备

Pasted image 20250805234312.png 移动端运作流程如下:

Pasted image 20250805234821.png 方式一:

  1. 应用启动获取该终端IP
  2. 通过IP查询推送到设备的文档信息

方式二:

  1. 操作员通过web端推送文档到终端,后端通过Websocket向移动端发送消息
  2. 移动端获取到消息后处理消息,获取最新的文档信息

主要代码说明:

  1. 使用uniapp 【swiper】组件,实现文档切换
<swiper 
				v-if="docList?.length > 0" 
				:skip-hidden-item-layout="false" 
				:autoplay="sysConfig.isAutoPageTurning"
				:interval="sysConfig.cycleTime" 
				:indicator-dots="false" 
				:current="currentIndex"
				:disable-touch="docList?.length <= 1" 
				style="height: 100%" circular @change="handleSwiperChange"
				@touchstart="isTap = true" @touchend="handleTouchEnd">
				<swiper-item v-for="(item, index) in docList" style="width: 100%; height: 100%" :key="index + itemKey">
					<template v-if="item.fileType === 'video'">
						<video :src="item.filePath" :autoplay="true" :muted="true" :controls="true" :show-center-play-btn="false"
							:enable-progress-gesture="false" object-fit="contain" style="width: 100%; height: 100%"
							@ended="handleVideoEnded(index)" @play="handleVideoPlay(index)" :id="'video-' + index"></video>
					</template>
					<template v-else-if="item.fileType === 'url'">
						<web-view :src="item.filePath"></web-view>
					</template>
					<template v-else>
						<view class="content-view-content">
							<u-image :class="[
									'content-view-content-image',
									{ fullScreen: isFullScreen }
								]" :src="item.filePath" mode="scaleToFill" width="100%" height="100%"></u-image>
						</view>
					</template>
				</swiper-item>
			</swiper>

这里文档会以三种方式呈现:图片、视频、网页,终端通过获取推送记录的类型对不同类型,使用不同的组件来显示文档内容。

  1. 定时器定时检测
/**
	 * 定时器,频率可以通过系统配置修改
	 * 1. 检测WebSocket是否连接,连接异常重新连接
	 * 2. 定时删除提示信息
	 * 3. 视频播放处理
	 * 4. 30秒没有操作自动全屏显示
	 * 5. 如果Websocket不能连接,定时更新文档
	 * 6. WebSocket定时发送心跳信息
	 */
	const handleCheck = () => {
		....
	}
  1. Websocket收到信息更新文档
uni.onSocketMessage(async (res) => {
	console.log(`WebSocket收到内容`, res.data);
	alertPush({
		type: "success",
		message: "收到ws服务信息!"
	});

	try {
		const result = JSON.parse(res.data);

		if (result.msg === 'ping') {
			wsConfig.value.messagePingTime = Date.now();
			return;
		}

		if (!result.message) {
			alertPush({
				type: "error",
				message: result.message
			});
		} else {
			await handleUpdateEsopDocData(result);

			if (wsConfig.value.status) {
				sendMessage({
					type: 'msg',
					ip: ipAddress.value,
					msg: `[${ipAddress.value}]收到信息,处理返回最新数据!`,
					file: JSON.stringify(currentFileInfo.value),
				});
			}
		}
	} catch (e) {
		alertPush({
			type: "error",
			message: `ws服务信息:${res.data}`
		});
	}
}

本文源码已上传Gitee 开源项目地址

欢迎在评论区分享你的技术选型经验,或对本文方案的改进建议!

关注公众号「慧工云创」 扫码_搜索联合传播样式-标准色版.png