Browse Source

实现电子发票证明联PDF及平台只读查看

qmj 1 day ago
parent
commit
5fc185052a
35 changed files with 1540 additions and 2 deletions
  1. 13 0
      ruoyi-admin/pom.xml
  2. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosOrderInvoiceController.java
  3. 5 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java
  4. 25 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  5. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintRequest.java
  6. 112 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintSnapshot.java
  7. 190 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRenderer.java
  8. 141 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintService.java
  9. 24 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/EzPay.java
  10. 16 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  11. 16 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  12. 16 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  13. 16 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  14. 16 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  15. 92 0
      ruoyi-admin/src/main/resources/invoice/fonts/LICENSE
  16. BIN
      ruoyi-admin/src/main/resources/invoice/fonts/NotoSansTC-Regular.ttf
  17. 5 0
      ruoyi-admin/src/main/resources/invoice/fonts/README.md
  18. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/UserInvoiceAuthResponseTest.java
  19. 55 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/AdminInvoicePdfAccessTest.java
  20. 148 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintConcurrencyTest.java
  21. 66 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintSnapshotTest.java
  22. 86 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRendererTest.java
  23. 60 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintControllerTest.java
  24. 122 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintServiceTest.java
  25. 28 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoicePrintJob.java
  26. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoiceMapper.java
  27. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoicePrintJobMapper.java
  28. 5 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoiceMapper.xml
  29. 34 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoicePrintJobMapper.xml
  30. 96 0
      specs/010-order-invoice/frontend-app-printing.md
  31. 19 0
      specs/010-order-invoice/plan.md
  32. 10 0
      specs/010-order-invoice/research.md
  33. 22 0
      specs/010-order-invoice/spec.md
  34. 26 0
      specs/010-order-invoice/tasks.md
  35. 31 0
      updatesql/sql.md

+ 13 - 0
ruoyi-admin/pom.xml

@@ -17,6 +17,19 @@
 
 
     <dependencies>
     <dependencies>
 
 
+        <!-- 010 证明联PDF:嵌入繁体字体、保留真实纸张尺寸,App无需重排法定版式。 -->
+        <dependency>
+            <groupId>org.apache.pdfbox</groupId>
+            <artifactId>pdfbox</artifactId>
+            <version>3.0.8</version>
+        </dependency>
+        <dependency>
+            <groupId>com.h2database</groupId>
+            <artifactId>h2</artifactId>
+            <version>2.4.240</version>
+            <scope>test</scope>
+        </dependency>
+
         <!-- spring-boot-devtools -->
         <!-- spring-boot-devtools -->
         <dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <groupId>org.springframework.boot</groupId>

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosOrderInvoiceController.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.mendian;
 package com.ruoyi.app.mendian;
 
 
 import com.ruoyi.app.order.OrderInvoiceService;
 import com.ruoyi.app.order.OrderInvoiceService;
+import com.ruoyi.app.order.invoice.MerchantInvoicePrintService;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -26,6 +27,16 @@ public class PosOrderInvoiceController extends BaseController {
     @Autowired
     @Autowired
     private OrderInvoiceService orderInvoiceService;
     private OrderInvoiceService orderInvoiceService;
 
 
+    @Autowired
+    private MerchantInvoicePrintService merchantInvoicePrintService;
+
+    /** 平台仅查看已有PDF,沿用发票详情权限,不提供打印/补印操作。 */
+    @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:query')")
+    @GetMapping("/{orderId}/pdf")
+    public AjaxResult getPdf(@PathVariable Long orderId) {
+        return success(merchantInvoicePrintService.storedPdf(orderId));
+    }
+
     /** 订单发票列表(分页 + 筛选)。 */
     /** 订单发票列表(分页 + 筛选)。 */
     @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:list')")
     @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:list')")
     @GetMapping("/list")
     @GetMapping("/list")

+ 5 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java

@@ -379,6 +379,8 @@ public class OrderInvoiceService {
     }
     }
 
 
     /** 商家端作废发票(校验归属后复用作废逻辑)。 */
     /** 商家端作废发票(校验归属后复用作废逻辑)。 */
+    // 内部调用invalid不会经过Spring代理,商家入口也必须开启事务,持锁覆盖ezPay作废请求。
+    @org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
     public int invalidForMerchant(Long orderId, String reason, Long merchantUserId) {
     public int invalidForMerchant(Long orderId, String reason, Long merchantUserId) {
         requireOwnedOrder(orderId, merchantUserId);
         requireOwnedOrder(orderId, merchantUserId);
         return invalid(orderId, reason);
         return invalid(orderId, reason);
@@ -463,9 +465,10 @@ public class OrderInvoiceService {
     }
     }
 
 
     /** 管理端:作废发票(仅 已开 单,调 ezPay invoice_invalid)。 */
     /** 管理端:作废发票(仅 已开 单,调 ezPay invoice_invalid)。 */
+    @org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
     public int invalid(Long orderId, String reason) {
     public int invalid(Long orderId, String reason) {
-        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
-                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+        // 必须在远端作废之前与打印共用行锁;只锁最后的本地UPDATE挡不住生成已作废证明联。
+        PosOrderInvoice row = posOrderInvoiceMapper.selectForPrintUpdate(orderId);
         if (row == null || !Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
         if (row == null || !Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
             throw new ServiceException("仅已开发票可作废");
             throw new ServiceException("仅已开发票可作废");
         }
         }

+ 25 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -102,6 +102,31 @@ public class PosOrderShOprateController extends BaseController {
     @Autowired
     @Autowired
     private MerchantOrderPrintService merchantOrderPrintService;
     private MerchantOrderPrintService merchantOrderPrintService;
 
 
+    @Autowired
+    private com.ruoyi.app.order.invoice.MerchantInvoicePrintService merchantInvoicePrintService;
+
+    /** 正式证明联由后端排版;复用商家订单权限,不允许App自传开票金额或防伪码。 */
+    @PostMapping("/printInvoice")
+    @Auth
+    @Anonymous
+    public AjaxResult printInvoice(@RequestHeader String token,
+            @RequestBody com.ruoyi.app.order.invoice.InvoicePrintRequest request) {
+        if (request == null || request.getOrderId() == null) {
+            throw new ServiceException(MessageUtils.message("invoice.print.invalidRequest"));
+        }
+        requireManagedOrder(request.getOrderId());
+        return success(merchantInvoicePrintService.print(request, AuthContext.requireUserId()));
+    }
+
+    /** 普通重复查看提供无兑奖码的交易明细,不消耗首次证明联打印资格。 */
+    @GetMapping("/getInvoicePrintDetail")
+    @Auth
+    @Anonymous
+    public AjaxResult getInvoicePrintDetail(@RequestHeader String token, @RequestParam Long orderId) {
+        requireManagedOrder(orderId);
+        return success(merchantInvoicePrintService.detail(orderId));
+    }
+
     /** 小票仅提供授权订单的快照数据,读取接口不触发打印或订单状态变化。 */
     /** 小票仅提供授权订单的快照数据,读取接口不触发打印或订单状态变化。 */
     @GetMapping("/getOrderPrintInfo")
     @GetMapping("/getOrderPrintInfo")
     @Auth
     @Auth

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintRequest.java

@@ -0,0 +1,17 @@
+package com.ruoyi.app.order.invoice;
+
+import lombok.Data;
+
+/** App只传订单身份和操作声明,版式、金额、防伪码及是否补印均由服务端决定。 */
+@Data
+public class InvoicePrintRequest {
+    private Long orderId;
+    /** 每次实际打印意图生成UUID;网络重试复用,不能每次重试生成新值。 */
+    private String requestId;
+    /** 首次为false;再次申请须显式为true,防止连点或旧页面自动消耗补印次数。 */
+    private Boolean reprint;
+    private String reprintReason;
+    /** 补印仅限原联记载不清等法定情形,必须保有原联并核对内容一致。 */
+    private Boolean originalAvailable;
+    private Boolean contentsVerified;
+}

+ 112 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintSnapshot.java

@@ -0,0 +1,112 @@
+package com.ruoyi.app.order.invoice;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.ezPayCrypto.EzPayEncryptUtil;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrderInvoice;
+import com.ruoyi.system.domain.PosStoreEzpay;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.ResolverStyle;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/** 只使用实际开票快照排版;订单现价、配送费和当前商品资料不能替代发票数据。 */
+public record InvoicePrintSnapshot(String sellerName, String invoiceNumber, String period, String issuedAt,
+        String randomNum, String sellerUbn, String buyerUbn, boolean businessBuyer,
+        BigDecimal sales, BigDecimal tax, BigDecimal total, List<Item> items,
+        String barCode, String qrLeft, String qrRight) {
+
+    public record Item(String name, BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount) { }
+
+    public static InvoicePrintSnapshot from(JSONObject r, PosOrderInvoice local, PosStoreEzpay config,
+                                             String sellerName) {
+        try {
+            require(r != null && Objects.equals(local.getInvoiceStatus(), 1), "unavailable");
+            require("1".equals(r.getString("InvoiceStatus")), "unavailable");
+            require("Y".equals(r.getString("PrintFlag")) && blank(r.getString("LoveCode"))
+                    && blank(r.getString("CarrierNum")) && blank(r.getString("CarrierType")), "notPaper");
+            // 当前系统只开一般税额应税票。其它税制不能静默套用格式25和TX税别。
+            require("07".equals(r.getString("InvoiceType")) && "1".equals(r.getString("TaxType")), "unsupported");
+            String category = required(r, "Category");
+            require("B2C".equals(category) || "B2B".equals(category), "invalidData");
+            require(Objects.equals(required(r, "MerchantID"), config.getMerchantId())
+                    && Objects.equals(required(r, "MerchantOrderNo"), local.getOrderNo())
+                    && Objects.equals(required(r, "InvoiceTransNo"), local.getInvoiceTransNo())
+                    && Objects.equals(required(r, "InvoiceNumber"), local.getInvoiceNumber())
+                    && Objects.equals(required(r, "RandomNum"), local.getRandomNum()), "invalidData");
+            String signature = EzPayEncryptUtil.genCheckCode(Map.of(
+                    "MerchantID", required(r, "MerchantID"), "MerchantOrderNo", required(r, "MerchantOrderNo"),
+                    "InvoiceTransNo", required(r, "InvoiceTransNo"), "RandomNum", required(r, "RandomNum"),
+                    "TotalAmt", required(r, "TotalAmt")), config.getHashKey(), config.getHashIv());
+            require(signature.equalsIgnoreCase(required(r, "CheckCode")), "invalidData");
+            BigDecimal total = amount(r, "TotalAmt"), sales = amount(r, "Amt"), tax = amount(r, "TaxAmt");
+            require(local.getTotalAmt() != null && total.compareTo(BigDecimal.valueOf(local.getTotalAmt())) == 0
+                    && sales.add(tax).compareTo(total) == 0, "invalidData");
+            String number = required(r, "InvoiceNumber"), random = required(r, "RandomNum");
+            require(number.matches("[A-Z]{2}[0-9]{8}") && random.matches("[0-9]{4}"), "invalidData");
+            String issuedAt = required(r, "CreateTime");
+            LocalDateTime date = LocalDateTime.parse(issuedAt,
+                    DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT));
+            int year = date.getYear() - 1911, endMonth = (date.getMonthValue() + 1) / 2 * 2;
+            require(year > 0 && year < 1000, "invalidData");
+            String bar = required(r, "BarCode"), left = required(r, "QRcodeL"), right = required(r, "QRcodeR");
+            require(bar.equals(String.format("%03d%02d%s%s", year, endMonth, number, random)), "invalidData");
+            // 财政部左码固定头:号码10、民国日期7、随机码4、销售/总额各8、买卖统编各8。
+            // 防伪密文及后续商品编码保持原文,不用商家密钥自行重造二维码。
+            String head = number + String.format("%03d%02d%02d", year, date.getMonthValue(), date.getDayOfMonth()) + random;
+            require(left.length() >= 77 && left.startsWith(head) && right.startsWith("**"), "invalidData");
+            require(new BigDecimal(Long.parseLong(left.substring(21, 29), 16)).compareTo(sales) == 0
+                    && new BigDecimal(Long.parseLong(left.substring(29, 37), 16)).compareTo(total) == 0, "invalidData");
+            String buyer = blank(r.getString("BuyerUBN")) ? "00000000" : r.getString("BuyerUBN");
+            String seller = left.substring(45, 53);
+            require(buyer.matches("[0-9]{8}") && seller.matches("[0-9]{8}") && !seller.equals("00000000")
+                    && left.substring(37, 45).equals(buyer), "invalidData");
+            boolean business = "B2B".equals(category) || !buyer.equals("00000000");
+            require(!"B2B".equals(category) || !buyer.equals("00000000"), "invalidData");
+            JSONArray rows = r.getJSONArray("ItemDetail");
+            require(rows != null && !rows.isEmpty() && rows.size() <= 500, "invalidData");
+            List<Item> items = new ArrayList<>();
+            for (int i = 0; i < rows.size(); i++) {
+                JSONObject row = rows.getJSONObject(i);
+                String name = required(row, "ItemName");
+                BigDecimal quantity = amount(row, "ItemCount"), price = amount(row, "ItemPrice");
+                // 官方查询字段为ItemAmount,兼容部分回应仅提供ItemAmt,不从订单金额推算。
+                BigDecimal lineAmount = amount(row, blank(row.getString("ItemAmount")) ? "ItemAmt" : "ItemAmount");
+                require(quantity.signum() > 0 && name.length() <= 1000 && price.multiply(quantity).compareTo(lineAmount) == 0,
+                        "invalidData");
+                require(blank(row.getString("ItemTaxType")) || "1".equals(row.getString("ItemTaxType")), "unsupported");
+                items.add(new Item(name, quantity, price, lineAmount));
+            }
+            require(!blank(sellerName) && sellerName.length() <= 200, "invalidData");
+            return new InvoicePrintSnapshot(sellerName, number, String.format("%03d年%02d-%02d月", year, endMonth - 1, endMonth),
+                    issuedAt, random, seller, business ? buyer : "", business, sales, tax, total, List.copyOf(items), bar, left, right);
+        } catch (ServiceException e) {
+            throw e;
+        } catch (RuntimeException e) {
+            throw error("invalidData");
+        }
+    }
+
+    private static BigDecimal amount(JSONObject row, String key) {
+        BigDecimal value = new BigDecimal(required(row, key));
+        require(value.signum() >= 0 && value.precision() <= 14 && value.scale() <= 6, "invalidData");
+        return value;
+    }
+
+    private static String required(JSONObject row, String key) {
+        String value = row.getString(key);
+        require(!blank(value), "invalidData");
+        return value;
+    }
+
+    static boolean blank(String value) { return value == null || value.isBlank(); }
+    static void require(boolean valid, String code) { if (!valid) throw error(code); }
+    static ServiceException error(String code) { return new ServiceException(MessageUtils.message("invoice.print." + code)); }
+}

+ 190 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRenderer.java

@@ -0,0 +1,190 @@
+package com.ruoyi.app.order.invoice;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.MultiFormatWriter;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** 财政部2026-04-22附件一格式一;毫米坐标,不把57mm证明联缩放成普通小票。 */
+@Component
+public class InvoiceProofPdfRenderer {
+    private static final float MM = 72f / 25.4f;
+    private static final float WIDTH = 57 * MM;
+    private static final float TEXT_SIZE = 9.2f;
+    private static final String FONT = "/invoice/fonts/NotoSansTC-Regular.ttf";
+    private static final String PRIZE_URL = "https://invoice.etax.nat.gov.tw/";
+
+    public byte[] proof(InvoicePrintSnapshot s, boolean reprint) throws Exception {
+        try (PDDocument doc = new PDDocument(); InputStream input = getClass().getResourceAsStream(FONT)) {
+            PDType0Font font = PDType0Font.load(doc, input, true);
+            List<String> detail = detailLines(s, font);
+            // B2B明细与证明联同页不裁切;B2C另页,对应纸卷裁切边界。
+            float height = s.businessBuyer() ? 94 + detail.size() * 4.5f + 5 : 90;
+            InvoicePrintSnapshot.require(height < 5000, "tooLarge");
+            PDPage page = addPage(doc, height);
+            try (var out = new PDPageContentStream(doc, page)) {
+                text(out, page, font, s.sellerName(), 2, 8, TEXT_SIZE, false, 53);
+                // 字高不通过缩小字号适配;长标题仅水平压缩,补印必须与标题并列。
+                text(out, page, font, "電子發票證明聯" + (reprint ? " 補印" : ""), 2, 17, 19, false, 53);
+                text(out, page, font, s.period(), 2, 26, 22, true, 53);
+                text(out, page, font, s.invoiceNumber(), 2, 35, 22, true, 53);
+                text(out, page, font, s.issuedAt(), 2, 41, TEXT_SIZE, false, 53);
+                text(out, page, font, "隨機碼" + s.randomNum() + "  總計" + n(s.total()), 2, 46, TEXT_SIZE, false, 53);
+                text(out, page, font, "賣方" + s.sellerUbn(), 2, 51, TEXT_SIZE, false, 53);
+                if (s.businessBuyer()) {
+                    text(out, page, font, "買方" + s.buyerUbn() + "  格式25", 2, 56, TEXT_SIZE, false, 53);
+                }
+                matrix(out, page, s.barCode(), BarcodeFormat.CODE_39, "UTF-8", 2, 59, 53, 6);
+                String charset = qrCharset(s.qrLeft());
+                // 两码的实际黑白符号同高同宽、上缘对齐;外侧保留至少四模块白色静区。
+                // 不能将不同版本二维码连同静区一起缩成同尺寸,否则实际符号大小不同。
+                matrix(out, page, s.qrLeft(), BarcodeFormat.QR_CODE, charset, 5, 68.5f, 17.5f, 17.5f);
+                matrix(out, page, s.qrRight(), BarcodeFormat.QR_CODE, charset, 34, 68.5f, 17.5f, 17.5f);
+                if (s.businessBuyer()) lines(out, page, font, detail, 94);
+            }
+            if (!s.businessBuyer()) detailPage(doc, font, detail);
+            return save(doc);
+        }
+    }
+
+    /** 供App查看或普通重复打印,故意不含证明联标题及兑奖条码,也不占用首次打印次数。 */
+    public byte[] detail(InvoicePrintSnapshot s) throws Exception {
+        try (PDDocument doc = new PDDocument(); InputStream input = getClass().getResourceAsStream(FONT)) {
+            PDType0Font font = PDType0Font.load(doc, input, true);
+            detailPage(doc, font, detailLines(s, font));
+            return save(doc);
+        }
+    }
+
+    /** 背面单独返回,避免普通热敏打印机误将背面内容顺序打印到证明联正面。 */
+    public byte[] reverse() throws Exception {
+        try (PDDocument doc = new PDDocument(); InputStream input = getClass().getResourceAsStream(FONT)) {
+            PDType0Font font = PDType0Font.load(doc, input, true);
+            PDPage page = addPage(doc, 90);
+            try (var out = new PDPageContentStream(doc, page)) {
+                lines(out, page, font, List.of("領獎收據", "金額(新臺幣):", "______________________",
+                        "中獎人簽名(正楷)或蓋章:", "______________________", "電話:________________", "",
+                        "兌獎若有疑義,請洽客服專線:", "(手機請加02)4128282;",
+                        "或請至下列網址查詢", PRIZE_URL), 7);
+                matrix(out, page, PRIZE_URL, BarcodeFormat.QR_CODE, "UTF-8", 18.5f, 63, 20, 20);
+            }
+            return save(doc);
+        }
+    }
+
+    private static String qrCharset(String left) {
+        String[] sections = left.substring(77).split(":", 6);
+        InvoicePrintSnapshot.require(sections.length >= 5 && ("0".equals(sections[4]) || "1".equals(sections[4])), "invalidData");
+        return "0".equals(sections[4]) ? "Big5" : "UTF-8";
+    }
+
+    private static List<String> detailLines(InvoicePrintSnapshot s, PDType0Font font) throws Exception {
+        List<String> result = new ArrayList<>();
+        for (String header : List.of("交易明細(非兌獎憑證)", s.sellerName(), "發票號碼 " + s.invoiceNumber(), s.issuedAt())) {
+            wrap(result, header, font);
+        }
+        for (var item : s.items()) {
+            wrap(result, item.name(), font);
+            wrap(result, "數量 " + n(item.quantity()) + "  單價 " + n(item.unitPrice()), font);
+            wrap(result, "金額 " + n(item.amount()) + "  TX", font);
+        }
+        if (s.businessBuyer()) {
+            wrap(result, "應稅銷售額 " + n(s.sales()), font);
+            wrap(result, "零稅率銷售額 0", font);
+            wrap(result, "免稅銷售額 0", font);
+            wrap(result, "稅額 " + n(s.tax()), font);
+        }
+        wrap(result, "總計 " + n(s.total()), font);
+        return result;
+    }
+
+    private static void wrap(List<String> lines, String value, PDType0Font font) throws Exception {
+        StringBuilder line = new StringBuilder();
+        // 按Unicode码点换行,长品名不截断,不用省略号丢失开票明细。
+        for (int point : value.codePoints().toArray()) {
+            if (Character.isISOControl(point)) point = ' ';
+            String character = new String(Character.toChars(point));
+            if (font.getStringWidth(line + character) / 1000 * TEXT_SIZE > 53 * MM && !line.isEmpty()) {
+                lines.add(line.toString()); line.setLength(0);
+            }
+            line.append(character);
+        }
+        if (!line.isEmpty()) lines.add(line.toString());
+    }
+
+    private static void detailPage(PDDocument doc, PDType0Font font, List<String> detail) throws Exception {
+        float height = Math.max(40, 12 + detail.size() * 4.5f);
+        InvoicePrintSnapshot.require(height < 5000, "tooLarge");
+        PDPage page = addPage(doc, height);
+        try (var out = new PDPageContentStream(doc, page)) { lines(out, page, font, detail, 7); }
+    }
+
+    private static void lines(PDPageContentStream out, PDPage page, PDType0Font font, List<String> lines, float top) throws Exception {
+        for (String line : lines) {
+            if (!line.isEmpty()) text(out, page, font, line, 2, top, TEXT_SIZE, false, 53);
+            top += 4.5f;
+        }
+    }
+
+    private static PDPage addPage(PDDocument doc, float heightMm) {
+        var page = new PDPage(new PDRectangle(WIDTH, heightMm * MM)); doc.addPage(page); return page;
+    }
+
+    private static void text(PDPageContentStream out, PDPage page, PDType0Font font, String value,
+                             float x, float top, float size, boolean bold, float maxWidth) throws Exception {
+        value = value.replaceAll("\\p{Cntrl}", " ");
+        out.saveGraphicsState();
+        out.beginText(); out.setFont(font, size);
+        // 采用同字体描边加粗,避免数字/年期别替换字体后改变繁体字形与实际字高。
+        if (bold) { out.setRenderingMode(RenderingMode.FILL_STROKE); out.setLineWidth(0.35f); }
+        float width = font.getStringWidth(value) / 1000 * size;
+        out.setHorizontalScaling(Math.min(100, maxWidth * MM / width * 100));
+        out.newLineAtOffset(x * MM, page.getMediaBox().getHeight() - top * MM);
+        out.showText(value); out.endText(); out.restoreGraphicsState();
+    }
+
+    private static void matrix(PDPageContentStream out, PDPage page, String value, BarcodeFormat format,
+                               String charset, float x, float top, float width, float height) throws Exception {
+        // 原始码值绘制成矢量矩形;不经有损压缩、不截断,也不将店名/金额重新编码成假二维码。
+        Map<EncodeHintType, Object> hints = format == BarcodeFormat.QR_CODE
+                ? Map.of(EncodeHintType.CHARACTER_SET, charset, EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L,
+                         EncodeHintType.MARGIN, 0)
+                : Map.of(EncodeHintType.MARGIN, 20);
+        BitMatrix bits = new MultiFormatWriter().encode(value, format, 0, 0, hints);
+        float dx = width * MM / bits.getWidth(), dy = height * MM / bits.getHeight();
+        float left = x * MM;
+        if (format == BarcodeFormat.CODE_39) {
+            // 19位Code39在57mm纸上若拉伸为分数像素,203dpi热敏输出会破坏窄宽条比例。
+            // 以203dpi整数点宽居中排版,保留10模块左右静区;PDF仍是可缩放矢量。
+            dx = 72f / 203;
+            left = (float) Math.floor((WIDTH / dx - bits.getWidth()) / 2) * dx;
+        }
+        for (int row = 0; row < bits.getHeight(); row++) {
+            for (int col = 0; col < bits.getWidth(); col++) {
+                if (bits.get(col, row)) out.addRect(left + col * dx,
+                        page.getMediaBox().getHeight() - top * MM - (row + 1) * dy, dx, dy);
+            }
+        }
+        out.fill();
+    }
+
+    private static byte[] save(PDDocument doc) throws Exception {
+        var output = new ByteArrayOutputStream(); doc.save(output); return output.toByteArray();
+    }
+    private static String n(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
+}

+ 141 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintService.java

@@ -0,0 +1,141 @@
+package com.ruoyi.app.order.invoice;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.app.utils.ezPay.EzPay;
+import com.ruoyi.app.utils.ezPay.EzPayConfig;
+import com.ruoyi.system.domain.PosOrderInvoice;
+import com.ruoyi.system.domain.PosOrderInvoicePrintJob;
+import com.ruoyi.system.mapper.PosOrderInvoiceMapper;
+import com.ruoyi.system.mapper.PosOrderInvoicePrintJobMapper;
+import com.ruoyi.system.mapper.PosStoreEzpayMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.domain.PosStoreEzpay;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Base64;
+import java.util.Date;
+import java.util.Objects;
+
+import static com.ruoyi.app.order.invoice.InvoicePrintSnapshot.*;
+
+@Service
+public class MerchantInvoicePrintService {
+    private final PosOrderInvoiceMapper invoices;
+    private final PosOrderInvoicePrintJobMapper jobs;
+    private final PosStoreEzpayMapper configs;
+    private final PosStoreMapper stores;
+    private final EzPay ezPay;
+    private final InvoiceProofPdfRenderer renderer;
+    @Value("${ezpay.base-url}")
+    private String baseUrl;
+
+    public MerchantInvoicePrintService(PosOrderInvoiceMapper invoices, PosOrderInvoicePrintJobMapper jobs,
+            PosStoreEzpayMapper configs, PosStoreMapper stores, EzPay ezPay, InvoiceProofPdfRenderer renderer) {
+        this.invoices = invoices; this.jobs = jobs; this.configs = configs;
+        this.stores = stores; this.ezPay = ezPay; this.renderer = renderer;
+    }
+
+    public record DetailResult(String invoiceNumber, int printCount, String fileName, String contentType,
+                               String pdfBase64) { }
+    public record PrintResult(Long printJobId, String invoiceNumber, int printSequence, boolean reprint,
+                              String fileName, String contentType, String pdfBase64, String reversePdfBase64) { }
+
+    public record StoredPdfResult(String fileName, String contentType, String pdfBase64) { }
+
+    /** 平台查看已保存文件,不联网查票、不重新排版,也不分配新的打印序号。 */
+    public StoredPdfResult storedPdf(Long orderId) {
+        PosOrderInvoicePrintJob job = jobs.selectLatestForCurrentInvoice(orderId);
+        if (job == null) return null;
+        return new StoredPdfResult(job.getInvoiceNumber() + "-" + job.getPrintSequence() + ".pdf",
+                "application/pdf", encode(job.getPdfContent()));
+    }
+
+    /** 只读交易明细,供重复查看和展示本系统打印次数;不生成第二份兑奖证明联。 */
+    public DetailResult detail(Long orderId) {
+        PosOrderInvoice invoice = invoices.selectOne(new LambdaQueryWrapper<PosOrderInvoice>()
+                .eq(PosOrderInvoice::getOrderId, orderId));
+        InvoicePrintSnapshot data = query(invoice);
+        try {
+            return new DetailResult(invoice.getInvoiceNumber(), jobs.countForInvoice(invoice.getId(), invoice.getInvoiceTransNo()),
+                    invoice.getInvoiceNumber() + "-detail.pdf", "application/pdf", encode(renderer.detail(data)));
+        } catch (Exception e) { throw error("renderFailed"); }
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public PrintResult print(InvoicePrintRequest request, Long userId) {
+        require(request != null && request.getOrderId() != null && userId != null
+                && request.getRequestId() != null && request.getRequestId().matches("[A-Za-z0-9_-]{16,64}")
+                && request.getReprint() != null, "invalidRequest");
+        if (Boolean.TRUE.equals(request.getReprint())) {
+            require(!blank(request.getReprintReason()) && request.getReprintReason().length() <= 200
+                    && Boolean.TRUE.equals(request.getOriginalAvailable()) && Boolean.TRUE.equals(request.getContentsVerified()),
+                    "reprintConfirmation");
+        } else {
+            require(blank(request.getReprintReason()) && !Boolean.TRUE.equals(request.getOriginalAvailable())
+                    && !Boolean.TRUE.equals(request.getContentsVerified()), "invalidRequest");
+        }
+        // 多实例共享数据库行锁;不能仅用本机synchronized或先查次数再异步写记录。
+        PosOrderInvoice invoice = invoices.selectForPrintUpdate(request.getOrderId());
+        InvoicePrintSnapshot data = query(invoice);
+        PosOrderInvoicePrintJob existing = jobs.selectByRequest(request.getOrderId(), request.getRequestId());
+        if (existing != null) {
+            // 作废/重开后不能用旧幂等键取回旧证明联;不同操作者也不能复用别人的申请键。
+            require(Objects.equals(existing.getInvoiceTransNo(), invoice.getInvoiceTransNo())
+                    && Objects.equals(existing.getInvoiceNumber(), invoice.getInvoiceNumber())
+                    && Objects.equals(existing.getRequestedBy(), userId)
+                    && (existing.getPrintSequence() > 1) == request.getReprint()
+                    && Objects.equals(normalize(existing.getReprintReason()), normalize(request.getReprintReason())), "requestConflict");
+            return response(existing);
+        }
+        int count = jobs.countForInvoice(invoice.getId(), invoice.getInvoiceTransNo());
+        require((count > 0) == request.getReprint(), count > 0 ? "alreadyPrinted" : "firstPrintRequired");
+        byte[] pdf;
+        try { pdf = renderer.proof(data, count > 0); }
+        catch (Exception e) { throw error("renderFailed"); }
+        require(pdf.length <= 8 * 1024 * 1024, "tooLarge");
+        var job = new PosOrderInvoicePrintJob();
+        job.setInvoiceId(invoice.getId()); job.setOrderId(invoice.getOrderId()); job.setStoreId(invoice.getStoreId());
+        job.setInvoiceTransNo(invoice.getInvoiceTransNo()); job.setInvoiceNumber(invoice.getInvoiceNumber());
+        job.setRequestedBy(userId); job.setRequestId(request.getRequestId()); job.setPrintSequence(count + 1);
+        job.setReprintReason(normalize(request.getReprintReason()));
+        job.setOriginalAvailable(Boolean.TRUE.equals(request.getOriginalAvailable()));
+        job.setContentsVerified(Boolean.TRUE.equals(request.getContentsVerified()));
+        job.setPdfContent(pdf); job.setCreateTime(new Date());
+        require(jobs.insert(job) == 1, "saveFailed");
+        // 事务提交后才响应。即使客户端未收到响应,也只能用原requestId取回同一文件。
+        return response(job);
+    }
+
+    private InvoicePrintSnapshot query(PosOrderInvoice invoice) {
+        require(invoice != null && Objects.equals(invoice.getInvoiceStatus(), 1), "unavailable");
+        var config = configs.selectOne(new LambdaQueryWrapper<PosStoreEzpay>()
+                .eq(PosStoreEzpay::getStoreId, invoice.getStoreId()));
+        require(config != null && !blank(config.getMerchantId()) && !blank(config.getHashKey()) && !blank(config.getHashIv()), "configuration");
+        var store = stores.selectById(invoice.getStoreId());
+        require(store != null, "unavailable");
+        JSONObject response;
+        try {
+            response = ezPay.searchInvoiceForPrint(baseUrl,
+                    new EzPayConfig(config.getMerchantId(), config.getHashKey(), config.getHashIv()),
+                    invoice.getInvoiceNumber(), invoice.getRandomNum());
+        } catch (Exception e) { throw error("queryFailed"); }
+        require(response != null && "SUCCESS".equals(response.getString("Status")), "queryFailed");
+        JSONObject result;
+        try { result = response.getJSONObject("Result"); }
+        catch (RuntimeException e) { throw error("invalidData"); }
+        return InvoicePrintSnapshot.from(result, invoice, config, store.getPosName());
+    }
+
+    private PrintResult response(PosOrderInvoicePrintJob job) {
+        try {
+            return new PrintResult(job.getId(), job.getInvoiceNumber(), job.getPrintSequence(), job.getPrintSequence() > 1,
+                    job.getInvoiceNumber() + "-" + job.getPrintSequence() + ".pdf", "application/pdf",
+                    encode(job.getPdfContent()), encode(renderer.reverse()));
+        } catch (Exception e) { throw error("renderFailed"); }
+    }
+    private static String normalize(String text) { return blank(text) ? "" : text.trim(); }
+    private static String encode(byte[] bytes) { return Base64.getEncoder().encodeToString(bytes); }
+}

+ 24 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/EzPay.java

@@ -117,6 +117,30 @@ public class EzPay {
         return doPost(baseUrl + URL_SEARCH, config, postData);
         return doPost(baseUrl + URL_SEARCH, config, postData);
     }
     }
 
 
+    /**
+     * 打印前的只读查验。打印事务持有发票行锁,必须限制网络等待时间;
+     * 不记录包含随机码、二维码和买受人信息的完整请求/回应。
+     * 保留原查询入口行为,本方法不影响现有开票、作废和其它调用。
+     */
+    public JSONObject searchInvoiceForPrint(String baseUrl, EzPayConfig config,
+                                            String invoiceNumber, String randomNum) throws Exception {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("RespondType", "JSON"); data.put("Version", "1.3");
+        data.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
+        data.put("SearchType", "0"); data.put("InvoiceNumber", invoiceNumber); data.put("RandomNum", randomNum);
+        String encrypted = EzPayEncryptUtil.encrypt(buildQuery(data), config.getHashKey(), config.getHashIV());
+        HttpPost post = new HttpPost(baseUrl + URL_SEARCH);
+        post.setConfig(org.apache.http.client.config.RequestConfig.custom()
+                .setConnectTimeout(5000).setConnectionRequestTimeout(5000).setSocketTimeout(15000).build());
+        post.setEntity(new UrlEncodedFormEntity(List.of(new BasicNameValuePair("MerchantID_", config.getMerchantId()),
+                new BasicNameValuePair("PostData_", encrypted)), StandardCharsets.UTF_8));
+        try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
+             CloseableHttpResponse response = client.execute(post)) {
+            if (response.getStatusLine().getStatusCode() != 200) throw new java.io.IOException("ezPay query HTTP failure");
+            return JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
+        }
+    }
+
     /**
     /**
      * 捐赠码验证。对应 POST /Api_inv_application/checkLoveCode(BDV v1.0)。
      * 捐赠码验证。对应 POST /Api_inv_application/checkLoveCode(BDV v1.0)。
      *
      *

+ 16 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -508,3 +508,19 @@ merchant.order.print.invalidSnapshot=訂單小票資料不完整,請聯絡平
 # 029 device phone switch
 # 029 device phone switch
 no.oauth.device.phone.changed=請先以對應手機號碼及驗證碼登入,再使用第三方登入
 no.oauth.device.phone.changed=請先以對應手機號碼及驗證碼登入,再使用第三方登入
 no.oauth.binding.conflict=第三方帳號綁定已變更,請重新登入
 no.oauth.binding.conflict=第三方帳號綁定已變更,請重新登入
+
+# 010 电子发票证明联打印
+invoice.print.unavailable=发票尚未开立或已作废,不能打印
+invoice.print.notPaper=载具、捐赠或非纸本发票不能打印证明联
+invoice.print.unsupported=此发票税制暂不支持打印
+invoice.print.invalidData=发票查询数据不完整或校验不一致,请联系平台
+invoice.print.invalidRequest=打印参数不正确
+invoice.print.reprintConfirmation=补印须填写原因,并确认原联仍在且内容已核对一致
+invoice.print.requestConflict=此打印请求号已用于其他操作,请核对请求
+invoice.print.alreadyPrinted=已申请过原件,请查看交易明细或按规定申请补印
+invoice.print.firstPrintRequired=此发票尚未打印,请使用首次打印
+invoice.print.configuration=门店发票查询配置不完整
+invoice.print.queryFailed=发票商查询失败,请稍后重试
+invoice.print.renderFailed=发票PDF生成失败,请联系平台
+invoice.print.saveFailed=打印记录保存失败,请使用原请求号重试
+invoice.print.tooLarge=发票明细过长,无法生成打印文件

+ 16 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -494,3 +494,19 @@ merchant.order.print.invalidSnapshot=Order receipt data is incomplete. Please co
 # 029 device phone switch
 # 029 device phone switch
 no.oauth.device.phone.changed=Please sign in with the corresponding phone number and verification code before using third-party sign-in.
 no.oauth.device.phone.changed=Please sign in with the corresponding phone number and verification code before using third-party sign-in.
 no.oauth.binding.conflict=The third-party account binding has changed. Please sign in again.
 no.oauth.binding.conflict=The third-party account binding has changed. Please sign in again.
+
+# 010 电子发票证明联打印
+invoice.print.unavailable=The invoice is not issued or has been voided.
+invoice.print.notPaper=Carrier, donated or non-paper invoices cannot be printed as proof.
+invoice.print.unsupported=This invoice tax type is not supported for printing.
+invoice.print.invalidData=Invoice data is incomplete or inconsistent. Please contact support.
+invoice.print.invalidRequest=Invalid print request.
+invoice.print.reprintConfirmation=Provide a reprint reason and confirm that the original is retained and its contents verified.
+invoice.print.requestConflict=This request ID was used for a different print operation.
+invoice.print.alreadyPrinted=The original was already requested. View transaction details or request an eligible reprint.
+invoice.print.firstPrintRequired=This invoice has not been printed. Request the original first.
+invoice.print.configuration=Store invoice credentials are incomplete.
+invoice.print.queryFailed=Invoice provider query failed. Please retry later.
+invoice.print.renderFailed=Invoice PDF generation failed. Please contact support.
+invoice.print.saveFailed=Could not save the print record. Retry with the same request ID.
+invoice.print.tooLarge=Invoice details exceed the supported print size.

+ 16 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -32,3 +32,19 @@ merchant.order.print.invalidSnapshot=Dữ liệu phiếu đơn hàng không đ
 # 029 device phone switch
 # 029 device phone switch
 no.oauth.device.phone.changed=Vui lòng đăng nhập bằng số điện thoại tương ứng và mã xác minh trước khi đăng nhập bên thứ ba.
 no.oauth.device.phone.changed=Vui lòng đăng nhập bằng số điện thoại tương ứng và mã xác minh trước khi đăng nhập bên thứ ba.
 no.oauth.binding.conflict=Liên kết tài khoản bên thứ ba đã thay đổi. Vui lòng đăng nhập lại.
 no.oauth.binding.conflict=Liên kết tài khoản bên thứ ba đã thay đổi. Vui lòng đăng nhập lại.
+
+# 010 电子发票证明联打印
+invoice.print.unavailable=Hóa đơn chưa được phát hành hoặc đã bị hủy.
+invoice.print.notPaper=Không thể in chứng từ cho hóa đơn lưu trên phương tiện, quyên tặng hoặc không phải bản giấy.
+invoice.print.unsupported=Chưa hỗ trợ in loại thuế của hóa đơn này.
+invoice.print.invalidData=Dữ liệu hóa đơn không đầy đủ hoặc không nhất quán. Vui lòng liên hệ hỗ trợ.
+invoice.print.invalidRequest=Thông tin yêu cầu in không hợp lệ.
+invoice.print.reprintConfirmation=Cần nhập lý do in lại và xác nhận còn bản gốc, nội dung đã được đối chiếu.
+invoice.print.requestConflict=Mã yêu cầu đã được sử dụng cho thao tác in khác.
+invoice.print.alreadyPrinted=Đã yêu cầu bản gốc. Xem chi tiết giao dịch hoặc yêu cầu in lại theo quy định.
+invoice.print.firstPrintRequired=Hóa đơn chưa được in. Vui lòng yêu cầu bản gốc trước.
+invoice.print.configuration=Cấu hình tra cứu hóa đơn của cửa hàng chưa đầy đủ.
+invoice.print.queryFailed=Tra cứu nhà cung cấp hóa đơn thất bại. Vui lòng thử lại sau.
+invoice.print.renderFailed=Không thể tạo PDF hóa đơn. Vui lòng liên hệ hỗ trợ.
+invoice.print.saveFailed=Không thể lưu lần in. Hãy thử lại với cùng mã yêu cầu.
+invoice.print.tooLarge=Chi tiết hóa đơn vượt quá kích thước in được hỗ trợ.

+ 16 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -496,3 +496,19 @@ merchant.order.print.invalidSnapshot=订单小票数据不完整,请联系平
 # 029 device phone switch
 # 029 device phone switch
 no.oauth.device.phone.changed=请先使用对应手机号及验证码登录,再使用第三方登录
 no.oauth.device.phone.changed=请先使用对应手机号及验证码登录,再使用第三方登录
 no.oauth.binding.conflict=第三方账号绑定已变更,请重新登录
 no.oauth.binding.conflict=第三方账号绑定已变更,请重新登录
+
+# 010 电子发票证明联打印
+invoice.print.unavailable=发票尚未开立或已作废,不能打印
+invoice.print.notPaper=载具、捐赠或非纸本发票不能打印证明联
+invoice.print.unsupported=此发票税制暂不支持打印
+invoice.print.invalidData=发票查询数据不完整或校验不一致,请联系平台
+invoice.print.invalidRequest=打印参数不正确
+invoice.print.reprintConfirmation=补印须填写原因,并确认原联仍在且内容已核对一致
+invoice.print.requestConflict=此打印请求号已用于其他操作,请核对请求
+invoice.print.alreadyPrinted=已申请过原件,请查看交易明细或按规定申请补印
+invoice.print.firstPrintRequired=此发票尚未打印,请使用首次打印
+invoice.print.configuration=门店发票查询配置不完整
+invoice.print.queryFailed=发票商查询失败,请稍后重试
+invoice.print.renderFailed=发票PDF生成失败,请联系平台
+invoice.print.saveFailed=打印记录保存失败,请使用原请求号重试
+invoice.print.tooLarge=发票明细过长,无法生成打印文件

+ 16 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -495,3 +495,19 @@ merchant.order.print.invalidSnapshot=訂單小票資料不完整,請聯絡平
 # 029 device phone switch
 # 029 device phone switch
 no.oauth.device.phone.changed=請先以對應手機號碼及驗證碼登入,再使用第三方登入
 no.oauth.device.phone.changed=請先以對應手機號碼及驗證碼登入,再使用第三方登入
 no.oauth.binding.conflict=第三方帳號綁定已變更,請重新登入
 no.oauth.binding.conflict=第三方帳號綁定已變更,請重新登入
+
+# 010 电子发票证明联打印
+invoice.print.unavailable=發票尚未開立或已作廢,不能列印
+invoice.print.notPaper=載具、捐贈或非紙本發票不能列印證明聯
+invoice.print.unsupported=此發票稅制暫不支援列印
+invoice.print.invalidData=發票查詢資料不完整或校驗不一致,請聯絡平台
+invoice.print.invalidRequest=列印參數不正確
+invoice.print.reprintConfirmation=補印須填寫原因,並確認原聯仍在且內容已核對一致
+invoice.print.requestConflict=此列印請求號已用於其他操作,請核對請求
+invoice.print.alreadyPrinted=已申請過原件,請查看交易明細或按規定申請補印
+invoice.print.firstPrintRequired=此發票尚未列印,請使用首次列印
+invoice.print.configuration=門市發票查詢設定不完整
+invoice.print.queryFailed=發票商查詢失敗,請稍後重試
+invoice.print.renderFailed=發票PDF產生失敗,請聯絡平台
+invoice.print.saveFailed=列印紀錄儲存失敗,請使用原請求號重試
+invoice.print.tooLarge=發票明細過長,無法產生列印檔案

+ 92 - 0
ruoyi-admin/src/main/resources/invoice/fonts/LICENSE

@@ -0,0 +1,92 @@
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
ruoyi-admin/src/main/resources/invoice/fonts/NotoSansTC-Regular.ttf


+ 5 - 0
ruoyi-admin/src/main/resources/invoice/fonts/README.md

@@ -0,0 +1,5 @@
+# Noto Sans TC
+
+来源:https://github.com/notofonts/noto-cjk/blob/main/Sans/Variable/TTF/Subset/NotoSansTC-VF.ttf
+许可证:同目录 LICENSE(SIL Open Font License 1.1)。
+NotoSansTC-Regular.ttf 由源可变字体固定 wght=400 生成,其他字形未更改。PDFBox 按PDF实际使用字形子集嵌入,不依赖服务器系统字体。

+ 2 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserInvoiceAuthResponseTest.java

@@ -57,6 +57,8 @@ class UserInvoiceAuthResponseTest {
         RedisCache redis = mock(RedisCache.class);
         RedisCache redis = mock(RedisCache.class);
         OrderInvoiceService invoices = mock(OrderInvoiceService.class);
         OrderInvoiceService invoices = mock(OrderInvoiceService.class);
         String token = JwtUtil.token("7", "user");
         String token = JwtUtil.token("7", "user");
+        // 当前鉴权已移除无Redis会话兼容;有效会话用例必须同时模拟真实会话存在。
+        when(redis.hasKey((String) JwtUtil.verifyToken(token).get("jti"))).thenReturn(true);
         MockHttpServletRequest request = new MockHttpServletRequest();
         MockHttpServletRequest request = new MockHttpServletRequest();
         request.addHeader("token", token);
         request.addHeader("token", token);
         PosOrderInvoiceVo invoice = new PosOrderInvoiceVo(); invoice.setOrderId(123L);
         PosOrderInvoiceVo invoice = new PosOrderInvoiceVo(); invoice.setOrderId(123L);

+ 55 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/AdminInvoicePdfAccessTest.java

@@ -0,0 +1,55 @@
+package com.ruoyi.app.order.invoice;
+
+import com.ruoyi.app.mendian.PosOrderInvoiceController;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class AdminInvoicePdfAccessTest {
+    @Test void existingInvoiceQueryPermissionIsRequiredBeforeReadingPdf() {
+        try (var messages = mockStatic(com.ruoyi.common.utils.MessageUtils.class, call -> call.getArgument(0));
+             var context = new AnnotationConfigApplicationContext()) {
+            // mock作为现成单例注册,避免Spring继续注入真实开票服务的外部依赖。
+            context.getBeanFactory().registerSingleton("service", mock(MerchantInvoicePrintService.class));
+            context.getBeanFactory().registerSingleton("invoices", mock(com.ruoyi.app.order.OrderInvoiceService.class));
+            context.register(Config.class);
+            context.refresh();
+            SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("admin", "test", "ROLE_ADMIN"));
+            var controller = context.getBean(PosOrderInvoiceController.class);
+            var service = context.getBean(MerchantInvoicePrintService.class);
+            assertThrows(AccessDeniedException.class, () -> controller.getPdf(17L));
+            verifyNoInteractions(service);
+            context.getBean(Permission.class).allowed = true;
+            var pdf = new MerchantInvoicePrintService.StoredPdfResult("test.pdf", "application/pdf", "AQID");
+            when(service.storedPdf(17L)).thenReturn(pdf);
+            assertSame(pdf, controller.getPdf(17L).get("data"));
+            verify(service).storedPdf(17L);
+        } finally { SecurityContextHolder.clearContext(); }
+    }
+
+    public static class Permission {
+        boolean allowed;
+        public boolean hasPermi(String name) { return allowed && "chanting:orderInvoice:query".equals(name); }
+    }
+
+    @Configuration
+    @EnableMethodSecurity
+    static class Config {
+        @Bean(name = "ss") Permission permission() { return new Permission(); }
+        @Bean PosOrderInvoiceController controller(MerchantInvoicePrintService service) {
+            var controller = new PosOrderInvoiceController();
+            ReflectionTestUtils.setField(controller, "merchantInvoicePrintService", service);
+            ReflectionTestUtils.setField(controller, "orderInvoiceService", mock(com.ruoyi.app.order.OrderInvoiceService.class));
+            return controller;
+        }
+    }
+}

+ 148 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintConcurrencyTest.java

@@ -0,0 +1,148 @@
+package com.ruoyi.app.order.invoice;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
+import com.ruoyi.app.utils.ezPay.EzPay;
+import com.ruoyi.app.order.OrderInvoiceService;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.mapper.*;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.mybatis.spring.SqlSessionTemplate;
+import org.springframework.aop.framework.ProxyFactory;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
+import org.springframework.transaction.interceptor.TransactionInterceptor;
+
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** 独立内存数据库验证真实Mapper/事务/锁,不连接项目测试或生产数据库。 */
+class InvoicePrintConcurrencyTest {
+    @Test void concurrentRequestsCannotAllocateTwoOriginalsAndRetryUsesStoredBlob() throws Exception {
+        var ds = new JdbcDataSource();
+        ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000");
+        var jdbc = new JdbcTemplate(ds);
+        jdbc.execute("CREATE TABLE pos_order_invoice(id BIGINT PRIMARY KEY,order_id BIGINT UNIQUE,store_id BIGINT,order_no VARCHAR(64),"
+                + "invoice_trans_no VARCHAR(64),invoice_number VARCHAR(10),random_num VARCHAR(4),invoice_status INT,total_amt INT)");
+        jdbc.execute("INSERT INTO pos_order_invoice VALUES(7,17,27,'ORDER17','TRANS17','AB12345678','1234',1,105)");
+        jdbc.execute("CREATE TABLE pos_order_invoice_print_job(id BIGINT AUTO_INCREMENT PRIMARY KEY,invoice_id BIGINT,order_id BIGINT,"
+                + "store_id BIGINT,invoice_trans_no VARCHAR(64),invoice_number VARCHAR(10),request_id VARCHAR(64),requested_by BIGINT,"
+                + "print_sequence INT,reprint_reason VARCHAR(200),original_available BOOLEAN,contents_verified BOOLEAN,"
+                + "pdf_content BLOB,create_time TIMESTAMP,UNIQUE(order_id,request_id),UNIQUE(invoice_id,invoice_trans_no,print_sequence))");
+        var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
+        var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
+        factory.setMapperLocations(new ClassPathResource("mapper/chanting/PosOrderInvoiceMapper.xml"),
+                new ClassPathResource("mapper/chanting/PosOrderInvoicePrintJobMapper.xml"));
+        var session = new SqlSessionTemplate(factory.getObject());
+        var configs = mock(PosStoreEzpayMapper.class); var stores = mock(PosStoreMapper.class);
+        when(configs.selectOne(any())).thenReturn(InvoicePrintSnapshotTest.config());
+        var store = new PosStore(); store.setPosName("店"); when(stores.selectById(27L)).thenReturn(store);
+        var ezPay = mock(EzPay.class); var renderer = mock(InvoiceProofPdfRenderer.class);
+        when(ezPay.searchInvoiceForPrint(anyString(), any(), anyString(), anyString()))
+                .thenReturn(new JSONObject().fluentPut("Status", "SUCCESS").fluentPut("Result", InvoicePrintSnapshotTest.data()));
+        when(renderer.proof(any(), anyBoolean())).thenReturn(new byte[]{1, 2, 3});
+        when(renderer.reverse()).thenReturn(new byte[]{4});
+        var target = new MerchantInvoicePrintService(session.getMapper(PosOrderInvoiceMapper.class),
+                session.getMapper(PosOrderInvoicePrintJobMapper.class), configs, stores, ezPay, renderer);
+        ReflectionTestUtils.setField(target, "baseUrl", "https://example.invalid");
+        var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
+        proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
+        var service = (MerchantInvoicePrintService) proxy.getProxy();
+        var start = new CountDownLatch(1);
+        try (var pool = Executors.newFixedThreadPool(2)) {
+            var a = pool.submit(() -> attempt(service, "parallel-request-0001", start));
+            var b = pool.submit(() -> attempt(service, "parallel-request-0002", start));
+            start.countDown();
+            Object ra = a.get(15, TimeUnit.SECONDS), rb = b.get(15, TimeUnit.SECONDS);
+            assertTrue((ra instanceof MerchantInvoicePrintService.PrintResult) != (rb instanceof MerchantInvoicePrintService.PrintResult));
+            assertEquals("invoice.print.alreadyPrinted", ra instanceof String ? ra : rb);
+            assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_invoice_print_job", Integer.class));
+            String winningKey = ra instanceof String ? "parallel-request-0002" : "parallel-request-0001";
+            var retry = service.print(request(winningKey), 10L);
+            assertEquals(ra instanceof String ? rb : ra, retry);
+            verify(renderer, times(1)).proof(any(), eq(false));
+            assertEquals("AQID", service.storedPdf(17L).pdfBase64());
+            assertNull(service.storedPdf(99L));
+            // 平台按当前开立身份只读取文件;重开后不能把旧发票PDF当成新发票显示。
+            jdbc.update("UPDATE pos_order_invoice SET invoice_trans_no='NEW-TRANS' WHERE id=7");
+            assertNull(service.storedPdf(17L));
+            jdbc.update("UPDATE pos_order_invoice SET invoice_trans_no='TRANS17' WHERE id=7");
+            assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_invoice_print_job", Integer.class));
+
+            // 验证本系统两种作废入口都先等打印释放行锁,再请求远端,不能只锁最后的UPDATE。
+            jdbc.execute("ALTER TABLE pos_order_invoice ADD invalid_time TIMESTAMP");
+            jdbc.execute("ALTER TABLE pos_order_invoice ADD update_time TIMESTAMP");
+            jdbc.execute("ALTER TABLE pos_order_invoice ADD update_by VARCHAR(64)");
+            for (boolean merchantEntry : new boolean[]{false, true}) {
+                jdbc.update("UPDATE pos_order_invoice SET invoice_status=1 WHERE id=7");
+                var rendering = new CountDownLatch(1); var release = new CountDownLatch(1);
+                var invalidLockAttempt = new CountDownLatch(1); var remoteInvalid = new CountDownLatch(1);
+                when(renderer.proof(any(), eq(true))).thenAnswer(invocation -> {
+                    rendering.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return new byte[]{5};
+                });
+                var realMapper = session.getMapper(PosOrderInvoiceMapper.class);
+                var invalidMapper = spy(realMapper);
+                doAnswer(invocation -> { invalidLockAttempt.countDown(); return realMapper.selectForPrintUpdate(17L); })
+                        .when(invalidMapper).selectForPrintUpdate(17L);
+                var invalidTarget = new OrderInvoiceService();
+                var orders = mock(PosOrderMapper.class); var users = mock(IInfoUserService.class);
+                var order = new PosOrder(); order.setId(17L); order.setMdId(27L); order.setShId(10L);
+                var user = new InfoUser(); user.setUserId(10L); user.setUserType("1");
+                when(orders.selectPosOrderById(17L)).thenReturn(order); when(users.getOne(any())).thenReturn(user);
+                ReflectionTestUtils.setField(invalidTarget, "posOrderInvoiceMapper", invalidMapper);
+                ReflectionTestUtils.setField(invalidTarget, "posOrderMapper", orders);
+                ReflectionTestUtils.setField(invalidTarget, "posStoreEzpayMapper", configs);
+                ReflectionTestUtils.setField(invalidTarget, "infoUserService", users);
+                ReflectionTestUtils.setField(invalidTarget, "ezPay", ezPay);
+                ReflectionTestUtils.setField(invalidTarget, "ezpayBaseUrl", "https://example.invalid");
+                when(ezPay.doPost(anyString(), any(), any())).thenAnswer(invocation -> {
+                    remoteInvalid.countDown(); return new JSONObject().fluentPut("Status", "SUCCESS");
+                });
+                var invalidProxy = new ProxyFactory(invalidTarget); invalidProxy.setProxyTargetClass(true);
+                invalidProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
+                var invalidService = (OrderInvoiceService) invalidProxy.getProxy();
+                var reprint = request("reprint-during-invalid-" + merchantEntry);
+                reprint.setReprint(true); reprint.setReprintReason("原聯模糊");
+                reprint.setOriginalAvailable(true); reprint.setContentsVerified(true);
+                var printing = pool.submit(() -> service.print(reprint, 10L));
+                assertTrue(rendering.await(5, TimeUnit.SECONDS));
+                var invalidating = pool.submit(() -> merchantEntry
+                        ? invalidService.invalidForMerchant(17L, "測試作廢", 10L) : invalidService.invalid(17L, "測試作廢"));
+                try {
+                    assertTrue(invalidLockAttempt.await(5, TimeUnit.SECONDS));
+                    assertFalse(remoteInvalid.await(250, TimeUnit.MILLISECONDS), "远端作废必须等打印事务结束");
+                } finally { release.countDown(); }
+                assertTrue(printing.get(5, TimeUnit.SECONDS).reprint());
+                assertEquals(1, invalidating.get(5, TimeUnit.SECONDS));
+                assertEquals(3, jdbc.queryForObject("SELECT invoice_status FROM pos_order_invoice WHERE id=7", Integer.class));
+                assertEquals(printing.get().fileName(), service.storedPdf(17L).fileName());
+            }
+        } finally { jdbc.execute("SHUTDOWN"); }
+    }
+
+    private static Object attempt(MerchantInvoicePrintService service, String id, CountDownLatch start) throws Exception {
+        start.await(5, TimeUnit.SECONDS);
+        try (var messages = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            try { return service.print(request(id), 10L); }
+            catch (com.ruoyi.common.exception.ServiceException e) { return e.getMessage(); }
+        }
+    }
+    private static InvoicePrintRequest request(String id) {
+        var r = new InvoicePrintRequest(); r.setOrderId(17L); r.setRequestId(id); r.setReprint(false); return r;
+    }
+}

+ 66 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintSnapshotTest.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.order.invoice;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.ezPayCrypto.EzPayEncryptUtil;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrderInvoice;
+import com.ruoyi.system.domain.PosStoreEzpay;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class InvoicePrintSnapshotTest {
+    static PosOrderInvoice invoice() {
+        var i = new PosOrderInvoice();
+        i.setId(7L); i.setOrderId(17L); i.setStoreId(27L); i.setOrderNo("ORDER17");
+        i.setInvoiceNumber("AB12345678"); i.setRandomNum("1234");
+        i.setInvoiceTransNo("TRANS17"); i.setInvoiceStatus(1); i.setTotalAmt(105);
+        return i;
+    }
+
+    static PosStoreEzpay config() {
+        var c = new PosStoreEzpay();
+        c.setMerchantId("MERCHANT"); c.setHashKey("test-key"); c.setHashIv("test-iv");
+        c.setUbn("12345678"); return c;
+    }
+
+    static JSONObject data() {
+        var r = JSONObject.parseObject("""
+                {"MerchantID":"MERCHANT","MerchantOrderNo":"ORDER17","InvoiceTransNo":"TRANS17",
+                "InvoiceNumber":"AB12345678","RandomNum":"1234","InvoiceStatus":"1",
+                "PrintFlag":"Y","Category":"B2C","InvoiceType":"07","TaxType":"1",
+                "CreateTime":"2026-10-30 12:34:56","Amt":100,"TaxAmt":5,"TotalAmt":105,
+                "BuyerUBN":"","BarCode":"11510AB123456781234",
+                "QRcodeL":"AB123456781151030123400000064000000690000000012345678abcdefghijklmnopqrstuv==:**********:1:1:1:薑蔥焗三鮮:1:105",
+                "QRcodeR":"**","ItemDetail":[{"ItemName":"薑蔥焗三鮮","ItemCount":1,"ItemPrice":105,"ItemAmount":105}]}
+                """);
+        r.put("CheckCode", EzPayEncryptUtil.genCheckCode(Map.of("InvoiceTransNo", "TRANS17",
+                "MerchantID", "MERCHANT", "MerchantOrderNo", "ORDER17", "RandomNum", "1234",
+                "TotalAmt", "105"), "test-key", "test-iv"));
+        return r;
+    }
+
+    @Test void usesProviderIssueTimePeriodAndItemsInsteadOfCurrentOrder() {
+        var s = InvoicePrintSnapshot.from(data(), invoice(), config(), "測試餐廳");
+        assertEquals("115年09-10月", s.period());
+        assertEquals("2026-10-30 12:34:56", s.issuedAt());
+        assertEquals("薑蔥焗三鮮", s.items().getFirst().name());
+        assertEquals("12345678", s.sellerUbn());
+    }
+
+    @Test void rejectsVoidNonPaperWrongSignatureAndMismatchedCodes() {
+        try (var m = mockStatic(MessageUtils.class, invocation -> invocation.getArgument(0))) {
+            for (var change : Map.of("InvoiceStatus", "2", "PrintFlag", "N", "CheckCode", "wrong",
+                    "InvoiceNumber", "AB99999999", "QRcodeR", "", "LoveCode", "919",
+                    "CarrierNum", "/ABCDEFG", "TaxType", "9", "BarCode", "11512AB123456781234").entrySet()) {
+                var r = data(); r.put(change.getKey(), change.getValue());
+                assertThrows(ServiceException.class,
+                        () -> InvoicePrintSnapshot.from(r, invoice(), config(), "店"), change.getKey());
+            }
+        }
+    }
+}

+ 86 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRendererTest.java

@@ -0,0 +1,86 @@
+package com.ruoyi.app.order.invoice;
+
+import com.google.zxing.BinaryBitmap;
+import com.google.zxing.DecodeHintType;
+import com.google.zxing.MultiFormatReader;
+import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
+import com.google.zxing.common.HybridBinarizer;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.BufferedImage;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class InvoiceProofPdfRendererTest {
+    @Test void proofHasOfficialPaperSizeAndMachineReadableProviderCodes() throws Exception {
+        var data = InvoicePrintSnapshot.from(InvoicePrintSnapshotTest.data(), InvoicePrintSnapshotTest.invoice(),
+                InvoicePrintSnapshotTest.config(), "恰恰吃測試餐廳");
+        byte[] bytes = new InvoiceProofPdfRenderer().proof(data, false);
+        write("b2c-original.pdf", bytes);
+        try (var pdf = Loader.loadPDF(bytes)) {
+            assertEquals(2, pdf.getNumberOfPages());
+            assertEquals(57, pdf.getPage(0).getMediaBox().getWidth() * 25.4 / 72, .01);
+            assertEquals(90, pdf.getPage(0).getMediaBox().getHeight() * 25.4 / 72, .01);
+            String text = new PDFTextStripper().getText(pdf);
+            assertTrue(text.contains("電子發票證明聯"));
+            assertTrue(text.contains("薑蔥焗三鮮"));
+            assertTrue(text.contains("115年09-10月"));
+            assertFalse(text.contains("補印"));
+            // 按常见热敏机203dpi栅格化后解码,覆盖矢量边界、静区、字串编码与二维码位置。
+            BufferedImage page = new PDFRenderer(pdf).renderImageWithDPI(0, 203);
+            assertEquals(data.qrLeft(), assertDoesNotThrow(() -> decode(crop(page, 2, 65, 24, 25)), "left QR"));
+            assertEquals(data.qrRight(), assertDoesNotThrow(() -> decode(crop(page, 30, 65, 25, 25)), "right QR"));
+            assertEquals(data.barCode(), assertDoesNotThrow(() -> decode(crop(page, 0, 58, 57, 8)), "Code39"));
+        }
+    }
+
+    @Test void businessDetailIsNotCutAndReprintIsOnTitleLine() throws Exception {
+        var r = InvoicePrintSnapshotTest.data();
+        r.put("Category", "B2B"); r.put("BuyerUBN", "87654321");
+        String left = r.getString("QRcodeL");
+        r.put("QRcodeL", left.substring(0, 37) + "87654321" + left.substring(45));
+        var s = InvoicePrintSnapshot.from(r, InvoicePrintSnapshotTest.invoice(), InvoicePrintSnapshotTest.config(), "測試店");
+        var renderer = new InvoiceProofPdfRenderer();
+        byte[] bytes = renderer.proof(s, true); write("b2b-reprint.pdf", bytes);
+        try (var pdf = Loader.loadPDF(bytes)) {
+            assertEquals(1, pdf.getNumberOfPages());
+            assertTrue(pdf.getPage(0).getMediaBox().getHeight() > 90 * 72 / 25.4);
+            String text = new PDFTextStripper().getText(pdf);
+            assertTrue(text.contains("電子發票證明聯 補印"));
+            assertTrue(text.contains("買方87654321"));
+            assertTrue(text.contains("格式25"));
+            assertTrue(text.contains("應稅銷售額 100"));
+        }
+        byte[] detail = renderer.detail(s); write("detail.pdf", detail);
+        try (var pdf = Loader.loadPDF(detail)) {
+            String text = new PDFTextStripper().getText(pdf);
+            assertTrue(text.contains("非兌獎憑證")); assertFalse(text.contains("電子發票證明聯"));
+        }
+        byte[] reverse = renderer.reverse(); write("reverse.pdf", reverse);
+        try (var pdf = Loader.loadPDF(reverse)) {
+            String text = new PDFTextStripper().getText(pdf);
+            assertTrue(text.contains("中獎人簽名")); assertTrue(text.contains("4128282"));
+            assertEquals("https://invoice.etax.nat.gov.tw/", decode(crop(new PDFRenderer(pdf).renderImageWithDPI(0, 203), 15, 59, 27, 28)));
+        }
+    }
+
+    private static BufferedImage crop(BufferedImage image, float x, float y, float w, float h) {
+        double scale = 203 / 25.4;
+        int left = (int) (x * scale), top = (int) (y * scale);
+        return image.getSubimage(left, top, Math.min((int) (w * scale), image.getWidth() - left),
+                Math.min((int) (h * scale), image.getHeight() - top));
+    }
+    private static String decode(BufferedImage image) throws Exception {
+        return new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer(
+                new BufferedImageLuminanceSource(image))), Map.of(DecodeHintType.TRY_HARDER, true)).getText();
+    }
+    private static void write(String name, byte[] data) throws Exception {
+        Path dir = Path.of("target/invoice-print-samples"); Files.createDirectories(dir); Files.write(dir.resolve(name), data);
+    }
+}

+ 60 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintControllerTest.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.order.invoice;
+
+import com.ruoyi.app.order.PosOrderShOprateController;
+import com.ruoyi.app.pay.LinePayOrderGuard;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.any;
+
+class MerchantInvoicePrintControllerTest {
+    @Test void bothEndpointsCheckOrderOwnershipBeforeAccessingPdfService() {
+        var controller = new PosOrderShOprateController();
+        var users = mock(IInfoUserService.class);
+        var orders = mock(IPosOrderService.class);
+        var access = mock(MerchantStoreAccessService.class);
+        var print = mock(MerchantInvoicePrintService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", access);
+        ReflectionTestUtils.setField(controller, "merchantInvoicePrintService", print);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", new LinePayOrderGuard(mock(IPosOrderLinePaymentService.class)));
+        var user = new InfoUser(); user.setUserId(10L); user.setStoreId(27L);
+        var order = new PosOrder(); order.setId(17L); order.setShId(10L); order.setMdId(27L);
+        var input = new InvoicePrintRequest(); input.setOrderId(17L);
+        when(users.getOne(any())).thenReturn(user); when(orders.getById(17L)).thenReturn(order);
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            auth.when(AuthContext::requireUserId).thenReturn(10L);
+            for (String role : List.of("1", "5", "3", "4")) {
+                user.setUserType(role);
+                controller.printInvoice("token", input); controller.getInvoicePrintDetail("token", 17L);
+            }
+            for (String role : List.of("0", "2")) {
+                user.setUserType(role);
+                assertThrows(ServiceException.class, () -> controller.printInvoice("token", input));
+                assertThrows(ServiceException.class, () -> controller.getInvoicePrintDetail("token", 17L));
+            }
+            user.setUserType("4"); user.setStoreId(28L);
+            assertThrows(ServiceException.class, () -> controller.printInvoice("token", input));
+            assertThrows(ServiceException.class, () -> controller.getInvoicePrintDetail("token", 17L));
+            user.setUserType("1");
+            doThrow(new ServiceException("denied")).when(access).requireOrderAccess(10L, 17L);
+            assertThrows(ServiceException.class, () -> controller.printInvoice("token", input));
+            assertThrows(ServiceException.class, () -> controller.getInvoicePrintDetail("token", 17L));
+            verify(print, times(4)).print(input, 10L); verify(print, times(4)).detail(17L);
+        }
+    }
+}

+ 122 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintServiceTest.java

@@ -0,0 +1,122 @@
+package com.ruoyi.app.order.invoice;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.ezPay.EzPay;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrderInvoicePrintJob;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.*;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class MerchantInvoicePrintServiceTest {
+    private final PosOrderInvoiceMapper invoices = mock(PosOrderInvoiceMapper.class);
+    private final PosOrderInvoicePrintJobMapper jobs = mock(PosOrderInvoicePrintJobMapper.class);
+    private final PosStoreEzpayMapper configs = mock(PosStoreEzpayMapper.class);
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final EzPay ezPay = mock(EzPay.class);
+    private final InvoiceProofPdfRenderer renderer = mock(InvoiceProofPdfRenderer.class);
+    private final MerchantInvoicePrintService service = new MerchantInvoicePrintService(invoices, jobs, configs, stores, ezPay, renderer);
+
+    @Test void platformViewOnlyReturnsStoredPdfAndNeverQueriesProviderOrAllocatesPrint() {
+        var job = new PosOrderInvoicePrintJob();
+        job.setInvoiceNumber("AB12345678"); job.setPrintSequence(2); job.setPdfContent(new byte[]{1, 2, 3});
+        when(jobs.selectLatestForCurrentInvoice(17L)).thenReturn(job);
+        var result = service.storedPdf(17L);
+        assertEquals("AQID", result.pdfBase64());
+        assertEquals("AB12345678-2.pdf", result.fileName());
+        verify(jobs).selectLatestForCurrentInvoice(17L);
+        verifyNoMoreInteractions(jobs);
+        verifyNoInteractions(invoices, configs, stores, ezPay, renderer);
+    }
+
+    @Test void platformViewWithoutExistingPdfDoesNotGenerateOne() {
+        assertNull(service.storedPdf(17L));
+        verify(jobs).selectLatestForCurrentInvoice(17L);
+        verifyNoMoreInteractions(jobs);
+        verifyNoInteractions(invoices, configs, stores, ezPay, renderer);
+    }
+
+    private InvoicePrintRequest setup() throws Exception {
+        ReflectionTestUtils.setField(service, "baseUrl", "https://example.invalid");
+        when(invoices.selectForPrintUpdate(17L)).thenReturn(InvoicePrintSnapshotTest.invoice());
+        when(configs.selectOne(any())).thenReturn(InvoicePrintSnapshotTest.config());
+        var store = new PosStore(); store.setPosName("店"); when(stores.selectById(27L)).thenReturn(store);
+        when(ezPay.searchInvoiceForPrint(anyString(), any(), anyString(), anyString()))
+                .thenReturn(new JSONObject().fluentPut("Status", "SUCCESS").fluentPut("Result", InvoicePrintSnapshotTest.data()));
+        when(renderer.proof(any(), anyBoolean())).thenReturn(new byte[]{1, 2});
+        when(renderer.reverse()).thenReturn(new byte[]{3});
+        when(jobs.insert(any(PosOrderInvoicePrintJob.class))).thenAnswer(a -> { ((PosOrderInvoicePrintJob) a.getArgument(0)).setId(91L); return 1; });
+        var r = new InvoicePrintRequest(); r.setOrderId(17L); r.setRequestId("test-request-00001"); r.setReprint(false);
+        return r;
+    }
+
+    @Test void firstRequestPersistsOriginalAndRetryReturnsIdenticalBytesWithoutNewPrint() throws Exception {
+        var request = setup();
+        var first = service.print(request, 10L);
+        ArgumentCaptor<PosOrderInvoicePrintJob> capture = ArgumentCaptor.forClass(PosOrderInvoicePrintJob.class);
+        verify(jobs).insert(capture.capture());
+        assertEquals(1, first.printSequence()); assertFalse(first.reprint());
+        when(jobs.selectByRequest(17L, request.getRequestId())).thenReturn(capture.getValue());
+        var retry = service.print(request, 10L);
+        assertEquals(first, retry);
+        verify(renderer, times(1)).proof(any(), eq(false));
+        verify(jobs, times(1)).insert(any(PosOrderInvoicePrintJob.class));
+        // 重试仍重新确认ezPay有效状态,不能从缓存返回已作废票。
+        verify(ezPay, times(2)).searchInvoiceForPrint(anyString(), any(), anyString(), anyString());
+    }
+
+    @Test void reprintRequiresOriginalAndVerificationAndCannotIssueSecondOriginal() throws Exception {
+        var request = setup();
+        try (var m = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            when(jobs.countForInvoice(7L, "TRANS17")).thenReturn(1);
+            assertEquals("invoice.print.alreadyPrinted", assertThrows(ServiceException.class,
+                    () -> service.print(request, 10L)).getMessage());
+            request.setReprint(true); request.setReprintReason("原聯字跡不清");
+            assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            request.setOriginalAvailable(true); request.setContentsVerified(true);
+            var result = service.print(request, 10L);
+            assertEquals(2, result.printSequence()); assertTrue(result.reprint());
+            verify(renderer).proof(any(), eq(true));
+        }
+    }
+
+    @Test void failedQueryOrRenderingDoesNotCreatePrintRecord() throws Exception {
+        var request = setup();
+        try (var m = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            when(renderer.proof(any(), anyBoolean())).thenThrow(new IllegalStateException("missing glyph"));
+            assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            when(ezPay.searchInvoiceForPrint(anyString(), any(), anyString(), anyString())).thenThrow(new java.io.IOException());
+            assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            verify(jobs, never()).insert(any(PosOrderInvoicePrintJob.class));
+        }
+    }
+
+    @Test void oldRequestCannotBeReusedAfterReissueOrByAnotherOperator() throws Exception {
+        var request = setup();
+        var job = new PosOrderInvoicePrintJob();
+        job.setInvoiceTransNo("OLD"); job.setInvoiceNumber("AB12345678"); job.setPrintSequence(1); job.setRequestedBy(10L);
+        when(jobs.selectByRequest(17L, request.getRequestId())).thenReturn(job);
+        try (var m = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            job.setInvoiceTransNo("TRANS17"); job.setRequestedBy(11L);
+            assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            verifyNoInteractions(renderer);
+        }
+    }
+
+    @Test void voidInvoiceIsRejectedBeforeProviderQuery() throws Exception {
+        var request = setup(); var invoice = InvoicePrintSnapshotTest.invoice(); invoice.setInvoiceStatus(3);
+        when(invoices.selectForPrintUpdate(17L)).thenReturn(invoice);
+        try (var m = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            verifyNoInteractions(ezPay, jobs, renderer);
+        }
+    }
+}

+ 28 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoicePrintJob.java

@@ -0,0 +1,28 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.util.Date;
+
+/** 证明联PDF申请审计;生成文件不表示打印机已完成出纸,记录不得用于回滚首次状态。 */
+@Data
+@TableName("pos_order_invoice_print_job")
+public class PosOrderInvoicePrintJob {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long invoiceId;
+    private Long orderId;
+    private Long storeId;
+    private String invoiceTransNo;
+    private String invoiceNumber;
+    private String requestId;
+    private Long requestedBy;
+    private Integer printSequence;
+    private String reprintReason;
+    private Boolean originalAvailable;
+    private Boolean contentsVerified;
+    private byte[] pdfContent;
+    private Date createTime;
+}

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoiceMapper.java

@@ -15,6 +15,9 @@ import java.util.List;
  */
  */
 public interface PosOrderInvoiceMapper extends BaseMapper<PosOrderInvoice> {
 public interface PosOrderInvoiceMapper extends BaseMapper<PosOrderInvoice> {
 
 
+    /** 同一订单发票的打印申请在事务内串行,避免两个设备同时生成首次证明联。 */
+    PosOrderInvoice selectForPrintUpdate(@Param("orderId") Long orderId);
+
     /**
     /**
      * 订单发票列表(pos_order_invoice LEFT JOIN pos_store)。
      * 订单发票列表(pos_order_invoice LEFT JOIN pos_store)。
      * 配合 PageHelper startPage() 分页。
      * 配合 PageHelper startPage() 分页。

+ 12 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoicePrintJobMapper.java

@@ -0,0 +1,12 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderInvoicePrintJob;
+import org.apache.ibatis.annotations.Param;
+
+public interface PosOrderInvoicePrintJobMapper extends BaseMapper<PosOrderInvoicePrintJob> {
+    /** 平台只读查看当前发票的最新文件;重开后的旧票不能被此入口误取出。 */
+    PosOrderInvoicePrintJob selectLatestForCurrentInvoice(@Param("orderId") Long orderId);
+    PosOrderInvoicePrintJob selectByRequest(@Param("orderId") Long orderId, @Param("requestId") String requestId);
+    int countForInvoice(@Param("invoiceId") Long invoiceId, @Param("invoiceTransNo") String invoiceTransNo);
+}

+ 5 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoiceMapper.xml

@@ -4,6 +4,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.ruoyi.system.mapper.PosOrderInvoiceMapper">
 <mapper namespace="com.ruoyi.system.mapper.PosOrderInvoiceMapper">
 
 
+    <!-- 打印事务先锁发票再读申请记录;与作废/重开写同一行形成数据库互斥。 -->
+    <select id="selectForPrintUpdate" resultType="com.ruoyi.system.domain.PosOrderInvoice">
+        SELECT * FROM pos_order_invoice WHERE order_id = #{orderId} FOR UPDATE
+    </select>
+
     <!-- 订单发票列表:列名别名对齐 VO 属性,resultType 自动映射 -->
     <!-- 订单发票列表:列名别名对齐 VO 属性,resultType 自动映射 -->
     <select id="selectInvoiceList" parameterType="com.ruoyi.system.domain.vo.PosOrderInvoiceVo"
     <select id="selectInvoiceList" parameterType="com.ruoyi.system.domain.vo.PosOrderInvoiceVo"
             resultType="com.ruoyi.system.domain.vo.PosOrderInvoiceVo">
             resultType="com.ruoyi.system.domain.vo.PosOrderInvoiceVo">

+ 34 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoicePrintJobMapper.xml

@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosOrderInvoicePrintJobMapper">
+    <resultMap id="job" type="com.ruoyi.system.domain.PosOrderInvoicePrintJob">
+        <id column="id" property="id"/>
+        <result column="invoice_id" property="invoiceId"/>
+        <result column="order_id" property="orderId"/>
+        <result column="store_id" property="storeId"/>
+        <result column="invoice_trans_no" property="invoiceTransNo"/>
+        <result column="invoice_number" property="invoiceNumber"/>
+        <result column="request_id" property="requestId"/>
+        <result column="requested_by" property="requestedBy"/>
+        <result column="print_sequence" property="printSequence"/>
+        <result column="reprint_reason" property="reprintReason"/>
+        <result column="original_available" property="originalAvailable"/>
+        <result column="contents_verified" property="contentsVerified"/>
+        <result column="pdf_content" property="pdfContent" jdbcType="LONGVARBINARY"/>
+        <result column="create_time" property="createTime"/>
+    </resultMap>
+    <select id="selectByRequest" resultMap="job">
+        SELECT * FROM pos_order_invoice_print_job WHERE order_id = #{orderId} AND request_id = #{requestId}
+    </select>
+    <select id="countForInvoice" resultType="int">
+        SELECT COUNT(*) FROM pos_order_invoice_print_job
+        WHERE invoice_id = #{invoiceId} AND invoice_trans_no = #{invoiceTransNo}
+    </select>
+    <select id="selectLatestForCurrentInvoice" resultMap="job">
+        SELECT j.* FROM pos_order_invoice_print_job j
+        INNER JOIN pos_order_invoice i ON i.id = j.invoice_id AND i.order_id = j.order_id
+            AND i.invoice_trans_no = j.invoice_trans_no AND i.invoice_number = j.invoice_number
+        WHERE j.order_id = #{orderId}
+        ORDER BY j.print_sequence DESC LIMIT 1
+    </select>
+</mapper>

+ 96 - 0
specs/010-order-invoice/frontend-app-printing.md

@@ -0,0 +1,96 @@
+# 商家 App:电子发票 PDF 打印接入
+
+本功能是 ezPay **电子发票证明联**,与 `/getOrderPrintInfo` 餐点小票不同。后端负责版式、金额和条码,App 只负责保存、展示 PDF 和调用打印机。
+
+接口前缀:`/system/orderShOprate`。请求头传商家登录 `token`;订单 ID 使用 `pos_order.id`。普通商家、夜市、摊位、授权子账号都沿用现有订单权限。
+
+## 1. 查看明细及打印次数
+
+`GET /getInvoicePrintDetail?orderId=3173`
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "invoiceNumber": "AB12345678",
+    "printCount": 0,
+    "fileName": "AB12345678-detail.pdf",
+    "contentType": "application/pdf",
+    "pdfBase64": "JVBERi0..."
+  }
+}
+```
+
+- `pdfBase64` 是无 Data URL 前缀的 Base64;解码成 PDF 文件后查看。
+- 这是交易明细,**没有兑奖二维码,不是证明联**。重复查看/打印明细不增加 `printCount`。
+- `printCount=0` 显示“打印发票”;大于 0 显示“申请补印”。只统计我们系统,不同步 ezPay 后台历史。
+- 无纸本资格、未开立、已作废或发票商查询失败,会返回业务错误;直接展示 `msg`,不要改用订单金额拼发票。
+
+## 2. 首次打印证明联
+
+用户点击打印时调用 `POST /printInvoice`:
+
+```json
+{
+  "orderId": 3173,
+  "requestId": "78490c8a-6755-4a49-bd36-2b661f46f653",
+  "reprint": false
+}
+```
+
+`requestId`:每次打印意图生成一个 UUID,调用前保存。**超时、断网或丢失响应时必须带原值重试**;同一请求号不能换订单发票、补印参数或操作账号。按钮提交中禁用,避免连点。
+
+成功返回:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "printJobId": 123,
+    "invoiceNumber": "AB12345678",
+    "printSequence": 1,
+    "reprint": false,
+    "fileName": "AB12345678-1.pdf",
+    "contentType": "application/pdf",
+    "pdfBase64": "JVBERi0...",
+    "reversePdfBase64": "JVBERi0..."
+  }
+}
+```
+
+- `pdfBase64`:正面打印文件。个人 B2C 第1页为证明联,第2页为交易明细;分开裁切。B2B 为同一长页,明细接续打印,不能中途裁切。
+- `reversePdfBase64`:57×90mm 背面参考文件,含兑奖说明及领奖收据。**不要在正面后顺序打印它**。使用已有合规预印背面的纸卷时无需打印此文件;其他纸张需确保相应背面符合要求。
+- 纸宽57mm,证明区域高90mm。按100%实际尺寸输出,关闭“适应页面”、缩放、额外页眉页脚。不得自行改二维码、字体、顺序或裁切证明区域。
+- 服务端成功生成并保存 PDF 就增加次数;不是打印机完成出纸回执。App 不提供“打印失败重置为首次”功能。原请求重试只用于恢复同一次未收妥的响应,不能用缓存原件反复打印。
+
+## 3. 补印
+
+仅适用于原联记载不清等法定情形,并需核对内容、保留原联。不是重复点击就能再出一份原件。
+
+```json
+{
+  "orderId": 3173,
+  "requestId": "a25ece72-8030-475d-97c9-3c49ce308d26",
+  "reprint": true,
+  "reprintReason": "原证明联字迹不清,已核对发票内容一致",
+  "originalAvailable": true,
+  "contentsVerified": true
+}
+```
+
+- 原因必填,最多200字;两个确认必须为 true。
+- 成功后 `printSequence>=2`、`reprint=true`,PDF 标题自动加“補印”,App 无需拼字。
+- 首次尚未申请时不能直接补印;已经申请过原件不能再 `reprint=false`。出现并发冲突时刷新打印次数并展示错误,不自动改成补印重试。
+- 作废票不可打印;重开后的新发票重新计数,必须使用新的 `requestId`。
+
+## 上线联调
+
+1. 开发者先手动执行 `updatesql/sql.md` 的“2026-10-30 商家App电子发票证明联打印”建表段,再发布后端。
+2. ezPay 纸本 B2C/B2B 各验证一笔,核对真实日期、总额、商品和税额;查询失败时不能出纸。
+3. 真机验证中文字体、57mm实际尺寸、明细裁切,以及左右二维码/Code39均可读取。
+4. 首次、超时原请求重试、跨设备重复点击、补印、作废/重开、越权访问均需联调。
+5. 正式使用前完成适用的样张、二维码解密验证和纸张验收;本地生成 PDF 不等于已获核准。
+
+本期不包含 App 页面、蓝牙/网络打印驱动或载具发票转纸本。普通查看继续使用交易明细,首次打印接口不要在进入详情页时自动调用。

+ 19 - 0
specs/010-order-invoice/plan.md

@@ -178,3 +178,22 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 # 2026-10-26 增量修复执行记录
 # 2026-10-26 增量修复执行记录
 
 
 用户发票夹异常定位为AuthAspect鉴权短路返回AjaxResult与Controller声明TableDataInfo不匹配。在切面匹配分页声明返回401分页对象,并通过当前Servlet响应设置HTTP401;其它AjaxResult接口保留原响应体并同步HTTP401。正常请求保持HTTP200和原查询权限。UserInvoiceAuthResponseTest使用真实CGLIB代理复现原异常,覆盖签名失效/会话撤销/正常用户分页、实际HTTP状态及既有鉴权回归;无SQL、无App调整。
 用户发票夹异常定位为AuthAspect鉴权短路返回AjaxResult与Controller声明TableDataInfo不匹配。在切面匹配分页声明返回401分页对象,并通过当前Servlet响应设置HTTP401;其它AjaxResult接口保留原响应体并同步HTTP401。正常请求保持HTTP200和原查询权限。UserInvoiceAuthResponseTest使用真实CGLIB代理复现原异常,覆盖签名失效/会话撤销/正常用户分页、实际HTTP状态及既有鉴权回归;无SQL、无App调整。
+
+
+## 2026-10-30 PDF 打印增量计划
+
+1. 新增 pos_order_invoice_print_job,保存发票身份、申请人、幂等键、序号、补印声明/原因、不可变 PDF;SQL 仅登记 updatesql/sql.md。
+2. ruoyi-admin 新增打印服务、ezPay 快照校验、PDFBox 模板;ruoyi-system 仅存实体/Mapper。复用现有商家订单归属守卫。
+3. 以 pos_order_invoice 行锁和事务保护计数/幂等;查询 ezPay 校验 CheckCode、开立状态、纸本标志、金额和条码身份。PDF 失败不落申请记录。
+4. 内嵌开源繁体字体并保留许可证;Code39/QR 使用 ZXing 矢量绘制,PDFBox 输出真实毫米尺寸。独立交易明细不含兑奖条码,不占打印次数。
+5. 定向测试权限、快照错误、并发所需 SQL、幂等、补印、作废/重开;PDF 文本/尺寸/二维码回读并渲染检查。同步前端接入文档。
+
+非目标:修改开票金额算法、载具转纸本、App 蓝牙/打印驱动、部署、执行迁移、替代商家办理样张和纸张验收。
+
+
+审查修正:作废的商家/平台两个入口均开启事务,在远端作废之前获取同一发票行锁;商家入口自调用由外层事务覆盖。H2独立内存库交错测试验证真实Mapper/事务,不连接测试环境数据库。ezPay后台外部作废仍不具备跨系统原子性;每次申请实时查询,App不得离线重复打印缓存原件。
+
+回归夹具同步:UserInvoiceAuthResponseTest 的正常登录用例补充Redis会话存在模拟,符合当前已移除旧token放行的鉴权规则;本次未改用户鉴权生产代码。
+
+
+平台查看PDF:新增GET /system/orderInvoice/{orderId}/pdf,只读打印申请表并关联当前发票开立身份,重开后不能返回旧票文件。返回{fileName,contentType,pdfBase64};无文件时data为空或不返回。Vue详情按钮通过Blob URL内嵌显示,关闭/切页释放资源;四语言同步,无新增DDL。

+ 10 - 0
specs/010-order-invoice/research.md

@@ -252,3 +252,13 @@ ezPay `Category=B2C` 三种载具(`CarrierType`):
 ### 补充:查询发票明细接口(invoice_search)能力备忘
 ### 补充:查询发票明细接口(invoice_search)能力备忘
 
 
 ezPay **有**查询发票明细的接口 `/Api/invoice_search`(v1.3),工具类已封装 `EzPay.searchInvoice(baseUrl, cfg, invoiceNumber, randomNum)`(`ruoyi-admin/.../utils/ezPay/EzPay.java`),按 D10 决策本期业务层不调用它。其回应字段(手册第八章)覆盖:MerchantID/InvoiceTransNo/MerchantOrderNo/InvoiceNumber/RandomNum/BuyerName/BuyerUBN/BuyerAddress/BuyerPhone/BuyerEmail/InvoiceType/Category/TaxType/TaxRate/Amt(含AmtSales/Zero/Free)/TaxAmt/TotalAmt/CarrierType/CarrierNum/LoveCode/PrintFlag/KioskPrintFlag/CreateTime/ItemDetail(JSON)/InvoiceStatus/UploadStatus/CheckCode/BarCode/QRcodeL/QRcodeR。**注意**:中奖注记、目前可折让金额、通知状态、折让单号该接口**不返回**(属 ezPay 控制台聚合 / 折让接口范畴)。日后若需「确认上传状态」等 live 字段再启用此接口。
 ezPay **有**查询发票明细的接口 `/Api/invoice_search`(v1.3),工具类已封装 `EzPay.searchInvoice(baseUrl, cfg, invoiceNumber, randomNum)`(`ruoyi-admin/.../utils/ezPay/EzPay.java`),按 D10 决策本期业务层不调用它。其回应字段(手册第八章)覆盖:MerchantID/InvoiceTransNo/MerchantOrderNo/InvoiceNumber/RandomNum/BuyerName/BuyerUBN/BuyerAddress/BuyerPhone/BuyerEmail/InvoiceType/Category/TaxType/TaxRate/Amt(含AmtSales/Zero/Free)/TaxAmt/TotalAmt/CarrierType/CarrierNum/LoveCode/PrintFlag/KioskPrintFlag/CreateTime/ItemDetail(JSON)/InvoiceStatus/UploadStatus/CheckCode/BarCode/QRcodeL/QRcodeR。**注意**:中奖注记、目前可折让金额、通知状态、折让单号该接口**不返回**(属 ezPay 控制台聚合 / 折让接口范畴)。日后若需「确认上传状态」等 live 字段再启用此接口。
+
+
+## D14:2026-10-30 官方证明联模板与后端 PDF
+
+本节更新 D13 中不自行输出证明联的旧范围限制。财政部已提供格式一/二与图例,可以实现排版,但拿到模板不等于本系统样张已通过验收。
+格式一适用本项目B2C和B2B;不能把B2C直接放到A4格式二。官方正反面、字体物理高度、57mm纸宽、90mm证明区域、明细裁切和补印要求均需落实。
+ezPay invoice_search v1.3 返回 CreateTime、ItemDetail、PrintFlag、InvoiceStatus、BarCode、QRcodeL/R,使用实际开票数据而非当前商品或订单重算。KioskPrintFlag不是打印次数,不用于判定首次/补印。
+财政部第26点:https://law-out.mof.gov.tw/LawContent.aspx?id=FL041411
+官方附件:https://law-out.mof.gov.tw/Download.ashx?FileID=53599&id=FL041411&type=LAW
+ezPay样张及二维码验证说明:https://cinv.ezpay.com.tw/Invoice_index/qa

+ 22 - 0
specs/010-order-invoice/spec.md

@@ -222,3 +222,25 @@
 
 
 ### 待办
 ### 待办
 - 客户点餐 app(独立仓库,非 foodie_server)结账发票选择器加「会员载具」选项 + 4 份 i18n(zh/tw/en/vi);仓库路径待确认。
 - 客户点餐 app(独立仓库,非 foodie_server)结账发票选择器加「会员载具」选项 + 4 份 i18n(zh/tw/en/vi);仓库路径待确认。
+
+
+## 2026-10-30 商家 App 电子发票证明联 PDF(增量规格)
+
+状态:实现中;沿用当前分支,不修改 App、平台或商家 PC。
+
+- ezPay 继续开票,服务端只查询已开立纸本发票并生成 PDF,不重新开票、不生成防伪密文、不改变订单/付款状态。
+- 用户确认:打印次数仅以本系统为准,不同步 ezPay 后台打印历史。首次生成原件;后续必须填写补印原因,确认原联仍在且已核对记载事项。普通重复查看使用交易明细,不重复申请原件。
+- 依据财政部《电子发票实施作业要点》2026-04-22 版本附件一格式一:57mm 宽、正面证明区域90mm;B2C 明细另页裁切;B2B 明细接续不裁切;正面 Code39、左右 QR 使用 ezPay 原始码值。背面提供官方兑奖文字、网址 QR、领奖收据栏位。
+- 第26点限制补印适用范围:原联记载不清等法定情形,并非任意重复打印;补印须与原联配合使用。补印标识与「電子發票證明聯」同一行。
+- 当前开票链路为一般税额、应税发票,本期覆盖该链路的 B2C/B2B。未支持的税制、混合税别必须拒绝,不能套用错误模板。
+- 仅已开立、PrintFlag=Y、无载具/捐赠且本地与 ezPay 身份及金额一致的发票可打印。每次申请重新查询;查询失败不回退订单数据。
+- 接口放在商家订单控制器,复用订单归属权限。App 不传金额、店名、二维码或 PDF 路径。请求幂等键重试返回同一文件;相同键不得更换发票或补印参数。
+- PDF 生成并持久化即占用一次申请序号,不等同物理打印成功。打印机失败不回滚为未打印,丢失响应使用原幂等键重试。数据库行锁串行化同一发票申请,重开发票按新开立序号重新计数。
+- 正面 PDF 与背面参考 PDF 分开返回,另有纯交易明细 PDF。预印合规背面的感热纸只打印正面;不具有合规背面的普通纸不能只打印正面作为完整证明联。App 禁止自动缩放,按100%实际尺寸输出。
+- 本地 PDF/扫码测试不代替 ezPay 样张、二维码解密验证及实际打印机/纸张验收;上线前由商家完成适用验收。
+
+官方来源:https://law-out.mof.gov.tw/LawContent.aspx?id=FL041411
+官方附件:https://law-out.mof.gov.tw/Download.ashx?FileID=53599&id=FL041411&type=LAW
+
+
+平台增量(2026-10-30):仅在订单发票详情增加查看已有PDF,读取当前发票最近一次保存的文件;暂无文件时提示,不生成新证明联、不增加打印次数。本轮不做平台打印/补印/记录管理。使用现有chanting:orderInvoice:query权限。

+ 26 - 0
specs/010-order-invoice/tasks.md

@@ -285,3 +285,29 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - [x] T061 修复AuthAspect与用户发票分页返回类型不匹配,失效会话HTTP401+code401;真实AOP代理回归覆盖失效、撤销、正常分页和HTTP状态,保留其它AjaxResult响应体。
 - [x] T061 修复AuthAspect与用户发票分页返回类型不匹配,失效会话HTTP401+code401;真实AOP代理回归覆盖失效、撤销、正常分页和HTTP状态,保留其它AjaxResult响应体。
 
 
 2026-10-26 本地验证:JDK21,`mvn -q -pl ruoyi-admin -am -Dtest=AuthAspectTest,UserInvoiceAuthResponseTest,MerchantSessionAuthAnnotationTest,*Invoice*Test -Dsurefire.failIfNoSpecifiedTests=false test`,14项通过、失败/错误均0;原ClassCastException已由真实代理复现后修复,`git diff --check`通过。位于test-202609v2,未提交/部署;不修改App或数据库。未执行测试环境真机登录和发票查询联调。
 2026-10-26 本地验证:JDK21,`mvn -q -pl ruoyi-admin -am -Dtest=AuthAspectTest,UserInvoiceAuthResponseTest,MerchantSessionAuthAnnotationTest,*Invoice*Test -Dsurefire.failIfNoSpecifiedTests=false test`,14项通过、失败/错误均0;原ClassCastException已由真实代理复现后修复,`git diff --check`通过。位于test-202609v2,未提交/部署;不修改App或数据库。未执行测试环境真机登录和发票查询联调。
+
+
+## Phase 12: 商家 App 证明联 PDF(2026-10-30)
+
+状态:实现中。首次与补印规则已确认,仅以本系统次数为准。
+
+- [x] T062 官方版式、补印限制及 API 契约写入规格和前端接入文档。
+- [x] T063 新增打印申请表 SQL、实体、Mapper,事务行锁与幂等处理。
+- [x] T064 ezPay 查询快照验证,禁止作废/载具/捐赠/不完整码值生成证明联。
+- [x] T065 繁体中文 PDF 模板:正面、背面、交易明细、补印;保留字体许可。
+- [x] T066 商家订单控制器接口、权限与国际化错误。
+- [x] T067 定向测试、PDF 视觉及条码回读检查、模块构建。
+- [ ] T068 开发者手动迁移、ezPay 样张/二维码解密验证、真实打印机及合规纸张验收、App 联调(外部验收,不能用本地测试替代)。
+
+
+2026-10-30 本地验证结果:
+- JDK21,`mvn -q -pl ruoyi-admin -am -Dtest=InvoicePrintConcurrencyTest,InvoicePrintSnapshotTest,InvoiceProofPdfRendererTest,MerchantInvoicePrintServiceTest,MerchantInvoicePrintControllerTest,MerchantOrderPrintControllerTest,MerchantOrderPrintServiceTest,*Invoice*Test -Dsurefire.failIfNoSpecifiedTests=false test`:24项,0失败、0错误。
+- `mvn -q -pl ruoyi-admin -am -DskipTests package`:通过;JAR包含PDFBox、原样嵌入字体与许可证,H2只在测试作用域。
+- PDF:57×90mm证明区域、B2B接续明细/B2C明细另页、补印与背面渲染检查;203dpi回读Code39及左右二维码通过。
+- 并发:独立H2内存库真实Mapper、Spring事务、双请求仅一份首次;原请求返回同一BLOB;平台/商家作废请求均等打印事务释放行锁才调用远端。
+- 独立代码审查提出作废并发1项,已修复并复核闭合;发票夹旧测试补Redis有效会话夹具,生产鉴权未改。
+- 当前test-202609v2,未新建分支、未提交、未部署。SQL仅登记,未连接或修改测试环境数据库。未做完整项目回归、真实ezPay请求/样张或解密验收、真实MySQL并发与打印机/App联调;T068继续待外部验收。
+
+
+- [x] T069 平台详情查看当前发票已保存PDF:只读权限接口、页面入口、四语言、只读与重开身份隔离验证。其他平台打印管理暂不处理。
+平台查看PDF本地验证:相关用例最终报告合计27项通过(新增权限用例独立复测通过);真实Spring方法权限验证拒绝未授权读取;只读/无文件/重开隔离/最新PDF选择均覆盖。Vue生产构建通过,四语言及CRLF检查通过。两仓库仍为test-202609v2;未提交、未部署,未做线上浏览器联调。本项无新增DDL。

+ 31 - 0
updatesql/sql.md

@@ -2513,3 +2513,34 @@ INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_defa
 SELECT 1, '注销翻悔期天数', '7', 'user_cancellation_grace_days', 'Y', '0', 'admin', NOW()
 SELECT 1, '注销翻悔期天数', '7', 'user_cancellation_grace_days', 'Y', '0', 'admin', NOW()
 WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='user_cancellation_grace_days');
 WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='user_cancellation_grace_days');
 ```
 ```
+
+
+## 2026-10-30 商家App电子发票证明联打印(010 Phase 12)
+
+仅登记,由开发者手动执行。须先迁移再发布后端;不改已有发票与订单状态。
+
+```sql
+CREATE TABLE IF NOT EXISTS pos_order_invoice_print_job (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '打印申请ID',
+  invoice_id BIGINT NOT NULL COMMENT 'pos_order_invoice.id',
+  order_id BIGINT NOT NULL COMMENT '订单ID',
+  store_id BIGINT NOT NULL COMMENT '门店ID',
+  invoice_trans_no VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'ezPay开立序号,重开另计',
+  invoice_number VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'App幂等键,网络重试复用',
+  requested_by BIGINT NOT NULL COMMENT '实际操作账号',
+  print_sequence INT NOT NULL COMMENT '1原件,大于1补印;生成PDF即占用',
+  reprint_reason VARCHAR(200) NOT NULL DEFAULT '' COMMENT '补印原因',
+  original_available TINYINT(1) NOT NULL DEFAULT 0 COMMENT '确认原联仍在',
+  contents_verified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '确认原联内容与存根相符',
+  pdf_content MEDIUMBLOB NOT NULL COMMENT '不可变正面PDF,幂等重试不重新生成',
+  create_time DATETIME(3) NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_invoice_print_request (order_id, request_id),
+  UNIQUE KEY uk_invoice_print_sequence (invoice_id, invoice_trans_no, print_sequence),
+  KEY idx_invoice_print_store (store_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电子发票证明联打印申请审计,非物理打印完成记录';
+```
+
+上线后不要删除打印记录或重置序号,否则会重复放出原件。数据库备份须包含本表PDF。
+回退代码可以保留该表;不得以清空表作为打印失败重试措施。