Ver código fonte

OP40镭雕对接改造:从联动镭雕机改为轮询MES队列打印
方案调整:
旧:客户端调镭雕软件接口取码 + 收完成回调 + 本地生成客户码 + 建档 + 打印
新:钢印码由PDA/操作员提交到MES,客户端只做定时拉取+建档+打印+ack

主要改动:
- 删除 LaserClient / LaserCompleteServer / LaserFlowManager 旧实现
- 重写 LaserFlowManager 为轮询模式:定时拉 /mesLaser/pendingPrint
→ 单线程串行 printOne → 建档(sendCreate) → 打印 → ack
→ 自动/手动模式开关,手动模式下队列行按"打印"按钮触发单条
→ 内存 processing 去重防止 ack 前重复消费
- 重写 LaserCardPrintUtil:PDF模板作底图,ZXing叠加二维码 + 两行动态文字
引入 pdfbox 2.0.30 / fontbox / commons-logging
采用预渲染 BufferedImage 方案,避开 PrinterJob Graphics2D 兼容坑(打印全黑)
- DataUtil 新增 submitSteelSn / listPendingPrint / ackPrint 三个HTTP方法
ackPrint 对含 '+' 的 customerSn URL 编码,避免 form-urlencoded 被解析为空格
- 新增 SwitchButton 组件(左右拨动开关,绿色=开)
- 主UI 重构:
· 顶部:自动打印开关 + 待打印数量
· 5行队列:有已打则 Row0 绿框显示,否则 pending 从 Row0 开始
· 每行末尾"打印"按钮,手动模式下点击打印这一条
· 右侧:钢印码输入 + "提交并打印"(等同 PDA 提交入口)
- config.properties 加 print_mode / poll_interval / poll_batch_size / card_template_path
- 内置流转卡模板 config/liuzhuanka_template.pdf

wangxichen 2 semanas atrás
pai
commit
8d573897d9

+ 3 - 0
.classpath

@@ -25,4 +25,7 @@
 	<classpathentry kind="lib" path="lib/jSerialComm-2.6.2.jar"/>
 	<classpathentry kind="lib" path="lib/core-3.5.1.jar"/>
 	<classpathentry kind="lib" path="lib/javase-3.5.1.jar"/>
+	<classpathentry kind="lib" path="lib/pdfbox-2.0.30.jar"/>
+	<classpathentry kind="lib" path="lib/fontbox-2.0.30.jar"/>
+	<classpathentry kind="lib" path="lib/commons-logging-1.2.jar"/>
 </classpath>

BIN
lib/commons-logging-1.2.jar


BIN
lib/fontbox-2.0.30.jar


BIN
lib/pdfbox-2.0.30.jar


+ 84 - 0
src/com/mes/component/SwitchButton.java

@@ -0,0 +1,84 @@
+package com.mes.component;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 左右拨动的开关组件。绿色=开,灰色=关。
+ * 用法:new SwitchButton() → addActionListener(...) → isSelected() 查状态
+ * @author hzd
+ */
+public class SwitchButton extends JComponent {
+
+    private boolean on = false;
+    private final Color colorOn = new Color(46, 160, 67);   // 绿色
+    private final Color colorOff = new Color(190, 190, 190); // 灰色
+    private final Color knob = Color.WHITE;
+    private final List<ActionListener> listeners = new ArrayList<>();
+
+    public SwitchButton(boolean initialOn) {
+        this.on = initialOn;
+        setPreferredSize(new Dimension(80, 40));
+        setOpaque(false);
+        setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+        addMouseListener(new MouseAdapter() {
+            @Override
+            public void mouseClicked(MouseEvent e) {
+                setSelected(!on);
+                fireAction();
+            }
+        });
+    }
+
+    public boolean isSelected() {
+        return on;
+    }
+
+    /** 静默设置状态,不触发 ActionListener */
+    public void setSelected(boolean on) {
+        if (this.on == on) return;
+        this.on = on;
+        repaint();
+    }
+
+    public void addActionListener(ActionListener l) {
+        if (l != null) listeners.add(l);
+    }
+
+    private void fireAction() {
+        ActionEvent evt = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, "toggle");
+        for (ActionListener l : new ArrayList<>(listeners)) l.actionPerformed(evt);
+    }
+
+    @Override
+    protected void paintComponent(Graphics g) {
+        Graphics2D g2 = (Graphics2D) g.create();
+        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        int w = getWidth();
+        int h = getHeight();
+        // 圆角背景
+        g2.setColor(on ? colorOn : colorOff);
+        g2.fillRoundRect(0, 0, w, h, h, h);
+        // 圆钮
+        int d = h - 8;
+        int x = on ? (w - d - 4) : 4;
+        g2.setColor(knob);
+        g2.fillOval(x, 4, d, d);
+        // 文字 ON / OFF
+        g2.setFont(new Font("微软雅黑", Font.BOLD, 13));
+        g2.setColor(Color.WHITE);
+        String text = on ? "ON" : "OFF";
+        FontMetrics fm = g2.getFontMetrics();
+        int tw = fm.stringWidth(text);
+        int textX = on ? 10 : (w - tw - 10);
+        int textY = (h + fm.getAscent()) / 2 - 3;
+        g2.drawString(text, textX, textY);
+        g2.dispose();
+    }
+}

+ 242 - 67
src/com/mes/laser/LaserCardPrintUtil.java

@@ -6,131 +6,306 @@ import com.google.zxing.WriterException;
 import com.google.zxing.common.BitMatrix;
 import com.google.zxing.qrcode.QRCodeWriter;
 import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.rendering.ImageType;
+import org.apache.pdfbox.rendering.PDFRenderer;
 
+import javax.imageio.ImageIO;
 import javax.print.PrintService;
 import javax.print.PrintServiceLookup;
-import javax.print.attribute.HashPrintRequestAttributeSet;
-import javax.print.attribute.PrintRequestAttributeSet;
-import javax.print.attribute.standard.MediaPrintableArea;
 import java.awt.*;
 import java.awt.image.BufferedImage;
 import java.awt.print.PageFormat;
+import java.awt.print.Paper;
 import java.awt.print.Printable;
 import java.awt.print.PrinterJob;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
 
 /**
- * 流转卡打印:二维码内容=客户编码,二维码右侧文字=钢印码+客户编码。
- * 打印机联动逻辑与镭雕供应商无关,纯本地实现。
+ * 流转卡打印:以 PDF 模板为底图,叠加二维码 + 两行动态文字。
+ *
+ * 三个动态元素坐标(从 PDF 里精确提取,单位 pt,A5 纵向 419.5x595.25):
+ * - 左上二维码方框:x=194.5 y=65.4 w=35.7 h=34.6
+ * - 「钢印明码:」冒号后 baseline:x=291.5 y=85.5
+ * - 「二 维 码:」冒号后 baseline:x=291.5 y=96.4
+ *
+ * 打印时按打印页面可用区域等比缩放,PDF 内容居中。
  * @author hzd
  */
 public class LaserCardPrintUtil {
 
-    private static final int QR_CODE_SIZE = 200; // 二维码像素尺寸
+    // PDF 模板尺寸(A5 纵向 148x210mm)
+    private static final float PDF_PAGE_W_PT = 419.5f;
+    private static final float PDF_PAGE_H_PT = 595.25f;
+
+    // 三个动态元素在 PDF 坐标系里的位置(pt,左上原点)
+    private static final float QR_X = 194.5f;
+    private static final float QR_Y = 65.4f;
+    private static final float QR_W = 35.7f;
+    private static final float QR_H = 34.6f;
+
+    // 文字 baseline(Graphics2D.drawString 的 y 参数)
+    private static final float STEEL_TEXT_X = 291.5f;
+    private static final float STEEL_TEXT_Y = 85.5f;
+    private static final float SN_TEXT_X = 291.5f;
+    private static final float SN_TEXT_Y = 96.4f;
+
+    private static final int TEXT_FONT_SIZE = 8;
+
+    // 中文字体候选,按顺序找到系统里第一个可用的
+    private static final String[] FONT_CANDIDATES = {"SimSun", "宋体", "Microsoft YaHei", "微软雅黑", Font.SANS_SERIF};
+
+    // 打印用的底图 DPI;越高越清晰但内存/耗时越大,300 是常规打印标准
+    private static final float PRINT_TEMPLATE_DPI = 300f;
+
+    // 模板 PDF 缓存,程序生命周期内加载一次
+    private static volatile PDDocument templateDoc;
+    private static volatile PDFRenderer templateRenderer;
+    // 预渲染好的底图:Java Print 的 Graphics2D 跟 PDFRenderer.renderPageToGraphics 兼容性差
+    // (会出现整页涂黑),所以打印时改用这张 BufferedImage 直接 drawImage
+    private static volatile BufferedImage templateImage;
+    private static final Object LOCK = new Object();
 
     /**
-     * 打印流转卡
-     * @param printerName 打印机名称,为空则使用系统默认打印机
+     * 打印一张流转卡(新方案入口,模板从 config.properties 的 mes.laser.card_template_path 读取)
+     * @param printerName 打印机名,空则用默认打印机
      * @param steelSn 钢印码
-     * @param customerSn 客户编码(二维码内容)
+     * @param customerSn 客户码(21位,二维码内容)
      * @return true=打印任务已提交
      */
     public static boolean printCard(String printerName, String steelSn, String customerSn) {
-        BufferedImage qrImage = generateQRCode(customerSn);
-        if (qrImage == null) {
-            System.out.println("[流转卡打印] 二维码生成失败,customerSn=" + customerSn);
-            return false;
-        }
+        return printCard(printerName, null, steelSn, customerSn);
+    }
 
-        PrintService selectedService = null;
-        if (printerName != null && !printerName.trim().isEmpty()) {
-            PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
-            selectedService = Arrays.stream(services)
-                    .filter(s -> s.getName().equals(printerName.trim()))
-                    .findFirst().orElse(null);
-            if (selectedService == null) {
-                System.out.println("[流转卡打印] 未找到打印机:" + printerName + ",改用系统默认打印机");
+    public static boolean printCard(String printerName, String templatePath, String steelSn, String customerSn) {
+        try {
+            BufferedImage bgImage = getTemplateImage(templatePath);
+            BufferedImage qr = generateQrCode(customerSn, 600);
+            if (qr == null) {
+                System.out.println("[流转卡] 二维码生成失败 customerSn=" + customerSn);
+                return false;
             }
-        }
 
-        try {
             PrinterJob job = PrinterJob.getPrinterJob();
-            if (selectedService != null) {
-                job.setPrintService(selectedService);
+            PrintService svc = findPrintService(printerName);
+            if (svc != null) {
+                job.setPrintService(svc);
             }
-            job.setPrintable(new CardPrintable(qrImage, steelSn, customerSn));
 
-            int x = 60; // 标签宽度(mm)
-            int y = 40; // 标签高度(mm)
-            PrintRequestAttributeSet aset = new HashPrintRequestAttributeSet();
-            MediaPrintableArea area = new MediaPrintableArea(0, 0, x, y, MediaPrintableArea.MM);
-            aset.add(area);
+            PageFormat pf = buildA5Portrait(job);
+            job.setPrintable(new CardPrintable(bgImage, qr, steelSn, customerSn), pf);
+            job.print();
 
-            job.print(aset);
-            System.out.println("[流转卡打印] 打印完成:steelSn=" + steelSn + " customerSn=" + customerSn);
+            System.out.println("[流转卡] 打印完成 steelSn=" + steelSn + " customerSn=" + customerSn);
             return true;
         } catch (Exception e) {
-            System.out.println("[流转卡打印] 打印失败:" + e.getMessage());
+            System.out.println("[流转卡] 打印失败:" + e.getMessage());
             e.printStackTrace();
             return false;
         }
     }
 
-    private static class CardPrintable implements Printable {
-        private final BufferedImage qrImage;
-        private final String steelSn;
-        private final String customerSn;
+    /** 本地预览(不接打印机也能看效果),dpi 建议 300 */
+    public static boolean previewToPng(String templatePath, String steelSn, String customerSn, String outPath, float dpi) {
+        try {
+            PDFRenderer renderer = getRenderer(templatePath);
+            BufferedImage img = renderer.renderImageWithDPI(0, dpi, ImageType.RGB);
 
-        CardPrintable(BufferedImage qrImage, String steelSn, String customerSn) {
-            this.qrImage = qrImage;
-            this.steelSn = steelSn;
-            this.customerSn = customerSn;
+            Graphics2D g2 = img.createGraphics();
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+            g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
+
+            // 坐标切换到 pt 单位
+            double s = dpi / 72.0;
+            g2.scale(s, s);
+
+            drawDynamic(g2, generateQrCode(customerSn, 600), steelSn, customerSn);
+            g2.dispose();
+
+            File out = new File(outPath);
+            if (out.getParentFile() != null) {
+                out.getParentFile().mkdirs();
+            }
+            ImageIO.write(img, "PNG", out);
+            System.out.println("[流转卡预览] 已输出 " + out.getAbsolutePath()
+                    + " size=" + img.getWidth() + "x" + img.getHeight());
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
         }
+    }
 
-        @Override
-        public int print(Graphics g, PageFormat pf, int page) {
-            if (page > 0) {
-                return Printable.NO_SUCH_PAGE;
+    // ==== 内部实现 ====
+
+    private static PDFRenderer getRenderer(String templatePath) throws IOException {
+        if (templateRenderer != null) return templateRenderer;
+        synchronized (LOCK) {
+            if (templateRenderer == null) {
+                String path = (templatePath == null || templatePath.trim().isEmpty())
+                        ? "config/liuzhuanka_template.pdf" : templatePath.trim();
+                File pdfFile = extractToTemp(path);
+                PDDocument doc = PDDocument.load(pdfFile);
+                templateDoc = doc;
+                templateRenderer = new PDFRenderer(doc);
+                System.out.println("[流转卡] 已加载模板:" + pdfFile.getAbsolutePath());
             }
-            Graphics2D g2 = (Graphics2D) g;
-            g2.translate(pf.getImageableX(), pf.getImageableY());
+        }
+        return templateRenderer;
+    }
 
-            // 左侧画二维码
-            g2.drawImage(qrImage, 0, 0, null);
+    /** 打印用底图:把 PDF 预渲染成 BufferedImage 缓存起来,避免 renderPageToGraphics 在打印机 Graphics 上崩掉 */
+    private static BufferedImage getTemplateImage(String templatePath) throws IOException {
+        if (templateImage != null) return templateImage;
+        synchronized (LOCK) {
+            if (templateImage == null) {
+                PDFRenderer r = getRenderer(templatePath);
+                templateImage = r.renderImageWithDPI(0, PRINT_TEMPLATE_DPI, ImageType.RGB);
+                System.out.println("[流转卡] 底图已预渲染 " + templateImage.getWidth() + "x" + templateImage.getHeight()
+                        + " @" + PRINT_TEMPLATE_DPI + "dpi");
+            }
+        }
+        return templateImage;
+    }
 
-            // 右侧画文字:钢印码 + 客户编码
-            int textX = qrImage.getWidth() + 20;
-            g2.setFont(new Font("微软雅黑", Font.PLAIN, 14));
-            g2.drawString("钢印码:" + steelSn, textX, 40);
-            g2.drawString("客户码:" + customerSn, textX, 65);
+    private static File extractToTemp(String cpPath) throws IOException {
+        File direct = new File(cpPath);
+        if (direct.isFile()) {
+            return direct;
+        }
+        try (InputStream is = ClassLoader.getSystemResourceAsStream(cpPath)) {
+            if (is == null) {
+                throw new IOException("找不到流转卡模板:" + cpPath);
+            }
+            Path tmp = Files.createTempFile("liuzhuanka_template_", ".pdf");
+            tmp.toFile().deleteOnExit();
+            Files.copy(is, tmp, StandardCopyOption.REPLACE_EXISTING);
+            return tmp.toFile();
+        }
+    }
+
+    private static PageFormat buildA5Portrait(PrinterJob job) {
+        PageFormat pf = job.defaultPage();
+        Paper paper = new Paper();
+        double wPt = 148 * 72.0 / 25.4;   // ≈ 419.53
+        double hPt = 210 * 72.0 / 25.4;   // ≈ 595.28
+        paper.setSize(wPt, hPt);
+        double margin = 4 * 72.0 / 25.4;  // 4mm 边距
+        paper.setImageableArea(margin, margin, wPt - 2 * margin, hPt - 2 * margin);
+        pf.setPaper(paper);
+        pf.setOrientation(PageFormat.PORTRAIT);
+        return job.validatePage(pf);
+    }
+
+    private static PrintService findPrintService(String printerName) {
+        if (printerName == null || printerName.trim().isEmpty()) return null;
+        PrintService[] all = PrintServiceLookup.lookupPrintServices(null, null);
+        PrintService s = Arrays.stream(all)
+                .filter(p -> p.getName().equalsIgnoreCase(printerName.trim()))
+                .findFirst().orElse(null);
+        if (s == null) {
+            System.out.println("[流转卡] 未找到打印机 " + printerName + ",改用系统默认");
+        }
+        return s;
+    }
 
-            return Printable.PAGE_EXISTS;
+    private static void drawDynamic(Graphics2D g2, BufferedImage qr, String steelSn, String customerSn) {
+        // 白底盖一下原模板上的二维码占位图
+        g2.setColor(Color.WHITE);
+        g2.fill(new java.awt.geom.Rectangle2D.Float(QR_X, QR_Y, QR_W, QR_H));
+        g2.drawImage(qr, (int) QR_X, (int) QR_Y, (int) QR_W, (int) QR_H, null);
+
+        g2.setColor(Color.BLACK);
+        g2.setFont(pickChineseFont(TEXT_FONT_SIZE));
+        if (steelSn != null) {
+            g2.drawString(steelSn, STEEL_TEXT_X, STEEL_TEXT_Y);
+        }
+        if (customerSn != null) {
+            g2.drawString(customerSn, SN_TEXT_X, SN_TEXT_Y);
         }
     }
 
-    private static BufferedImage generateQRCode(String content) {
+    private static Font pickChineseFont(int size) {
+        String[] available = GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
+        for (String name : FONT_CANDIDATES) {
+            for (String a : available) {
+                if (a.equalsIgnoreCase(name)) {
+                    return new Font(name, Font.BOLD, size);
+                }
+            }
+        }
+        return new Font(Font.SANS_SERIF, Font.BOLD, size);
+    }
+
+    private static BufferedImage generateQrCode(String content, int pixelSize) {
         try {
             Map<EncodeHintType, Object> hints = new HashMap<>();
             hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
             hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
-            hints.put(EncodeHintType.MARGIN, 1);
+            hints.put(EncodeHintType.MARGIN, 0);
 
-            QRCodeWriter qrCodeWriter = new QRCodeWriter();
-            BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_SIZE, QR_CODE_SIZE, hints);
-
-            BufferedImage image = new BufferedImage(QR_CODE_SIZE, QR_CODE_SIZE, BufferedImage.TYPE_INT_RGB);
-            for (int x = 0; x < QR_CODE_SIZE; x++) {
-                for (int y = 0; y < QR_CODE_SIZE; y++) {
-                    image.setRGB(x, y, bitMatrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
+            BitMatrix bm = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, pixelSize, pixelSize, hints);
+            BufferedImage img = new BufferedImage(pixelSize, pixelSize, BufferedImage.TYPE_INT_RGB);
+            for (int x = 0; x < pixelSize; x++) {
+                for (int y = 0; y < pixelSize; y++) {
+                    img.setRGB(x, y, bm.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
                 }
             }
-            return image;
+            return img;
         } catch (WriterException e) {
             e.printStackTrace();
             return null;
         }
     }
 
+    /** 打印任务:底图(预渲染 BufferedImage) + 二维码 + 两行文字 */
+    private static class CardPrintable implements Printable {
+        private final BufferedImage bg;
+        private final BufferedImage qr;
+        private final String steelSn;
+        private final String customerSn;
+
+        CardPrintable(BufferedImage bg, BufferedImage qr, String steelSn, String customerSn) {
+            this.bg = bg;
+            this.qr = qr;
+            this.steelSn = steelSn;
+            this.customerSn = customerSn;
+        }
+
+        @Override
+        public int print(Graphics g, PageFormat pf, int page) {
+            if (page > 0) return NO_SUCH_PAGE;
+            Graphics2D g2 = (Graphics2D) g;
+
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+            g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
+
+            // 把 PDF 页面尺寸(pt)等比缩放到打印区域,居中
+            double sx = pf.getImageableWidth() / PDF_PAGE_W_PT;
+            double sy = pf.getImageableHeight() / PDF_PAGE_H_PT;
+            double scale = Math.min(sx, sy);
+            double tx = pf.getImageableX() + (pf.getImageableWidth() - PDF_PAGE_W_PT * scale) / 2.0;
+            double ty = pf.getImageableY() + (pf.getImageableHeight() - PDF_PAGE_H_PT * scale) / 2.0;
+
+            g2.translate(tx, ty);
+            g2.scale(scale, scale);
+
+            // 底图 BufferedImage 是 A5 @ 300dpi ≈ 1747x2480 像素,drawImage 到 pt 坐标 419.5x595.25
+            // 会自动做高质量重采样,避免 PDFBox 直接渲染到打印 Graphics 时出现的黑屏问题
+            g2.drawImage(bg, 0, 0, (int) Math.ceil(PDF_PAGE_W_PT), (int) Math.ceil(PDF_PAGE_H_PT), null);
+
+            drawDynamic(g2, qr, steelSn, customerSn);
+            return PAGE_EXISTS;
+        }
+    }
 }

+ 0 - 113
src/com/mes/laser/LaserClient.java

@@ -1,113 +0,0 @@
-package com.mes.laser;
-
-import com.alibaba.fastjson2.JSONObject;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-
-/**
- * 镭雕软件HTTP接口调用(接口1获取待镭雕编码、接口2开始镭雕)
- * 都是MES客户端主动调用镭雕软件提供的接口,JSON格式。
- * @author hzd
- */
-public class LaserClient {
-
-    /**
-     * 接口1:获取待镭雕编码
-     * @param baseUrl 镭雕软件服务地址
-     * @param path 接口路径
-     * @param lineSn 产线编号
-     * @param oprno 工位号
-     * @param timeoutMs 超时时间
-     * @return 成功返回镭雕软件的data对象(含codeId/code),失败返回null
-     */
-    public static JSONObject getCode(String baseUrl, String path, String lineSn, String oprno, int timeoutMs) {
-        JSONObject req = new JSONObject();
-        req.put("lineSn", lineSn);
-        req.put("oprno", oprno);
-
-        JSONObject resp = postJson(baseUrl + path, req, timeoutMs);
-        if (resp == null) {
-            return null;
-        }
-        Boolean success = resp.getBoolean("success");
-        if (success == null || !success) {
-            return null;
-        }
-        return resp.getJSONObject("data");
-    }
-
-    /**
-     * 接口2:开始镭雕
-     * @return true=镭雕软件成功接收开始指令(不代表打码已完成)
-     */
-    public static boolean start(String baseUrl, String path, String codeId, String code, int timeoutMs) {
-        JSONObject req = new JSONObject();
-        req.put("codeId", codeId);
-        req.put("code", code);
-
-        JSONObject resp = postJson(baseUrl + path, req, timeoutMs);
-        if (resp == null) {
-            return false;
-        }
-        Boolean success = resp.getBoolean("success");
-        return success != null && success;
-    }
-
-    /**
-     * 发送HTTP POST JSON请求,返回解析后的JSON对象,异常/失败返回null
-     */
-    private static JSONObject postJson(String urlStr, JSONObject body, int timeoutMs) {
-        HttpURLConnection conn = null;
-        try {
-            URL url = new URL(urlStr);
-            conn = (HttpURLConnection) url.openConnection();
-            conn.setRequestMethod("POST");
-            conn.setConnectTimeout(timeoutMs);
-            conn.setReadTimeout(timeoutMs);
-            conn.setDoOutput(true);
-            conn.setDoInput(true);
-            conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
-
-            byte[] data = body.toJSONString().getBytes(StandardCharsets.UTF_8);
-            try (OutputStream os = conn.getOutputStream()) {
-                os.write(data);
-            }
-
-            int code = conn.getResponseCode();
-            InputStream is = (code == 200) ? conn.getInputStream() : conn.getErrorStream();
-            if (is == null) {
-                return null;
-            }
-            StringBuilder sb = new StringBuilder();
-            try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
-                String line;
-                while ((line = br.readLine()) != null) {
-                    sb.append(line);
-                }
-            }
-            System.out.println("[镭雕接口] url=" + urlStr + " req=" + body.toJSONString() + " resp=" + sb);
-            if (code != 200) {
-                return null;
-            }
-            return JSONObject.parseObject(sb.toString());
-        } catch (IOException e) {
-            System.out.println("[镭雕接口] 调用失败:url=" + urlStr + " error=" + e.getMessage());
-            return null;
-        } catch (Exception e) {
-            e.printStackTrace();
-            return null;
-        } finally {
-            if (conn != null) {
-                conn.disconnect();
-            }
-        }
-    }
-
-}

+ 0 - 114
src/com/mes/laser/LaserCompleteServer.java

@@ -1,114 +0,0 @@
-package com.mes.laser;
-
-import com.alibaba.fastjson2.JSONObject;
-import com.sun.net.httpserver.HttpExchange;
-import com.sun.net.httpserver.HttpHandler;
-import com.sun.net.httpserver.HttpServer;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
-
-/**
- * 接口3:镭雕完成上报本地HTTP服务
- * MES客户端起一个本地HTTP服务,接收镭雕软件主动调用的完成上报。
- * @author hzd
- */
-public class LaserCompleteServer {
-
-    private HttpServer httpServer;
-    private final LaserCompleteHandler handler;
-
-    public interface LaserCompleteHandler {
-        /**
-         * 收到镭雕完成上报后回调
-         * @param codeId 对应的请求标识
-         * @param code 钢印码
-         * @param result OK/NG
-         * @param errorMsg 失败原因
-         */
-        void onComplete(String codeId, String code, String result, String errorMsg);
-    }
-
-    public LaserCompleteServer(LaserCompleteHandler handler) {
-        this.handler = handler;
-    }
-
-    public void start(int port) {
-        try {
-            httpServer = HttpServer.create(new InetSocketAddress(port), 0);
-            httpServer.createContext("/mes/laser/complete", new CompleteHandler());
-            httpServer.setExecutor(null);
-            httpServer.start();
-            System.out.println("[镭雕完成上报服务] 已启动,端口=" + port);
-        } catch (IOException e) {
-            System.out.println("[镭雕完成上报服务] 启动失败:" + e.getMessage());
-            e.printStackTrace();
-        }
-    }
-
-    public void stop() {
-        if (httpServer != null) {
-            httpServer.stop(0);
-        }
-    }
-
-    private class CompleteHandler implements HttpHandler {
-        @Override
-        public void handle(HttpExchange exchange) throws IOException {
-            JSONObject resp = new JSONObject();
-            try {
-                if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
-                    resp.put("success", false);
-                    resp.put("message", "只支持POST");
-                    writeResp(exchange, 405, resp);
-                    return;
-                }
-
-                String body = readBody(exchange.getRequestBody());
-                System.out.println("[镭雕完成上报] 收到:" + body);
-                JSONObject req = JSONObject.parseObject(body);
-
-                String codeId = req.getString("codeId");
-                String code = req.getString("code");
-                String result = req.getString("result");
-                String errorMsg = req.getString("errorMsg");
-
-                if (handler != null) {
-                    handler.onComplete(codeId, code, result, errorMsg);
-                }
-
-                resp.put("success", true);
-                resp.put("message", "已接收");
-                writeResp(exchange, 200, resp);
-            } catch (Exception e) {
-                e.printStackTrace();
-                resp.put("success", false);
-                resp.put("message", "处理失败:" + e.getMessage());
-                writeResp(exchange, 200, resp);
-            }
-        }
-    }
-
-    private static String readBody(InputStream is) throws IOException {
-        StringBuilder sb = new StringBuilder();
-        byte[] buf = new byte[1024];
-        int len;
-        while ((len = is.read(buf)) != -1) {
-            sb.append(new String(buf, 0, len, StandardCharsets.UTF_8));
-        }
-        return sb.toString();
-    }
-
-    private static void writeResp(HttpExchange exchange, int code, JSONObject resp) throws IOException {
-        byte[] data = resp.toJSONString().getBytes(StandardCharsets.UTF_8);
-        exchange.getResponseHeaders().set("Content-Type", "application/json;charset=UTF-8");
-        exchange.sendResponseHeaders(code, data.length);
-        try (OutputStream os = exchange.getResponseBody()) {
-            os.write(data);
-        }
-    }
-
-}

+ 239 - 219
src/com/mes/laser/LaserFlowManager.java

@@ -1,57 +1,97 @@
 package com.mes.laser;
 
+import com.alibaba.fastjson2.JSONArray;
 import com.alibaba.fastjson2.JSONObject;
 import com.mes.ui.DataUtil;
 
 import java.io.BufferedReader;
-import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.util.Properties;
-import java.util.Timer;
-import java.util.TimerTask;
-import java.util.UUID;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 
 /**
- * 镭雕整体流程状态机:
- * 获取镭雕码 -> 回显钢印码 -> 开始镭雕 -> (镭雕软件异步)等待完成上报
- * -> 生成客户编码并绑定 -> 覆盖回显为客户编码 -> 按op040逻辑提交建档 -> 打印流转卡 -> 解锁
+ * 镭雕流程管理器 —— 新方案:客户端不再联动镭雕机,改为定时轮询 mescloud
+ * 拉取本产线待打印列表 → 建档 → 打印流转卡 → 上报 ack。
  *
- * 从点击获取镭雕码到收到完成信号之间,按钮必须锁死,不允许重复触发。
+ * 类名和公共 API 保持与旧版一致,MesClient.java 里的调用点不用改。
+ * 旧的"获取镭雕码 / 完成回调 / 模拟"等语义在新流程下已经没有意义,
+ * 相关方法保留但退化为空操作或转发到轮询开关。
+ *
+ * 数据流:定时器 → GET /mesLaser/pendingPrint → 内存 Set 去重 → 单线程消费
+ *        → onSubmitResult 建档(走 sendCreate 报文)→ LaserCardPrintUtil.printCard
+ *        → POST /mesLaser/printAck
  * @author hzd
  */
 public class LaserFlowManager {
 
     public interface UiCallback {
+        /** 状态栏提示,error=true 走错误样式 */
         void onStatus(String msg, boolean error);
+        /** 打印开始/结束时的按钮锁定信号,UI 用来防止操作员打印进行中重复点击 */
         void onLockButton(boolean lock);
-        // 把code内容显示到扫码框:先显示钢印码,完成后覆盖显示客户编码
+        /** 客户码回显到扫码框 */
         void onCodeReceived(String code);
-        // 按op040既有逻辑提交建档(复用finish_ok_bt里的sendCreate),返回是否提交成功
+        /** 客户端建档回调(走 op040 sendCreate 报文),true=建档成功 */
         boolean onSubmitResult(String customerSn, String qret);
+        /** 待打印数量变化通知,UI 用来更新 pending label */
+        void onPendingCount(int count);
+        /**
+         * 队列快照更新:3 行队列
+         * rows[0] = 最近打完的一条(done=true,UI 显示绿框),首次启动没打过时为 null
+         * rows[1..N] = 服务端 pending 最早的几条(done=false,UI 显示灰框)
+         */
+        void onQueueUpdated(java.util.List<QueueRow> rows);
     }
 
-    private final UiCallback uiCallback;
-    private final ExecutorService executor = Executors.newSingleThreadExecutor();
-    private LaserCompleteServer completeServer;
-
-    // 当前进行中的请求标识和对应的待镭雕码,用于校验完成上报是否对应本次请求,
-    // 也用于"模拟完成"调试按钮直接复用(真实回调则由镭雕软件POST传入,不依赖这两个字段)
-    private volatile String currentCodeId = null;
-    private volatile String currentCode = null;
-    private volatile boolean waitingComplete = false;
-    private Timer completeTimeoutTimer;
+    /** 队列行数据:钢印码 + 客户码 + 是否已打印 */
+    public static class QueueRow {
+        public final String steelSn;
+        public final String customerSn;
+        public final boolean done;
+        public QueueRow(String steelSn, String customerSn, boolean done) {
+            this.steelSn = steelSn;
+            this.customerSn = customerSn;
+            this.done = done;
+        }
+    }
 
-    private String laserServerUrl;
-    private String getCodePath = "/laser/getCode";
-    private String startPath = "/laser/start";
-    private int laserTimeout = 15000;
-    private int localPort = 9002;
-    private int completeTimeoutMs = 60000;
+    private final UiCallback uiCallback;
+    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+        Thread t = new Thread(r, "LaserPoll-Scheduler");
+        t.setDaemon(true);
+        return t;
+    });
+    // 单线程打印,避免并发抢打印机队列,也保证扫码框回显顺序
+    private final ExecutorService printerExecutor = Executors.newSingleThreadExecutor(r -> {
+        Thread t = new Thread(r, "LaserPoll-Printer");
+        t.setDaemon(true);
+        return t;
+    });
+    // 内存去重:已经在处理(打印中/待 ack)的客户码,防止 ack 之前被同一次拉取里的下一批重复消费
+    private final Set<String> processing = ConcurrentHashMap.newKeySet();
+
+    // 队列快照:最近打完的一条 + 上次轮询拿到的 pending 列表
+    private volatile QueueRow lastPrintedRow = null;
+    private volatile java.util.List<QueueRow> lastPending = new java.util.ArrayList<>();
+
+    // paused = true 等价于手动模式(轮询定时器空转)
+    private volatile boolean paused = false;
+    private volatile boolean running = false;
+
+    // ==== 配置项 ====
+    private String lineSn = "T9";
     private String prodCode = "T09";
+    private long pollIntervalMs = 3000;
+    private int batchSize = 10;
     private String printerName = "";
+    // 启动时默认模式:auto=自动轮询打印,manual=只在点击后打印一次
+    private String defaultMode = "manual";
 
     public LaserFlowManager(UiCallback uiCallback) {
         this.uiCallback = uiCallback;
@@ -59,246 +99,226 @@ public class LaserFlowManager {
     }
 
     private void loadConfig() {
-        try {
-            InputStream is = ClassLoader.getSystemResourceAsStream("config/config.properties");
+        try (InputStream is = ClassLoader.getSystemResourceAsStream("config/config.properties")) {
+            if (is == null) return;
             Properties pro = new Properties();
-            BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
-            pro.load(br);
-            laserServerUrl = pro.getProperty("mes.laser.server_url", "").trim();
-            laserTimeout = Integer.parseInt(pro.getProperty("mes.laser.timeout", "15000").trim());
-            localPort = Integer.parseInt(pro.getProperty("mes.laser.local_port", "9002").trim());
-            completeTimeoutMs = Integer.parseInt(pro.getProperty("mes.laser.complete_timeout", "60000").trim());
+            try (BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"))) {
+                pro.load(br);
+            }
+            lineSn = pro.getProperty("mes.line_sn", "T9").trim();
             prodCode = pro.getProperty("mes.prod_code", "T09").trim();
+            pollIntervalMs = Long.parseLong(pro.getProperty("mes.laser.poll_interval", "3000").trim());
+            batchSize = Integer.parseInt(pro.getProperty("mes.laser.poll_batch_size", "10").trim());
             printerName = pro.getProperty("mes.laser.printer_name", "").trim();
+            defaultMode = pro.getProperty("mes.laser.print_mode", "manual").trim().toLowerCase();
+            paused = "manual".equals(defaultMode);
         } catch (Exception e) {
             e.printStackTrace();
         }
     }
 
-    /**
-     * 启动本地HTTP服务,接收镭雕软件的完成上报(应用启动时调用一次)
-     */
+    // ==== 对外 API(兼容 MesClient.java 原调用点)====
+
+    /** 应用启动时调一次。旧名字沿用,实际启动的是轮询定时器 */
     public void startCompleteServer() {
-        completeServer = new LaserCompleteServer(this::handleComplete);
-        completeServer.start(localPort);
+        if (running) return;
+        running = true;
+        scheduler.scheduleWithFixedDelay(this::pollOnce, 500, pollIntervalMs, TimeUnit.MILLISECONDS);
+        String mode = paused ? "手动模式(点按钮打一次)" : "自动模式(间隔 " + pollIntervalMs + " ms)";
+        uiCallback.onStatus("镭雕轮询已启动 - " + mode, false);
     }
 
-    public boolean isWaitingComplete() {
-        return waitingComplete;
-    }
+    /** 应用关闭时调一次 */
     public void stopCompleteServer() {
-        if (completeServer != null) {
-            completeServer.stop();
-        }
+        running = false;
+        scheduler.shutdownNow();
+        printerExecutor.shutdown();
     }
 
     /**
-     * 点击"获取镭雕码"按钮触发的入口
-     * @param oprno 工位号
-     * @param lineSn 产线编号
+     * 旧 UI 用来判断"镭雕进行中"以禁用按钮。新方案下轮询是常态、单件打印是异步,
+     * 直接返回是否有正在处理的记录即可。为了不误锁 UI,保守返回 false。
      */
-    public void startLaserFlow(String oprno, String lineSn) {
-        if (waitingComplete) {
-            uiCallback.onStatus("镭雕进行中,请等待完成", true);
-            return;
-        }
-        uiCallback.onLockButton(true);
-        uiCallback.onStatus("正在获取镭雕码...", false);
+    public boolean isWaitingComplete() {
+        return false;
+    }
 
-        executor.submit(() -> {
-            JSONObject codeData = LaserClient.getCode(laserServerUrl, getCodePath, lineSn, oprno, laserTimeout);
-            if (codeData == null) {
-                uiCallback.onStatus("获取镭雕码失败,请重试", true);
-                uiCallback.onLockButton(false);
-                return;
-            }
-            String codeId = codeData.getString("codeId");
-            String code = codeData.getString("code");
-            if (codeId == null || code == null) {
-                uiCallback.onStatus("获取镭雕码返回数据不完整", true);
-                uiCallback.onLockButton(false);
-                return;
-            }
+    /** 设置自动打印开关:true=开启定时轮询自动打印,false=只更新 UI 不自动打(靠队列行按钮打) */
+    public void setAutoMode(boolean auto) {
+        paused = !auto;
+        uiCallback.onStatus(auto ? "自动打印:开" : "自动打印:关", false);
+    }
 
-            // 回显钢印码到扫码框
-            uiCallback.onCodeReceived(code);
-            uiCallback.onStatus("已获取镭雕码,正在发送开始信号...", false);
+    public boolean isAutoMode() {
+        return !paused;
+    }
 
-            boolean startOk = false;
-            // 接口2失败重试2-3次
-            for (int i = 0; i < 3 && !startOk; i++) {
-                startOk = LaserClient.start(laserServerUrl, startPath, codeId, code, laserTimeout);
-                if (!startOk && i < 2) {
-                    try { Thread.sleep(500); } catch (InterruptedException ignored) {}
+    /** 提交钢印码并打印,独立于轮询队列。操作员手工输入 steelSn 后调用 */
+    public void submitAndPrint(String steelSn) {
+        if (steelSn == null || steelSn.trim().isEmpty()) {
+            uiCallback.onStatus("请输入钢印码", true);
+            return;
+        }
+        final String ss = steelSn.trim();
+        printerExecutor.submit(() -> {
+            uiCallback.onLockButton(true);
+            try {
+                uiCallback.onStatus("正在提交钢印码到服务端:" + ss, false);
+                JSONObject resp = DataUtil.submitSteelSn(prodCode, ss);
+                if (resp == null || resp.get("result") == null
+                        || !"true".equalsIgnoreCase(resp.get("result").toString())) {
+                    String msg = resp != null && resp.get("message") != null
+                            ? resp.get("message").toString() : "服务端无响应";
+                    uiCallback.onStatus("提交失败:" + msg, true);
+                    return;
                 }
-            }
-
-            if (!startOk) {
-                uiCallback.onStatus("发送开始镭雕信号失败,请人工确认设备状态后重试", true);
+                JSONObject data = resp.getJSONObject("data");
+                String customerSn = data == null ? null : data.getString("customerSn");
+                if (customerSn == null || customerSn.isEmpty()) {
+                    uiCallback.onStatus("服务端未返回客户码,无法打印", true);
+                    return;
+                }
+                // 回显客户码到扫码框
+                uiCallback.onCodeReceived(customerSn);
+                uiCallback.onStatus("已入库,正在建档并打印:" + customerSn, false);
+
+                // 直接打印(此时已在 printerExecutor 单线程里,直接 printOne 串行执行)
+                if (!processing.add(customerSn)) return;
+                try {
+                    printOne(customerSn, ss);
+                } finally {
+                    processing.remove(customerSn);
+                }
+            } finally {
                 uiCallback.onLockButton(false);
-                return;
             }
-
-            // 进入等待完成状态
-            currentCodeId = codeId;
-            currentCode = code;
-            waitingComplete = true;
-            uiCallback.onStatus("镭雕中,请等待完成信号...", false);
-            startCompleteTimeoutTimer(codeId);
         });
     }
 
-    // 完成信号超时定时器:超时后视为该codeId废弃,允许人工复位解锁
-    private void startCompleteTimeoutTimer(String codeId) {
-        if (completeTimeoutTimer != null) {
-            completeTimeoutTimer.cancel();
-        }
-        completeTimeoutTimer = new Timer();
-        completeTimeoutTimer.schedule(new TimerTask() {
-            @Override
-            public void run() {
-                if (waitingComplete && codeId.equals(currentCodeId)) {
-                    uiCallback.onStatus("镭雕完成信号超时,可点击复位后重试", true);
-                }
-            }
-        }, completeTimeoutMs);
-    }
 
-    /**
-     * 人工复位:超时或异常时,操作员手动点击解锁按钮
-     */
-    public void manualReset() {
-        currentCodeId = null;
-        currentCode = null;
-        waitingComplete = false;
-        if (completeTimeoutTimer != null) {
-            completeTimeoutTimer.cancel();
-        }
-        uiCallback.onLockButton(false);
-        uiCallback.onStatus("已复位,可重新获取镭雕码", false);
+
+    // ==== 内部实现 ====
+
+    /** 定时器每 pollIntervalMs 触发一次。无论开关状态都拉数据更新 UI,只有开关 ON 时才自动打印 */
+    private void pollOnce() {
+        pollBatch(batchSize, !paused);
     }
 
     /**
-     * 调试用:模拟"获取镭雕码+开始镭雕"整个前半段,完全绕开真实HTTP请求。
-     * 供应商接口未接通前,用于本地走通整体流程:直接进入"镭雕中"状态,
-     * 之后可以点"模拟完成信号"按钮继续走完后半段。
-     * @param steelSn 模拟的钢印码,传空则自动生成一个测试用码
+     * 拉待打印列表并更新 UI 队列;printAll=true 时把拉到的每一条都送去打印(仅自动模式用)
      */
-    public void simulateGetCode(String steelSn) {
-        if (waitingComplete) {
-            uiCallback.onStatus("镭雕进行中,请等待完成或先复位", true);
-            return;
-        }
-        String code = (steelSn == null || steelSn.trim().isEmpty())
-                ? "JS" + System.currentTimeMillis() % 1000000000L
-                : steelSn.trim();
-        String codeId = UUID.randomUUID().toString();
+    private void pollBatch(int limit, boolean printAll) {
+        try {
+            JSONObject resp = DataUtil.listPendingPrint(lineSn, limit);
+            if (resp == null || resp.get("result") == null
+                    || !"true".equalsIgnoreCase(resp.get("result").toString())) {
+                return;
+            }
+            Object dataObj = resp.get("data");
+            if (!(dataObj instanceof JSONArray)) return;
+            JSONArray list = (JSONArray) dataObj;
+            uiCallback.onPendingCount(list.size());
+            // 更新 pending 快照供 UI 展示:取前 5 条足够填 5 行 UI(即使 Row0 让给了 lastPrinted)
+            java.util.List<QueueRow> newPending = new java.util.ArrayList<>();
+            for (int i = 0; i < Math.min(5, list.size()); i++) {
+                JSONObject r = list.getJSONObject(i);
+                newPending.add(new QueueRow(r.getString("steel_sn"), r.getString("customer_sn"), false));
+            }
+            lastPending = newPending;
+            pushQueueSnapshot();
 
-        uiCallback.onCodeReceived(code);
-        uiCallback.onLockButton(true);
+            if (!printAll || list.isEmpty()) return;
 
-        currentCodeId = codeId;
-        currentCode = code;
-        waitingComplete = true;
-        uiCallback.onStatus("[调试]已模拟获取镭雕码并发送开始信号,镭雕中,可点模拟完成信号", false);
-        startCompleteTimeoutTimer(codeId);
+            // 自动模式:把拉到的每一条都送去打印(内存 processing 去重防止重复)
+            for (int i = 0; i < list.size(); i++) {
+                JSONObject row = list.getJSONObject(i);
+                submitPrintJob(row.getString("customer_sn"), row.getString("steel_sn"));
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
     }
 
-    /**
-     * 调试用:模拟镭雕软件的完成回调(供应商真实接口未接通前,用于走通整体流程)。
-     * 使用当前进行中的codeId/code,等效于镭雕软件真实POST了完成上报。
-     * @param result OK/NG
-     * @param errorMsg 失败原因(result=NG时使用)
-     */
-    public void simulateComplete(String result, String errorMsg) {
-        if (!waitingComplete || currentCodeId == null) {
-            uiCallback.onStatus("当前没有进行中的镭雕请求,无法模拟完成信号", true);
+    /** 打印指定的一条:队列 UI 行点「打印」按钮走这里 */
+    public void printSpecific(String customerSn, String steelSn) {
+        if (customerSn == null || customerSn.isEmpty() || steelSn == null || steelSn.isEmpty()) {
+            uiCallback.onStatus("参数缺失,无法打印", true);
             return;
         }
-        System.out.println("[镭雕流程-调试] 模拟完成信号:codeId=" + currentCodeId + " code=" + currentCode + " result=" + result);
-        handleComplete(currentCodeId, currentCode, result, errorMsg);
+        submitPrintJob(customerSn, steelSn);
     }
 
-    /**
-     * 调试用:单独测试"生成客户编码"接口,不依赖整体流程状态,不做提交/打印。
-     * @param steelSn 测试用钢印码
-     */
-    public void manualGenCustomerSn(String steelSn) {
-        if (steelSn == null || steelSn.trim().isEmpty()) {
-            uiCallback.onStatus("请输入测试用钢印码", true);
-            return;
-        }
-        uiCallback.onStatus("正在测试生成客户编码...", false);
-        executor.submit(() -> {
-            JSONObject genResp = DataUtil.genCustomerSn(prodCode, steelSn.trim());
-            if (genResp == null || genResp.get("result") == null
-                    || !"true".equalsIgnoreCase(genResp.get("result").toString())) {
-                String msg = genResp != null && genResp.get("message") != null
-                        ? genResp.get("message").toString() : "生成客户编码失败";
-                uiCallback.onStatus("测试失败:" + msg, true);
-                return;
+    /** 把一次打印任务塞到单线程 executor,内存去重防止同一 sn 并发打两次 */
+    private void submitPrintJob(String customerSn, String steelSn) {
+        if (customerSn == null || steelSn == null) return;
+        if (!processing.add(customerSn)) return;
+        printerExecutor.submit(() -> {
+            try {
+                uiCallback.onLockButton(true);
+                uiCallback.onCodeReceived(customerSn);
+                uiCallback.onStatus("正在建档并打印:" + customerSn, false);
+                printOne(customerSn, steelSn);
+            } finally {
+                processing.remove(customerSn);
+                uiCallback.onLockButton(false);
             }
-            String customerSn = genResp.get("data") == null ? "" : genResp.get("data").toString();
-            uiCallback.onCodeReceived(customerSn);
-            uiCallback.onStatus("测试生成客户编码成功:" + customerSn, false);
         });
     }
 
-    // 镭雕软件调用接口3上报完成结果的回调(真实HTTP回调 或 模拟按钮 都走这里)
-    private void handleComplete(String codeId, String steelSn, String result, String errorMsg) {
-        if (completeTimeoutTimer != null) {
-            completeTimeoutTimer.cancel();
-        }
-
-        if (!waitingComplete || codeId == null || !codeId.equals(currentCodeId)) {
-            // 迟到的旧回调或未处于等待状态,直接忽略(该codeId已被判定废弃或未曾发起)
-            System.out.println("[镭雕流程] 忽略无效/迟到的完成上报,codeId=" + codeId);
-            return;
-        }
-
-        waitingComplete = false;
-        currentCodeId = null;
-        currentCode = null;
-
-        if (!"OK".equalsIgnoreCase(result)) {
-            uiCallback.onStatus("镭雕失败:" + (errorMsg == null ? "" : errorMsg), true);
-            uiCallback.onLockButton(false);
+    private void printOne(String customerSn, String steelSn) {
+        // 1. 建档(走 op040 现有 sendCreate 报文)
+        boolean buildOk = uiCallback.onSubmitResult(customerSn, "OK");
+        if (!buildOk) {
+            uiCallback.onStatus("建档失败:" + customerSn + ",下次继续重试", true);
+            DataUtil.ackPrint(customerSn, "NG", "客户端建档失败");
             return;
         }
-
-        uiCallback.onStatus("镭雕完成,正在生成客户编码...", false);
-
-        executor.submit(() -> {
-            JSONObject genResp = DataUtil.genCustomerSn(prodCode, steelSn);
-            if (genResp == null || genResp.get("result") == null
-                    || !"true".equalsIgnoreCase(genResp.get("result").toString())) {
-                String msg = genResp != null && genResp.get("message") != null
-                        ? genResp.get("message").toString() : "生成客户编码失败";
-                uiCallback.onStatus(msg, true);
-                uiCallback.onLockButton(false);
-                return;
+        // 2. 打印流转卡(PrinterJob.print 返回 = 任务提交系统队列成功 = 视为完成)
+        boolean printOk = LaserCardPrintUtil.printCard(printerName, steelSn, customerSn);
+        // 3. ack:让服务端把 print_status 从 0 更新为 1
+        if (printOk) {
+            boolean ackOk = DataUtil.ackPrint(customerSn, "OK", null);
+            if (ackOk) {
+                uiCallback.onStatus("打印完成并已入库:" + customerSn, false);
+            } else {
+                uiCallback.onStatus("打印完成但 ack 失败(服务端会保留待打印状态):" + customerSn, true);
             }
-
-            String customerSn = genResp.get("data") == null ? "" : genResp.get("data").toString();
-            // 覆盖回显为客户编码,随后按此编码提交建档
-            uiCallback.onCodeReceived(customerSn);
-
-            boolean submitOk = uiCallback.onSubmitResult(customerSn, "OK");
-            if (!submitOk) {
-                uiCallback.onStatus("客户编码生成成功,但提交MES建档失败:" + customerSn, true);
-                uiCallback.onLockButton(false);
-                return;
+            // 顶部绿框显示刚打完的这条
+            lastPrintedRow = new QueueRow(steelSn, customerSn, true);
+            // 先清空 lastPending 里跟刚打完这条重复的记录,避免 UI 出现 "已打印+待打印" 两行内容一样
+            java.util.List<QueueRow> filtered = new java.util.ArrayList<>();
+            for (QueueRow r : lastPending) {
+                if (r != null && !customerSn.equals(r.customerSn)) filtered.add(r);
             }
+            lastPending = filtered;
+            pushQueueSnapshot();
+            // 立即触发一次刷新(printAll=false 只更新 UI 不打印,避免重复打)
+            scheduler.execute(() -> pollBatch(batchSize, false));
+        } else {
+            DataUtil.ackPrint(customerSn, "NG", "客户端打印失败");
+            uiCallback.onStatus("打印失败:" + customerSn, true);
+        }
+    }
 
-            boolean printOk = LaserCardPrintUtil.printCard(printerName, steelSn, customerSn);
-            if (!printOk) {
-                uiCallback.onStatus("已提交建档,但流转卡打印失败:" + customerSn, true);
-            } else {
-                uiCallback.onStatus("流转卡打印完成,可继续下一件", false);
+    /**
+     * 合成 5 行队列推给 UI。策略:
+     *   有 lastPrinted:Row 0 = lastPrinted(绿框),Row 1-4 = pending[0..3]
+     *   没有:Row 0-4 全部是 pending[0..4]("只有 1 条就在第一行")
+     */
+    private void pushQueueSnapshot() {
+        final int total = 5;
+        java.util.List<QueueRow> rows = new java.util.ArrayList<>(total);
+        java.util.List<QueueRow> p = lastPending;
+        if (lastPrintedRow != null) {
+            rows.add(lastPrintedRow);
+            for (int i = 0; i < total - 1; i++) {
+                rows.add(i < p.size() ? p.get(i) : null);
             }
-
-            uiCallback.onLockButton(false);
-        });
+        } else {
+            for (int i = 0; i < total; i++) {
+                rows.add(i < p.size() ? p.get(i) : null);
+            }
+        }
+        uiCallback.onQueueUpdated(rows);
     }
-
 }

+ 72 - 0
src/com/mes/ui/DataUtil.java

@@ -253,6 +253,78 @@ public class DataUtil {
         }
     }
 
+    // Manual submit steelSn (same interface as PDA), server generates customerSn and enqueues for printing
+    public static JSONObject submitSteelSn(String prodCode, String steelSn) {
+        try {
+            InputStream is = ClassLoader.getSystemResourceAsStream("config/config.properties");
+            Properties pro = new Properties();
+            BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
+            pro.load(br);
+            String mes_server_ip = pro.getProperty("mes.server_ip");
+            String url = "http://" + mes_server_ip + ":8980/js/a/mes/mesLaser/submitSteelSn";
+            String params = "__ajax=json&prodCode=" + prodCode + "&steelSn=" + steelSn;
+            System.out.println("[submitSteelSn] " + params);
+            String result = doPost(url, params);
+            System.out.println("[submitSteelSn] resp=" + result);
+            if (result == null || result.equalsIgnoreCase("false")) {
+                return null;
+            }
+            return JSONObject.parseObject(result);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return null;
+        }
+    }
+
+    // Poll pending print queue from mescloud (OP040 new flow)
+    public static JSONObject listPendingPrint(String lineSn, int limit) {
+        try {
+            InputStream is = ClassLoader.getSystemResourceAsStream("config/config.properties");
+            Properties pro = new Properties();
+            BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
+            pro.load(br);
+            String mes_server_ip = pro.getProperty("mes.server_ip");
+            String url = "http://" + mes_server_ip + ":8980/js/a/mes/mesLaser/pendingPrint";
+            String params = "__ajax=json&lineSn=" + lineSn + "&limit=" + limit;
+            String result = doPost(url, params);
+            // 日常轮询不打日志,避免噪音
+            if (result == null || result.equalsIgnoreCase("false")) {
+                return null;
+            }
+            return JSONObject.parseObject(result);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return null;
+        }
+    }
+
+    // Ack print result to mescloud, result = OK / NG
+    public static boolean ackPrint(String customerSn, String result, String msg) {
+        try {
+            InputStream is = ClassLoader.getSystemResourceAsStream("config/config.properties");
+            Properties pro = new Properties();
+            BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
+            pro.load(br);
+            String mes_server_ip = pro.getProperty("mes.server_ip");
+            String url = "http://" + mes_server_ip + ":8980/js/a/mes/mesLaser/printAck";
+            // customerSn 里带 '+' 号,form-urlencoded 传输时 '+' 会被服务端解码成空格,
+            // 必须 URL 编码后再拼接
+            String encSn = java.net.URLEncoder.encode(customerSn, "UTF-8");
+            String encMsg = msg == null ? "" : java.net.URLEncoder.encode(msg, "UTF-8");
+            String params = "__ajax=json&customerSn=" + encSn + "&result=" + result + "&msg=" + encMsg;
+            System.out.println("[ackPrint] " + params);
+            String resp = doPost(url, params);
+            System.out.println("[ackPrint] resp=" + resp);
+            if (resp == null) return false;
+            JSONObject json = JSONObject.parseObject(resp);
+            return json != null && json.get("result") != null
+                    && "true".equalsIgnoreCase(json.get("result").toString());
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
     // 调用mescloud生成客户编码并绑定钢印码(镭雕OP040专用)
     // prodCode: 产品标识 T09/T68;steelSn: 镭雕软件回传的钢印码
     public static JSONObject genCustomerSn(String prodCode,String steelSn){

+ 206 - 163
src/com/mes/ui/MesClient.java

@@ -105,16 +105,26 @@ public class MesClient extends JFrame {
 
     public static SerialPort serialPort; // 串口对象
 
-    // 镭雕相关
-    public static JButton laser_get_code_bt; // 获取镭雕码按钮
-    public static JButton laser_reset_bt; // 人工复位按钮
+    // 镭雕相关(新方案:定时轮询 + 手动补录 + 手动补打 + 解绑)
     public static LaserFlowManager laserFlowManager;
-    public static JTextField manualSteelSnInput;
-    public static JButton manualGetCodeBt;
-    public static JButton manualFinishOkBt;
-    public static JButton manualUnbindBt;
-    private static boolean manualWaitingComplete = false;
-    private static boolean manualCompleting = false;
+    public static com.mes.component.SwitchButton autoPrintToggleBt;   // 自动打印开关(左右拨动)
+    public static JLabel pendingCountLabel;           // 待打印数量显示
+    public static JTextField manualSteelSnInput;      // 手工输入钢印码
+    public static JButton submitAndPrintBt;           // 提交并打印(本地补录)
+    // 5 行队列 UI:有已打印时顶部占一行绿框,否则全部行显示 pending,末尾每行一个"打印"按钮
+    public static final int QUEUE_ROWS = 5;
+    public static JPanel[] queueRowPanels = new JPanel[QUEUE_ROWS];
+    public static JLabel[] queueSteelLabels = new JLabel[QUEUE_ROWS];
+    public static JLabel[] queueCustomerLabels = new JLabel[QUEUE_ROWS];
+    public static JLabel[] queueStatusLabels = new JLabel[QUEUE_ROWS];
+    public static JButton[] queuePrintButtons = new JButton[QUEUE_ROWS];
+    private static final javax.swing.border.Border BORDER_EMPTY_ROW =
+            BorderFactory.createLineBorder(new Color(220, 220, 220), 1);
+    private static final javax.swing.border.Border BORDER_PENDING_ROW =
+            BorderFactory.createLineBorder(new Color(160, 160, 160), 1);
+    private static final javax.swing.border.Border BORDER_DONE_ROW =
+            BorderFactory.createLineBorder(new Color(46, 160, 67), 3);
+    private static boolean laserBusy = false;         // 打印进行中,禁用按钮防止重复点击
     private static final ExecutorService laserActionExecutor = Executors.newSingleThreadExecutor();
 
     public static void main(String[] args) {
@@ -378,27 +388,63 @@ public class MesClient extends JFrame {
         return manualSteelSnInput != null && manualSteelSnInput.getText().trim().length() == 11;
     }
 
+    /** 根据当前 laserBusy 和输入框状态更新按钮 enable */
     private static void updateManualSubmitButtons() {
         boolean ready = isManualSteelSnReady();
-        if (manualGetCodeBt != null) {
-            manualGetCodeBt.setEnabled(!manualWaitingComplete && ready);
+        if (submitAndPrintBt != null) {
+            submitAndPrintBt.setEnabled(!laserBusy && ready);
         }
-        if (manualFinishOkBt != null) {
-            manualFinishOkBt.setEnabled(manualWaitingComplete && !manualCompleting);
-        }
-        if (manualUnbindBt != null) {
-            manualUnbindBt.setEnabled(!manualWaitingComplete && !manualCompleting && ready);
+        // 队列每行的打印按钮:只有非 busy 且该行是"待打印"状态才可用
+        for (int i = 0; i < queuePrintButtons.length; i++) {
+            if (queuePrintButtons[i] != null && queuePrintButtons[i].isVisible()) {
+                queuePrintButtons[i].setEnabled(!laserBusy);
+            }
         }
     }
 
-    private static void resetManualSubmitPanel(boolean clearInput) {
-        manualWaitingComplete = false;
-        manualCompleting = false;
-        if (clearInput && manualSteelSnInput != null) {
-            manualSteelSnInput.setText("");
+    /** 刷新一行队列 UI,row 参数可以为 null(空行) */
+    private static void refreshQueueRow(int idx, com.mes.laser.LaserFlowManager.QueueRow row) {
+        if (idx < 0 || idx >= queueRowPanels.length) return;
+        if (queueRowPanels[idx] == null) return;
+        JButton btn = queuePrintButtons[idx];
+        if (row == null) {
+            queueSteelLabels[idx].setText(" ");
+            queueCustomerLabels[idx].setText(" ");
+            queueStatusLabels[idx].setText(" ");
+            queueStatusLabels[idx].setOpaque(false);
+            queueRowPanels[idx].setBorder(BORDER_EMPTY_ROW);
+            if (btn != null) {
+                btn.setVisible(false);
+                btn.putClientProperty("customerSn", null);
+                btn.putClientProperty("steelSn", null);
+            }
+        } else {
+            queueSteelLabels[idx].setText("钢印: " + row.steelSn);
+            queueCustomerLabels[idx].setText("客户: " + row.customerSn);
+            if (row.done) {
+                queueStatusLabels[idx].setText(" 已打印 ");
+                queueStatusLabels[idx].setForeground(Color.WHITE);
+                queueStatusLabels[idx].setBackground(new Color(46, 160, 67));
+                queueStatusLabels[idx].setOpaque(true);
+                queueRowPanels[idx].setBorder(BORDER_DONE_ROW);
+                if (btn != null) btn.setVisible(false);
+            } else {
+                queueStatusLabels[idx].setText(" 待打印 ");
+                queueStatusLabels[idx].setForeground(Color.DARK_GRAY);
+                queueStatusLabels[idx].setBackground(new Color(230, 230, 230));
+                queueStatusLabels[idx].setOpaque(true);
+                queueRowPanels[idx].setBorder(BORDER_PENDING_ROW);
+                if (btn != null) {
+                    btn.setVisible(true);
+                    btn.setEnabled(!laserBusy);
+                    btn.putClientProperty("customerSn", row.customerSn);
+                    btn.putClientProperty("steelSn", row.steelSn);
+                }
+            }
         }
-        updateManualSubmitButtons();
+        queueRowPanels[idx].repaint();
     }
+
     private static void requestUnbindSteelSn(String steelSn) {
         requestUnbindSteelSn(steelSn, manualSteelSnInput);
     }
@@ -406,40 +452,25 @@ public class MesClient extends JFrame {
     private static void requestUnbindSteelSn(String steelSn, final JTextField steelSnInputToClear) {
         if (steelSn == null || steelSn.trim().length() != 11) {
             setMenuStatus("请输入11位钢印码", 1);
-            updateManualSubmitButtons();
-            return;
-        }
-        if (laserFlowManager != null && laserFlowManager.isWaitingComplete()) {
-            setMenuStatus("镭雕进行中,不能解绑", 1);
             return;
         }
-        manualCompleting = true;
-        updateManualSubmitButtons();
         final String steelSnTrim = steelSn.trim();
-        laserActionExecutor.submit(new Runnable() {
-            @Override
-            public void run() {
-                JSONObject retObj = DataUtil.unbindSteelSn(steelSnTrim);
-                SwingUtilities.invokeLater(new Runnable() {
-                    @Override
-                    public void run() {
-                        manualCompleting = false;
-                        if (retObj != null && retObj.get("result") != null
-                                && "true".equalsIgnoreCase(retObj.get("result").toString())) {
-                            setMenuStatus(retObj.getString("message"), 0);
-                            if (steelSnInputToClear != null) {
-                                steelSnInputToClear.setText("");
-                            }
-                            product_sn.setText("");
-                        } else {
-                            String msg = retObj != null && retObj.get("message") != null
-                                    ? retObj.get("message").toString() : "解绑失败,请重试";
-                            setMenuStatus(msg, 1);
-                        }
-                        updateManualSubmitButtons();
+        laserActionExecutor.submit(() -> {
+            JSONObject retObj = DataUtil.unbindSteelSn(steelSnTrim);
+            SwingUtilities.invokeLater(() -> {
+                if (retObj != null && retObj.get("result") != null
+                        && "true".equalsIgnoreCase(retObj.get("result").toString())) {
+                    setMenuStatus(retObj.getString("message"), 0);
+                    if (steelSnInputToClear != null) {
+                        steelSnInputToClear.setText("");
                     }
-                });
-            }
+                    product_sn.setText("");
+                } else {
+                    String msg = retObj != null && retObj.get("message") != null
+                            ? retObj.get("message").toString() : "解绑失败,请重试";
+                    setMenuStatus(msg, 1);
+                }
+            });
         });
     }
     public static void scanBarcode() {
@@ -536,7 +567,7 @@ public class MesClient extends JFrame {
         return true;
     }
 
-    // 初始化镭雕流程管理器
+    // 初始化镭雕流程管理器:拉服务端待打印队列 → 建档 → 打印 → ack
     public static void initLaserFlow(){
         laserFlowManager = new LaserFlowManager(new LaserFlowManager.UiCallback() {
             @Override
@@ -547,12 +578,8 @@ public class MesClient extends JFrame {
             @Override
             public void onLockButton(boolean lock) {
                 SwingUtilities.invokeLater(() -> {
-                    if(laser_get_code_bt != null){
-                        laser_get_code_bt.setEnabled(!lock);
-                    }
-                    if (!lock && manualWaitingComplete) {
-                        resetManualSubmitPanel(true);
-                    }
+                    laserBusy = lock;
+                    updateManualSubmitButtons();
                 });
             }
 
@@ -565,6 +592,25 @@ public class MesClient extends JFrame {
             public boolean onSubmitResult(String customerSn, String qret) {
                 return submitLaserResult(customerSn, qret);
             }
+
+            @Override
+            public void onPendingCount(int count) {
+                SwingUtilities.invokeLater(() -> {
+                    if (pendingCountLabel != null) {
+                        pendingCountLabel.setText("待打印:" + count);
+                    }
+                });
+            }
+
+            @Override
+            public void onQueueUpdated(java.util.List<com.mes.laser.LaserFlowManager.QueueRow> rows) {
+                SwingUtilities.invokeLater(() -> {
+                    for (int i = 0; i < QUEUE_ROWS; i++) {
+                        com.mes.laser.LaserFlowManager.QueueRow row = (i < rows.size()) ? rows.get(i) : null;
+                        refreshQueueRow(i, row);
+                    }
+                });
+            }
         });
         laserFlowManager.startCompleteServer();
     }
@@ -743,42 +789,95 @@ public class MesClient extends JFrame {
         indexScrollPaneA = new JScrollPane(indexPanelA);
         indexPanelA.setLayout(null);
 
-        // 镭雕:获取镭雕码 / 复位按钮,放在扫码框(回显框)上方
-        laser_get_code_bt = new JButton("获取镭雕码");
-        laser_get_code_bt.addActionListener(new ActionListener() {
+        // 「自动打印」标签
+        JLabel autoLabel = new JLabel("自动打印");
+        autoLabel.setFont(new Font("微软雅黑", Font.PLAIN, 24));
+        autoLabel.setBounds(81, 40, 130, 50);
+        indexPanelA.add(autoLabel);
+
+        // 自动打印开关:左右拨动,绿色=开
+        boolean initAuto = laserFlowManager != null && laserFlowManager.isAutoMode();
+        autoPrintToggleBt = new com.mes.component.SwitchButton(initAuto);
+        autoPrintToggleBt.setBounds(220, 45, 90, 40);
+        autoPrintToggleBt.addActionListener(new ActionListener() {
             public void actionPerformed(ActionEvent e) {
-                laserFlowManager.startLaserFlow(mes_gw, mes_line_sn);
+                if (laserFlowManager == null) return;
+                laserFlowManager.setAutoMode(autoPrintToggleBt.isSelected());
             }
         });
-        laser_get_code_bt.setFont(new Font("微软雅黑", Font.PLAIN, 24));
-        laser_get_code_bt.setBounds(81, 40, 300, 50);
-        indexPanelA.add(laser_get_code_bt);
+        indexPanelA.add(autoPrintToggleBt);
 
-        // 镭雕:人工复位按钮(完成信号超时或异常时使用,强制解锁并作废当前请求)
-        laser_reset_bt = new JButton("复位");
-        laser_reset_bt.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                int result = JOptionPane.showConfirmDialog(MesClient.this,
-                        "复位会取消当前镭雕流程,确定要继续吗?", "确认复位",
-                        JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE);
-                if (result == JOptionPane.YES_OPTION) {
-                    laserFlowManager.manualReset();
-                    resetManualSubmitPanel(true);
-                }
-            }
-        });
-        laser_reset_bt.setFont(new Font("微软雅黑", Font.PLAIN, 24));
-        laser_reset_bt.setBounds(400, 40, 150, 50);
-        indexPanelA.add(laser_reset_bt);
+        // 待打印数量显示(由 LaserFlowManager 每次轮询自动刷新)
+        pendingCountLabel = new JLabel("待打印:0");
+        pendingCountLabel.setFont(new Font("微软雅黑", Font.PLAIN, 24));
+        pendingCountLabel.setHorizontalAlignment(SwingConstants.CENTER);
+        pendingCountLabel.setBounds(400, 40, 300, 50);
+        indexPanelA.add(pendingCountLabel);
 
+        // product_sn 字段保留(其他流程 scanBarcode/解绑 仍会 setText),但不加到 UI 显示,
+        // 客户码回显直接体现在打印队列 3 行里
         product_sn = new JTextField();
-        product_sn.setHorizontalAlignment(SwingConstants.CENTER);
         product_sn.setEditable(false);
-        product_sn.setFont(new Font("微软雅黑", Font.PLAIN, 28));
-        product_sn.setBounds(81, 100, 602, 70);
-        indexPanelA.add(product_sn);
         product_sn.setColumns(10);
 
+        // ===== 打印队列(3 行):顶部=最近已打,其余=服务端 pending 最早 2 条 =====
+        // 参考原 product_sn 尺寸:602 宽、70 高、字号大
+        JLabel queueTitle = new JLabel("打印队列");
+        queueTitle.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        queueTitle.setForeground(Color.GRAY);
+        queueTitle.setBounds(81, 100, 200, 20);
+        indexPanelA.add(queueTitle);
+
+        int rowH = 48;
+        int rowGap = 8;
+        int rowY0 = 125;
+        for (int i = 0; i < QUEUE_ROWS; i++) {
+            JPanel row = new JPanel(null);
+            row.setBounds(81, rowY0 + i * (rowH + rowGap), 602, rowH);
+            row.setBackground(Color.WHITE);
+            row.setBorder(BORDER_EMPTY_ROW);
+
+            JLabel steelLbl = new JLabel(" ");
+            steelLbl.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+            steelLbl.setBounds(15, 12, 200, 24);
+            row.add(steelLbl);
+
+            JLabel snLbl = new JLabel(" ");
+            snLbl.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+            snLbl.setBounds(220, 12, 250, 24);
+            row.add(snLbl);
+
+            JLabel statusLbl = new JLabel(" ");
+            statusLbl.setFont(new Font("微软雅黑", Font.PLAIN, 13));
+            statusLbl.setHorizontalAlignment(SwingConstants.CENTER);
+            statusLbl.setBounds(465, 12, 70, 24);
+            row.add(statusLbl);
+
+            // 打印按钮:只有"待打印"行显示,点击打印这一条
+            JButton printBt = new JButton("打印");
+            printBt.setFont(new Font("微软雅黑", Font.PLAIN, 13));
+            printBt.setBounds(542, 9, 55, 30);
+            printBt.setMargin(new Insets(2, 2, 2, 2));
+            printBt.setVisible(false);
+            printBt.addActionListener(new ActionListener() {
+                public void actionPerformed(ActionEvent e) {
+                    Object csn = printBt.getClientProperty("customerSn");
+                    Object ssn = printBt.getClientProperty("steelSn");
+                    if (csn == null || ssn == null || laserFlowManager == null) return;
+                    laserFlowManager.printSpecific(csn.toString(), ssn.toString());
+                }
+            });
+            row.add(printBt);
+
+            queueRowPanels[i] = row;
+            queueSteelLabels[i] = steelLbl;
+            queueCustomerLabels[i] = snLbl;
+            queueStatusLabels[i] = statusLbl;
+            queuePrintButtons[i] = printBt;
+            indexPanelA.add(row);
+        }
+        // ===== 打印队列结束 =====
+
         // 原扫码按钮不再需要,隐藏但保留逻辑(scanBarcode等方法仍被其他流程复用)
         f_scan_data_bt_1 = new JButton("扫码");
         f_scan_data_bt_1.addActionListener(new ActionListener() {
@@ -895,98 +994,43 @@ public class MesClient extends JFrame {
         finish_ng_bt.setVisible(false);
         indexPanelA.add(finish_ng_bt);
 
-        // ===== 手动模式面板 =====
-        JLabel debugTitle = new JLabel("手动模式");
-        debugTitle.setFont(new Font("微软雅黑", Font.PLAIN, 16));
-        debugTitle.setForeground(Color.GRAY);
-        debugTitle.setHorizontalAlignment(SwingConstants.CENTER);
-        debugTitle.setVisible(true);
-        debugTitle.setBounds(760, 40, 220, 30);
-        indexPanelA.add(debugTitle);
+        // ===== 右侧手动操作面板:手动录入 / 补打 / 解绑 =====
+        JLabel manualTitle = new JLabel("手动操作");
+        manualTitle.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        manualTitle.setForeground(Color.GRAY);
+        manualTitle.setHorizontalAlignment(SwingConstants.CENTER);
+        manualTitle.setBounds(760, 40, 220, 30);
+        indexPanelA.add(manualTitle);
 
+        // 钢印码输入框(供"提交并打印"和"解绑"共用)
         manualSteelSnInput = new JTextField();
         manualSteelSnInput.setFont(new Font("微软雅黑", Font.PLAIN, 16));
         manualSteelSnInput.setBounds(760, 80, 220, 40);
         manualSteelSnInput.setToolTipText("输入11位钢印码");
         manualSteelSnInput.getDocument().addDocumentListener(new DocumentListener() {
-            public void insertUpdate(DocumentEvent e) {
-                updateManualSubmitButtons();
-            }
-
-            public void removeUpdate(DocumentEvent e) {
-                updateManualSubmitButtons();
-            }
-
-            public void changedUpdate(DocumentEvent e) {
-                updateManualSubmitButtons();
-            }
+            public void insertUpdate(DocumentEvent e) { updateManualSubmitButtons(); }
+            public void removeUpdate(DocumentEvent e) { updateManualSubmitButtons(); }
+            public void changedUpdate(DocumentEvent e) { updateManualSubmitButtons(); }
         });
         indexPanelA.add(manualSteelSnInput);
 
-        manualGetCodeBt = new JButton("模拟获取镭雕码");
-        manualGetCodeBt.setEnabled(false);
-        manualGetCodeBt.addActionListener(new ActionListener() {
+        // 提交并打印:把钢印码提交给服务端,服务端生成客户码后本地建档 + 打印
+        submitAndPrintBt = new JButton("提交并打印");
+        submitAndPrintBt.setEnabled(false);
+        submitAndPrintBt.addActionListener(new ActionListener() {
             public void actionPerformed(ActionEvent e) {
                 if (!isManualSteelSnReady()) {
-                    MesClient.setMenuStatus("请输入11位钢印码", 1);
-                    updateManualSubmitButtons();
+                    setMenuStatus("请输入11位钢印码", 1);
                     return;
                 }
-                if (laserFlowManager.isWaitingComplete()) {
-                    MesClient.setMenuStatus("镭雕进行中,请先完成或复位", 1);
-                    return;
-                }
-                manualWaitingComplete = true;
-                manualCompleting = false;
-                laserFlowManager.simulateGetCode(manualSteelSnInput.getText());
-                updateManualSubmitButtons();
-            }
-        });
-        manualGetCodeBt.setFont(new Font("微软雅黑", Font.PLAIN, 16));
-        manualGetCodeBt.setBounds(760, 130, 220, 50);
-        indexPanelA.add(manualGetCodeBt);
-
-        manualFinishOkBt = new JButton("模拟完成信号(OK)");
-        manualFinishOkBt.setEnabled(false);
-        manualFinishOkBt.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                manualCompleting = true;
-                updateManualSubmitButtons();
-                laserFlowManager.simulateComplete("OK", "");
+                laserFlowManager.submitAndPrint(manualSteelSnInput.getText());
             }
         });
-        manualFinishOkBt.setFont(new Font("微软雅黑", Font.PLAIN, 16));
-        manualFinishOkBt.setBounds(760, 190, 220, 50);
-        indexPanelA.add(manualFinishOkBt);
+        submitAndPrintBt.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        submitAndPrintBt.setBounds(760, 130, 220, 50);
+        indexPanelA.add(submitAndPrintBt);
 
-        manualUnbindBt = new JButton("解绑钢印码");
-        manualUnbindBt.setEnabled(false);
-        manualUnbindBt.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                requestUnbindSteelSn(manualSteelSnInput.getText());
-            }
-        });
-        manualUnbindBt.setFont(new Font("微软雅黑", Font.PLAIN, 16));
-        manualUnbindBt.setBounds(760, 250, 220, 50);
-        indexPanelA.add(manualUnbindBt);
-        JButton debugSimNgBtn = new JButton("模拟完成信号(NG)");
-        debugSimNgBtn.setVisible(false);
-        debugSimNgBtn.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                laserFlowManager.simulateComplete("NG", "模拟打码失败");
-            }
-        });
-        indexPanelA.add(debugSimNgBtn);
-
-        JButton debugGenSnBtn = new JButton("测试生成客户编码");
-        debugGenSnBtn.setVisible(false);
-        debugGenSnBtn.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                laserFlowManager.manualGenCustomerSn(manualSteelSnInput.getText());
-            }
-        });
-        indexPanelA.add(debugGenSnBtn);
-        // ===== 手动模式面板结束 =====
+        // ===== 手动操作面板结束(打印靠队列行的"打印"按钮;解绑在独立 tab)=====
 
         JButton modbusBtn = new JButton("关");
         modbusBtn.addActionListener(new ActionListener() {
@@ -1062,7 +1106,6 @@ public class MesClient extends JFrame {
         unbindSteelSnInput.getDocument().addDocumentListener(new DocumentListener() {
             private void update() {
                 boolean ready = unbindSteelSnInput.getText().trim().length() == 11;
-                boolean laserBusy = laserFlowManager != null && laserFlowManager.isWaitingComplete();
                 unbindSteelSnBt.setEnabled(ready && !laserBusy);
             }
 

+ 16 - 12
src/resources/config/config.properties

@@ -1,6 +1,6 @@
 mes.gw=OP040A
-#mes.server_ip=127.0.0.1
-mes.server_ip=192.168.114.99
+mes.server_ip=127.0.0.1
+#mes.server_ip=192.168.114.99
 mes.tcp_port=3000
 mes.heart_beat_cycle=60
 mes.line_sn=XT
@@ -8,20 +8,24 @@ mes.line_sn=XT
 # prodCode: T09/T68, used for customer sn prefix, independent from mes.line_sn(XT)
 mes.prod_code=T09
 
-# laser software http server url (getCode/start api)
-mes.laser.server_url=http://127.0.0.1:9001
+# 拉取待打印的轮询间隔(毫秒)
+mes.laser.poll_interval=3000
 
-# local http server port for receiving laser complete callback
-mes.laser.local_port=9002
+# 单次拉取条数上限
+mes.laser.poll_batch_size=10
 
-# laser api timeout in ms
-mes.laser.timeout=15000
+# 启动默认打印模式:auto=自动轮询打印,manual=只在点击后打印一次
+mes.laser.print_mode=manual
 
-# laser complete signal wait timeout in ms
-mes.laser.complete_timeout=60000
-
-# printer name for laser card, empty = use default printer
+# 打印机名,空则使用系统默认打印机
 mes.laser.printer_name=
 
+# 流转卡 PDF 模板路径(classpath 内相对路径)
+mes.laser.card_template_path=config/liuzhuanka_template.pdf
+
+# true=只生成预览 PNG 到 preview_path 不真打印,false=送打印机
+mes.laser.preview_only=false
+mes.laser.preview_path=logs/card_preview.png
+
 # UI design panel password, triggered by triple-clicking time status bar
 mes.ui.design.password=mes123

BIN
src/resources/config/liuzhuanka_template.pdf


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
ui-designs/op40-main.ui.json