今天我们来讲一下ESOP功能设计
本文将深入探讨电子标准作业程序(ESOP)系统的核心功能设计,通过UniApp移动端与Web管理端的WebSocket实时交互,实现制造业现场作业指导的高效管理。
1. 概述
电子标准作业程序(ESOP) 是制造业数字化转型的关键系统,它将传统的纸质作业指导书电子化,实现:
- 标准化作业:确保所有操作人员遵循统一标准
- 实时更新:作业指导书变更即时同步到生产线
- 多媒体支持:集成图文、视频等富媒体指导内容
- 操作追溯:记录文档查看与操作历史 相比传统作业指导方式,ESOP系统可提升生产效率15-30%,减少操作错误率40-60%,是智能制造的核心基础设施。
2、系统架构设计
技术栈说明:
- 移动终端:UniApp(Android 8.0+)
- 后端:Spring Boot 2.7 + Spring WebSocket
- Web管理端:React 18 + Ant Design 5.x
- 协议:WebSocket(STOMP子协议)
- 存储:MinIO分布式文件存储、本地存储
- 数据库:MySQL 8.0
3. 数据库设计
sop_attachment(sop上传文件)
| 列名 | 类型 | 是否为空 | 主键 | 注释 |
|---|---|---|---|---|
| id | bigint | 否 | 是 | 文档附件ID |
| document_id | bigint | 是 | 否 | |
| parent_id | bigint | 是 | 否 | |
| storage | varchar(128) | 是 | 否 | 上传类型 |
| file_name | varchar(1024) | 是 | 否 | 文件名 |
| file_url | varchar(1024) | 是 | 否 | 文件路径 |
| file_type | varchar(256) | 是 | 否 | 文件类型 |
| file_size | bigint | 是 | 否 | 文件大小 |
| page | int | 是 | 否 | 页码 |
| serial_number | int | 是 | 否 | 图片序号 |
| remark | varchar(128) | 是 | 否 | |
| create_by | bigint | 是 | 否 | |
| create_time | datetime | 是 | 否 | |
| update_by | bigint | 是 | 否 | |
| update_time | datetime | 是 | 否 |
sop_document_manage(文档集管理)
| 列名 | 类型 | 是否为空 | 主键 | 默认值 | 注释 |
|---|---|---|---|---|---|
| id | bigint unsigned | 否 | 是 | 文档集ID | |
| code | varchar(64) | 否 | 否 | 文档编码 | |
| name | varchar(64) | 否 | 否 | 文档名称 | |
| type | varchar(64) | 否 | 否 | 文档类型 | |
| status | varchar(64) | 否 | 否 | '' | 状态 |
| remark | varchar(500) | 是 | 否 | '' | 备注 |
| create_by | bigint | 是 | 否 | 创建者 | |
| create_time | datetime | 是 | 否 | 创建时间 | |
| update_by | bigint | 是 | 否 | 更新者 | |
| update_time | datetime | 是 | 否 | 更新时间 |
sop_document_product(关联产品)
| 列名 | 类型 | 是否为空 | 主键 | 默认值 | 注释 |
|---|---|---|---|---|---|
| id | bigint | 否 | 是 | 关联ID | |
| manage_id | bigint | 否 | 否 | 文档集ID | |
| material_id | bigint | 否 | 否 | 物料产品ID | |
| remark | varchar(500) | 是 | 否 | '' | 备注 |
| create_by | bigint | 是 | 否 | 创建者 | |
| create_time | datetime | 是 | 否 | 创建时间 | |
| update_by | bigint | 是 | 否 | 更新者 | |
| update_time | datetime | 是 | 否 | 更新时间 |
sop_document(文档管理)
| 列名 | 类型 | 是否为空 | 主键 | 默认值 | 注释 |
|---|---|---|---|---|---|
| id | bigint | 否 | 是 | 文档ID | |
| manage_id | bigint | 否 | 否 | 文档集ID | |
| attachment_id | bigint | 是 | 否 | 附件ID | |
| start_index | int | 是 | 否 | 开始页码 | |
| end_index | int | 是 | 否 | 结束页码 | |
| remark | varchar(500) | 是 | 否 | '' | 备注 |
| create_by | bigint | 是 | 否 | 创建者 | |
| create_time | datetime | 是 | 否 | 创建时间 | |
| update_by | bigint | 是 | 否 | 更新者 | |
| update_time | datetime | 是 | 否 | 更新时间 |
sop_send_record(推送记录)
| 列名 | 类型 | 是否为空 | 主键 | 默认值 | 注释 |
|---|---|---|---|---|---|
| id | bigint | 否 | 是 | 逻辑ID | |
| manage_id | bigint | 是 | 否 | 文档ID | |
| document_id | bigint | 是 | 否 | 文档集ID | |
| material_id | bigint | 是 | 否 | 物料ID | |
| file_name | varchar(255) | 是 | 否 | 文件名 | |
| file_url | varchar(255) | 是 | 否 | 文件地址 | |
| file_type | varchar(64) | 是 | 否 | 文件类型 | |
| file_size | int | 是 | 否 | 文件大小(KB) | |
| line_id | bigint | 是 | 否 | 产线ID | |
| station_id | bigint | 是 | 否 | 工位ID | |
| process_id | bigint | 是 | 否 | 工序ID | |
| terminal_id | bigint | 是 | 否 | 终端ID | |
| ip | varchar(64) | 是 | 否 | 终端IP | |
| remark | varchar(500) | 是 | 否 | 备注 | |
| status | varchar(2) | 否 | 否 | 状态 | |
| create_by | bigint | 是 | 否 | 创建者 | |
| create_time | datetime | 是 | 否 | 创建时间 | |
| update_by | bigint | 是 | 否 | 更新者 | |
| update_time | datetime | 是 | 否 | 更新时间 |
sop_terminal(显示终端)
| 列名 | 类型 | 是否为空 | 主键 | 默认值 | 注释 |
|---|---|---|---|---|---|
| id | bigint | 否 | 是 | 终端ID | |
| code | varchar(64) | 是 | 否 | 终端编码 | |
| name | varchar(255) | 是 | 否 | 终端名称 | |
| workshop_id | bigint | 是 | 否 | 车间ID | |
| line_id | bigint | 是 | 否 | 产线ID | |
| station_id | bigint | 是 | 否 | 工位ID | |
| ip | varchar(64) | 是 | 否 | 终端IP | |
| reporting | varchar(8) | 是 | 否 | 是否报工设备Y|N | |
| status | varchar(32) | 是 | 否 | 状态 | |
| remark | varchar(500) | 是 | 否 | 备注 | |
| create_by | bigint | 是 | 否 | 创建者 | |
| create_time | datetime | 是 | 否 | 创建时间 | |
| update_by | bigint | 是 | 否 | 更新者 | |
| update_time | datetime | 是 | 否 | 更新时间 |
4. 功能设计
4.1. 文档管理模块
文档集管理
新增文档集
这里允许设置在终端显示文档的页码范围。
上传文档
添加关联产品
这里需要把【文件上传】功能详细说一下:
如果上传的是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 文件发送
只用在【文档集】是发布 #状态 才能执行发送操作。
勾选要推送的文档集,点击工具栏中的【推送文件】按钮。
左侧为选中文档集下的文档信息,右侧是设备信息。
- 选择车间、产线点击【查询】按钮
- 选中文件列表中的文件,使用拖拽的方式把文件拖入到要显示的设备看片中。
- 点击【确定】
如上图,设备卡片中显示是已经拖放进去的文档信息,允许点击删除按钮删除。
以下是文件上传的代码片段,如有需要请查看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 显示终端管理
这里可以看到状态列,如果设备已经通过Websocket连接上,这里会显示 #在线
新增设备
这里需要填写设备的 【车间/产线/工位 】、【终端IP】信息,IP是不允许重复。
4.4 移动设备
移动端运作流程如下:
方式一:
- 应用启动获取该终端IP
- 通过IP查询推送到设备的文档信息
方式二:
- 操作员通过web端推送文档到终端,后端通过Websocket向移动端发送消息
- 移动端获取到消息后处理消息,获取最新的文档信息
主要代码说明:
- 使用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. 检测WebSocket是否连接,连接异常重新连接
* 2. 定时删除提示信息
* 3. 视频播放处理
* 4. 30秒没有操作自动全屏显示
* 5. 如果Websocket不能连接,定时更新文档
* 6. WebSocket定时发送心跳信息
*/
const handleCheck = () => {
....
}
- 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 开源项目地址:
欢迎在评论区分享你的技术选型经验,或对本文方案的改进建议!
关注公众号「慧工云创」