Parcourir la source

优化发票平台品牌与交易明细排版并升级打印版式

qmj il y a 2 heures
Parent
commit
e88ec13b25

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

@@ -16,7 +16,7 @@ import static com.ruoyi.app.order.invoice.InvoicePrintSnapshot.*;
 /** 后台可重试的预生成服务,与开票请求/打印次数完全分离。 */
 @Service
 public class InvoicePrintAssetService {
-    public static final String VERSION="SUNMI_384_V1";
+    public static final String VERSION="SUNMI_384_V2";
     private static final Logger log=LoggerFactory.getLogger(InvoicePrintAssetService.class);
     private final PosOrderInvoiceMapper invoices;
     private final PosOrderInvoicePrintAssetMapper assets;

+ 45 - 18
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRenderer.java

@@ -34,13 +34,16 @@ public class InvoiceProofPdfRenderer {
     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);
+            List<DetailLine> detail = detailLines(s, font);
             // B2B明细与证明联同页不裁切;B2C另页,对应纸卷裁切边界。
-            float height = s.businessBuyer() ? 94 + detail.size() * 4.5f + 5 : 90;
+            float height = s.businessBuyer() ? 94 + detailHeight(detail) + 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(), 5, 8, TEXT_SIZE, false, 47);
+                // 平台品牌独立居中,门店名称仅在交易明细出现。
+                String brand = "恰恰吃";
+                float brandWidth = font.getStringWidth(brand) / 1000 * 16 / MM;
+                text(out, page, font, brand, (57 - brandWidth) / 2, 9, 16, true, 47);
                 // 字高不通过缩小字号适配;长标题仅水平压缩,补印必须与标题并列。
                 text(out, page, font, "電子發票證明聯" + (reprint ? " 補印" : ""), 5, 17, 19, false, 47);
                 text(out, page, font, s.period(), 5, 26, 22, true, 47);
@@ -57,7 +60,7 @@ public class InvoiceProofPdfRenderer {
                 // 不能将不同版本二维码连同静区一起缩成同尺寸,否则实际符号大小不同。
                 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()) detailText(out, page, font, detail, 94);
             }
             if (!s.businessBuyer()) detailPage(doc, font, detail);
             return save(doc);
@@ -95,15 +98,20 @@ public class InvoiceProofPdfRenderer {
         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);
-        }
+    private record DetailLine(String value, float size, boolean bold, float advance) { }
+
+    private static List<DetailLine> detailLines(InvoicePrintSnapshot s, PDType0Font font) throws Exception {
+        List<DetailLine> result = new ArrayList<>();
+        wrap(result, "交易明細(非兌獎憑證)", font, TEXT_SIZE, true);
+        wrap(result, s.sellerName(), font, 11, true);
+        wrap(result, "發票號碼 " + s.invoiceNumber(), font);
+        wrap(result, s.issuedAt(), font);
+        result.add(new DetailLine("", TEXT_SIZE, false, 2));
         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);
+            result.add(new DetailLine("", TEXT_SIZE, false, 1.5f));
         }
         if (s.businessBuyer()) {
             wrap(result, "應稅銷售額 " + n(s.sales()), font);
@@ -111,29 +119,48 @@ public class InvoiceProofPdfRenderer {
             wrap(result, "免稅銷售額 0", font);
             wrap(result, "稅額 " + n(s.tax()), font);
         }
-        wrap(result, "總計 " + n(s.total()), font);
+        wrap(result, "總計 " + n(s.total()), font, 11, true);
         return result;
     }
 
-    private static void wrap(List<String> lines, String value, PDType0Font font) throws Exception {
+    private static void wrap(List<DetailLine> lines, String value, PDType0Font font) throws Exception {
+        wrap(lines, value, font, TEXT_SIZE, false);
+    }
+
+    private static void wrap(List<DetailLine> lines, String value, PDType0Font font, float size, boolean bold) throws Exception {
         StringBuilder line = new StringBuilder();
-        // 按Unicode码点换行,长品名不截断,不用省略号丢失开票明细。
+        float advance = size > TEXT_SIZE ? 5.5f : 4.5f;
+        // 按实际字号及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 > 47 * MM && !line.isEmpty()) {
-                lines.add(line.toString()); line.setLength(0);
+            if (font.getStringWidth(line + character) / 1000 * size > 47 * MM && !line.isEmpty()) {
+                lines.add(new DetailLine(line.toString(), size, bold, advance)); line.setLength(0);
             }
             line.append(character);
         }
-        if (!line.isEmpty()) lines.add(line.toString());
+        if (!line.isEmpty()) lines.add(new DetailLine(line.toString(), size, bold, advance));
+    }
+
+    private static float detailHeight(List<DetailLine> detail) {
+        float height = 0;
+        for (var line : detail) height += line.advance();
+        return height;
+    }
+
+    private static void detailText(PDPageContentStream out, PDPage page, PDType0Font font,
+                                   List<DetailLine> detail, float top) throws Exception {
+        for (var line : detail) {
+            if (!line.value().isEmpty()) text(out, page, font, line.value(), 5, top, line.size(), line.bold(), 47);
+            top += line.advance();
+        }
     }
 
-    private static void detailPage(PDDocument doc, PDType0Font font, List<String> detail) throws Exception {
-        float height = Math.max(40, 12 + detail.size() * 4.5f);
+    private static void detailPage(PDDocument doc, PDType0Font font, List<DetailLine> detail) throws Exception {
+        float height = Math.max(40, 12 + detailHeight(detail));
         InvoicePrintSnapshot.require(height < 5000, "tooLarge");
         PDPage page = addPage(doc, height);
-        try (var out = new PDPageContentStream(doc, page)) { lines(out, page, font, detail, 7); }
+        try (var out = new PDPageContentStream(doc, page)) { detailText(out, page, font, detail, 7); }
     }
 
     private static void lines(PDPageContentStream out, PDPage page, PDType0Font font, List<String> lines, float top) throws Exception {

+ 20 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoicePrintAssetPersistenceTest.java

@@ -57,7 +57,7 @@ class InvoicePrintAssetPersistenceTest {
     }
     @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));
+            assertEquals(List.of(17L),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,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;});
@@ -67,34 +67,46 @@ class InvoicePrintAssetPersistenceTest {
             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));
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,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 oldLayoutDoesNotPreventNewVersionPreparation() throws Exception {
+        try (var f = new Fixture()) {
+            f.service.prepare(17L);
+            f.jdbc.update("UPDATE pos_order_invoice_print_asset SET renderer_version='SUNMI_384_V1'");
+            assertNull(f.service.currentReady(17L));
+            assertEquals(List.of(17L), f.assets.selectCandidates(new Date(), InvoicePrintAssetService.VERSION, 10));
+            f.service.prepare(17L);
+            assertEquals(2, f.jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_invoice_print_asset", Integer.class));
+            assertEquals(InvoicePrintAssetService.VERSION, f.service.currentReady(17L).getRendererVersion());
+        }
+    }
+
     @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));
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,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));
+            assertEquals(List.of(17L),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,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));
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,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));
+            assertEquals(List.of(17L),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,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));
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,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));
+            assertEquals(List.of(),f.assets.selectCandidates(new Date(),InvoicePrintAssetService.VERSION,10));
         }
     }
 }

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

@@ -40,14 +40,14 @@ class InvoicePrintAssetServiceTest {
         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);
+        a.setId(8L); when(assets.selectForInvoice(7L,"TRANS17",InvoicePrintAssetService.VERSION)).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);
+        when(assets.selectForInvoice(7L,"TRANS17",InvoicePrintAssetService.VERSION)).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);

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

@@ -23,12 +23,20 @@ class InvoiceProofPdfRendererTest {
                 InvoicePrintSnapshotTest.config(), "恰恰吃測試餐廳");
         byte[] bytes = new InvoiceProofPdfRenderer().proof(data, false);
         write("b2c-original.pdf", bytes);
+        for (var image : new InvoicePrintImageRenderer().render(bytes)) {
+            Files.write(Path.of("target/invoice-print-samples/brand-preview-" + image.pageNumber() + ".png"),
+                    java.util.Base64.getDecoder().decode(image.pngBase64()));
+        }
         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("電子發票證明聯"));
+            assertEquals(1, text.split("恰恰吃測試餐廳", -1).length - 1);
+            var firstPage = new PDFTextStripper(); firstPage.setEndPage(1);
+            assertTrue(firstPage.getText(pdf).contains("恰恰吃"));
+            assertFalse(firstPage.getText(pdf).contains("恰恰吃測試餐廳"));
             assertTrue(text.contains("薑蔥焗三鮮"));
             assertTrue(text.contains("115年09-10月"));
             assertFalse(text.contains("補印"));
@@ -107,6 +115,26 @@ class InvoiceProofPdfRendererTest {
                 java.util.Base64.getDecoder().decode(images.getFirst().pngBase64()));
     }
 
+    @Test void styledLongStoreNameAndItemsRemainInsidePrintableArea() throws Exception {
+        String name = "测试門店繁體長名稱分店 Foodie 123";
+        var snapshot = InvoicePrintSnapshot.from(InvoicePrintSnapshotTest.data(), InvoicePrintSnapshotTest.invoice(),
+                InvoicePrintSnapshotTest.config(), name);
+        byte[] proof = new InvoiceProofPdfRenderer().proof(snapshot, true);
+        write("brand-layout-preview.pdf", proof);
+        var images = new InvoicePrintImageRenderer().render(proof);
+        for (var image : images) {
+            Files.write(Path.of("target/invoice-print-samples/brand-layout-page-" + image.pageNumber() + ".png"),
+                    java.util.Base64.getDecoder().decode(image.pngBase64()));
+        }
+        try (var pdf = Loader.loadPDF(proof)) {
+            var stripper = new PDFTextStripper();
+            stripper.setStartPage(2);
+            String text = stripper.getText(pdf).replaceAll("\\s", "");
+            assertTrue(text.contains(name.replace(" ", "")));
+            assertTrue(text.contains("總計" + snapshot.total().stripTrailingZeros().toPlainString()));
+        }
+    }
+
     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);

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

@@ -243,3 +243,9 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 3. 保留当前生产实现,追加规格与任务记录。现有列表/详情字段和前端接入说明已符合决定,无需新增接口、SQL或前端改动。
 
 本轮仅文档调整及源码核对,不重复运行 Maven、数据库或实机联调。下午提交43f69b76的测试记录属于历史验证,不作为本轮重新执行的结果。
+
+## 2026-11-04 品牌版式实现
+
+1. 证明联顶部使用确认的平台名称「恰恰吃」,在有效区域内居中加粗;明细用带字号/字重/行距的内部行记录处理长店名和动态纸高。
+2. 升级预生成版式版本,保留历史打印任务;无需数据库结构变更或App改动。
+3. 运行发票定向测试与模块构建,检查384点黑白输出、长店名、B2B/B2C以及203dpi条码解码,输出实际渲染预览。

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

@@ -289,3 +289,9 @@
 验收场景:载具票即使有发票号也返回 false;捐赠票返回 false;纸本票缺码导致预生成失败、无 READY 文件时返回 false;符合现有资格且文件 READY 的纸本票按次数进入首次/补印。
 
 依据:[ezPay 标准手册1.2.2,第22、52–53页](https://inv.ezpay.com.tw/dw_files/info_api/EZP_INVI_1_2_2.pdf)。码值仅在 PrintFlag=Y 时返回;已核查的公开手册未列出已开立载具转纸本接口,不将此认定为供应商所有渠道均不支持。
+
+## 2026-11-04 发票品牌与明细层级优化
+
+- 用户确认平台名称为「恰恰吃」,证明联顶部以16pt加粗居中显示;门店名称仅在交易明细标题下出现一次,11pt加粗,长名称按实际字号换行。
+- 明细标题加粗、商品之间增加留白、合计11pt加粗;B2C分离页与B2B同页共用排版。保留证明联期别、号码、补印标记、真实条码/二维码及57mm纸面、48mm有效区域。
+- 版式版本升级为 SUNMI_384_V2,后台按现有机制为符合条件的发票生成新版文件。新版READY前不能将旧版缓存当作新版可打印;已保存的历史打印任务及相同requestId重试仍保持原文件、原次数。

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

@@ -365,3 +365,11 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - [x] T082 静态核对列表/详情打印资格 SQL、预生成资格与快照码值校验,更新现有 spec.md / plan.md;当前实现已符合本次范围,无生产代码或SQL改动。
 
 本轮只执行源码/既有测试源码阅读及文档差异检查;未运行 Maven、编译、测试或外部联调,未连接数据库、调用真实开票/转换接口、部署或创建新提交。既有生产实现提交为43f69b76;T075外部验收继续保留。
+
+## 2026-11-04 发票品牌版式
+
+- [x] T083 确认平台名称「恰恰吃」及顶部居中加粗、明细店名仅出现一次的展示要求。
+- [x] T084 优化明细字重、字号及间距,升级版式版本,保留历史打印与幂等规则。
+- [x] T085 验证发票定向测试、构建及实际打印图预览,记录验证边界。
+
+验证:JDK 21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=*Invoice*Test -Dsurefire.failIfNoSpecifiedTests=false package` 退出码0,36项测试通过(admin 35、system 1),失败/错误/跳过均0。覆盖203dpi真实码值解码、384点有效区、B2B/B2C、长店名换行及新版缓存重建。已查看实际黑白PNG预览;`git diff --check`通过。日志 `ruoyi-admin/target/invoice-brand-layout.log`,样张位于 `ruoyi-admin/target/invoice-print-samples/`。未进行实体热敏打印机验收或线上部署;提交状态以 Git 记录为准。