今天我们来讲一下打印模板。
一、概述
在制造业工厂环境中,打印需求无处不在且形式多样:
- 标签打印:如产品序列号标签、包装箱标签、仓库货位标签等。这类打印通常格式固定,对打印精度和速度要求高,且常需要连接专用的条码打印机。
- 单据打印:如工单、领料单、检验报告、入库单等。这类打印格式复杂、多变,需要灵活的排版能力,并且经常随着业务调整而改变。 单一的技术方案很难同时完美满足这两种需求。因此,我们采用了混合打印架构:
- 标签打印:采用行业标准的 Bartender 软件,通过 WebSocket 调用其客户端的方式实现精准打印。
- 单据打印:采用国产优秀的开源工具 hiprint,通过 React 集成其在线设计器,实现单据模板的可视化设计和动态打印。 后端则通过自定义注解和策略模式统一提供数据源,实现了打印业务与核心逻辑的解耦。
二、功能讲解
打印模版分为:Bartender模板和Hiprint模板。下面来详细说一下打印模板的功能。
2.1 新增模板
点击【新增】按钮,添加一个打印模板
- 模板类型:BarTender、Hiprint,根据需要打印模板选择对应的类型,一般情况下标签打印使用BarTender,单据打印使用Hiprint。
- 物料编码:如果是标签打印需要选择对应的物料信息,客户可以根据相关业务选择。如果打印成品标签,不同客户需要不同的标签格式,则需要选择客户信息。
- 模板文件:使用BarTender作为打印模板,需要在BarTender中设计完另存标签模板 xxx.btw 上传模板文件。如果是Hiprint模版则不需要上传。
- 数据源:选择打印需要的数据源,数据源由后端代码定义下一个章节会具体讲解如何定义数据源。
2.2 设计模板
新增完打印模板之后,我们需要设计模版,在打印模板数据表格中,最右侧操作列点击【设计】按钮。 BarTender模板 如果是使用BarTender作为打印模板,前提需要安装BarTender设计工具和打印客户端。
如果打印服务器启动后,在当前操作系统右下角会显示一个打印的图标。
配置BarTender的数据源
- 点击顶部菜单,选择数据库类型
点击完成之后,左侧栏中,会出现定义的数据源字段。
- 设计模板,把需要的字段拖入到模板中
Hiprint模板 点击完设计之后,会打开Hiprint的设计界面,根据业务单据,从左侧工具栏中把需要的组件拖拽到设计画布上,其中左侧工具栏中--字段为打印模板选择的数据源字段。
2.3 打印
这里讲解一下标签打印的功能,单据打印在对应的单据页面中点击【打印】按钮即可。
标签打印
- 物料编码:选择需要打印标签的物料信息
- 编码规则:选择物料编码的生成规则,在以往的文章[从0到1构建MES系统11-编码规则]中定义的编码规则。
- 打印模板:选择打印模板中的模板
- 批次号:非必填,如果业务上需要批次号则自己填写
- 生产日期:由业务具体需求填写生产日期
- 班组:非必填,如果标签需要记录生产的班组信息由用户自行填写
- 打印数量:需要打印的标签数量
- 打印记录:点击打印之后,可以查看之前打印过的标签信息记录。
三、功能设计
3.1 数据源
这里以物料数据源为例:
/**
* 物料打印数据模板
* @author fwj
* @since 2024/6/28 */@EqualsAndHashCode(callSuper = true)
@Data
@PrintDataTemplate(name = "物料标签", value= "MaterialLabel")
public class MaterialTemplate extends PrintTemplateBase {
@PrintFieldProperty(name = "物料ID")
private Long materialId;
@PrintFieldProperty(name = "物料编码")
private String materialCode;
/**
* 序列号
*/
@PrintFieldProperty(name = "序列号")
private String relId;
/**
* 批次号
*/
@PrintFieldProperty(name = "批次号")
private String lotNo;
@PrintFieldProperty(name = "生产日期")
private String productDate;
/**
* 物料名称
*/
@PrintFieldProperty(name = "物料名称")
private String materialName;
@PrintFieldProperty(name = "规格型号")
private String materialSpec;
@PrintFieldProperty(name = "数量")
private Integer qty;
}
说明:
- 注解 @PrintDataTemplate 定义了数据源名称和数据源的标注(模板设计和打印的的时候会根据标注获取到对应的service类)
- 注解 @PrintFieldProperty 定义数据源字段
系统启动时会扫描包下面的所有定义的数据源加载到缓存中
/**
* 启动时,自动扫描加载打印模板数据结构
* 扫描标有@PrintDataTemplate注解的类
* @author fwj
*/@Component
public class PrintTemplateRegister implements ApplicationRunner, ResourceLoaderAware, EnvironmentAware {
/**
* 资源加载器
*/
private ResourceLoader resourceLoader;
/**
* 环境
*/
private Environment environment;
@Autowired
private RedisCache redisCache;
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
@Override
public void run(ApplicationArguments args) throws Exception {
// 创建scanner
ClassPathScanningCandidateComponentProvider scanner = getScanner();
scanner.setResourceLoader(resourceLoader);
// 设置扫描器scanner扫描的过滤条件
AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(PrintDataTemplate.class);
scanner.addIncludeFilter(annotationTypeFilter);
// 获取指定要扫描的basePackages
Set<String> basePackages = getBasePackages();
// 遍历每一个basePackages
for (String basePackage : basePackages) {
// 通过scanner获取basePackage下的候选类(有标@Domain)
Set<BeanDefinition> candidateComponents = scanner.findCandidateComponents(basePackage);
// 遍历每一个候选类,如果符合条件就把他们注册到容器
List<PrintTemplateDataVO> printTemplateDataVOList = new ArrayList<>();
for (BeanDefinition candidateComponent : candidateComponents) {
if (candidateComponent instanceof AnnotatedBeanDefinition) {
// verify annotated class is an interface
AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
// 获取PrintDataTemplate
Map<String, Object> attributes = annotationMetadata.getAnnotationAttributes(PrintDataTemplate.class.getCanonicalName());
PrintTemplateDataVO printTemplateDataVO = new PrintTemplateDataVO();
printTemplateDataVO.setName((String) attributes.get("name"));
printTemplateDataVO.setValue((String) attributes.get("value"));
printTemplateDataVOList.add(printTemplateDataVO);
// 数据源字段存入缓存
setCacheData(annotationMetadata, attributes);
}
}
redisCache.deleteObject(CacheConstants.PRINT_TEMPLATE_DATA_CACHE_KEY);
redisCache.setCacheList(CacheConstants.PRINT_TEMPLATE_DATA_CACHE_KEY, printTemplateDataVOList);
}
}
private void setCacheData(AnnotationMetadata annotationMetadata,Map<String, Object> attributes) {
// 类名(接口全限定名)
String className = annotationMetadata.getClassName();
ClassLoader classLoader = PrintTemplateRegister.class.getClassLoader();
try {
Class<?> clazz = classLoader.loadClass(className);
Field[] childFields = clazz.getDeclaredFields();
//获取父类字段
Class<?> superclass = clazz.getSuperclass();
Field[] superclassDeclaredFields = superclass.getDeclaredFields();
Field[] fields = ArrayUtils.addAll(childFields, superclassDeclaredFields);
String key = (String) attributes.get("value");
List<PrintTemplateDataField> templateFieldList = new ArrayList<>();
for (Field field : fields) {
PrintTemplateDataField printTemplateDataField = new PrintTemplateDataField();
printTemplateDataField.setObjectName(key);
printTemplateDataField.setFieldName(field.getName());
PrintFieldProperty annotation = field.getAnnotation(PrintFieldProperty.class);
if (annotation != null) {
printTemplateDataField.setDescription(annotation.name());
}
printTemplateDataField.setType(field.getType());
templateFieldList.add(printTemplateDataField);
}
redisCache.deleteObject(CacheConstants.PRINT_TEMPLATE_DATA_FIELD_CACHE_KEY + key);
redisCache.setCacheList(CacheConstants.PRINT_TEMPLATE_DATA_FIELD_CACHE_KEY + key, templateFieldList);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
/**
* 创建扫描器
*/
protected ClassPathScanningCandidateComponentProvider getScanner() {
return new ClassPathScanningCandidateComponentProvider(false, environment) {
@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
boolean isCandidate = false;
if (beanDefinition.getMetadata().isIndependent()) {
if (!beanDefinition.getMetadata().isAnnotation()) {
isCandidate = true;
}
}
return isCandidate;
}
};
}
/**
* 获取base packages
*/ protected static Set<String> getBasePackages() {
Set<String> basePackages = new HashSet<>();
basePackages.add(ClassUtils.getPackageName(MomApplication.class));
return basePackages;
}
}
3.2 打印模板
表设计 tool_print_table (标签打印模板)
| 字段名 | 类型 | 允许空 | 说明 |
|---|---|---|---|
| id | bigint | NO | 主键,自增 |
| name | varchar(256) | NO | 名称 |
| type | varchar(32) | NO | 模板类型 |
| material_id | bigint | NO | 物料ID |
| client_id | bigint | YES | 客户ID |
| template_file_name | varchar(128) | YES | 模板文件名称 |
| template_file | mediumblob | YES | 模板文件 |
| template_json | json | YES | hiprint模板数据 |
| data_source | varchar(128) | NO | 数据源 |
| is_default | tinyint | YES | 是否默认 |
| enable_flag | tinyint | YES | 启用状态 |
| check_sum | varchar(128) | YES | 模板校验码 |
| remark | varchar(128) | YES | 备注 |
| create_by | bigint | YES | 创建人 |
| create_time | datetime | YES | 创建时间 |
| update_by | bigint | YES | 更新人 |
| update_time | datetime | YES | 更新时间 |
说明: BarTender的文件是以二进制的方式存在在数据库,由于BarTender的模版一般比较小,所以我没有使用文件存储的方式存储。
3.3 加载数据源数据
这里分为两个方式分别是:模板设计数据、打印数据 模版设计的数据是自定义了一些Mock数据,打印数据根据业务场景去数据库查询数据。
流程如下:
这里获取数据源数据使用了策略模式。所有实现类都集成接口
PrintTemplateDataService
/**
* 打印模板数据获取类
* @author fwj
* @since 2025/07/17 */public interface PrintTemplateDataService {
/**
* 获取数据字段
*/
List<String> getFields();
/**
* 获取设计数据
* @return 数据
*/
List<Map<String, Object>> getDesignData();
/**
* 获取数据
* @param bizId 业务ID
* @param codeRule 编码规则
* @param qty 打印数量
* @return 数据
*/
List<Map<String, Object>> getDataList(Long bizId,Map<String,String> paramValue, String codeRule, Integer qty);
}
实现类以物料标签数据源为例:
/**
* 物料标签打印数据获取类
*
* @author fwj * @since 2025/7/17 16:11 **/
@Service("MaterialLabelService")
public class MaterialPrintTemplateDateService implements PrintTemplateDataService {
@Autowired
private MaterialService materialService;
@Autowired
private RedisCache redisCache;
@Autowired
private AutoCodeUtil autoCodeUtil;
@Override
public List<String> getFields() {
String key = CacheConstants.PRINT_TEMPLATE_DATA_FIELD_CACHE_KEY + "MaterialLabel";
List<PrintTemplateDataField> fieldList = redisCache.getCacheList(key, PrintTemplateDataField.class);
return fieldList.stream()
.map(PrintTemplateDataField::getFieldName)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
}
@Override
public List<Map<String, Object>> getDesignData() {
MaterialTemplate mockData = new MaterialTemplate();
mockData.setBarcode("PN20250101001");
mockData.setQrcode("PN20250101001");
mockData.setMaterialId(1001L);
mockData.setMaterialCode("M-20250715");
mockData.setRelId("SN-0001");
mockData.setLotNo("LOT-88");
mockData.setMaterialName("测试物料");
mockData.setMaterialSpec("规格A1");
mockData.setQty(100);
mockData.setProductDate("2025-07-01 09:00:00");
Map<String, Object> dataMap = BeanUtils.beanToMap(mockData);
List<Map<String, Object>> dataMapList = new ArrayList<>();
dataMapList.add(dataMap);
return dataMapList;
}
@Override
public List<Map<String, Object>> getDataList(Long bizId,Map<String,String> paramValue, String codeRule, Integer qty) {
Material material = materialService.getById(bizId);
List<String> codeList = autoCodeUtil.batchGenerateCode(codeRule, paramValue, qty);
List<MaterialTemplate> materialTemplateList = new ArrayList<>();
if (!CollectionUtils.isEmpty(codeList)) {
for (String code : codeList) {
MaterialTemplate materialTemplate = new MaterialTemplate();
materialTemplate.setBarcode(code);
materialTemplate.setQrcode(code);
materialTemplate.setMaterialId(material.getId());
materialTemplate.setMaterialCode(material.getMaterialCode());
materialTemplate.setMaterialName(material.getMaterialName());
materialTemplate.setMaterialSpec(material.getSpec());
materialTemplate.setQty(qty);
if (paramValue != null) {
if (paramValue.containsKey("lotNo")) {
materialTemplate.setLotNo(paramValue.get("lotNo"));
}
if (paramValue.containsKey("productDate")) {
materialTemplate.setProductDate(paramValue.get("productDate"));
}
}
materialTemplateList.add(materialTemplate);
}
}
List<Map<String, Object>> dataMapList = BeanUtils.convertMapList(materialTemplateList);
return dataMapList;
}
}
说明:
- 使用 @Service("MaterialLabelService") 来标注类,
MaterialLabel为定义数据源注解@PrintDataTemplate中的value值。getDesignData()方法为打印模板设计时需要的数据,可以自己模拟数据。getDataList()方法为打印需要的数据,根据业务逻辑查询数据库,然后把数据填充到数据源实体类中。
以下拿获取打印数据为例,讲解如何使用策略模式获取打印数据
@Override
@Transactional(rollbackFor = Exception.class)
public TemplateDataVO print(PrintRequestVO printRequestVO) {
// 1. 获取打印模板
PrintTemplate printTemplate = baseMapper.selectById(printRequestVO.getTemplateId());
// 2. 获取打印模板需要的字段
List<PrintTemplateDataField> fieldList = redisCache.getCacheList(CacheConstants.PRINT_TEMPLATE_DATA_FIELD_CACHE_KEY + printTemplate.getDataSource(), PrintTemplateDataField.class);
if (!CollectionUtils.isEmpty(fieldList)) {
// 3. 获取数据源字段
List<String> hearderFieldList = fieldList.stream().map(PrintTemplateDataField::getFieldName).collect(Collectors.toList());
// 4. 获取数据源标注
String dataSource = printTemplate.getDataSource();
// 5. 通过策略获取数据源实现类
PrintTemplateDataService printTemplateDataService = SpringUtils.getBean(dataSource+"DataService");
AutoCodeRule autoCodeRule = autoCodeRuleService.getById(printRequestVO.getRuleCodeId());
// 6. 获取数据
List<Map<String, Object>> dataList = printTemplateDataService.getDataList(printRequestVO.getBizId(), printRequestVO.getParams(), autoCodeRule.getRuleCode(), printRequestVO.getPrintQty());
// 7. 返回前端需要的打印数据格式
TemplateDataVO templateDataVO = new TemplateDataVO();
templateDataVO.setData(ExcelUtil.convertToCsv(hearderFieldList, dataList));
templateDataVO.setHash(printTemplate.getCheckSum());
templateDataVO.setTemplate("/tool/print/template/download/" + printTemplate.getId());
templateDataVO.setTitle(printTemplate.getName());
if (Objects.equals(printTemplate.getType(), "barTender")) {
templateDataVO.setType("btw");
} else if (Objects.equals(printTemplate.getType(), "label")) {
templateDataVO.setType("lpt");
} else if (Objects.equals(printTemplate.getType(), "hiprint")) {
templateDataVO.setType("hiprint");
} else {
templateDataVO.setType("btw");
}
templateDataVO.setRecordList(labelRecordList);
return templateDataVO;
}
return null;
}
说明: 这里重点在于
PrintTemplateDataService printTemplateDataService = SpringUtils.getBean(dataSource+"DataService"); List<Map<String, Object>> dataList = printTemplateDataService.getDataList(printRequestVO.getBizId(), printRequestVO.getParams(), autoCodeRule.getRuleCode(), printRequestVO.getPrintQty());这段代码,通过数据源备注获取对应的数据源数据记录。
3.4 前端实现
BarTender 接入BarTender比较简单,只需要调用后端接口获取到打印数据,再通过Websocket的方式调用本地客户端应用程序,本地应用程序就会调用BarTender打印。
printTemplateService
.print(params)
.then((res) => {
const { recordList, ...rest } = res;
// 修改这里:将新数据与现有数据合并
setPrintRecordList((prevList) => {
const newList = [...prevList, ...recordList];
// 更新分页信息中的总数
setPagination((prev) => ({
...prev,
total: newList.length,
}));
return newList;
});
const clientData = rest;
const protocol = window.location.protocol;
const hostname = window.location.hostname;
const requestUrl = `${protocol}//${hostname}:8001`;
clientData.template = `${requestUrl}${clientData.template}`;
console.log("模板下载地址:", clientData.template);
const ws = new WebSocket("ws://localhost:8086/ws");
ws.onopen = () => {
ws.send(JSON.stringify(clientData));
};
ws.onmessage = () => {
ws.close();
setLoading(false);
};
ws.onclose = () => {
console.log("关闭WebSocket连接!");
setLoading(false);
};
ws.onerror = (evt) => {
console.log(evt);
message.error("打印服务调用失败");
setLoading(false);
};
}).catch((e) => {
console.error("打印失败", e);
setLoading(false);
});
Hiprint
hiprint的集成会单独在另外的章节讲解,如果使用vue集成的话可以参考开源项目 ## vue-plugin-hiprint 这是由于技术选型前端使用Reactjs所以集成的方式有点不太一样,不过和vue-plugin-hiprint也是类似的。
本文源码已上传Gitee 开源项目地址:
欢迎在评论区分享你的技术选型经验,或对本文方案的改进建议!
关注公众号「慧工云创」