|
|
@@ -3,6 +3,11 @@ package shop.alien.store.service.impl;
|
|
|
import com.alibaba.fastjson.JSON;
|
|
|
import com.alibaba.fastjson.JSONArray;
|
|
|
import com.alibaba.fastjson.JSONObject;
|
|
|
+import com.fasterxml.jackson.core.JsonParser;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
import java.time.LocalDateTime;
|
|
|
import java.util.ArrayList;
|
|
|
import java.util.HashMap;
|
|
|
@@ -14,6 +19,8 @@ import java.util.regex.Pattern;
|
|
|
|
|
|
public class PrintTemplate {
|
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(PrintTemplate.class);
|
|
|
+
|
|
|
/**
|
|
|
* 兼容旧的静态示例生成方法(保留)
|
|
|
*/
|
|
|
@@ -51,7 +58,7 @@ public class PrintTemplate {
|
|
|
if (templateConfigJson == null || templateConfigJson.trim().isEmpty()) {
|
|
|
return "";
|
|
|
}
|
|
|
- JSONObject root = JSON.parseObject(templateConfigJson);
|
|
|
+ JSONObject root = parseTemplateRoot(templateConfigJson);
|
|
|
if (root == null) {
|
|
|
return "";
|
|
|
}
|
|
|
@@ -146,10 +153,20 @@ public class PrintTemplate {
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
- // 普通文本字段
|
|
|
+ // 普通文本字段(票据名/店铺名默认仅输出值;模板 field.printTitle 为 true 时带 titleName)
|
|
|
Object value = context.get(key);
|
|
|
if (value != null && !"".equals(String.valueOf(value).trim())) {
|
|
|
- sb.append(titleName).append(":").append(value).append("\n");
|
|
|
+ if (isHeaderTicketField(key) && !field.getBooleanValue("printTitle")) {
|
|
|
+ String v = String.valueOf(value);
|
|
|
+ String a = nvl(field.getString("align"));
|
|
|
+ if ("right".equalsIgnoreCase(a)) {
|
|
|
+ sb.append(alignRight(v, 32)).append("\n");
|
|
|
+ } else {
|
|
|
+ sb.append(center(v, 32)).append("\n");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ sb.append(titleName).append(":").append(value).append("\n");
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -167,13 +184,13 @@ public class PrintTemplate {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 按照固定宽度进行精美排版,尽量贴近示例图片的样式(等宽打印机友好)。
|
|
|
- * - 顶部标题与店铺名居中
|
|
|
- * - 关键信息按 key:value 左对齐
|
|
|
- * - 品项表格对齐(名称/单价/数量/小计)
|
|
|
- * - 分割线区分模块
|
|
|
+ * 按照固定宽度进行精美排版(等宽打印机友好)。
|
|
|
+ * 根据模板 JSON 中每个 field 的 titleName、visible、align、bold、fontSize、divider、blankLines 等动态排版;
|
|
|
+ * 字段顺序与模板 sections[].fields 数组顺序一致。
|
|
|
+ * 票据名(ticketName)、店铺名(storeName) 与其它标量字段相同,受上述属性控制;默认不打印 titleName 文案,
|
|
|
+ * 若需在纸上显示「票据名称:xxx」,可在对应 field 上设 {@code "printTitle": true}。
|
|
|
*
|
|
|
- * 注意:依然基于模板可见字段决定哪些行需要显示;若未配置则不输出。
|
|
|
+ * 加粗:部分云打印接口支持 {@code <B>...</B>},若打印机原样输出可关闭模板中的 bold。
|
|
|
*/
|
|
|
public static String buildFromConfigPretty(String templateConfigJson, Map<String, Object> context, int lineWidth) {
|
|
|
if (lineWidth <= 0) {
|
|
|
@@ -182,7 +199,7 @@ public class PrintTemplate {
|
|
|
if (templateConfigJson == null || templateConfigJson.trim().isEmpty()) {
|
|
|
return "";
|
|
|
}
|
|
|
- JSONObject root = JSON.parseObject(templateConfigJson);
|
|
|
+ JSONObject root = parseTemplateRoot(templateConfigJson);
|
|
|
if (root == null) {
|
|
|
return "";
|
|
|
}
|
|
|
@@ -195,116 +212,299 @@ public class PrintTemplate {
|
|
|
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 可自行调整
|
|
|
+ String sectionKey = section.getString("key");
|
|
|
+
|
|
|
+ // 基础信息:票据名/店铺名等与单号等同一套 field 样式,并在头部与后续内容之间插入粗分割线(兼容旧版视觉)
|
|
|
+ if (Objects.equals(sectionKey, "basic")) {
|
|
|
+ boolean headerEmitted = false;
|
|
|
+ boolean thickInserted = false;
|
|
|
+ for (int j = 0; j < fields.size(); j++) {
|
|
|
+ JSONObject field = fields.getJSONObject(j);
|
|
|
+ if (field == null || !field.getBooleanValue("visible")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String fk = field.getString("key");
|
|
|
+ if (!isHeaderTicketField(fk) && headerEmitted && !thickInserted) {
|
|
|
+ sb.append(thick);
|
|
|
+ thickInserted = true;
|
|
|
+ }
|
|
|
+ boolean wrote = appendPrettyScalarField(field, context, sb, lineWidth, thin);
|
|
|
+ if (wrote && isHeaderTicketField(fk)) {
|
|
|
+ headerEmitted = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (headerEmitted && !thickInserted) {
|
|
|
+ sb.append(thick);
|
|
|
+ }
|
|
|
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));
|
|
|
+ if (Objects.equals(sectionKey, "itemInfo")) {
|
|
|
+ for (int j = 0; j < fields.size(); j++) {
|
|
|
+ JSONObject field = fields.getJSONObject(j);
|
|
|
+ if (field == null || !field.getBooleanValue("visible")) {
|
|
|
+ continue;
|
|
|
}
|
|
|
- }
|
|
|
- // 整单备注、数量合计等
|
|
|
- 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);
|
|
|
+ String fk = field.getString("key");
|
|
|
+ if ("item".equals(fk)) {
|
|
|
+ JSONArray children = field.getJSONArray("children");
|
|
|
+ boolean showUnitPrice = childVisible(children, "unitPrice");
|
|
|
+ boolean showQuantity = childVisible(children, "quantity");
|
|
|
+ boolean showSubtotal = childVisible(children, "subtotal");
|
|
|
+ String secTitle = nvl(field.getString("titleName"));
|
|
|
+ if (!secTitle.isEmpty()) {
|
|
|
+ appendPrettyRawLine(secTitle, field, sb, lineWidth);
|
|
|
+ }
|
|
|
+ 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));
|
|
|
+ }
|
|
|
+ appendFieldAffixes(field, sb, thin);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if ("orderPrice".equals(fk)) {
|
|
|
+ appendPrettyChildrenGroup(field, context, sb, lineWidth, thin, "orderTotal");
|
|
|
+ continue;
|
|
|
}
|
|
|
+ appendPrettyScalarField(field, context, sb, lineWidth, thin);
|
|
|
}
|
|
|
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()) {
|
|
|
- // 简单相加(均为负数或正负混合时请自行保证格式)
|
|
|
- // 此处仅展示由前端直接传入更安全,保留计算注释
|
|
|
- }
|
|
|
+ if (Objects.equals(sectionKey, "settlement")) {
|
|
|
+ for (int j = 0; j < fields.size(); j++) {
|
|
|
+ JSONObject field = fields.getJSONObject(j);
|
|
|
+ if (field == null || !field.getBooleanValue("visible")) {
|
|
|
+ continue;
|
|
|
}
|
|
|
- }
|
|
|
- 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); // 强调
|
|
|
+ String fk = field.getString("key");
|
|
|
+ if ("paymentDiscount".equals(fk) || "paymentInfo".equals(fk)) {
|
|
|
+ appendPrettyChildrenGroup(field, context, sb, lineWidth, thin, "paymentTotal");
|
|
|
+ continue;
|
|
|
}
|
|
|
+ appendPrettyScalarField(field, context, sb, lineWidth, thin);
|
|
|
}
|
|
|
sb.append(thin);
|
|
|
continue;
|
|
|
+ } else {
|
|
|
+ // 底栏及其他自定义 section:按字段顺序输出
|
|
|
+ for (int j = 0; j < fields.size(); j++) {
|
|
|
+ JSONObject field = fields.getJSONObject(j);
|
|
|
+ if (field == null || !field.getBooleanValue("visible")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String fk = field.getString("key");
|
|
|
+ if ("item".equals(fk) || "orderPrice".equals(fk) || "paymentDiscount".equals(fk) || "paymentInfo".equals(fk)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ appendPrettyScalarField(field, context, sb, lineWidth, thin);
|
|
|
+ }
|
|
|
}
|
|
|
+ }
|
|
|
+ sb.append("\n\n");
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
|
|
|
- // 底栏
|
|
|
- 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);
|
|
|
+ private static boolean isHeaderTicketField(String key) {
|
|
|
+ return "ticketName".equals(key) || "storeName".equals(key);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 普通标量字段:一行展示 titleName:value,并按 align / bold / fontSize 等处理。
|
|
|
+ * @return 是否有内容输出(非空值)
|
|
|
+ */
|
|
|
+ private static boolean appendPrettyScalarField(JSONObject field, Map<String, Object> ctx, StringBuilder sb, int lineWidth, String thin) {
|
|
|
+ String key = field.getString("key");
|
|
|
+ if (key == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String titleName = nvl(field.getString("titleName"));
|
|
|
+ String raw = str(ctx.get(key));
|
|
|
+ if ("diningTime".equals(key)) {
|
|
|
+ raw = formatDiningTime(raw);
|
|
|
+ }
|
|
|
+ if (raw.isEmpty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // 票据名、店铺名默认只打业务值(不拼 titleName),样式仍完全来自模板:visible / align / bold / fontSize / divider / blankLines
|
|
|
+ // 若需在纸上带出「票据名称:」等标签,将模板中对应 field 增加 "printTitle": true
|
|
|
+ String titleForLine = titleName;
|
|
|
+ if (isHeaderTicketField(key) && !field.getBooleanValue("printTitle")) {
|
|
|
+ titleForLine = "";
|
|
|
+ }
|
|
|
+ JSONObject styleField = effectiveStyleForHeaderScalar(field, key, titleForLine);
|
|
|
+ appendPrettyKeyValueLine(titleForLine, raw, styleField, sb, lineWidth);
|
|
|
+ appendFieldAffixes(field, sb, thin);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 票据名、店铺名仅输出内容时,小票惯例为居中;模板 align 为 {@code right} 时仍右对齐;
|
|
|
+ * 带 {@code printTitle} 展示「标题:值」时完全沿用模板 align。
|
|
|
+ */
|
|
|
+ private static JSONObject effectiveStyleForHeaderScalar(JSONObject field, String key, String titleForLine) {
|
|
|
+ if (!isHeaderTicketField(key) || !titleForLine.isEmpty()) {
|
|
|
+ return field;
|
|
|
+ }
|
|
|
+ String a = nvl(field.getString("align"));
|
|
|
+ if ("right".equalsIgnoreCase(a)) {
|
|
|
+ return field;
|
|
|
+ }
|
|
|
+ JSONObject copy = JSON.parseObject(field.toJSONString());
|
|
|
+ copy.put("align", "center");
|
|
|
+ return copy;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void appendPrettyKeyValueLine(String titleName, String value, JSONObject style, StringBuilder sb, int lineWidth) {
|
|
|
+ String align = nvl(style.getString("align"));
|
|
|
+ if (align.isEmpty()) {
|
|
|
+ align = "left";
|
|
|
+ }
|
|
|
+ boolean bold = style.getBooleanValue("bold");
|
|
|
+ String fontSize = nvl(style.getString("fontSize"));
|
|
|
+ if (fontSize.isEmpty()) {
|
|
|
+ fontSize = "normal";
|
|
|
+ }
|
|
|
+
|
|
|
+ if ("large".equalsIgnoreCase(fontSize)) {
|
|
|
+ sb.append("\n");
|
|
|
+ }
|
|
|
+
|
|
|
+ String core;
|
|
|
+ if ("left".equals(align)) {
|
|
|
+ if (titleName.isEmpty()) {
|
|
|
+ core = truncateToWidth(value, lineWidth);
|
|
|
+ } else {
|
|
|
+ core = kvColon(titleName, value, lineWidth).trim();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ String full = titleName.isEmpty() ? value : (titleName + ":" + value);
|
|
|
+ if ("center".equals(align)) {
|
|
|
+ core = center(full, lineWidth).trim();
|
|
|
+ } else if ("right".equals(align)) {
|
|
|
+ core = alignRight(full, lineWidth).trim();
|
|
|
+ } else {
|
|
|
+ core = titleName.isEmpty() ? truncateToWidth(value, lineWidth) : kvColon(titleName, value, lineWidth).trim();
|
|
|
}
|
|
|
}
|
|
|
- sb.append("\n");
|
|
|
- return sb.toString();
|
|
|
+ // small:纯文本无法缩字号,行前缩进以区分(与 appendPrettyRawLine 一致)
|
|
|
+ if ("small".equalsIgnoreCase(fontSize)) {
|
|
|
+ core = " " + core;
|
|
|
+ }
|
|
|
+ if (bold) {
|
|
|
+ core = "<B>" + core + "</B>";
|
|
|
+ }
|
|
|
+ sb.append(core).append("\n");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 不带「标题:值」结构的纯文本一行(如品项分组标题) */
|
|
|
+ private static void appendPrettyRawLine(String text, JSONObject style, StringBuilder sb, int lineWidth) {
|
|
|
+ JSONObject s = style == null ? new JSONObject() : style;
|
|
|
+ String align = nvl(s.getString("align"));
|
|
|
+ if (align.isEmpty()) {
|
|
|
+ align = "left";
|
|
|
+ }
|
|
|
+ boolean bold = s.getBooleanValue("bold");
|
|
|
+ String fontSize = nvl(s.getString("fontSize"));
|
|
|
+ if (fontSize.isEmpty()) {
|
|
|
+ fontSize = "normal";
|
|
|
+ }
|
|
|
+ if ("large".equalsIgnoreCase(fontSize)) {
|
|
|
+ sb.append("\n");
|
|
|
+ }
|
|
|
+ String core;
|
|
|
+ if ("center".equals(align)) {
|
|
|
+ core = center(text, lineWidth).trim();
|
|
|
+ } else if ("right".equals(align)) {
|
|
|
+ core = alignRight(text, lineWidth).trim();
|
|
|
+ } else {
|
|
|
+ core = truncateToWidth(text, lineWidth);
|
|
|
+ }
|
|
|
+ if ("small".equalsIgnoreCase(fontSize)) {
|
|
|
+ core = " " + core;
|
|
|
+ }
|
|
|
+ if (bold) {
|
|
|
+ core = "<B>" + core + "</B>";
|
|
|
+ }
|
|
|
+ sb.append(core).append("\n");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void appendFieldAffixes(JSONObject field, StringBuilder sb, String thin) {
|
|
|
+ if (field.getBooleanValue("divider")) {
|
|
|
+ sb.append(thin);
|
|
|
+ }
|
|
|
+ int blankLines = field.getIntValue("blankLines");
|
|
|
+ for (int b = 0; b < blankLines; b++) {
|
|
|
+ sb.append("\n");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 带子项的分组字段:按 children 顺序渲染;子项 key 为 divider 时输出分割线。
|
|
|
+ * emphasizeTotalKey 对应的行使用居中强调(兼容原「订单合计 / 支付合计」样式)。
|
|
|
+ */
|
|
|
+ private static void appendPrettyChildrenGroup(JSONObject groupField, Map<String, Object> ctx, StringBuilder sb,
|
|
|
+ int lineWidth, String thin, String emphasizeTotalKey) {
|
|
|
+ JSONArray children = groupField.getJSONArray("children");
|
|
|
+ if (children == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ for (int k = 0; k < children.size(); k++) {
|
|
|
+ JSONObject child = children.getJSONObject(k);
|
|
|
+ if (child == null || !child.getBooleanValue("visible")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String subKey = child.getString("key");
|
|
|
+ if ("divider".equals(subKey)) {
|
|
|
+ sb.append(thin);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String subTitle = nvl(child.getString("titleName"));
|
|
|
+ String val = str(ctx.get(subKey));
|
|
|
+ if (val.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ boolean emphasize = emphasizeTotalKey != null && emphasizeTotalKey.equals(subKey);
|
|
|
+ if (emphasize) {
|
|
|
+ String line = kvColon(subTitle, val, lineWidth).trim();
|
|
|
+ sb.append(em(line, lineWidth)).append("\n");
|
|
|
+ } else {
|
|
|
+ appendPrettyKeyValueLine(subTitle, val, child, sb, lineWidth);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ appendFieldAffixes(groupField, sb, thin);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String kvColon(String title, String v, int width) {
|
|
|
+ String left = title + ":";
|
|
|
+ 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 alignRight(String s, int width) {
|
|
|
+ int w = displayWidth(s);
|
|
|
+ int pad = Math.max(0, width - w);
|
|
|
+ return repeat(' ', pad) + s;
|
|
|
}
|
|
|
|
|
|
private static JSONObject fieldOf(JSONArray fields, String key) {
|
|
|
@@ -558,6 +758,94 @@ public class PrintTemplate {
|
|
|
s = s.replaceAll("(\\d{2}:\\d{2}):\\d{2}", "$1");
|
|
|
return s;
|
|
|
}
|
|
|
+ /**
|
|
|
+ * 解析门店票据模板 JSON。库中内容可能含尾随逗号、被二次序列化成字符串、或仅 Jackson 能容忍的格式;
|
|
|
+ * 解析失败时返回 null(调用方得到空内容),并打日志,避免打印接口直接 500。
|
|
|
+ */
|
|
|
+ private static JSONObject parseTemplateRoot(String raw) {
|
|
|
+ if (raw == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String s = raw.trim();
|
|
|
+ if (s.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (s.charAt(0) == '\uFEFF') {
|
|
|
+ s = s.substring(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ JSONObject o = tryFastjsonParseObject(s);
|
|
|
+ if (o != null) {
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ String noTrailing = stripTrailingCommas(s);
|
|
|
+ if (!noTrailing.equals(s)) {
|
|
|
+ o = tryFastjsonParseObject(noTrailing);
|
|
|
+ if (o != null) {
|
|
|
+ log.info("票据模板 JSON 已去除尾随逗号后解析成功");
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+ s = noTrailing;
|
|
|
+ }
|
|
|
+
|
|
|
+ o = tryJacksonToFastjsonObject(s);
|
|
|
+ if (o != null) {
|
|
|
+ log.info("票据模板 JSON 已通过 Jackson 兼容解析成功");
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.warn("票据模板 JSON 无法解析,跳过模板渲染。若需打印请检查库中 template_config_json 是否为合法 JSON。");
|
|
|
+ if (log.isDebugEnabled()) {
|
|
|
+ log.debug("模板 JSON 原文(截断前800字符): {}", s.length() > 800 ? s.substring(0, 800) + "..." : s);
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 支持根节点为对象,或整段被引号包成字符串且内层仍是 JSON 字符串的情况 */
|
|
|
+ private static JSONObject tryFastjsonParseObject(String s) {
|
|
|
+ try {
|
|
|
+ Object v = JSON.parse(s);
|
|
|
+ int guard = 0;
|
|
|
+ while (v instanceof String && guard++ < 6) {
|
|
|
+ v = JSON.parse(((String) v).trim());
|
|
|
+ }
|
|
|
+ if (v instanceof JSONObject) {
|
|
|
+ return (JSONObject) v;
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // fall through
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 去掉 ,] 与 ,} 形式的尾随逗号,便于兼容手写/导出 JSON */
|
|
|
+ private static String stripTrailingCommas(String json) {
|
|
|
+ if (json == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String cur = json;
|
|
|
+ String prev;
|
|
|
+ do {
|
|
|
+ prev = cur;
|
|
|
+ cur = cur.replaceAll(",(\\s*[}\\]])", "$1");
|
|
|
+ } while (!cur.equals(prev));
|
|
|
+ return cur;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Jackson 对尾随逗号等更宽松,再转回 Fastjson 对象供后续逻辑使用 */
|
|
|
+ private static JSONObject tryJacksonToFastjsonObject(String s) {
|
|
|
+ try {
|
|
|
+ ObjectMapper om = new ObjectMapper();
|
|
|
+ // 使用 JsonParser.Feature,避免部分版本无 ObjectMapper.enable(JsonReadFeature) 重载导致编译失败
|
|
|
+ om.configure(JsonParser.Feature.ALLOW_TRAILING_COMMA, true);
|
|
|
+ Object tree = om.readValue(s, Object.class);
|
|
|
+ return JSON.parseObject(JSON.toJSONString(tree));
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private static boolean childVisible(JSONArray children, String key) {
|
|
|
if (children == null) return false;
|
|
|
for (int i = 0; i < children.size(); i++) {
|