Explorar el Código

fix: 修复航信电子发票税收分类编码和开票参数问题

- 税编从汇总项改为叶子节点编码 1100101020200000000(供电*电费)
- 服务费采用合并模式归入供电编码,避免 304 开头的编码被拒
- 新增启动时自动获取开票人 issuerId
- 发票明细不再体现度数/次数,只显示金额
- 新增 HuapiaoerApiTest 测试类覆盖完整开票流程

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline hace 3 días
padre
commit
586f618586

+ 222 - 0
admin/src/test/java/HuapiaoerApiTest.java

@@ -0,0 +1,222 @@
+import com.alibaba.fastjson2.JSONObject;
+import com.kym.huapiaoer.HuapiaoerClient;
+import com.kym.huapiaoer.HuapiaoerConfig;
+import com.kym.huapiaoer.HuapiaoerException;
+import com.kym.huapiaoer.enums.InvoiceQueryType;
+import com.kym.huapiaoer.enums.InvoiceType;
+import com.kym.huapiaoer.enums.PayType;
+import com.kym.huapiaoer.model.request.InvoiceQrcodeRequest;
+import com.kym.huapiaoer.model.request.OpenBlueInvoiceRequest;
+import com.kym.huapiaoer.model.response.InvoiceInfo;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.RequestBody;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 航信电子发票 API 测试。
+ * 直接使用 HuapiaoerClient,无需启动 Spring 容器。
+ *
+ * 运行前请确认 application.yml 中 huapiaoer 相关配置已正确填写:
+ *   huapiaoer.app-secret
+ *   huapiaoer.nsrsbh
+ *   huapiaoer.b2cshopid
+ */
+public class HuapiaoerApiTest {
+
+    private static final String APP_SECRET = "xK9mQ2pR5nT7vL4cW6fY8jH1aB3sD0gZ";
+    private static final String NSRSBH = "91440300MA5HJNDG14";
+    private static final String B2C_SHOP_ID = "91440300MA5HJNDG14";
+    private static final String BASE_URL = "https://erp.huapiaoer.com";
+
+    private static HuapiaoerClient client;
+
+    @BeforeAll
+    public static void setUp() {
+        HuapiaoerConfig config = HuapiaoerConfig.builder()
+                .baseUrl(BASE_URL)
+                .appSecret(APP_SECRET)
+                .build();
+        client = new HuapiaoerClient(config);
+    }
+
+    /**
+     * 测试 1:查询开票人列表(只读,安全)。
+     * 验证 appsecret 和 nsrsbh 是否有效。
+     */
+    @Test
+    public void testGetDrawerIdList() {
+        System.out.println("=== 测试:查询开票人列表 ===");
+        var drawers = client.getDrawerIdList(NSRSBH);
+        assertNotNull(drawers);
+        System.out.println("开票人数量: " + drawers.size());
+        drawers.forEach(d -> System.out.println("  issuerid=" + d.getIssuerid() + ", name=" + d.getName()));
+    }
+
+    /**
+     * 测试 2:企业搜索(只读,安全)。
+     * 通过企业名称模糊查询税号等信息。
+     */
+    @Test
+    public void testSearchEnterprise() {
+        System.out.println("=== 测试:企业搜索 ===");
+        var enterprises = client.searchEnterprise("快与慢");
+        assertNotNull(enterprises);
+        System.out.println("搜索结果数量: " + enterprises.size());
+        enterprises.forEach(e -> System.out.println("  name=" + e.getEnterpriseName() + ", taxpayerNum=" + e.getTaxpayerNum()));
+    }
+
+    /**
+     * 测试 3:生成开票二维码(只读,安全)。
+     * 生成二维码后用户可扫码自助开票,不会直接开票。
+     */
+    @Test
+    public void testGetInvoiceQrcode() {
+        System.out.println("=== 测试:生成开票二维码 ===");
+        String orderId = "TEST-" + UUID.randomUUID().toString().substring(0, 8);
+        var head = new InvoiceQrcodeRequest.HeadInfo()
+                .setOrderId(orderId)
+                .setB2cshopid(B2C_SHOP_ID)
+                .setInvoiceTypeCode(InvoiceType.DIGITAL_NORMAL.getCode());
+        var request = new InvoiceQrcodeRequest()
+                .setHead(head);
+        var resp = client.getInvoiceQrcode(request);
+        System.out.println("code=" + resp.getCode() + ", data=" + resp.getData());
+        assertNotNull(resp);
+    }
+
+    /**
+     * 测试 4:开具蓝字发票(会产生真实发票,慎用)。
+     * 默认跳过,需要时手动去掉 @Test 注释。
+     *
+     * 注意:此操作会在税局系统生成一张真实的电子发票!
+     * 建议使用极小金额(如 0.01 元)进行测试。
+     */
+    /**
+     * 测试 4:开具蓝字发票 + 轮询查询结果(完整开票流程)。
+     * 注意:此操作会在税局系统生成一张真实的电子发票(金额 0.01 元)!
+     *
+     * 税编说明:航信要求使用非汇总项的 19 位税收分类编码。
+     * 如果开票失败,请根据后台错误信息调整 taxcode。
+     */
+    @Test
+    public void testOpenBlueInvoice() {
+        System.out.println("=== 测试:开具蓝字发票 ===");
+        String orderId = "TEST-BLUE-" + UUID.randomUUID().toString().substring(0, 8);
+        System.out.println("出库单号: " + orderId);
+
+        // 先获取开票人 ID
+        var drawers = client.getDrawerIdList(NSRSBH);
+        assertFalse(drawers.isEmpty(), "至少需要一个开票人");
+        String issuerId = drawers.get(0).getIssuerid();
+        System.out.println("开票人 issuerid: " + issuerId);
+
+        // 税编:使用叶子节点编码(非汇总项)
+        //   "1100101020200000000" - 供电*电费 (13%)  -- 已验证可用
+        //   "3040101020100000000" - 专业技术服务 (6%)
+
+        var detail1 = new OpenBlueInvoiceRequest.DetailInfo()
+                .setGoodsname("充电电费")
+                .setPrice(0.01)
+                .setSl(1.0)
+                .setTaxcode("1100101020200000000")
+                .setTax("0.13");
+
+        var detail2 = new OpenBlueInvoiceRequest.DetailInfo()
+                .setGoodsname("充电服务费")
+                .setPrice(0.01)
+                .setSl(1.0)
+                .setTaxcode("1100101020200000000")
+                .setTax("0.13");
+
+        var head = new OpenBlueInvoiceRequest.HeadInfo()
+                .setOrderId(orderId)
+                .setB2cshopid(B2C_SHOP_ID)
+                .setKpje("0.02")
+                .setInvoicetitle("测试个人")
+                .setInvoiceTypeCode(InvoiceType.DIGITAL_NORMAL.getCode());
+
+        var request = new OpenBlueInvoiceRequest()
+                .setIssuerid(issuerId)
+                .setHead(head)
+                .setDetails(List.of(detail1, detail2))
+                .setTradeinfo(new OpenBlueInvoiceRequest.TradeInfo()
+                        .setPayType(PayType.WECHAT_PAY.getCode())
+                        .setOutTradeNo(orderId));
+
+        // 构建完整请求 JSON(模拟 SDK 的 buildBody)
+        JSONObject body = (JSONObject) com.alibaba.fastjson2.JSON.toJSON(request);
+        body.put("appsecret", APP_SECRET);
+        String reqJson = body.toJSONString();
+
+        // Step 1: 开票
+        var resp = client.openBlueInvoice(request);
+        System.out.println("开票响应: code=" + resp.getCode() + ", orderId=" + resp.getOrderId());
+        if (!resp.isSuccess()) {
+            System.out.println("【请求体】\n" + reqJson);
+            // 用原始 HTTP 获取完整响应
+            try {
+                var httpClient = new OkHttpClient.Builder()
+                        .connectTimeout(10, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build();
+                var httpReq = new okhttp3.Request.Builder()
+                        .url(BASE_URL + "/ewy_weberp/api/huapiaoer/outopenapis/openBlueInvoice")
+                        .post(RequestBody.create(reqJson, MediaType.parse("application/json")))
+                        .build();
+                var httpResp = httpClient.newCall(httpReq).execute();
+                String respBody = httpResp.body() != null ? httpResp.body().string() : "";
+                System.out.println("【原始响应】HTTP " + httpResp.code() + "\n" + respBody);
+            } catch (Exception ex) {
+                System.out.println("【原始请求异常】" + ex.getMessage());
+            }
+        }
+        assertTrue(resp.isSuccess(), "开票请求应返回 code=200, 实际 code=" + resp.getCode() + ", data=" + resp.getData());
+        System.out.println("开票请求成功!orderId=" + resp.getOrderId());
+
+        // Step 2: 轮询查询开票结果
+        System.out.println("开始轮询查询开票结果(每 5 秒一次,最多 2 分钟)...");
+        InvoiceInfo invoiceInfo = null;
+        for (int i = 0; i < 24; i++) {
+            try { Thread.sleep(5000); } catch (InterruptedException ignored) {}
+            try {
+                invoiceInfo = client.getInvoice(orderId, InvoiceQueryType.BLUE.getCode());
+                System.out.println("  [" + ((i + 1) * 5) + "s] 查询成功!");
+                break;
+            } catch (HuapiaoerException e) {
+                System.out.println("  [" + ((i + 1) * 5) + "s] 尚未完成: " + e.getMessage());
+            }
+        }
+
+        assertNotNull(invoiceInfo, "应在超时前查询到发票结果");
+        System.out.println("=== 开票结果 ===");
+        System.out.println("发票号码: " + invoiceInfo.getInvoicenumber());
+        System.out.println("发票代码: " + invoiceInfo.getInvoicecode());
+        System.out.println("开票日期: " + invoiceInfo.getTicketDate());
+        System.out.println("价税合计: " + invoiceInfo.getTicketTotalAmountHasTax());
+        System.out.println("PDF 地址: " + invoiceInfo.getInvoiceurl());
+        System.out.println("OFD 地址: " + invoiceInfo.getOfdurl());
+        System.out.println("XML 地址: " + invoiceInfo.getXmlurl());
+    }
+
+    /**
+     * 测试 5:查询发票结果(只读,安全)。
+     * 用失败码测试错误处理是否正确。
+     */
+    @Test
+    public void testQueryNonExistentInvoice() {
+        System.out.println("=== 测试:查询不存在的发票 ===");
+        try {
+            client.getInvoice("NOT-EXIST-ORDER-001", InvoiceQueryType.BLUE.getCode());
+            System.out.println("意外的成功返回");
+        } catch (HuapiaoerException e) {
+            System.out.println("预期错误 - code=" + e.getCode() + ", msg=" + e.getMessage());
+            assertNotNull(e.getCode());
+        }
+    }
+}

+ 14 - 4
service/src/main/java/com/kym/service/miniapp/HuapiaoerInvoiceService.java

@@ -39,6 +39,7 @@ public class HuapiaoerInvoiceService {
     private final InvoiceDetailService invoiceDetailService;
 
     private HuapiaoerClient client;
+    private String issuerId;
 
     @PostConstruct
     public void init() {
@@ -48,6 +49,15 @@ public class HuapiaoerInvoiceService {
                         .appSecret(properties.getAppSecret())
                         .build()
         );
+        try {
+            var drawers = client.getDrawerIdList(properties.getNsrsbh());
+            if (!drawers.isEmpty()) {
+                this.issuerId = drawers.get(0).getIssuerid();
+                log.info("航信开票人初始化完成, issuerId:{}", this.issuerId);
+            }
+        } catch (Exception e) {
+            log.error("获取航信开票人列表失败", e);
+        }
     }
 
     /**
@@ -157,7 +167,7 @@ public class HuapiaoerInvoiceService {
                     .setGoodsname("充电电费")
                     .setPrice(elecMoney / 100.0)
                     .setSl(1.0)
-                    .setTaxcode("1100101020000000000")
+                    .setTaxcode("1100101020200000000")
                     .setTax("0.13"));
         }
 
@@ -166,11 +176,10 @@ public class HuapiaoerInvoiceService {
                     .setGoodsname("充电服务费")
                     .setPrice(netServiceMoney / 100.0)
                     .setSl(1.0)
-                    .setTaxcode("3040100000000000000")
-                    .setTax("0.06"));
+                    .setTaxcode("1100101020200000000")
+                    .setTax("0.13"));
         }
 
-        // 发票类型:企业默认开普票,可后续扩展为专票
         var invoiceTypeCode = InvoiceType.DIGITAL_NORMAL.getCode();
 
         var head = new OpenBlueInvoiceRequest.HeadInfo()
@@ -188,6 +197,7 @@ public class HuapiaoerInvoiceService {
                 .setInvoiceTypeCode(invoiceTypeCode);
 
         return new OpenBlueInvoiceRequest()
+                .setIssuerid(issuerId)
                 .setHead(head)
                 .setDetails(details)
                 .setTradeinfo(new OpenBlueInvoiceRequest.TradeInfo()