7 Revize 5fc42085ec ... 01015e578e

Autor SHA1 Zpráva Datum
  liudongzhi 01015e578e 佳博打票机连通调试 před 1 týdnem
  liudongzhi d2b4d21887 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand před 1 týdnem
  liudongzhi c57d099e08 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand před 1 týdnem
  liudongzhi ef841325f5 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand před 1 týdnem
  liudongzhi cd106553a0 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand před 1 týdnem
  liudongzhi 7a292dcfea 添加打印机设备 před 1 týdnem
  liudongzhi be438a5abc 调试打印机样式 před 1 týdnem

+ 3 - 2
alien-entity/src/main/java/shop/alien/entity/store/PrinterDO.java

@@ -28,15 +28,16 @@ public class PrinterDO {
     // 设备编号SN(每台打印机唯一)
     private String sn;
 
-    // 开发者ID / 商户ID(芯烨=user,佳航=merchantId)
+    // 开发者ID / 商户ID(芯烨=userId,佳航=merchantId ,商鹏=appid)
     private String userId;
 
-    // API密钥 / UserKey / Token
+    // API密钥 / UserKey / Token   芯烨 =开发者秘钥  商鹏 =appsecret  佳航 =?暂时未知
     private String apiKey;
 
     // 备注
     private String remark;
 
+
     // 创建时间
     private LocalDateTime createTime;
     // 店铺ID

+ 130 - 48
alien-store/src/main/java/shop/alien/store/controller/PrintController.java

@@ -1,8 +1,13 @@
 package shop.alien.store.controller;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
@@ -14,10 +19,12 @@ import shop.alien.store.service.impl.PrintService;
 import shop.alien.store.service.impl.PrintTemplate;
 
 import javax.annotation.Resource;
+import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 @RestController
 @Slf4j
@@ -41,7 +48,9 @@ public class PrintController {
      */
     @GetMapping("/all")
     public String printAll(@RequestParam(value = "storeId", required = false) Integer storeId,
-                           @RequestParam(value = "receiptType", required = false) Integer receiptType) {
+                           @RequestParam(value = "receiptType", required = false) Integer receiptType,
+                           @RequestParam(value = "data", required = false) String dataJson
+    ) {
         // 默认店铺与票据类型(1:客单, 2:结账单)
         if (storeId == null) storeId = 1;
         if (receiptType == null) receiptType = 2;
@@ -62,65 +71,138 @@ public class PrintController {
             enabled = storeReceiptTemplateConfigService.getDetail(storeId, receiptType, 1);
         }
 
-        // 组装渲染所需数据(示例数据,可替换为真实订单数据)
+        // 组装渲染所需数据:优先使用 dataJson 覆盖默认演示数据
+        Map<String, Object> ctx = defaultContext(receiptType);
+        if (dataJson != null && !dataJson.trim().isEmpty()) {
+            try {
+                JSONObject obj = JSON.parseObject(dataJson);
+                if (obj != null) {
+                    // 扁平 key 直接覆盖
+                    for (String key : obj.keySet()) {
+                        Object val = obj.get(key);
+                        if (!"items".equals(key)) {
+                            ctx.put(key, val);
+                        }
+                    }
+                    // items 列表(可选)
+                    if (obj.containsKey("items")) {
+                        JSONArray arr = obj.getJSONArray("items");
+                        if (arr != null) {
+                            List<Map<String, Object>> items = new ArrayList<>();
+                            for (int i = 0; i < arr.size(); i++) {
+                                JSONObject it = arr.getJSONObject(i);
+                                if (it == null) continue;
+                                Map<String, Object> im = new HashMap<>();
+                                for (String k : it.keySet()) {
+                                    im.put(k, it.get(k));
+                                }
+                                items.add(im);
+                            }
+                            ctx.put("items", items);
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("解析 data JSON 失败,将使用默认演示数据: {}", e.getMessage());
+            }
+        }
+
+        // 动态渲染小票
+        String templateJson = enabled != null ? enabled.getTemplateConfigJson() : "{}";
+        // 使用精美排版,宽度 32 更贴合 58mm 打印机(ASCII 32列,中文等宽按2列计算)
+        String content = PrintTemplate.buildFromConfigPretty(templateJson, ctx, 32);
+
+        // 批量打印
+        log.info("打印内容:"+ content );
+        printService.printAll(list, content);
+        return "已下发打印指令,共" + list.size() + "台打印机";
+    }
+
+    private Map<String, Object> defaultContext(Integer receiptType) {
         Map<String, Object> ctx = new HashMap<>();
         ctx.put("ticketName", receiptType != null && receiptType == 2 ? "结账单" : "客单");
-        ctx.put("storeName", "测试门店");
-        ctx.put("orderNo", "ORDER20260406001");
-        ctx.put("tableNo", "A01");
-        ctx.put("peopleCount", 4);
-        ctx.put("diningTime", "2026-04-06 10:00");
-        ctx.put("cashierTime", "2026-04-06 13:00");
-        ctx.put("name", "张大大");
-        ctx.put("phone", "18000000000");
-        ctx.put("beginRemark", "无");
-
-        // 品项
+        ctx.put("storeName", "店铺名称");
+        ctx.put("orderNo", "2026");
+        ctx.put("tableNo", "A1054");
+        ctx.put("peopleCount", 2);
+        ctx.put("diningTime", "2026/01/01 12:00~14:00");
+        ctx.put("name", "熊二");
+        ctx.put("phone", "123 6544 7899");
+        ctx.put("cashierTime", "2026/01/01 12:00");
+        ctx.put("orderBy", "熊大");
+
         List<Map<String, Object>> items = new ArrayList<>();
         Map<String, Object> i1 = new HashMap<>();
-        i1.put("name", "可乐");
-        i1.put("unitPrice", "3.00");
+        i1.put("name", "锅包肉");
+        i1.put("unitPrice", "10.00");
         i1.put("quantity", 2);
-        i1.put("subtotal", "6.00");
+        i1.put("subtotal", "20.00");
         items.add(i1);
         Map<String, Object> i2 = new HashMap<>();
-        i2.put("name", "方便面");
-        i2.put("unitPrice", "5.00");
-        i2.put("quantity", 1);
-        i2.put("subtotal", "5.00");
+        i2.put("name", "米饭");
+        i2.put("unitPrice", "10.00");
+        i2.put("quantity", 2);
+        i2.put("subtotal", "20.00");
         items.add(i2);
         ctx.put("items", items);
 
-        // 订单价格合计
-        ctx.put("dishPriceTotal", "11.00");
-        ctx.put("serviceFeeTotal", "0.00");
-        ctx.put("otherServiceFeeTotal", "0.00");
-        ctx.put("chargeReason", "");
-        ctx.put("orderTotal", "11.00");
-
-        // 支付与结算信息
-        ctx.put("coupon", "0.00");
-        ctx.put("manualReduction", "0.00");
-        ctx.put("reductionReason", "");
-        ctx.put("settlementMethod", "在线支付");
-        ctx.put("paymentMethod", "微信支付");
-        ctx.put("paymentTotal", "11.00");
-
-        // 底栏信息
-        ctx.put("orderBy", "路通");
-        ctx.put("orderAt", "2026-04-06 10:00");
-        ctx.put("printBy", "系统");
-        ctx.put("printAt", String.valueOf(java.time.LocalDateTime.now()));
-        ctx.put("storeAddress", "XX市XX区XX路100号");
-        ctx.put("storeTel", "020-12345678");
+        ctx.put("dishPriceTotal", "40.00");
+        ctx.put("serviceFeeTotal", "63.00");
+        ctx.put("otherServiceFeeTotal", "1500.00");
+        ctx.put("orderTotal", "1603.00");
 
-        // 动态渲染小票
-        String content = PrintTemplate.buildFromConfig(enabled != null ? enabled.getTemplateConfigJson() : "{}", ctx);
+        ctx.put("coupon", "-10.00");
+        ctx.put("manualReduction", "-20.00");
 
-        // 批量打印
-        log.info("打印内容:"+ content );
-        printService.printAll(list, content);
-        return "已下发打印指令,共" + list.size() + "台打印机";
+        ctx.put("settlementMethod", "收银台");
+        ctx.put("paymentMethod", "支付宝");
+        ctx.put("paymentTotal", "630.00");
+
+        ctx.put("printBy", "吉尾尘");
+        ctx.put("orderAt", "2026/01/01 12:00");
+        ctx.put("printAt", "2026/01/01 12:00");
+        return ctx;
+    }
+
+    /**
+     * 新增打印机
+     * 访问地址:POST /print/createPrinter
+     * 请求体:PrinterDO JSON(至少包含 sn、brand、printerName、storeId)
+     */
+    @PostMapping("/createPrinter")
+    public Object createPrinter(@RequestBody PrinterDO body) {
+        if (body == null) {
+            return "参数不能为空";
+        }
+        if (body.getSn() == null || body.getSn().trim().isEmpty()) {
+            return "sn不能为空";
+        }
+        if (body.getBrand() == null || body.getBrand().trim().isEmpty()) {
+            return "brand不能为空";
+        }
+        if (body.getPrinterName() == null || body.getPrinterName().trim().isEmpty()) {
+            return "printerName不能为空";
+        }
+        if (body.getStoreId() == null || body.getStoreId() <= 0) {
+            return "storeId必须大于0";
+        }
+        // 去重:同门店下 SN 唯一且未删除
+        Long exists = Long.valueOf(printerMapper.selectCount(
+                new LambdaQueryWrapper<PrinterDO>()
+                        .eq(PrinterDO::getStoreId, body.getStoreId())
+                        .eq(PrinterDO::getSn, body.getSn())
+                        .eq(PrinterDO::getDelFlag, "0")
+        ));
+        if (exists > 0) {
+            return "该门店已存在相同SN的打印机";
+        }
+        body.setId(null);
+        body.setCreateTime(LocalDateTime.now());
+        if (body.getDelFlag() == null) {
+            body.setDelFlag("0");
+        }
+        printerMapper.insert(body);
+        return body;
     }
 
     /**

+ 86 - 25
alien-store/src/main/java/shop/alien/store/service/impl/PrintService.java

@@ -8,24 +8,11 @@ import okhttp3.Request;
 import okhttp3.RequestBody;
 import okhttp3.Response;
 import org.apache.commons.codec.digest.DigestUtils;
-import org.apache.http.HttpEntity;
-import org.apache.http.NameValuePair;
-import org.apache.http.client.ClientProtocolException;
-import org.apache.http.client.config.RequestConfig;
-import org.apache.http.client.entity.UrlEncodedFormEntity;
-import org.apache.http.client.methods.*;
-import org.apache.http.client.utils.URLEncodedUtils;
-import org.apache.http.impl.client.CloseableHttpClient;
-import org.apache.http.impl.client.HttpClients;
-import org.apache.http.message.BasicNameValuePair;
-import org.apache.http.util.EntityUtils;
 import org.springframework.stereotype.Service;
 import shop.alien.entity.store.PrinterDO;
 import shop.alien.store.util.Base64Util;
 import shop.alien.store.util.ShaUtil;
-
 import java.io.BufferedReader;
-import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.net.HttpURLConnection;
@@ -82,8 +69,8 @@ public class PrintService {
         String brand = printer.getBrand();
         if ("XINYE".equalsIgnoreCase(brand)) {
             return printXinYe(printer, content);
-        } else if ("JIAHANG".equalsIgnoreCase(brand)) {
-            return printJiaHang(printer, content);
+        } else if ("JIABO".equalsIgnoreCase(brand)) {
+            return printJiaBo(printer, content);
         } else if ("SHANGPENG".equalsIgnoreCase(brand)) {
             return printShangPeng(printer, content);
         }
@@ -112,15 +99,89 @@ public class PrintService {
         return post("https://open.xpyun.net/api/openapi/xprinter/print", req);
     }
 
-    // ==================== 佳航打印实现 ====================
-    private boolean printJiaHang(PrinterDO p, String content) {
-        JSONObject req = new JSONObject();
-        req.put("merchantId", p.getUserId());
-        req.put("deviceNo", p.getSn());
-        req.put("key", p.getApiKey());
-        req.put("printContent", Base64Util.encode(content));
-        req.put("copy", 1);
-        return post("http://api.jhprt.com/api/print", req);
+
+
+    // ==================== 佳博打印实现 ====================
+    private boolean printJiaBo(PrinterDO p, String content) {
+        String url = "http://api.poscom.cn/apisc/sendMsg";
+        String memberCode = p.getUserId(); // 假设 userId 是 memberCode
+        String apiKey = p.getApiKey();     // 假设 apiKey 是签名 Key
+        String deviceId = p.getSn();       // 打印机 SN 作为设备 ID
+
+        try {
+//            String reqTime = String.valueOf(System.currentTimeMillis());
+            String reqTime = String.valueOf(Calendar.getInstance().getTimeInMillis());
+            // 注意:佳博签名逻辑通常是 MD5(memberCode + deviceId + token + reqTime + key)
+
+            String signContent = memberCode + deviceId + "" + reqTime + apiKey;
+          String securityCode = DigestUtils.md5Hex(signContent);
+
+//            String securityCode = DigestUtils.md5Hex("4554A0DBFCB544F73393C6D6FE251E76" + "dmems3mhexv" + "" + reqTime + "MDH6X7DG4H0REWUFNUPUKGIUGPMN9RO1");
+            Map<String, String> params = new HashMap<>();
+            params.put("reqTime", reqTime);
+            params.put("securityCode", securityCode);
+            params.put("memberCode", memberCode);
+            params.put("deviceID", deviceId); // 必须传入设备 ID
+            params.put("token", "");          // 如果有 token 请填入
+            params.put("mode", "2");          // 2 通常代表云打印模式
+            params.put("msgDetail", content); // 传入实际打印内容
+            params.put("cmdType", "ESC");     // 指令类型
+            params.put("charset", "4");       // 字符集:
+//            params.put("msgNo", String.valueOf(System.currentTimeMillis())); // 唯一流水号
+            params.put("times", "");         // 打印份数
+            params.put("reprint", "0");       // 0-不重打
+            return sendJiaBoPost(url, params);
+
+        } catch (Exception e) {
+            log.error("佳博打印异常: deviceId={}, error={}", deviceId, e.getMessage(), e);
+            return false;
+        }
+
+    }
+
+    // 佳博专用 POST 请求 (Form 表单格式)
+    private boolean sendJiaBoPost(String urlStr, Map<String, String> params) {
+        HttpURLConnection conn = null;
+        try {
+            URL url = new URL(urlStr);
+            conn = (HttpURLConnection) url.openConnection();
+            conn.setRequestMethod("POST");
+            conn.setDoOutput(true);
+            conn.setConnectTimeout(5000);
+            conn.setReadTimeout(10000);
+            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
+
+            StringBuilder sb = new StringBuilder();
+            for (Map.Entry<String, String> entry : params.entrySet()) {
+                if (sb.length() > 0) sb.append("&");
+                sb.append(entry.getKey()).append("=").append(URLEncoder.encode(entry.getValue(), "UTF-8"));
+            }
+
+            try (OutputStream os = conn.getOutputStream()) {
+                os.write(sb.toString().getBytes("UTF-8"));
+            }
+
+            int code = conn.getResponseCode();
+            if (code == 200) {
+                try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"))) {
+                    String line;
+                    StringBuilder res = new StringBuilder();
+                    while ((line = br.readLine()) != null) res.append(line);
+                    
+                    log.info("佳博打印响应: {}", res);
+                    // 佳博成功判断:通常包含 "result":0 或 "success"
+                    return res.toString().contains("\"result\":0") || res.toString().contains("\"err\":0");
+                }
+            } else {
+                log.error("佳博打印请求失败,HTTP 状态码: {}", code);
+                return false;
+            }
+        } catch (Exception e) {
+            log.error("佳博打印网络异常: error={}", e.getMessage(), e);
+            return false;
+        } finally {
+            if (conn != null) conn.disconnect();
+        }
     }
 
     // ==================== 商鹏打印实现 (V1 官方签名版) ====================
@@ -152,7 +213,7 @@ public class PrintService {
             params.put("sn", sn);
 //            params.put("content", base64Content);
             params.put("content", content);
-            params.put("times", "1");
+            params.put("times", String.valueOf(times));
             params.put("appid", appid);
             params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
             

+ 395 - 0
alien-store/src/main/java/shop/alien/store/service/impl/PrintTemplate.java

@@ -8,6 +8,9 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 public class PrintTemplate {
 
@@ -163,6 +166,398 @@ public class PrintTemplate {
         return sb.toString();
     }
 
+    /**
+     * 按照固定宽度进行精美排版,尽量贴近示例图片的样式(等宽打印机友好)。
+     * - 顶部标题与店铺名居中
+     * - 关键信息按 key:value 左对齐
+     * - 品项表格对齐(名称/单价/数量/小计)
+     * - 分割线区分模块
+     *
+     * 注意:依然基于模板可见字段决定哪些行需要显示;若未配置则不输出。
+     */
+    public static String buildFromConfigPretty(String templateConfigJson, Map<String, Object> context, int lineWidth) {
+        if (lineWidth <= 0) {
+            lineWidth = 30;
+        }
+        if (templateConfigJson == null || templateConfigJson.trim().isEmpty()) {
+            return "";
+        }
+        JSONObject root = JSON.parseObject(templateConfigJson);
+        if (root == null) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        JSONArray sections = root.getJSONArray("sections");
+        if (sections == null) {
+            return "";
+        }
+
+        String thin = repeat('-', lineWidth) + "\n";
+        String thick = repeat('=', lineWidth) + "\n";
+
+        // 顶部:票据名称、店铺名称(若模板开启)
+        String ticketName = str(context.get("ticketName"));
+        String storeName = str(context.get("storeName"));
+        if (!ticketName.isEmpty()) sb.append(center(ticketName, lineWidth)).append("\n");
+        if (!storeName.isEmpty()) sb.append(center(storeName, lineWidth)).append("\n");
+        if (sb.length() > 0) sb.append(thick);
+
+        // 逐段渲染
+        for (int i = 0; i < sections.size(); i++) {
+            JSONObject section = sections.getJSONObject(i);
+            if (section == null) continue;
+            JSONArray fields = section.getJSONArray("fields");
+            if (fields == null) continue;
+
+            // 基础信息与头部
+            if (Objects.equals(section.getString("key"), "basic")) {
+                // 关键行顺序参考示例
+                kvIfVisible(fields, "orderNo", "单号", context, sb, lineWidth);
+                kvIfVisible(fields, "tableNo", "桌号", context, sb, lineWidth);
+                kvIfVisible(fields, "peopleCount", "人数", context, sb, lineWidth);
+                kvIfVisibleDiningTime(fields, "diningTime", "就餐时间", context, sb, lineWidth);
+                kvIfVisible(fields, "name", "姓名", context, sb, lineWidth);
+                kvIfVisible(fields, "phone", "电话", context, sb, lineWidth);
+                kvIfVisible(fields, "cashierTime", "结账时间", context, sb, lineWidth);
+                kvIfVisible(fields, "orderBy", "收银员", context, sb, lineWidth); // 若模板中使用 print/cashier 可自行调整
+                sb.append(thin);
+                continue;
+            }
+
+            // 品项信息
+            if (Objects.equals(section.getString("key"), "itemInfo")) {
+                JSONObject itemField = fieldOf(fields, "item");
+                if (itemField != null && itemField.getBooleanValue("visible")) {
+                    JSONArray children = itemField.getJSONArray("children");
+                    boolean showUnitPrice = childVisible(children, "unitPrice");
+                    boolean showQuantity = childVisible(children, "quantity");
+                    boolean showSubtotal = childVisible(children, "subtotal");
+
+                    // 表头
+                    sb.append(rowItemsHeader(lineWidth, showUnitPrice, showQuantity, showSubtotal));
+
+                    @SuppressWarnings("unchecked")
+                    List<Map<String, Object>> items = (List<Map<String, Object>>) context.getOrDefault("items", new ArrayList<>());
+                    for (Map<String, Object> item : items) {
+                        String name = str(item.get("name"));
+                        String unitPrice = showUnitPrice ? str(item.get("unitPrice")) : null;
+                        String qty = showQuantity ? str(item.get("quantity")) : null;
+                        String subtotal = showSubtotal ? str(item.get("subtotal")) : null;
+                        sb.append(rowItem(lineWidth, name, unitPrice, qty, subtotal));
+                    }
+                }
+                // 整单备注、数量合计等
+                kvIfVisible(fields, "wholeRemark", "整单备注", context, sb, lineWidth);
+                kvIfVisible(fields, "countSubtotal", "数量合计", context, sb, lineWidth);
+
+                // 订单价格分组
+                JSONObject orderPrice = fieldOf(fields, "orderPrice");
+                if (orderPrice != null && orderPrice.getBooleanValue("visible")) {
+                    JSONArray children = orderPrice.getJSONArray("children");
+                    if (children != null) {
+                        kvChild(children, "dishPriceTotal", "菜品价格合计", context, sb, lineWidth);
+                        kvChild(children, "serviceFeeTotal", "服务费合计", context, sb, lineWidth);
+                        kvChild(children, "otherServiceFeeTotal", "其他费用合计", context, sb, lineWidth);
+                        kvChild(children, "orderTotal", "订单合计", context, sb, lineWidth);
+                    }
+                }
+                sb.append(thin);
+                continue;
+            }
+
+            // 结算信息
+            if (Objects.equals(section.getString("key"), "settlement")) {
+                JSONObject paymentDiscount = fieldOf(fields, "paymentDiscount");
+                if (paymentDiscount != null && paymentDiscount.getBooleanValue("visible")) {
+                    JSONArray children = paymentDiscount.getJSONArray("children");
+                    if (children != null) {
+                        kvChild(children, "coupon", "优惠券", context, sb, lineWidth);
+                        kvChild(children, "manualReduction", "手动减免", context, sb, lineWidth);
+                        // 优惠合计:若有 coupon/manualReduction 则可由前端计算,也可由后端传入
+                        String coupon = str(context.get("coupon"));
+                        String manualReduction = str(context.get("manualReduction"));
+                        if (!coupon.isEmpty() || !manualReduction.isEmpty()) {
+                            // 简单相加(均为负数或正负混合时请自行保证格式)
+                            // 此处仅展示由前端直接传入更安全,保留计算注释
+                        }
+                    }
+                }
+                JSONObject paymentInfo = fieldOf(fields, "paymentInfo");
+                if (paymentInfo != null && paymentInfo.getBooleanValue("visible")) {
+                    JSONArray children = paymentInfo.getJSONArray("children");
+                    if (children != null) {
+                        kvChild(children, "settlementMethod", "结算方式", context, sb, lineWidth, false);
+                        kvChild(children, "paymentMethod", "支付方式", context, sb, lineWidth, false);
+                        kvChild(children, "paymentTotal", "支付合计", context, sb, lineWidth, true); // 强调
+                    }
+                }
+                sb.append(thin);
+                continue;
+            }
+
+            // 底栏
+            if (Objects.equals(section.getString("key"), "footer")) {
+                kvIfVisible(fields, "orderBy", "下单人", context, sb, lineWidth);
+                kvIfVisible(fields, "orderAt", "下单时间", context, sb, lineWidth);
+                kvIfVisible(fields, "printBy", "打印人", context, sb, lineWidth);
+                kvIfVisible(fields, "printAt", "打印时间", context, sb, lineWidth);
+            }
+        }
+        sb.append("\n");
+        return sb.toString();
+    }
+
+    private static JSONObject fieldOf(JSONArray fields, String key) {
+        if (fields == null) return null;
+        for (int i = 0; i < fields.size(); i++) {
+            JSONObject f = fields.getJSONObject(i);
+            if (f != null && key.equals(f.getString("key"))) return f;
+        }
+        return null;
+    }
+
+    private static void kvIfVisible(JSONArray fields, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width) {
+        JSONObject f = fieldOf(fields, key);
+        if (f != null && f.getBooleanValue("visible")) {
+            String v = str(ctx.get(key));
+            if (!v.isEmpty()) {
+                sb.append(kv(title, v, width));
+            }
+        }
+    }
+
+    private static void kvIfVisibleDiningTime(JSONArray fields, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width) {
+        JSONObject f = fieldOf(fields, key);
+        if (f != null && f.getBooleanValue("visible")) {
+            String raw = str(ctx.get(key));
+            String v = formatDiningTime(raw);
+            if (!v.isEmpty()) {
+                sb.append(kv(title, v, width));
+            }
+        }
+    }
+
+    private static void kvChild(JSONArray children, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width) {
+        kvChild(children, key, title, ctx, sb, width, false);
+    }
+
+    private static void kvChild(JSONArray children, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width, boolean strong) {
+        if (children == null) return;
+        for (int i = 0; i < children.size(); i++) {
+            JSONObject c = children.getJSONObject(i);
+            if (c == null) continue;
+            if (key.equals(c.getString("key")) && c.getBooleanValue("visible")) {
+                String v = str(ctx.get(key));
+                if (!v.isEmpty()) {
+                    String line = kv(title, v, width);
+                    if (strong) {
+                        sb.append(em(line.trim(), width)).append("\n");
+                    } else {
+                        sb.append(line);
+                    }
+                }
+                break;
+            }
+        }
+    }
+
+    private static String rowItemsHeader(int width, boolean showUnitPrice, boolean showQty, boolean showSubtotal) {
+        // 列宽(右侧列固定宽度),列间距让“单价 与 数量”更开一些
+        int unitW = 6;
+        int qtyW = 4;
+        int subW = 7;
+        int gapBetweenRightCols = 2; // 右侧各列之间的额外间距
+        int gapBeforeRightCols = 1;  // 左侧“品项”和第一个右列之间的间距
+        int rightCols = (showUnitPrice ? 1 : 0) + (showQty ? 1 : 0) + (showSubtotal ? 1 : 0);
+        int gapsTotal = (rightCols > 0 ? gapBeforeRightCols : 0) + Math.max(0, rightCols - 1) * gapBetweenRightCols;
+
+        int nameW = Math.max(6,
+                width
+                        - (showUnitPrice ? unitW : 0)
+                        - (showQty ? qtyW : 0)
+                        - (showSubtotal ? subW : 0)
+                        - gapsTotal);
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(padRight("品项", nameW));
+        if (rightCols > 0) sb.append(repeat(' ', gapBeforeRightCols));
+        boolean needGap = false;
+        if (showUnitPrice) {
+            sb.append(padLeft("单价", unitW));
+            needGap = true;
+        }
+        if (showQty) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft("数量", qtyW));
+            needGap = true;
+        }
+        if (showSubtotal) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft("小计", subW));
+        }
+        sb.append("\n");
+        return sb.toString();
+    }
+
+    private static String rowItem(int width, String name, String unitPrice, String qty, String subtotal) {
+        int unitW = 6;
+        int qtyW = 4;
+        int subW = 7;
+        int gapBetweenRightCols = 2;
+        int gapBeforeRightCols = 1;
+        int rightCols = (unitPrice != null ? 1 : 0) + (qty != null ? 1 : 0) + (subtotal != null ? 1 : 0);
+        int gapsTotal = (rightCols > 0 ? gapBeforeRightCols : 0) + Math.max(0, rightCols - 1) * gapBetweenRightCols;
+
+        int nameW = Math.max(6,
+                width
+                        - (unitPrice != null ? unitW : 0)
+                        - (qty != null ? qtyW : 0)
+                        - (subtotal != null ? subW : 0)
+                        - gapsTotal);
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(padRight(name, nameW));
+        if (rightCols > 0) sb.append(repeat(' ', gapBeforeRightCols));
+        boolean needGap = false;
+        if (unitPrice != null) {
+            sb.append(padLeft(unitPrice, unitW));
+            needGap = true;
+        }
+        if (qty != null) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft(qty, qtyW));
+            needGap = true;
+        }
+        if (subtotal != null) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft(subtotal, subW));
+        }
+        sb.append("\n");
+        return sb.toString();
+    }
+
+    private static String kv(String k, String v, int width) {
+        String left = k + " ";
+        String right = v;
+        int maxRight = Math.max(0, width - displayWidth(left));
+        String rightTrim = truncateToWidth(right, maxRight);
+        int gap = Math.max(0, width - displayWidth(left) - displayWidth(rightTrim));
+        return left + repeat(' ', gap) + rightTrim + "\n";
+    }
+
+    private static String center(String s, int width) {
+        if (s == null) s = "";
+        int w = displayWidth(s);
+        int pad = Math.max(0, (width - w) / 2);
+        String left = repeat(' ', pad) + s;
+        // 填满整行,避免后续拼接错位
+        int remain = Math.max(0, width - displayWidth(left));
+        return left + repeat(' ', remain);
+    }
+
+    private static String em(String s, int width) {
+        // 简单强调:居中(或左右空格加粗线外观)
+        String c = center(s, width);
+        if (c.length() < width) {
+            c = c + repeat(' ', Math.max(0, width - displayWidth(c)));
+        }
+        return c;
+    }
+
+    private static String padLeft(String s, int w) {
+        s = truncateToWidth(str(s), w);
+        int pad = Math.max(0, w - displayWidth(s));
+        return repeat(' ', pad) + s;
+    }
+
+    private static String padRight(String s, int w) {
+        s = truncateToWidth(str(s), w);
+        int pad = Math.max(0, w - displayWidth(s));
+        return s + repeat(' ', pad);
+    }
+
+    private static String repeat(char c, int n) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < n; i++) sb.append(c);
+        return sb.toString();
+    }
+
+    /**
+     * 打印机等宽字体对齐:中日韩全角字符按宽度2,ASCII按宽度1
+     */
+    private static int displayWidth(String s) {
+        if (s == null || s.isEmpty()) return 0;
+        int w = 0;
+        for (int i = 0; i < s.length(); i++) {
+            char ch = s.charAt(i);
+            // 基于 East Asian Width 的简化判断:
+            if (isFullWidth(ch)) {
+                w += 2;
+            } else {
+                w += 1;
+            }
+        }
+        return w;
+    }
+
+    private static boolean isFullWidth(char ch) {
+        // CJK Unified Ideographs, Hiragana, Katakana, Fullwidth Forms, Hangul, etc.
+        return (ch >= 0x1100 && ch <= 0x11FF)   // Hangul Jamo
+                || (ch >= 0x2E80 && ch <= 0xA4CF) // CJK Radicals, Kangxi, Yi, etc.
+                || (ch >= 0xAC00 && ch <= 0xD7AF) // Hangul Syllables
+                || (ch >= 0xF900 && ch <= 0xFAFF) // CJK Compatibility Ideographs
+                || (ch >= 0xFE10 && ch <= 0xFE6F) // Vertical forms
+                || (ch >= 0xFF00 && ch <= 0xFF60) // Fullwidth ASCII variants
+                || (ch >= 0xFFE0 && ch <= 0xFFE6); // Fullwidth symbol variants
+    }
+
+    private static String truncateToWidth(String s, int maxWidth) {
+        if (s == null) return "";
+        int w = 0;
+        StringBuilder out = new StringBuilder();
+        for (int i = 0; i < s.length(); i++) {
+            char ch = s.charAt(i);
+            int cw = isFullWidth(ch) ? 2 : 1;
+            if (w + cw > maxWidth) break;
+            out.append(ch);
+            w += cw;
+        }
+        return out.toString();
+    }
+
+    /**
+     * 将就餐时间格式化为:yyyy/MM/dd HH:mm-HH:mm
+     * 支持的原始格式示例:
+     * - 2026/01/01 12:00:00~14:00
+     * - 2026-01-01 12:00:00~14:00
+     * - 2026/01/01 12:00~14:00
+     * - 2026/01/01 12:00
+     */
+    private static String formatDiningTime(String raw) {
+        if (raw == null || raw.trim().isEmpty()) return "";
+        String s = raw.trim();
+        // 正则:日期 + 开始时间 + 分隔(~ 或 - 或 – —) + 结束时间
+        Pattern pRange = Pattern.compile("^(\\d{4}[-/]\\d{2}[-/]\\d{2})\\s+(\\d{2}:\\d{2})(?::\\d{2})?\\s*[~\\-–—]\\s*(\\d{2}:\\d{2})(?::\\d{2})?.*$");
+        Matcher m = pRange.matcher(s);
+        if (m.matches()) {
+            String date = m.group(1).replace('-', '/');
+            String start = m.group(2);
+            String end = m.group(3);
+            return date + " " + start + "-" + end;
+        }
+        // 仅有开始时间
+        Pattern pSingle = Pattern.compile("^(\\d{4}[-/]\\d{2}[-/]\\d{2})\\s+(\\d{2}:\\d{2})(?::\\d{2})?.*$");
+        Matcher m2 = pSingle.matcher(s);
+        if (m2.matches()) {
+            String date = m2.group(1).replace('-', '/');
+            String start = m2.group(2);
+            return date + " " + start;
+        }
+        // 若无法识别,回退为去秒、标准化斜杠的原串
+        s = s.replace('-', '/');
+        s = s.replaceAll("(\\d{2}:\\d{2}):\\d{2}", "$1");
+        return s;
+    }
     private static boolean childVisible(JSONArray children, String key) {
         if (children == null) return false;
         for (int i = 0; i < children.size(); i++) {