从0到1构建MES系统16-打印模板

170 阅读9分钟

今天我们来讲一下打印模板。

一、概述

在制造业工厂环境中,打印需求无处不在且形式多样:

  1. 标签打印:如产品序列号标签、包装箱标签、仓库货位标签等。这类打印通常格式固定,对打印精度和速度要求高,且常需要连接专用的条码打印机。
  2. 单据打印:如工单、领料单、检验报告、入库单等。这类打印格式复杂、多变,需要灵活的排版能力,并且经常随着业务调整而改变。 单一的技术方案很难同时完美满足这两种需求。因此,我们采用了混合打印架构
  • 标签打印:采用行业标准的 Bartender 软件,通过 WebSocket 调用其客户端的方式实现精准打印。
  • 单据打印:采用国产优秀的开源工具 hiprint,通过 React 集成其在线设计器,实现单据模板的可视化设计和动态打印。 后端则通过自定义注解策略模式统一提供数据源,实现了打印业务与核心逻辑的解耦。

二、功能讲解

打印模版分为:Bartender模板和Hiprint模板。下面来详细说一下打印模板的功能。

Pasted image 20250908170449.png

2.1 新增模板

点击【新增】按钮,添加一个打印模板

Pasted image 20250908170528.png

  1. 模板类型:BarTender、Hiprint,根据需要打印模板选择对应的类型,一般情况下标签打印使用BarTender,单据打印使用Hiprint。
  2. 物料编码:如果是标签打印需要选择对应的物料信息,客户可以根据相关业务选择。如果打印成品标签,不同客户需要不同的标签格式,则需要选择客户信息。
  3. 模板文件:使用BarTender作为打印模板,需要在BarTender中设计完另存标签模板 xxx.btw 上传模板文件。如果是Hiprint模版则不需要上传。
  4. 数据源:选择打印需要的数据源,数据源由后端代码定义下一个章节会具体讲解如何定义数据源。

2.2 设计模板

新增完打印模板之后,我们需要设计模版,在打印模板数据表格中,最右侧操作列点击【设计】按钮。 BarTender模板 如果是使用BarTender作为打印模板,前提需要安装BarTender设计工具和打印客户端。

Pasted image 20250908171801.png 如果打印服务器启动后,在当前操作系统右下角会显示一个打印的图标。

配置BarTender的数据源

  1. 点击顶部菜单,选择数据库类型

Pasted image 20250908172000.png

Pasted image 20250908172004.png

Pasted image 20250908172016.png 点击完成之后,左侧栏中,会出现定义的数据源字段。

  1. 设计模板,把需要的字段拖入到模板中

Pasted image 20250908172226.png

Hiprint模板 点击完设计之后,会打开Hiprint的设计界面,根据业务单据,从左侧工具栏中把需要的组件拖拽到设计画布上,其中左侧工具栏中--字段为打印模板选择的数据源字段。

Pasted image 20250908172308.png

2.3 打印

这里讲解一下标签打印的功能,单据打印在对应的单据页面中点击【打印】按钮即可。

标签打印

Pasted image 20250908172654.png

  • 物料编码:选择需要打印标签的物料信息
  • 编码规则:选择物料编码的生成规则,在以往的文章[从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;  
  
}

说明:

  1. 注解 @PrintDataTemplate 定义了数据源名称和数据源的标注(模板设计和打印的的时候会根据标注获取到对应的service类)
  2. 注解 @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 (标签打印模板)

字段名类型允许空说明
idbigintNO主键,自增
namevarchar(256)NO名称
typevarchar(32)NO模板类型
material_idbigintNO物料ID
client_idbigintYES客户ID
template_file_namevarchar(128)YES模板文件名称
template_filemediumblobYES模板文件
template_jsonjsonYEShiprint模板数据
data_sourcevarchar(128)NO数据源
is_defaulttinyintYES是否默认
enable_flagtinyintYES启用状态
check_sumvarchar(128)YES模板校验码
remarkvarchar(128)YES备注
create_bybigintYES创建人
create_timedatetimeYES创建时间
update_bybigintYES更新人
update_timedatetimeYES更新时间

说明: BarTender的文件是以二进制的方式存在在数据库,由于BarTender的模版一般比较小,所以我没有使用文件存储的方式存储。

3.3 加载数据源数据

这里分为两个方式分别是:模板设计数据、打印数据 模版设计的数据是自定义了一些Mock数据,打印数据根据业务场景去数据库查询数据。

流程如下:

Pasted image 20250909155346.png 这里获取数据源数据使用了策略模式。所有实现类都集成接口 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;  
    }  
}

说明:

  1. 使用 @Service("MaterialLabelService") 来标注类, MaterialLabel为定义数据源注解@PrintDataTemplate 中的value值。
  2. getDesignData()方法为打印模板设计时需要的数据,可以自己模拟数据。
  3. 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 开源项目地址

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

关注公众号「慧工云创」

扫码_搜索联合传播样式-标准色版.png