Ver Fonte

实现发票PDF预生成及商米图片打印

qmj há 10 horas atrás
pai
commit
7616afe3f9
29 ficheiros alterados com 799 adições e 119 exclusões
  1. 80 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintAssetService.java
  2. 43 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintAssetTask.java
  3. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintImage.java
  4. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintImageRenderer.java
  5. 46 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintQueryService.java
  6. 15 14
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRenderer.java
  7. 23 45
      ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintService.java
  8. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  9. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  10. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  11. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  12. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  13. 100 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintAssetPersistenceTest.java
  14. 57 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintAssetServiceTest.java
  15. 12 7
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintConcurrencyTest.java
  16. 52 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintImageRendererTest.java
  17. 17 2
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRendererTest.java
  18. 27 7
      ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintServiceTest.java
  19. 30 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoicePrintAsset.java
  20. 3 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoicePrintJob.java
  21. 14 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoicePrintAssetMapper.java
  22. 32 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoicePrintAssetMapper.xml
  23. 1 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoicePrintJobMapper.xml
  24. 9 0
      specs/010-order-invoice/data-model.md
  25. 69 43
      specs/010-order-invoice/frontend-app-printing.md
  26. 17 0
      specs/010-order-invoice/plan.md
  27. 16 0
      specs/010-order-invoice/spec.md
  28. 20 0
      specs/010-order-invoice/tasks.md
  29. 35 0
      updatesql/sql.md

+ 80 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintAssetService.java

@@ -0,0 +1,80 @@
+package com.ruoyi.app.order.invoice;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.system.domain.PosOrderInvoice;
+import com.ruoyi.system.domain.PosOrderInvoicePrintAsset;
+import com.ruoyi.system.mapper.PosOrderInvoiceMapper;
+import com.ruoyi.system.mapper.PosOrderInvoicePrintAssetMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.Date;
+import java.util.Objects;
+import static com.ruoyi.app.order.invoice.InvoicePrintSnapshot.*;
+
+/** 后台可重试的预生成服务,与开票请求/打印次数完全分离。 */
+@Service
+public class InvoicePrintAssetService {
+    public static final String VERSION="SUNMI_384_V1";
+    private static final Logger log=LoggerFactory.getLogger(InvoicePrintAssetService.class);
+    private final PosOrderInvoiceMapper invoices;
+    private final PosOrderInvoicePrintAssetMapper assets;
+    private final InvoicePrintQueryService query;
+    private final InvoiceProofPdfRenderer renderer;
+    private final InvoicePrintImageRenderer images;
+    public InvoicePrintAssetService(PosOrderInvoiceMapper invoices, PosOrderInvoicePrintAssetMapper assets,
+            InvoicePrintQueryService query, InvoiceProofPdfRenderer renderer, InvoicePrintImageRenderer images) {
+        this.invoices=invoices; this.assets=assets; this.query=query; this.renderer=renderer; this.images=images;
+    }
+    public PosOrderInvoicePrintAsset currentReady(Long orderId) {
+        return assets.selectCurrentReady(orderId,VERSION);
+    }
+    public PosOrderInvoicePrintAsset requireReady(PosOrderInvoice invoice) {
+        var asset=assets.selectForInvoice(invoice.getId(),invoice.getInvoiceTransNo(),VERSION);
+        require(asset!=null && "READY".equals(asset.getStatus())
+                && Objects.equals(asset.getInvoiceNumber(),invoice.getInvoiceNumber()),"preparing");
+        return asset;
+    }
+    @Transactional(rollbackFor=Exception.class)
+    public void prepare(Long orderId) {
+        // 多实例通过同一发票行互斥;作废先拿相同行锁,不能在作废中途产出可领取的旧文件。
+        var invoice=invoices.selectForPrintUpdate(orderId);
+        if (!eligible(invoice)) return;
+        var asset=assets.selectForInvoice(invoice.getId(),invoice.getInvoiceTransNo(),VERSION);
+        Date now=new Date();
+        if(asset!=null && ("READY".equals(asset.getStatus())
+                || asset.getNextRetryAt()!=null && asset.getNextRetryAt().after(now))) return;
+        if(asset==null) {
+            asset=new PosOrderInvoicePrintAsset(); asset.setInvoiceId(invoice.getId()); asset.setOrderId(orderId);
+            asset.setInvoiceTransNo(invoice.getInvoiceTransNo()); asset.setInvoiceNumber(invoice.getInvoiceNumber());
+            asset.setRendererVersion(VERSION); asset.setAttempts(0); asset.setCreateTime(now);
+        }
+        asset.setAttempts(asset.getAttempts()+1);
+        try {
+            var snapshot=query.query(invoice);
+            byte[] original=renderer.proof(snapshot,false), reprint=renderer.proof(snapshot,true);
+            require(original.length<=8*1024*1024 && reprint.length<=8*1024*1024,"tooLarge");
+            String originalImages=JSON.toJSONString(images.render(original));
+            String reprintImages=JSON.toJSONString(images.render(reprint));
+            // 所有文件均成功才发布READY;半成品不会暴露到商家或平台。
+            asset.setOriginalPdf(original); asset.setReprintPdf(reprint);
+            asset.setOriginalImages(originalImages); asset.setReprintImages(reprintImages);
+            asset.setStatus("READY"); asset.setFailureCode(""); asset.setNextRetryAt(now);
+        } catch(Exception failure) {
+            asset.setStatus("FAILED"); asset.setFailureCode("QUERY_OR_RENDER_FAILED");
+            int[] minutes={1,5,15,60};
+            asset.setNextRetryAt(new Date(System.currentTimeMillis()+60000L*minutes[Math.min(asset.getAttempts()-1,3)]));
+            // 不输出含随机码/二维码/密钥的异常正文,按发票ID和尝试次数排查。
+            log.warn("Invoice print assets failed, invoiceId={}, attempt={}, cause={}",
+                    invoice.getId(),asset.getAttempts(),failure.getClass().getSimpleName());
+        }
+        asset.setUpdateTime(new Date());
+        require((asset.getId()==null?assets.insert(asset):assets.updateById(asset))==1,"saveFailed");
+    }
+    private boolean eligible(PosOrderInvoice i) {
+        return i!=null && Objects.equals(i.getInvoiceStatus(),1) && !blank(i.getInvoiceTransNo())
+                && !blank(i.getInvoiceNumber()) && blank(i.getCarrierType()) && blank(i.getCarrierNum())
+                && blank(i.getLoveCode()) && !"DONATION".equals(i.getInvoiceCategory());
+    }
+}

+ 43 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintAssetTask.java

@@ -0,0 +1,43 @@
+package com.ruoyi.app.order.invoice;
+
+import com.ruoyi.system.mapper.PosOrderInvoicePrintAssetMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import java.time.Duration;
+import java.time.Instant;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.stereotype.Component;
+import java.util.Date;
+
+/** 独立调度线程扫描已提交的开票数据,不占支付补偿/订单定时任务线程,不依赖内存事件。 */
+@Component
+public class InvoicePrintAssetTask {
+    private static final Logger log=LoggerFactory.getLogger(InvoicePrintAssetTask.class);
+    private final PosOrderInvoicePrintAssetMapper assets;
+    private final InvoicePrintAssetService service;
+    public InvoicePrintAssetTask(PosOrderInvoicePrintAssetMapper assets, InvoicePrintAssetService service) {
+        this.assets=assets; this.service=service;
+    }
+    // 不注册成全局TaskScheduler Bean,避免Spring把其他未指定scheduler的支付任务也切到本线程。
+    private final ThreadPoolTaskScheduler scheduler=new ThreadPoolTaskScheduler();
+    @PostConstruct
+    public void start() {
+        scheduler.setPoolSize(1); scheduler.setThreadNamePrefix("invoice-print-");
+        scheduler.initialize();
+        scheduler.scheduleWithFixedDelay(this::generate,Instant.now().plusSeconds(10),Duration.ofSeconds(10));
+    }
+    @PreDestroy
+    public void stop() { scheduler.shutdown(); }
+    public void generate() {
+        long started=System.nanoTime();
+        try {
+            for(Long orderId:assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,10)) {
+                if(System.nanoTime()-started>45_000_000_000L) break;
+                try { service.prepare(orderId); }
+                catch(Exception failure) { log.warn("Invoice asset task failed, orderId={}, cause={}",orderId,failure.getClass().getSimpleName()); }
+            }
+        } catch(Exception failure) { log.warn("Invoice asset scan failed, cause={}",failure.getClass().getSimpleName()); }
+    }
+}

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintImage.java

@@ -0,0 +1,5 @@
+package com.ruoyi.app.order.invoice;
+
+/** PNG按页/分段顺序输出;separateAfter仅表示页间分离,同页长图分段绝不可额外走纸或裁切。 */
+public record InvoicePrintImage(int pageNumber, int partNumber, int width, int height,
+                               boolean lastPart, boolean separateAfter, String pngBase64) { }

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintImageRenderer.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.order.invoice;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.springframework.stereotype.Component;
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+/** SUNMI 58mm打印服务:8点/mm、384点有效宽。裁掉纸边,不能把57mm整页缩成48mm。 */
+@Component
+public class InvoicePrintImageRenderer {
+    public static final String PROFILE = "SUNMI_58_384";
+    public static final int DOTS_PER_MM = 8;
+    private static final int WIDTH = 384;
+    private static final int PART_HEIGHT = 2048;
+
+    public List<InvoicePrintImage> render(byte[] pdfBytes) throws Exception {
+        List<InvoicePrintImage> result = new ArrayList<>();
+        long encodedBytes = 0;
+        try (var pdf = Loader.loadPDF(pdfBytes)) {
+            var renderer = new PDFRenderer(pdf);
+            for (int page = 0; page < pdf.getNumberOfPages(); page++) {
+                var box = pdf.getPage(page).getMediaBox();
+                InvoicePrintSnapshot.require(Math.abs(box.getWidth()*25.4/72-57)<.02
+                        && box.getHeight()*25.4/72<=5000, "invalidData");
+                BufferedImage source = renderer.renderImageWithDPI(page, 203.2f);
+                int offset = (source.getWidth()-WIDTH)/2;
+                InvoicePrintSnapshot.require(offset>=0, "invalidData");
+                // 检查纸边确实空白。遇到旧版宽版式不能悄悄裁掉字符或二维码。
+                for (int y=0; y<source.getHeight(); y++) {
+                    for (int x=0; x<source.getWidth(); x++) {
+                        if (x<offset || x>=offset+WIDTH) {
+                            InvoicePrintSnapshot.require((source.getRGB(x,y)&0xffffff)==0xffffff,"invalidData");
+                        }
+                    }
+                }
+                int part=0;
+                for (int top=0; top<source.getHeight(); top+=PART_HEIGHT) {
+                    int height=Math.min(PART_HEIGHT,source.getHeight()-top);
+                    var bitmap=new BufferedImage(WIDTH,height,BufferedImage.TYPE_BYTE_BINARY);
+                    // 固定阈值黑白化,不使用抖动,避免二维码静区出现灰点。
+                    for(int y=0;y<height;y++) for(int x=0;x<WIDTH;x++) {
+                        int rgb=source.getRGB(offset+x,top+y);
+                        int gray=(((rgb>>16)&255)+((rgb>>8)&255)+(rgb&255))/3;
+                        bitmap.setRGB(x,y,gray<128?0xff000000:0xffffffff);
+                    }
+                    var out=new ByteArrayOutputStream();
+                    InvoicePrintSnapshot.require(ImageIO.write(bitmap,"PNG",out),"renderFailed");
+                    String data=Base64.getEncoder().encodeToString(out.toByteArray());
+                    encodedBytes+=data.length();
+                    InvoicePrintSnapshot.require(encodedBytes<=8*1024*1024,"tooLarge");
+                    boolean last=top+height==source.getHeight();
+                    result.add(new InvoicePrintImage(page+1,++part,WIDTH,height,last,
+                            last && page+1<pdf.getNumberOfPages(),data));
+                    bitmap.flush();
+                }
+                source.flush();
+            }
+        }
+        return List.copyOf(result);
+    }
+}

+ 46 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintQueryService.java

@@ -0,0 +1,46 @@
+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.PosStoreEzpay;
+import com.ruoyi.system.mapper.PosStoreEzpayMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import java.util.Objects;
+import static com.ruoyi.app.order.invoice.InvoicePrintSnapshot.*;
+
+/** 生成和打印共用的ezPay查验;缓存文件不能替代发票当前有效性检查。 */
+@Service
+public class InvoicePrintQueryService {
+    private final PosStoreEzpayMapper configs;
+    private final PosStoreMapper stores;
+    private final EzPay ezPay;
+    @Value("${ezpay.base-url}") private String baseUrl;
+    public InvoicePrintQueryService(PosStoreEzpayMapper configs, PosStoreMapper stores, EzPay ezPay) {
+        this.configs=configs; this.stores=stores; this.ezPay=ezPay;
+    }
+    public 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());
+    }
+
+}

+ 15 - 14
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRenderer.java

@@ -25,6 +25,7 @@ import java.util.Map;
 public class InvoiceProofPdfRenderer {
     private static final float MM = 72f / 25.4f;
     private static final float WIDTH = 57 * MM;
+    // 57mm纸面居中保留48mm有效区域;字体高度不变,只调整横向布局。
     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/";
@@ -38,23 +39,23 @@ public class InvoiceProofPdfRenderer {
             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, s.sellerName(), 5, 8, TEXT_SIZE, false, 47);
                 // 字高不通过缩小字号适配;长标题仅水平压缩,补印必须与标题并列。
-                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);
+                text(out, page, font, "電子發票證明聯" + (reprint ? " 補印" : ""), 5, 17, 19, false, 47);
+                text(out, page, font, s.period(), 5, 26, 22, true, 47);
+                text(out, page, font, s.invoiceNumber(), 5, 35, 22, true, 47);
+                text(out, page, font, s.issuedAt(), 5, 41, TEXT_SIZE, false, 47);
+                text(out, page, font, "隨機碼" + s.randomNum() + "  總計" + n(s.total()), 5, 46, TEXT_SIZE, false, 47);
+                text(out, page, font, "賣方" + s.sellerUbn(), 5, 51, TEXT_SIZE, false, 47);
                 if (s.businessBuyer()) {
-                    text(out, page, font, "買方" + s.buyerUbn() + "  格式25", 2, 56, TEXT_SIZE, false, 53);
+                    text(out, page, font, "買方" + s.buyerUbn() + "  格式25", 5, 56, TEXT_SIZE, false, 47);
                 }
                 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);
+                matrix(out, page, s.qrLeft(), BarcodeFormat.QR_CODE, charset, 8.5f, 68.5f, 17.5f, 17.5f);
+                matrix(out, page, s.qrRight(), BarcodeFormat.QR_CODE, charset, 31.5f, 68.5f, 17.5f, 17.5f);
                 if (s.businessBuyer()) lines(out, page, font, detail, 94);
             }
             if (!s.businessBuyer()) detailPage(doc, font, detail);
@@ -119,7 +120,7 @@ public class InvoiceProofPdfRenderer {
         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()) {
+            if (font.getStringWidth(line + character) / 1000 * TEXT_SIZE > 47 * MM && !line.isEmpty()) {
                 lines.add(line.toString()); line.setLength(0);
             }
             line.append(character);
@@ -136,7 +137,7 @@ public class InvoiceProofPdfRenderer {
 
     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);
+            if (!line.isEmpty()) text(out, page, font, line, 5, top, TEXT_SIZE, false, 47);
             top += 4.5f;
         }
     }
@@ -170,8 +171,8 @@ public class InvoiceProofPdfRenderer {
         float left = x * MM;
         if (format == BarcodeFormat.CODE_39) {
             // 19位Code39在57mm纸上若拉伸为分数像素,203dpi热敏输出会破坏窄宽条比例。
-            // 以203dpi整数点宽居中排版,保留10模块左右静区;PDF仍是可缩放矢量。
-            dx = 72f / 203;
+            // 以8点/mm整数点宽居中排版,保留10模块左右静区;PDF仍是可缩放矢量。
+            dx = MM / 8;
             left = (float) Math.floor((WIDTH / dx - bits.getWidth()) / 2) * dx;
         }
         for (int row = 0; row < bits.getHeight(); row++) {

+ 23 - 45
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintService.java

@@ -1,17 +1,10 @@
 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;
 
@@ -25,30 +18,31 @@ import static com.ruoyi.app.order.invoice.InvoicePrintSnapshot.*;
 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 InvoicePrintQueryService query;
+    private final InvoicePrintAssetService assets;
     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;
+            InvoicePrintQueryService query, InvoicePrintAssetService assets, InvoiceProofPdfRenderer renderer) {
+        this.invoices=invoices; this.jobs=jobs; this.query=query; this.assets=assets; 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) { }
+                              String fileName, String contentType, String pdfBase64, String reversePdfBase64,
+                              String printerProfile, int dotsPerMm, java.util.List<InvoicePrintImage> images) { }
 
     public record StoredPdfResult(String fileName, String contentType, String pdfBase64) { }
 
     /** 平台查看已保存文件,不联网查票、不重新排版,也不分配新的打印序号。 */
     public StoredPdfResult storedPdf(Long orderId) {
         PosOrderInvoicePrintJob job = jobs.selectLatestForCurrentInvoice(orderId);
-        if (job == null) return null;
+        if (job == null) {
+            var asset=assets.currentReady(orderId);
+            return asset==null?null:new StoredPdfResult(asset.getInvoiceNumber()+"-original.pdf",
+                    "application/pdf",encode(asset.getOriginalPdf()));
+        }
         return new StoredPdfResult(job.getInvoiceNumber() + "-" + job.getPrintSequence() + ".pdf",
                 "application/pdf", encode(job.getPdfContent()));
     }
@@ -57,7 +51,7 @@ public class MerchantInvoicePrintService {
     public DetailResult detail(Long orderId) {
         PosOrderInvoice invoice = invoices.selectOne(new LambdaQueryWrapper<PosOrderInvoice>()
                 .eq(PosOrderInvoice::getOrderId, orderId));
-        InvoicePrintSnapshot data = query(invoice);
+        InvoicePrintSnapshot data = query.query(invoice);
         try {
             return new DetailResult(invoice.getInvoiceNumber(), jobs.countForInvoice(invoice.getId(), invoice.getInvoiceTransNo()),
                     invoice.getInvoiceNumber() + "-detail.pdf", "application/pdf", encode(renderer.detail(data)));
@@ -79,7 +73,7 @@ public class MerchantInvoicePrintService {
         }
         // 多实例共享数据库行锁;不能仅用本机synchronized或先查次数再异步写记录。
         PosOrderInvoice invoice = invoices.selectForPrintUpdate(request.getOrderId());
-        InvoicePrintSnapshot data = query(invoice);
+        require(invoice != null && Objects.equals(invoice.getInvoiceStatus(), 1), "unavailable");
         PosOrderInvoicePrintJob existing = jobs.selectByRequest(request.getOrderId(), request.getRequestId());
         if (existing != null) {
             // 作废/重开后不能用旧幂等键取回旧证明联;不同操作者也不能复用别人的申请键。
@@ -88,14 +82,15 @@ public class MerchantInvoicePrintService {
                     && Objects.equals(existing.getRequestedBy(), userId)
                     && (existing.getPrintSequence() > 1) == request.getReprint()
                     && Objects.equals(normalize(existing.getReprintReason()), normalize(request.getReprintReason())), "requestConflict");
+            query.query(invoice);
             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 asset=assets.requireReady(invoice);
+        query.query(invoice);
+        byte[] pdf=count>0?asset.getReprintPdf():asset.getOriginalPdf();
         var job = new PosOrderInvoicePrintJob();
         job.setInvoiceId(invoice.getId()); job.setOrderId(invoice.getOrderId()); job.setStoreId(invoice.getStoreId());
         job.setInvoiceTransNo(invoice.getInvoiceTransNo()); job.setInvoiceNumber(invoice.getInvoiceNumber());
@@ -103,37 +98,20 @@ public class MerchantInvoicePrintService {
         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());
+        job.setPdfContent(pdf); job.setImagePages(count>0?asset.getReprintImages():asset.getOriginalImages());
+        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()));
+                    encode(job.getPdfContent()), encode(renderer.reverse()), InvoicePrintImageRenderer.PROFILE,
+                    InvoicePrintImageRenderer.DOTS_PER_MM, blank(job.getImagePages())?java.util.List.of():
+                    com.alibaba.fastjson2.JSON.parseArray(job.getImagePages(),InvoicePrintImage.class));
         } catch (Exception e) { throw error("renderFailed"); }
     }
     private static String normalize(String text) { return blank(text) ? "" : text.trim(); }

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

@@ -524,3 +524,5 @@ invoice.print.queryFailed=发票商查询失败,请稍后重试
 invoice.print.renderFailed=发票PDF生成失败,请联系平台
 invoice.print.saveFailed=打印记录保存失败,请使用原请求号重试
 invoice.print.tooLarge=发票明细过长,无法生成打印文件
+
+invoice.print.preparing=发票打印文件正在准备,请稍后重试

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

@@ -510,3 +510,5 @@ 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.
+
+invoice.print.preparing=Invoice print files are being prepared. Please try again shortly.

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

@@ -48,3 +48,5 @@ invoice.print.queryFailed=Tra cứu nhà cung cấp hóa đơn thất bại. Vui
 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ợ.
+
+invoice.print.preparing=Tệp in hóa đơn đang được chuẩn bị. Vui lòng thử lại sau.

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

@@ -512,3 +512,5 @@ invoice.print.queryFailed=发票商查询失败,请稍后重试
 invoice.print.renderFailed=发票PDF生成失败,请联系平台
 invoice.print.saveFailed=打印记录保存失败,请使用原请求号重试
 invoice.print.tooLarge=发票明细过长,无法生成打印文件
+
+invoice.print.preparing=发票打印文件正在准备,请稍后重试

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

@@ -511,3 +511,5 @@ invoice.print.queryFailed=發票商查詢失敗,請稍後重試
 invoice.print.renderFailed=發票PDF產生失敗,請聯絡平台
 invoice.print.saveFailed=列印紀錄儲存失敗,請使用原請求號重試
 invoice.print.tooLarge=發票明細過長,無法產生列印檔案
+
+invoice.print.preparing=發票列印檔案正在準備,請稍後重試

+ 100 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintAssetPersistenceTest.java

@@ -0,0 +1,100 @@
+package com.ruoyi.app.order.invoice;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
+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.transaction.annotation.AnnotationTransactionAttributeSource;
+import org.springframework.transaction.interceptor.TransactionInterceptor;
+import java.util.*;
+import java.util.concurrent.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+/** 真实Mapper和事务,使用独立H2数据库,不连接项目测试库。 */
+class InvoicePrintAssetPersistenceTest {
+    private static class Fixture implements AutoCloseable {
+        final JdbcTemplate jdbc;
+        final PosOrderInvoicePrintAssetMapper assets;
+        final InvoicePrintQueryService query=mock(InvoicePrintQueryService.class);
+        final InvoicePrintAssetService service;
+        Fixture() throws Exception {
+            var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000");
+            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,"
+                    +"carrier_type VARCHAR(10),carrier_num VARCHAR(64),love_code VARCHAR(16),invoice_category VARCHAR(16),issue_time TIMESTAMP,create_time TIMESTAMP)");
+            jdbc.execute("INSERT INTO pos_order_invoice(id,order_id,store_id,order_no,invoice_trans_no,invoice_number,random_num,invoice_status,total_amt,issue_time) "
+                    +"VALUES(7,17,27,'ORDER17','TRANS17','AB12345678','1234',1,105,CURRENT_TIMESTAMP)");
+            jdbc.execute("CREATE TABLE pos_order_invoice_print_asset(id BIGINT AUTO_INCREMENT PRIMARY KEY,invoice_id BIGINT,order_id BIGINT,"
+                    +"invoice_trans_no VARCHAR(64),invoice_number VARCHAR(10),renderer_version VARCHAR(32),status VARCHAR(16),attempts INT,"
+                    +"next_retry_at TIMESTAMP,failure_code VARCHAR(64),original_pdf BLOB,reprint_pdf BLOB,original_images CLOB,reprint_images CLOB,"
+                    +"create_time TIMESTAMP,update_time TIMESTAMP,UNIQUE(invoice_id,invoice_trans_no,renderer_version))");
+            var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
+            var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
+            factory.setMapperLocations(new ClassPathResource("mapper/chanting/PosOrderInvoiceMapper.xml"),
+                    new ClassPathResource("mapper/chanting/PosOrderInvoicePrintAssetMapper.xml"));
+            var session=new SqlSessionTemplate(factory.getObject()); assets=session.getMapper(PosOrderInvoicePrintAssetMapper.class);
+            var renderer=mock(InvoiceProofPdfRenderer.class); var images=mock(InvoicePrintImageRenderer.class);
+            when(query.query(any())).thenReturn(InvoicePrintSnapshot.from(InvoicePrintSnapshotTest.data(),InvoicePrintSnapshotTest.invoice(),
+                    InvoicePrintSnapshotTest.config(),"店"));
+            when(renderer.proof(any(),eq(false))).thenReturn(new byte[]{1,2,3});
+            when(renderer.proof(any(),eq(true))).thenReturn(new byte[]{4,5,6});
+            when(images.render(any())).thenReturn(List.of(new InvoicePrintImage(1,1,384,720,true,false,"cG5n")));
+            var target=new InvoicePrintAssetService(session.getMapper(PosOrderInvoiceMapper.class),assets,query,renderer,images);
+            var proxy=new ProxyFactory(target); proxy.setProxyTargetClass(true);
+            proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
+            service=(InvoicePrintAssetService)proxy.getProxy();
+        }
+        @Override public void close() { jdbc.execute("SHUTDOWN"); }
+    }
+    @Test void scannerFindsPaperInvoicesAndDuplicateWorkersPersistOnlyOneVersion() throws Exception {
+        try(var f=new Fixture(); var pool=Executors.newFixedThreadPool(2)) {
+            assertEquals(List.of(17L),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+            var start=new CountDownLatch(1);
+            var a=pool.submit(()->{start.await();f.service.prepare(17L);return true;});
+            var b=pool.submit(()->{start.await();f.service.prepare(17L);return true;});
+            start.countDown(); assertTrue(a.get(10,TimeUnit.SECONDS)); assertTrue(b.get(10,TimeUnit.SECONDS));
+            assertEquals(1,f.jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_invoice_print_asset",Integer.class));
+            var ready=f.service.currentReady(17L); assertNotNull(ready);
+            assertEquals("READY",ready.getStatus()); assertEquals(1,ready.getAttempts());
+            assertArrayEquals(new byte[]{1,2,3},ready.getOriginalPdf()); assertArrayEquals(new byte[]{4,5,6},ready.getReprintPdf());
+            assertTrue(ready.getOriginalImages().contains("cG5n"));
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+            verify(f.query,times(1)).query(any());
+            // 没有打印申请表也能预生成:不依赖或写入打印次数。
+            assertEquals(1,f.jdbc.queryForObject("SELECT invoice_status FROM pos_order_invoice WHERE id=7",Integer.class));
+        }
+    }
+    @Test void retriesPersistAcrossScansAndReissueCannotReadOldFiles() throws Exception {
+        try(var f=new Fixture()) {
+            when(f.query.query(any())).thenThrow(new IllegalStateException("provider offline"));
+            f.service.prepare(17L);
+            assertNull(f.service.currentReady(17L));
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+            f.jdbc.update("UPDATE pos_order_invoice_print_asset SET next_retry_at=?",new Date(0));
+            assertEquals(List.of(17L),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+            doReturn(InvoicePrintSnapshot.from(InvoicePrintSnapshotTest.data(),InvoicePrintSnapshotTest.invoice(),InvoicePrintSnapshotTest.config(),"店"))
+                    .when(f.query).query(any());
+            f.service.prepare(17L);
+            assertEquals(2,f.service.currentReady(17L).getAttempts());
+            assertEquals("",f.service.currentReady(17L).getFailureCode());
+            f.jdbc.update("UPDATE pos_order_invoice SET invoice_status=3 WHERE id=7");
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+            f.jdbc.update("UPDATE pos_order_invoice SET invoice_status=1,invoice_trans_no='NEW',invoice_number='AB99999999' WHERE id=7");
+            assertNull(f.service.currentReady(17L));
+            assertEquals(List.of(17L),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+            f.jdbc.update("UPDATE pos_order_invoice SET carrier_type='0',carrier_num='/ABCDEFG' WHERE id=7");
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+            f.jdbc.update("UPDATE pos_order_invoice SET carrier_type=NULL,carrier_num=NULL,love_code='919' WHERE id=7");
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),"SUNMI_384_V1",10));
+        }
+    }
+}

+ 57 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintAssetServiceTest.java

@@ -0,0 +1,57 @@
+package com.ruoyi.app.order.invoice;
+
+import com.ruoyi.system.domain.PosOrderInvoicePrintAsset;
+import com.ruoyi.system.mapper.*;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class InvoicePrintAssetServiceTest {
+    private final PosOrderInvoiceMapper invoices=mock(PosOrderInvoiceMapper.class);
+    private final PosOrderInvoicePrintAssetMapper assets=mock(PosOrderInvoicePrintAssetMapper.class);
+    private final InvoicePrintQueryService query=mock(InvoicePrintQueryService.class);
+    private final InvoiceProofPdfRenderer renderer=mock(InvoiceProofPdfRenderer.class);
+    private final InvoicePrintImageRenderer images=mock(InvoicePrintImageRenderer.class);
+    private final InvoicePrintAssetService service=new InvoicePrintAssetService(invoices,assets,query,renderer,images);
+    private void setup() throws Exception {
+        when(invoices.selectForPrintUpdate(17L)).thenReturn(InvoicePrintSnapshotTest.invoice());
+        when(query.query(any())).thenReturn(InvoicePrintSnapshot.from(InvoicePrintSnapshotTest.data(),
+                InvoicePrintSnapshotTest.invoice(),InvoicePrintSnapshotTest.config(),"店"));
+        when(renderer.proof(any(),eq(false))).thenReturn(new byte[]{1});
+        when(renderer.proof(any(),eq(true))).thenReturn(new byte[]{2});
+        when(images.render(any())).thenReturn(List.of(new InvoicePrintImage(1,1,384,720,true,false,"cG5n")));
+        when(assets.insert(any(PosOrderInvoicePrintAsset.class))).thenReturn(1);
+        when(assets.updateById(any(PosOrderInvoicePrintAsset.class))).thenReturn(1);
+    }
+    @Test void preparationPersistsBothVariantsWithoutChangingInvoiceOrCreatingPrintRequests() throws Exception {
+        setup(); service.prepare(17L);
+        var capture=ArgumentCaptor.forClass(PosOrderInvoicePrintAsset.class); verify(assets).insert(capture.capture());
+        var a=capture.getValue(); assertEquals("READY",a.getStatus()); assertArrayEquals(new byte[]{1},a.getOriginalPdf());
+        assertArrayEquals(new byte[]{2},a.getReprintPdf()); assertTrue(a.getOriginalImages().contains("cG5n"));
+        assertEquals("TRANS17",a.getInvoiceTransNo()); assertEquals(1,a.getAttempts());
+        verify(invoices,never()).updateById(any(com.ruoyi.system.domain.PosOrderInvoice.class));
+    }
+    @Test void failedGenerationRecordsBackoffAndDoesNotChangeIssuedState() throws Exception {
+        setup(); when(query.query(any())).thenThrow(new IllegalStateException("remote timeout"));
+        long before=System.currentTimeMillis(); service.prepare(17L);
+        var capture=ArgumentCaptor.forClass(PosOrderInvoicePrintAsset.class); verify(assets).insert(capture.capture());
+        var a=capture.getValue(); assertEquals("FAILED",a.getStatus()); assertNull(a.getOriginalPdf());
+        assertTrue(a.getNextRetryAt().getTime()>=before+60000); assertEquals(1,a.getAttempts());
+        a.setId(8L); when(assets.selectForInvoice(7L,"TRANS17","SUNMI_384_V1")).thenReturn(a);
+        service.prepare(17L); verify(assets,never()).updateById(any(PosOrderInvoicePrintAsset.class));
+        a.setNextRetryAt(new Date(0)); service.prepare(17L); assertEquals(2,a.getAttempts());
+        assertTrue(a.getNextRetryAt().getTime()>=before+300000);
+    }
+    @Test void readyOrVoidOrCarrierInvoiceDoesNotRegenerate() throws Exception {
+        setup(); var a=new PosOrderInvoicePrintAsset(); a.setStatus("READY");
+        when(assets.selectForInvoice(7L,"TRANS17","SUNMI_384_V1")).thenReturn(a);
+        service.prepare(17L); verifyNoInteractions(query,renderer,images);
+        var row=InvoicePrintSnapshotTest.invoice(); row.setInvoiceStatus(3);
+        when(invoices.selectForPrintUpdate(17L)).thenReturn(row); service.prepare(17L);
+        row.setInvoiceStatus(1); row.setCarrierType("0"); service.prepare(17L);
+        verifyNoInteractions(query,renderer,images);
+    }
+}

+ 12 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintConcurrencyTest.java

@@ -43,7 +43,7 @@ class InvoicePrintConcurrencyTest {
         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))");
+                + "pdf_content BLOB,image_pages CLOB,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"),
@@ -57,9 +57,12 @@ class InvoicePrintConcurrencyTest {
                 .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 query = new InvoicePrintQueryService(configs,stores,ezPay);
+        ReflectionTestUtils.setField(query, "baseUrl", "https://example.invalid");
+        var assets = mock(InvoicePrintAssetService.class);
+        when(assets.requireReady(any())).thenReturn(MerchantInvoicePrintServiceTest.readyAsset());
         var target = new MerchantInvoicePrintService(session.getMapper(PosOrderInvoiceMapper.class),
-                session.getMapper(PosOrderInvoicePrintJobMapper.class), configs, stores, ezPay, renderer);
-        ReflectionTestUtils.setField(target, "baseUrl", "https://example.invalid");
+                session.getMapper(PosOrderInvoicePrintJobMapper.class), query, assets, renderer);
         var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
         proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
         var service = (MerchantInvoicePrintService) proxy.getProxy();
@@ -75,8 +78,8 @@ class InvoicePrintConcurrencyTest {
             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());
+            verify(renderer, never()).proof(any(), anyBoolean());
+            assertEquals("AQI=", service.storedPdf(17L).pdfBase64());
             assertNull(service.storedPdf(99L));
             // 平台按当前开立身份只读取文件;重开后不能把旧发票PDF当成新发票显示。
             jdbc.update("UPDATE pos_order_invoice SET invoice_trans_no='NEW-TRANS' WHERE id=7");
@@ -92,8 +95,9 @@ class InvoicePrintConcurrencyTest {
                 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};
+                when(ezPay.searchInvoiceForPrint(anyString(),any(),anyString(),anyString())).thenAnswer(invocation -> {
+                    rendering.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS));
+                    return new JSONObject().fluentPut("Status","SUCCESS").fluentPut("Result",InvoicePrintSnapshotTest.data());
                 });
                 var realMapper = session.getMapper(PosOrderInvoiceMapper.class);
                 var invalidMapper = spy(realMapper);
@@ -131,6 +135,7 @@ class InvoicePrintConcurrencyTest {
                 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());
+                assertEquals("cG5n",printing.get().images().getFirst().pngBase64());
             }
         } finally { jdbc.execute("SHUTDOWN"); }
     }

+ 52 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintImageRendererTest.java

@@ -0,0 +1,52 @@
+package com.ruoyi.app.order.invoice;
+
+import com.google.zxing.*;
+import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
+import com.google.zxing.common.HybridBinarizer;
+import org.junit.jupiter.api.Test;
+import javax.imageio.ImageIO;
+import java.io.ByteArrayInputStream;
+import java.util.Base64;
+import java.util.Map;
+import java.nio.file.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class InvoicePrintImageRendererTest {
+    @Test void sunmiImagesKeepPhysicalSizeAndAllCodesReadable() throws Exception {
+        var data = InvoicePrintSnapshot.from(InvoicePrintSnapshotTest.data(), InvoicePrintSnapshotTest.invoice(),
+                InvoicePrintSnapshotTest.config(), "恰恰吃測試餐廳");
+        var images = new InvoicePrintImageRenderer().render(new InvoiceProofPdfRenderer().proof(data, false));
+        assertEquals(2, images.size());
+        var first = images.getFirst();
+        assertEquals(384, first.width()); assertEquals(720, first.height());
+        assertTrue(first.separateAfter()); assertEquals(1, first.pageNumber());
+        byte[] png = Base64.getDecoder().decode(first.pngBase64());
+        Files.createDirectories(Path.of("target/invoice-print-samples"));
+        Files.write(Path.of("target/invoice-print-samples/v3-original.png"), png);
+        var bitmap = ImageIO.read(new ByteArrayInputStream(png));
+        assertEquals(data.barCode(), decode(bitmap.getSubimage(0, 464, 384, 60)));
+        assertEquals(data.qrLeft(), decode(bitmap.getSubimage(0, 524, 208, 196)));
+        assertEquals(data.qrRight(), decode(bitmap.getSubimage(188, 524, 196, 196)));
+    }
+
+    @Test void longBusinessPageIsSplitWithoutAddingCutBoundaries() 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 rows = r.getJSONArray("ItemDetail");
+        for (int i=0; i<70; i++) rows.add(rows.getJSONObject(0).clone());
+        var data = InvoicePrintSnapshot.from(r, InvoicePrintSnapshotTest.invoice(), InvoicePrintSnapshotTest.config(), "店");
+        var pdf = new InvoiceProofPdfRenderer().proof(data, true);
+        var images = new InvoicePrintImageRenderer().render(pdf);
+        assertTrue(images.size()>1);
+        for (int i=0;i<images.size();i++) {
+            var part=images.get(i);
+            assertEquals(1, part.pageNumber()); assertEquals(i+1,part.partNumber());
+            assertTrue(part.height()<=2048); assertEquals(384,part.width());
+            assertFalse(part.separateAfter()); assertEquals(i==images.size()-1,part.lastPart());
+        }
+    }
+    private String decode(java.awt.image.BufferedImage image) throws Exception {
+        return new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer(new BufferedImageLuminanceSource(image))),
+                Map.of(DecodeHintType.TRY_HARDER,true)).getText();
+    }
+}

+ 17 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRendererTest.java

@@ -34,12 +34,27 @@ class InvoiceProofPdfRendererTest {
             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.qrLeft(), assertDoesNotThrow(() -> decode(crop(page, 4.5f, 65, 26, 25)), "left QR"));
+            assertEquals(data.qrRight(), assertDoesNotThrow(() -> decode(crop(page, 28, 65, 24.5f, 25)), "right QR"));
             assertEquals(data.barCode(), assertDoesNotThrow(() -> decode(crop(page, 0, 58, 57, 8)), "Code39"));
         }
     }
 
+    @Test void proofContentFitsSunmi48mmWithoutShrinkingPaperOrFontHeight() throws Exception {
+        var data = InvoicePrintSnapshot.from(InvoicePrintSnapshotTest.data(), InvoicePrintSnapshotTest.invoice(),
+                InvoicePrintSnapshotTest.config(), "恰恰吃測試餐廳");
+        try (var pdf = Loader.loadPDF(new InvoiceProofPdfRenderer().proof(data, false))) {
+            BufferedImage image = new PDFRenderer(pdf).renderImageWithDPI(0, 203.2f);
+            // 57mm纸在8点/mm下456点;V3有效384点,左右各36点只允许空白。
+            for (int y = 0; y < image.getHeight(); y++) {
+                for (int x = 0; x < image.getWidth(); x++) {
+                    if (x < 36 || x >= 420) assertEquals(0xffffff, image.getRGB(x, y) & 0xffffff,
+                            "内容超出有效打印区: " + x + "," + y);
+                }
+            }
+        }
+    }
+
     @Test void businessDetailIsNotCutAndReprintIsOnTitleLine() throws Exception {
         var r = InvoicePrintSnapshotTest.data();
         r.put("Category", "B2B"); r.put("BuyerUBN", "87654321");

+ 27 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintServiceTest.java

@@ -22,7 +22,16 @@ class MerchantInvoicePrintServiceTest {
     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);
+    private final InvoicePrintQueryService query = new InvoicePrintQueryService(configs,stores,ezPay);
+    private final InvoicePrintAssetService assets = mock(InvoicePrintAssetService.class);
+    private final MerchantInvoicePrintService service = new MerchantInvoicePrintService(invoices,jobs,query,assets,renderer);
+
+    static com.ruoyi.system.domain.PosOrderInvoicePrintAsset readyAsset() {
+        var a=new com.ruoyi.system.domain.PosOrderInvoicePrintAsset();
+        a.setInvoiceNumber("AB12345678"); a.setOriginalPdf(new byte[]{1,2}); a.setReprintPdf(new byte[]{3,4});
+        a.setOriginalImages("[{\"pageNumber\":1,\"partNumber\":1,\"width\":384,\"height\":720,\"lastPart\":true,\"separateAfter\":false,\"pngBase64\":\"cG5n\"}]");
+        a.setReprintImages(a.getOriginalImages()); return a;
+    }
 
     @Test void platformViewOnlyReturnsStoredPdfAndNeverQueriesProviderOrAllocatesPrint() {
         var job = new PosOrderInvoicePrintJob();
@@ -43,14 +52,22 @@ class MerchantInvoicePrintServiceTest {
         verifyNoInteractions(invoices, configs, stores, ezPay, renderer);
     }
 
+    @Test void platformCanViewPreparedOriginalBeforeAnyPrintRequest() {
+        when(assets.currentReady(17L)).thenReturn(readyAsset());
+        var result=service.storedPdf(17L);
+        assertEquals("AQI=",result.pdfBase64()); assertEquals("AB12345678-original.pdf",result.fileName());
+        verifyNoInteractions(ezPay,renderer,invoices);
+        verify(jobs,never()).insert(any(PosOrderInvoicePrintJob.class));
+    }
+
     private InvoicePrintRequest setup() throws Exception {
-        ReflectionTestUtils.setField(service, "baseUrl", "https://example.invalid");
+        ReflectionTestUtils.setField(query, "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(assets.requireReady(any())).thenReturn(readyAsset());
         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);
@@ -66,7 +83,8 @@ class MerchantInvoicePrintServiceTest {
         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));
+        assertEquals("cG5n",retry.images().getFirst().pngBase64());
+        verify(renderer, never()).proof(any(), anyBoolean());
         verify(jobs, times(1)).insert(any(PosOrderInvoicePrintJob.class));
         // 重试仍重新确认ezPay有效状态,不能从缓存返回已作废票。
         verify(ezPay, times(2)).searchInvoiceForPrint(anyString(), any(), anyString(), anyString());
@@ -83,15 +101,17 @@ class MerchantInvoicePrintServiceTest {
             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));
+            assertEquals("AwQ=",result.pdfBase64());
+            verify(renderer,never()).proof(any(),anyBoolean());
         }
     }
 
-    @Test void failedQueryOrRenderingDoesNotCreatePrintRecord() throws Exception {
+    @Test void notReadyOrFailedQueryDoesNotCreatePrintRecord() 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"));
+            when(assets.requireReady(any())).thenThrow(new ServiceException("invoice.print.preparing"));
             assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            doReturn(readyAsset()).when(assets).requireReady(any());
             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));

+ 30 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoicePrintAsset.java

@@ -0,0 +1,30 @@
+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;
+
+/** 开票后预生成文件,独立于打印申请;READY不代表申请过原件,更不代表设备已出纸。 */
+@Data
+@TableName("pos_order_invoice_print_asset")
+public class PosOrderInvoicePrintAsset {
+    @TableId(type=IdType.AUTO) private Long id;
+    private Long invoiceId;
+    private Long orderId;
+    private String invoiceTransNo;
+    private String invoiceNumber;
+    private String rendererVersion;
+    private String status;
+    private Integer attempts;
+    private Date nextRetryAt;
+    /** 只存内部错误代码,禁止持久化供应商回应/密钥/个人信息。 */
+    private String failureCode;
+    private byte[] originalPdf;
+    private byte[] reprintPdf;
+    private String originalImages;
+    private String reprintImages;
+    private Date createTime;
+    private Date updateTime;
+}

+ 3 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoicePrintJob.java

@@ -6,7 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import java.util.Date;
 
-/** 证明联PDF申请审计;生成文件不表示打印机已完成出纸,记录不得用于回滚首次状态。 */
+/** 证明联打印申请审计;预生成不写本表。领取文件不表示已出纸,记录不得用于回滚首次状态。 */
 @Data
 @TableName("pos_order_invoice_print_job")
 public class PosOrderInvoicePrintJob {
@@ -24,5 +24,7 @@ public class PosOrderInvoicePrintJob {
     private Boolean originalAvailable;
     private Boolean contentsVerified;
     private byte[] pdfContent;
+    /** 保存本次交付的PNG分段,确保相同requestId重试不随新版版式改变。旧记录允许为空。 */
+    private String imagePages;
     private Date createTime;
 }

+ 14 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoicePrintAssetMapper.java

@@ -0,0 +1,14 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderInvoicePrintAsset;
+import org.apache.ibatis.annotations.Param;
+import java.util.Date;
+import java.util.List;
+
+public interface PosOrderInvoicePrintAssetMapper extends BaseMapper<PosOrderInvoicePrintAsset> {
+    PosOrderInvoicePrintAsset selectForInvoice(@Param("invoiceId") Long invoiceId,
+            @Param("transNo") String transNo, @Param("version") String version);
+    PosOrderInvoicePrintAsset selectCurrentReady(@Param("orderId") Long orderId, @Param("version") String version);
+    List<Long> selectCandidates(@Param("now") Date now, @Param("version") String version, @Param("limit") int limit);
+}

+ 32 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoicePrintAssetMapper.xml

@@ -0,0 +1,32 @@
+<?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.PosOrderInvoicePrintAssetMapper">
+    <resultMap id="asset" type="com.ruoyi.system.domain.PosOrderInvoicePrintAsset" autoMapping="true">
+        <id column="id" property="id"/>
+        <result column="original_pdf" property="originalPdf" jdbcType="LONGVARBINARY"/>
+        <result column="reprint_pdf" property="reprintPdf" jdbcType="LONGVARBINARY"/>
+    </resultMap>
+    <select id="selectForInvoice" resultMap="asset">
+        SELECT * FROM pos_order_invoice_print_asset
+        WHERE invoice_id=#{invoiceId} AND invoice_trans_no=#{transNo} AND renderer_version=#{version}
+    </select>
+    <select id="selectCurrentReady" resultMap="asset">
+        SELECT a.* FROM pos_order_invoice_print_asset a
+        INNER JOIN pos_order_invoice i ON i.id=a.invoice_id AND i.order_id=a.order_id
+            AND i.invoice_trans_no=a.invoice_trans_no AND i.invoice_number=a.invoice_number
+        WHERE i.order_id=#{orderId} AND a.renderer_version=#{version} AND a.status='READY'
+    </select>
+    <!-- 已提交的开票行即持久任务源:进程重启、开票回调重复均不会漏掉生成或消耗打印次数。 -->
+    <select id="selectCandidates" resultType="java.lang.Long">
+        SELECT i.order_id FROM pos_order_invoice i
+        LEFT JOIN pos_order_invoice_print_asset a ON a.invoice_id=i.id
+            AND a.invoice_trans_no=i.invoice_trans_no AND a.renderer_version=#{version}
+        WHERE i.invoice_status=1 AND i.invoice_trans_no IS NOT NULL AND i.invoice_trans_no!=''
+          AND i.invoice_number IS NOT NULL AND i.invoice_number!=''
+          AND COALESCE(i.carrier_type,'')='' AND COALESCE(i.carrier_num,'')=''
+          AND COALESCE(i.love_code,'')='' AND COALESCE(i.invoice_category,'')!='DONATION'
+          AND (a.id IS NULL OR (a.status='FAILED' AND a.next_retry_at &lt;= #{now}))
+        ORDER BY COALESCE(a.next_retry_at,i.issue_time,i.create_time), i.id
+        LIMIT #{limit}
+    </select>
+</mapper>

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

@@ -15,6 +15,7 @@
         <result column="original_available" property="originalAvailable"/>
         <result column="contents_verified" property="contentsVerified"/>
         <result column="pdf_content" property="pdfContent" jdbcType="LONGVARBINARY"/>
+        <result column="image_pages" property="imagePages"/>
         <result column="create_time" property="createTime"/>
     </resultMap>
     <select id="selectByRequest" resultMap="job">

+ 9 - 0
specs/010-order-invoice/data-model.md

@@ -106,3 +106,12 @@ tax_amt       = total_amt - sales_amt       // 税额
 | love_org_name | VARCHAR(255) | 捐赠机构名(开票时从 pos_love_org 快照,便于历史展示) |
 
 > ALTER 写入 `updatesql/sql.md`。捐赠发票 invoice_category 仍为 `B2C`(ezPay 捐赠即 B2C+LoveCode),靠 `love_code` 非空区分捐赠与普通 B2C。
+
+
+## Phase13 文件与打印申请分离(2026-11-02)
+
+`pos_order_invoice_print_asset`:按(invoice_id, invoice_trans_no, renderer_version)唯一;原件/补印PDF、PNG分段JSON分别保存。status=READY/FAILED;不存在为待生成。attempts、next_retry_at实现持久退避,failure_code不含远端报文。后台读已提交且有效的纸本开票记录作为任务源,故进程退出无需恢复内存队列。
+
+`pos_order_invoice_print_job.image_pages`:新增可空MEDIUMTEXT;保存本次申请交付的PNG,旧申请为空。原requestId恢复时读取记录快照。次数只统计该表,预生成文件及平台读取不影响次数。
+
+领取/生成共用pos_order_invoice行锁;重开身份不同不能复用旧文件。平台最新打印记录优先,否则查看当前预生成原件。数据库迁移仅登记updatesql/sql.md,开发者执行。

+ 69 - 43
specs/010-order-invoice/frontend-app-printing.md

@@ -1,35 +1,23 @@
-# 商家 App:电子发票 PDF 打印接入
+# 商家 App:电子发票打印接入(商米 V3)
 
-本功能是 ezPay **电子发票证明联**,与 `/getOrderPrintInfo` 餐点小票不同。后端负责版式、金额和条码,App 只负责保存、展示 PDF 和调用打印机。
+后端在 ezPay 开票成功落库后,后台自动准备 PDF 和打印图片。**App 不需要再排版或把 PDF 转图片**;使用商米原生 SDK 的 `printBitmap` 打印 PNG。平台继续查看 PDF。
 
-接口前缀:`/system/orderShOprate`。请求头传商家登录 `token`;订单 ID 使用 `pos_order.id`。普通商家、夜市、摊位、授权子账号都沿用现有订单权限。
+这是电子发票证明联,与 `/getOrderPrintInfo` 餐点小票不同。App 代码由前端团队接入,本次后端不修改 App。
+
+接口前缀:`/system/orderShOprate`,请求头传商家 `token`;`orderId` 使用 `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..."
-  }
-}
-```
+返回 `invoiceNumber`、`printCount`、`fileName`、`contentType`、`pdfBase64`。此 PDF 仅交易明细,无兑奖二维码,重复查看不占首次资格。
 
-- `pdfBase64` 是无 Data URL 前缀的 Base64;解码成 PDF 文件后查看。
-- 这是交易明细,**没有兑奖二维码,不是证明联**。重复查看/打印明细不增加 `printCount`。
-- `printCount=0` 显示“打印发票”;大于 0 显示“申请补印”。只统计我们系统,不同步 ezPay 后台历史。
-- 无纸本资格、未开立、已作废或发票商查询失败,会返回业务错误;直接展示 `msg`,不要改用订单金额拼发票。
+- `printCount=0` 显示“打印发票”;大于0显示“申请补印”。仅统计本系统。
+- 自动预生成文件、平台查看文件均不增加次数。
 
-## 2. 首次打印证明联
+## 2. 用户点击打印时申请文件
 
-用户点击打印时调用 `POST /printInvoice`:
+`POST /printInvoice`
 
 ```json
 {
@@ -39,9 +27,9 @@
 }
 ```
 
-`requestId`:每次打印意图生成一个 UUID,调用前保存。**超时、断网或丢失响应时必须带原值重试**;同一请求号不能换订单发票、补印参数或操作账号。按钮提交中禁用,避免连点。
+`requestId` 是本次打印申请 UUID,调用前保存。超时、断网、响应丢失时使用同一值和同一参数重试,不能每次产生新编号。按钮提交中禁用。
 
-成功返回:
+成功响应示意(图片数量随页长变化,以下仅列一段):
 
 ```json
 {
@@ -55,42 +43,80 @@
     "fileName": "AB12345678-1.pdf",
     "contentType": "application/pdf",
     "pdfBase64": "JVBERi0...",
-    "reversePdfBase64": "JVBERi0..."
+    "reversePdfBase64": "JVBERi0...",
+    "printerProfile": "SUNMI_58_384",
+    "dotsPerMm": 8,
+    "images": [
+      {
+        "pageNumber": 1,
+        "partNumber": 1,
+        "width": 384,
+        "height": 720,
+        "lastPart": true,
+        "separateAfter": true,
+        "pngBase64": "iVBORw0..."
+      }
+    ]
   }
 }
 ```
 
-- `pdfBase64`:正面打印文件。个人 B2C 第1页为证明联,第2页为交易明细;分开裁切。B2B 为同一长页,明细接续打印,不能中途裁切。
-- `reversePdfBase64`:57×90mm 背面参考文件,含兑奖说明及领奖收据。**不要在正面后顺序打印它**。使用已有合规预印背面的纸卷时无需打印此文件;其他纸张需确保相应背面符合要求。
-- 纸宽57mm,证明区域高90mm。按100%实际尺寸输出,关闭“适应页面”、缩放、额外页眉页脚。不得自行改二维码、字体、顺序或裁切证明区域。
-- 服务端成功生成并保存 PDF 就增加次数;不是打印机完成出纸回执。App 不提供“打印失败重置为首次”功能。原请求重试只用于恢复同一次未收妥的响应,不能用缓存原件反复打印。
+新增字段:
+
+| 字段 | 用法 |
+|---|---|
+| `printerProfile` | 当前为 `SUNMI_58_384`,面向58mm纸卷、384点有效打印区 |
+| `dotsPerMm` | 8点/mm;按原像素输出,禁止再次缩放 |
+| `images[]` | PNG分段,必须按数组原顺序连续输出 |
+| `pageNumber` / `partNumber` | 从1开始;同页可能有多段 |
+| `width` / `height` | 图片实际像素尺寸,宽固定384,单段高最多2048 |
+| `lastPart` | 是否当前PDF页的最后一段 |
+| `separateAfter` | 此段后需要将证明联与明细分离;不是每段都切纸 |
+| `pngBase64` | 无Data URL前缀的PNG Base64 |
+
+保留的 `pdfBase64` 用于预览/兼容;打印以 `images` 为准。PDF纸面仍为57mm宽,PNG只包含居中的48mm有效打印区,已经去除白边,没有把整张PDF缩小。
+
+## 3. 商米 V3 打印步骤
 
-## 3. 补印
+1. App 运行在 V3 上时,连接商米内置打印服务。uni-app 通过 Android 原生插件或 UTS 对接商米 SDK。
+2. 校验实际设备有效宽度为384点,再使用本打印配置;不同打印宽度不能强行缩放套用。
+3. 将 `pngBase64` 解码为 PNG,再解码为 Bitmap;关闭资源密度自动缩放,确认 Bitmap 宽度为384。
+4. 按 `images` 顺序调用 `printBitmap`,处理异步回调,不能并发发送导致乱序。**同页分段之间不额外换行、走纸或裁切**。
+5. 遇到 `separateAfter=true`,按设备能力分离/提示撕纸后继续。个人票证明联与明细分开;企业票是同一长页,中途不能分离。V3是否具备切刀以实机为准,不能无条件调用切刀接口。
+6. 正常结束后处理设备出纸结果。`onRunResult`只表示指令执行情况;事务打印的`onPrintResult`用于确认打印结果。缺纸、开盖等由App提示,不能因回调不明确就自动重复整张原件。
 
-仅适用于原联记载不清等法定情形,并需核对内容、保留原联。不是重复点击就能再出一份原件。
+`reversePdfBase64`是背面参考,不属于`images`,不要接在正面后打印。使用符合要求的预印背面纸卷;背面及纸张仍需实际验收。
+
+## 4. 文件未准备好与重试
+
+- 后台每10秒扫描,包含已开纸本存量;每轮最多10张。积压或ezPay故障时可能更久,不能把10秒当成完成承诺。
+- 文件未就绪返回业务错误,`msg`为“发票打印文件正在准备,请稍后重试”(支持国际化)。本次不新增打印记录、不占次数;不要自动换成补印。
+- 后台失败按1/5/15/60分钟退避,此后每小时重试;不影响已成功的开票状态。
+- 实际申请打印仍会查询ezPay确认发票有效,因此网络故障时仍可能等待或失败;App按返回`msg`提示。
+- 旧版本已保存的打印申请可能返回`images=[]`,保留原PDF供查看;不要把旧PDF任意缩放或裁边输出。需要再次出纸时进入补印流程,使用新的requestId。
+
+## 5. 补印
 
 ```json
 {
   "orderId": 3173,
   "requestId": "a25ece72-8030-475d-97c9-3c49ce308d26",
   "reprint": true,
-  "reprintReason": "原证明联字迹不清,已核对发票内容一致",
+  "reprintReason": "原证明联字迹不清,已核对内容一致",
   "originalAvailable": true,
   "contentsVerified": true
 }
 ```
 
-- 原因必填,最多200字;两个确认必须为 true。
-- 成功后 `printSequence>=2`、`reprint=true`,PDF 标题自动加“補印”,App 无需拼字。
-- 首次尚未申请时不能直接补印;已经申请过原件不能再 `reprint=false`。出现并发冲突时刷新打印次数并展示错误,不自动改成补印重试。
-- 作废票不可打印;重开后的新发票重新计数,必须使用新的 `requestId`。
+原因最多200字,两个声明必须为true。成功后`printSequence>=2`,图片和PDF已经带“補印”,App不用拼字。仅适用于已确认的补印情形,需要保留原联。
+
+首次/补印资格按**成功受理打印申请**记录,不是按预生成文件记录,也不是按设备出纸回执记录。原requestId恢复响应不会新增次数;不能用缓存原件反复打印。作废后拒绝打印,重开后的新发票重新计数并使用新requestId。
 
-## 上线联调
+## 6. 平台查看与上线
 
-1. 开发者先手动执行 `updatesql/sql.md` 的“2026-10-30 商家App电子发票证明联打印”建表段,再发布后端。
-2. ezPay 纸本 B2C/B2B 各验证一笔,核对真实日期、总额、商品和税额;查询失败时不能出纸。
-3. 真机验证中文字体、57mm实际尺寸、明细裁切,以及左右二维码/Code39均可读取。
-4. 首次、超时原请求重试、跨设备重复点击、补印、作废/重开、越权访问均需联调。
-5. 正式使用前完成适用的样张、二维码解密验证和纸张验收;本地生成 PDF 不等于已获核准。
+- 平台现有“查看PDF”接口不变:已有打印记录看最新记录;没有打印记录时看已预生成原件。因此商家尚未打印,平台也可以看到PDF。
+- 先手动执行`updatesql/sql.md`中Phase12建表及“2026-11-02 开票后预生成PDF及商米V3图片”迁移,再发布后端;不需要配置若依sys_job。
+- 联调:新开纸本自动生成但次数为0、首次、重复请求、补印、缺纸/失败、作废/重开、越权、B2C/B2B长明细、重启后失败任务恢复。
+- 正式使用前需在真实V3上检查纸张尺寸、文字、Code39和两个二维码;后端本地图片回读不等于真实热敏打印验收或官方认可。
 
-本期不包含 App 页面、蓝牙/网络打印驱动或载具发票转纸本。普通查看继续使用交易明细,首次打印接口不要在进入详情页时自动调用。
+商米SDK参考:[内置打印机开发者文档](https://cdn.sunmi.com/public/generalfile/mgt-document/cb26f141d70b49f592a098db9e8a6114.pdf)。

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

@@ -197,3 +197,20 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 
 
 平台查看PDF:新增GET /system/orderInvoice/{orderId}/pdf,只读打印申请表并关联当前发票开立身份,重开后不能返回旧票文件。返回{fileName,contentType,pdfBase64};无文件时data为空或不返回。Vue详情按钮通过Blob URL内嵌显示,关闭/切页释放资源;四语言同步,无新增DDL。
+
+
+## Phase 13 实现计划(2026-11-02)
+
+目标:开票后异步准备证明联 PDF/商米图片,打印时领取文件并记录申请。沿用 test-202609v2,SQL 只登记,App 仅交付文档。
+
+1. `InvoiceProofPdfRenderer` 将内容收进 48mm 有效区域;新增 `InvoicePrintImageRenderer` 将 57mm PDF 按 8点/mm 栅格化、裁白边、黑白 PNG 分段。测试实际384点宽、90mm高度、条码回读、B2C/B2B分界与补印。
+2. `PosOrderInvoicePrintAsset` / Mapper/XML/SQL 持久化版本化文件。`InvoicePrintQueryService` 复用现有 ezPay 校验;`InvoicePrintAssetService.prepare(orderId)` 在发票锁内生成原件/补印并保存;`InvoicePrintAssetTask` 用独立调度器扫描已提交开票记录并恢复失败。无需修改开票/支付流程。
+3. `MerchantInvoicePrintService` 在文件就绪后分配打印次数、保存同一 PDF/图片;打印时继续实时查票。平台只读入口先读审计文件再读预生成文件。旧记录图片为空而非强行转码。
+4. 更新 Entity/XML、四语言错误、SQL、前端接入及数据模型文档。覆盖真实内存数据库候选扫描、多实例锁、作废/重开隔离、失败重试、首次资格不消耗与幂等。
+
+验收:JDK21 定向 Invoice/小票测试与模块 package;PNG/PDF视觉检查和384点扫码;git diff格式检查。SQL、ezPay真实样张、真实MySQL和V3验收由外部环境完成。提交推送按用户后续指令执行。
+
+评审重点:异步任务失败不能影响开票;多实例不能重复生成;旧开立文件不能跨重开使用;未准备好不能占次数;二维码及分页分段在热敏分辨率下可读。沿用现有 spec-kit 文件,任务清单作为执行进度记录。
+
+
+2026-11-02 执行记录:T070–T074本地验证完成,36项定向测试全部通过且模块package通过。排版测试先复现旧版内容越出48mm有效区(第20像素),调整后通过;384点PNG回读三种码通过。任务独立持有调度器,不注册全局TaskScheduler,避免把现有支付定时任务意外切换到打印线程。未修改OrderInvoiceService开票/付款流程。代码审查未发现P1/P2。本轮随代码提交;未部署、SQL未执行,T075保留外部验收。

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

@@ -244,3 +244,19 @@
 
 
 平台增量(2026-10-30):仅在订单发票详情增加查看已有PDF,读取当前发票最近一次保存的文件;暂无文件时提示,不生成新证明联、不增加打印次数。本轮不做平台打印/补印/记录管理。使用现有chanting:orderInvoice:query权限。
+
+
+## Phase 13:开票后预生成及商米 V3 图片打印(2026-11-02)
+
+本节替代 Phase12“打印时生成且生成即计数”的时机定义:预生成不计数,只有商家成功申请领取打印文件才计数。首次/补印权限与声明规则不变。
+
+用户已确认:后端提前生成 PDF 和图片,平台查看 PDF,商家 App 获取图片后通过商米 SDK 打印;不修改 App、不创建分支。
+
+- 开票结果成功落库后,由数据库驱动后台任务每 10 秒发现待生成纸本发票(每轮最多 10 张,单轮 45 秒预算)。覆盖自动开票、申请、重试、重开和存量纸本;不能在原开票事务里等待排版,也不能调用 printInvoice 消耗首次资格。
+- 新增独立文件表,按发票 ID、开立流水、版式版本隔离,保存原件/补印 PDF、对应 PNG 分段、生成状态、重试次数与下次重试时间。预生成不写打印申请表。失败按 1/5/15/60 分钟退避,之后最多每小时尝试;恢复后自动生成,不回滚已开票状态。
+- 原件和补印使用同一校验快照预生成;作废/重开与生成/打印共用发票行锁。载具/捐赠不生成证明联。
+- 图片面向 SUNMI 58mm/384 点有效区域,8 点/mm,PDF 纸面仍 57mm、证明区域 90mm。内容重新排入居中 48mm 区域,裁去空白纸边而不缩放整个 PDF;文字高度、条码比例保留。单图最多 2048 行,顺序连续输出,同页分段不可切纸;B2C 证明联与明细间分离、B2B 不分离。实际 V3/纸张需外部验收。
+- printInvoice 保留原参数、权限、ezPay 最新状态验证和幂等/补印规则。文件未就绪时返回国际化“正在准备”错误,不生成打印记录,不在请求中同步生成。成功时复制预生成文件到打印审计记录并新增 images 与打印参数;保留原 PDF 返回字段,兼容已接入客户端。
+- 相同 requestId 返回已保存的同一打印文件/图片,不能用缓存再次出原件。旧打印记录没有图片时返回空 images,App 提示使用补印流程,不任意缩放旧版 PDF。图片生成完成不等于真实打印完成,本轮不新增设备回执业务。
+- 平台查看优先已申请打印的最新文件;没有打印记录时显示当前发票已预生成原件。查看不联网、不生成、不计次数;重开后不读旧文件。
+- 仍保留打印时 ezPay 有效性校验;预生成减少排版/栅格化耗时,不承诺打印申请完全无第三方网络等待。图片不能公开暴露,通过原有鉴权接口返回 Base64。

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

@@ -311,3 +311,23 @@ description: "Task list for 订单 ezPay 电子发票开立"
 
 - [x] T069 平台详情查看当前发票已保存PDF:只读权限接口、页面入口、四语言、只读与重开身份隔离验证。其他平台打印管理暂不处理。
 平台查看PDF本地验证:相关用例最终报告合计27项通过(新增权限用例独立复测通过);真实Spring方法权限验证拒绝未授权读取;只读/无文件/重开隔离/最新PDF选择均覆盖。Vue生产构建通过,四语言及CRLF检查通过。两仓库仍为test-202609v2;未提交、未部署,未做线上浏览器联调。本项无新增DDL。
+
+
+## Phase 13:开票后预生成及商米 V3 图片(2026-11-02)
+
+状态:本地实现与验证完成,随本轮代码提交;未部署,外部验收T075待办。用户已确认方案并要求执行,不再重复确认;在当前 test-202609v2 修改,不改 App。
+- [x] T070 48mm有效区域排版、384点黑白PNG分段及扫码回归。
+- [x] T071 独立预生成文件表、任务扫描、发票锁、失败退避和版本身份隔离。
+- [x] T072 打印领取预生成图片、保留幂等/补印/实时查票、平台无需打印即可查看PDF。
+- [x] T073 SQL/四语言/数据模型/App接入文档同步。
+- [x] T074 JDK21定向测试与构建、视觉检查和代码审查。
+- [ ] T075 外部验收:手动迁移、真实ezPay样张与V3打印/纸张扫码,App联调(不以本地测试代替)。
+
+
+2026-11-02 验证记录:
+- JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=*Invoice*Test,MerchantOrderPrintControllerTest,MerchantOrderPrintServiceTest -Dsurefire.failIfNoSpecifiedTests=false package`:36项,失败0、错误0、跳过0,模块构建通过。日志 `ruoyi-admin/target/invoice-v3-verification.log`。
+- 已覆盖:后台两实例仅一份文件、失败退避/恢复、存量扫描、载具/捐赠排除、重开隔离、预生成不依赖打印申请表、未就绪不占首次、原申请重试文件相同、补印及平台只读权限、作废互斥。
+- PDF/图片:保留57mm纸面和90mm证明区域,内容收进48mm有效区;384点PNG码值回读成功,B2C分离、B2B长图连续输出检查通过。用Poppler及实际返回PNG检查原件、补印和明细,无裁字/重叠。
+- 独立代码审查未发现P1/P2;git diff --check通过。未执行全项目测试、真实MySQL、真实ezPay或V3出纸;T075保留。
+- 数据库需要手动建 `pos_order_invoice_print_asset` 并为 `pos_order_invoice_print_job` 添加 `image_pages`,见2026-11-02迁移。仅写脚本,未连接数据库执行。
+- 本轮未修改App或平台前端;现有平台PDF按钮直接读取预生成文件。前端交付 `frontend-app-printing.md`。

+ 35 - 0
updatesql/sql.md

@@ -2544,3 +2544,38 @@ CREATE TABLE IF NOT EXISTS pos_order_invoice_print_job (
 
 上线后不要删除打印记录或重置序号,否则会重复放出原件。数据库备份须包含本表PDF。
 回退代码可以保留该表;不得以清空表作为打印失败重试措施。
+
+
+## 2026-11-02 开票后预生成PDF及商米V3图片(010 Phase13)
+
+先执行 Phase12 `pos_order_invoice_print_job` 建表,再执行本段;仅登记,由开发者手动执行。旧打印记录保留,不回填/重置首次次数。未执行迁移不要发布本版本。
+
+```sql
+CREATE TABLE IF NOT EXISTS `pos_order_invoice_print_asset` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `invoice_id` bigint NOT NULL COMMENT '当前发票记录ID',
+  `order_id` bigint NOT NULL COMMENT 'pos_order.id',
+  `invoice_trans_no` varchar(64) NOT NULL COMMENT '开立身份,重开隔离',
+  `invoice_number` varchar(10) NOT NULL,
+  `renderer_version` varchar(32) NOT NULL,
+  `status` varchar(16) NOT NULL COMMENT 'READY/FAILED;无记录为待生成',
+  `attempts` int NOT NULL DEFAULT 0,
+  `next_retry_at` datetime DEFAULT NULL,
+  `failure_code` varchar(64) DEFAULT NULL COMMENT '内部错误代码,不含供应商敏感信息',
+  `original_pdf` mediumblob,
+  `reprint_pdf` mediumblob,
+  `original_images` mediumtext COMMENT '原件PNG分段JSON/Base64',
+  `reprint_images` mediumtext COMMENT '补印PNG分段JSON/Base64',
+  `create_time` datetime NOT NULL,
+  `update_time` datetime NOT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_invoice_trans_version` (`invoice_id`,`invoice_trans_no`,`renderer_version`),
+  KEY `idx_asset_order` (`order_id`),
+  KEY `idx_asset_retry` (`status`,`next_retry_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票预生成文件,不代表打印申请';
+
+ALTER TABLE `pos_order_invoice_print_job`
+  ADD COLUMN `image_pages` mediumtext NULL COMMENT '本次交付PNG分段JSON;旧记录为空' AFTER `pdf_content`;
+```
+
+后台每10秒小批扫描已开立纸本(含存量),通过发票行锁防止重复生成;文件准备失败按1/5/15/60分钟退避,不修改开票状态、不占打印次数。无需插入sys_job记录。PDF/图片保存在上述表中,不公开暴露文件目录。