|
|
@@ -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++) {
|