lfx 5 months ago
parent
commit
06ac66456a
22 changed files with 32359 additions and 11 deletions
  1. 34 5
      pom.xml
  2. 3 3
      src/main/java/merservice/MerServiceApplication.java
  3. 42 0
      src/main/java/com/malk/pro/aspect/ControllerYdAspect.java
  4. 148 0
      src/main/java/com/malk/pro/guyuan/controller/GYController.java
  5. 443 0
      src/main/java/com/malk/pro/guyuan/controller/IVController.java
  6. 526 0
      src/main/java/com/malk/pro/guyuan/controller/NhIVController.java
  7. 202 0
      src/main/java/com/malk/pro/guyuan/controller/NhOaTLYController.java
  8. 27 0
      src/main/java/com/malk/pro/guyuan/filter/CatchException_YXY.java
  9. 219 0
      src/main/java/com/malk/pro/guyuan/server/model/McInvoiceDto.java
  10. 67 0
      src/main/java/com/malk/pro/guyuan/server/model/McInvoiceKind.java
  11. 36 0
      src/main/java/com/malk/pro/guyuan/server/tencent/TXYConf.java
  12. 36 0
      src/main/java/com/malk/pro/guyuan/server/tencent/TXYConfNh.java
  13. 15 0
      src/main/java/com/malk/pro/guyuan/service/tencent/IvYdService.java
  14. 39 0
      src/main/java/com/malk/pro/guyuan/service/tencent/NhTXYInvoice.java
  15. 39 0
      src/main/java/com/malk/pro/guyuan/service/tencent/TXYInvoice.java
  16. 46 0
      src/main/java/com/malk/pro/guyuan/service/tencent/impl/IvYdServiceImpl.java
  17. 162 0
      src/main/java/com/malk/pro/guyuan/service/tencent/impl/NhTXYImplInvoice.java
  18. 162 0
      src/main/java/com/malk/pro/guyuan/service/tencent/impl/TXYImplInvoice.java
  19. 3 3
      src/main/resources/application-dev.yml
  20. 15053 0
      src/main/resources/static.mjs/mjs.js
  21. 1 0
      src/main/resources/static.mjs/mjs.min.js
  22. 15056 0
      src/main/resources/static.mjs/mjsnh.js

+ 34 - 5
pom.xml

@@ -12,7 +12,7 @@
     </parent>
 
     <groupId>com.malk</groupId>
-    <artifactId>merservice</artifactId>
+    <artifactId>pro</artifactId>
     <version>1.0-SNAPSHOT</version>
 
     <properties>
@@ -20,6 +20,13 @@
         <maven.compiler.target>8</maven.compiler.target>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     </properties>
+    <repositories>
+        <repository>
+            <id>com.e-iceblue</id>
+            <url>https://repo.e-iceblue.cn/repository/maven-public/</url>
+        </repository>
+    </repositories>
+
     <dependencies>
         <dependency>
             <groupId>org.springframework.boot</groupId>
@@ -30,6 +37,10 @@
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
         <dependency>
             <groupId>junit</groupId>
             <artifactId>junit</artifactId>
@@ -53,16 +64,34 @@
             <artifactId>mybatis-plus-boot-starter</artifactId>
             <version>3.5.3.2</version>
         </dependency>
+        <!-- 腾讯云 [go to https://search.maven.org/search?q=tencentcloud-sdk-java and getDefault the latest version.] -->
         <dependency>
-            <groupId>junit</groupId>
-            <artifactId>junit</artifactId>
-            <scope>test</scope>
+            <groupId>com.tencentcloudapi</groupId>
+            <artifactId>tencentcloud-sdk-java</artifactId>
+            <version>3.1.778</version>
+        </dependency>
+        <!-- 图片压缩 -->
+        <dependency>
+            <groupId>net.coobird</groupId>
+            <artifactId>thumbnailator</artifactId>
+            <version>0.4.8</version>
+        </dependency>
+        <!-- PDF压缩 -->
+        <dependency>
+            <groupId>com.twelvemonkeys.imageio</groupId>
+            <artifactId>imageio-tiff</artifactId>
+            <version>3.5</version>
+        </dependency>
+        <dependency>
+            <groupId>e-iceblue</groupId>
+            <artifactId>spire.pdf.free</artifactId>
+            <version>5.1.0</version>
         </dependency>
 
     </dependencies>
 
     <build>
-        <finalName>merservice</finalName>
+        <finalName>pro</finalName>
         <plugins>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>

+ 3 - 3
src/main/java/merservice/MerServiceApplication.java

@@ -1,4 +1,4 @@
-package merservice;
+package com.malk.pro;
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -6,10 +6,10 @@ import org.springframework.scheduling.annotation.EnableScheduling;
 
 @SpringBootApplication(scanBasePackages = {"com.malk"})
 @EnableScheduling
-public class MerServiceApplication {
+public class ProApplication {
     public static void main(String[] args) {
         try {
-            SpringApplication.run(MerServiceApplication.class,args);
+            SpringApplication.run(ProApplication.class,args);
         }catch (Exception e){
             e.printStackTrace();
         }

+ 42 - 0
src/main/java/com/malk/pro/aspect/ControllerYdAspect.java

@@ -0,0 +1,42 @@
+package com.malk.pro.aspect;
+
+
+import com.malk.core.McProject;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+import org.aspectj.lang.annotation.Pointcut;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+
+
+/***
+ * controller 切面 用于注入宜搭参数
+ */
+@Aspect
+@Component
+@Slf4j
+public class ControllerYdAspect {
+
+    // demo ===============================谷元/能辉========================================================================== start
+    // 定义切点:拦截所有Controller包下的方法
+    @Pointcut("execution(* com.malk.pro.guyuan.controller..*.*(..))")
+    public void guyuanControllerPointcut() {}
+    // 在方法执行前执行
+    @Before("guyuanControllerPointcut()")
+    public void guyuanBefore(JoinPoint joinPoint) {
+        // 项目编号 2001 每个项目一个编号,请勿重复
+        McProject.addYida("2001",new String[]{"APP_FKRK7Y94DPI1S9DV1605","FN7666A1ZD0STZZ75W4CKD1GD07X3PUW2FBRKT"});
+        MDC.put("MDC_KEY_PID","2001");
+    }
+    // 在方法执行后执行
+    @AfterReturning(pointcut = "guyuanControllerPointcut()", returning = "result")
+    public void guyuanAfterReturning(JoinPoint joinPoint, Object result) {
+        MDC.remove("MDC_KEY_PID");
+    }
+    // demo ===============================谷元/能辉========================================================================== end
+
+
+}

+ 148 - 0
src/main/java/com/malk/pro/guyuan/controller/GYController.java

@@ -0,0 +1,148 @@
+package com.malk.pro.guyuan.controller;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliyun.ALYInvoice;
+import com.malk.utils.UtilServlet;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 错误抛出与拦截详见CatchException
+ */
+@Slf4j
+@RestController
+@RequestMapping("/guyuan/")
+public class GYController {
+
+    @Autowired
+    private ALYInvoice invoice;
+
+    @Autowired
+    private YDClient ydClient;
+
+    /**
+     * 阿里发票验真
+     */
+    @PostMapping("/invoice/check")
+    McR invoiceCheckPDF(@RequestBody Map<String, String> param) {
+        log.info("阿里发票验真, 文件, {}", param);
+        McException.assertParamException_Null(param, "url");
+        return McR.success(invoice.invoiceCheckPDF("4e2c048bfe1d4feea7354a66c7944fd1", param.get("url")));
+    }
+
+    /**
+     * 阿里发票验真: image
+     */
+    @PostMapping("invoice/ocr")
+    McR invoiceCheckImage(@RequestBody Map<String, String> param) {
+        log.info("阿里发票验真, 图片, {}", param);
+        return McR.success(invoice.invoiceCheckOCR("4e2c048bfe1d4feea7354a66c7944fd1", param.get("url")));
+    }
+
+    /**
+     * 宜搭临时免登地址: 钉钉平台
+     */
+    @PostMapping("openUrl")
+    McR openUrl(@RequestBody Map<String, String> param) {
+        McException.assertParamException_Null(param, "url");
+        return McR.success(ydClient.convertTemporaryUrl(param.get("url")));
+    }
+
+    /**
+     * 全局查询子表单
+     */
+    @PostMapping("queryAll")
+    McR queryAll(@RequestBody YDParam ydParam) {
+        return McR.success(ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_list_all));
+    }
+
+    /**
+     * 全局查询(不匹配子表单)
+     */
+    @PostMapping("validate")
+    McR queryAll(HttpServletRequest request) {
+        Map<String, ?> param = UtilServlet.getParamMap(request);
+        log.info("全局查询(不匹配子表单), {}", param);
+        if (ObjectUtil.isNull(param.get("uniques"))) {
+            return McR.success();
+        }
+        McException.assertParamException_Null(param, "uniques", "formUuid", "compId");
+        // 容错 - 尾部分号的空格会被输入框忽略
+        String[] uniques = String.valueOf(param.get("uniques")).replace("; ", ";").split(";");
+        for (String val : uniques) {
+            // 查重校验: 单张发票唯一标识 + 审批已通过 / 审批中
+            List<Map> conditions = new ArrayList<>();
+            Map unique = new HashMap();
+            unique.put("key", param.get("compId"));
+            unique.put("value", val.split(": ")[1]);
+            unique.put("type", "TEXT");
+            unique.put("operator", "like");
+            unique.put("componentName", "TextField");
+            conditions.add(unique);
+
+            Map approve = new HashMap();
+            approve.put("key", "processApprovedResult");
+            approve.put("value", new String[]{"agree"});
+            approve.put("type", "ARRAY");
+            approve.put("operator", "in");
+            approve.put("componentName", "SelectField");
+            conditions.add(approve);
+            YDParam ydParam = YDParam.builder()
+                    .appType("APP_FKRK7Y94DPI1S9DV1605")
+                    .systemToken("FN7666A1ZD0STZZ75W4CKD1GD07X3PUW2FBRKT")
+                    .formUuid(String.valueOf(param.get("formUuid")))
+                    .searchCondition(JSON.toJSONString(conditions))
+                    .build();
+            DDR_New ddr_new = ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_list);
+            log.info("审批通过匹配结果, {}, {}", ddr_new.getTotalCount(), ddr_new.getData());
+            if (ddr_new.getTotalCount() > 0) {
+                return McR.errorAccess("发票已被使用, 请勿重复提交!");
+            }
+
+            conditions.remove(approve);
+            Map status = new HashMap();
+            status.put("key", "processInstanceStatus");
+            status.put("value", new String[]{"RUNNING"});
+            status.put("type", "ARRAY");
+            status.put("operator", "in");
+            status.put("componentName", "SelectField");
+            conditions.add(status);
+            ydParam.setSearchCondition(JSON.toJSONString(conditions));
+            ddr_new = ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_list);
+            log.info("审批通过匹配结果, {}, {}", ddr_new.getTotalCount(), ddr_new.getData());
+            if (ddr_new.getTotalCount() > 0) {
+                return McR.errorAccess("发票已在流程中, 请勿重复提交!");
+            }
+        }
+        return McR.success();
+    }
+
+    /**
+     * 服务状态返回
+     */
+    @PostMapping("validateTips")
+    McR validateTaxNo(HttpServletRequest request) {
+        Map data = UtilServlet.getParamMap(request);
+        if (!data.get("status").equals(1)) {
+            return McR.errorAccess("error");
+        }
+        return McR.success(data);
+    }
+}

+ 443 - 0
src/main/java/com/malk/pro/guyuan/controller/IVController.java

@@ -0,0 +1,443 @@
+package com.malk.pro.guyuan.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.pro.guyuan.server.model.McInvoiceDto;
+import com.malk.pro.guyuan.server.model.McInvoiceKind;
+import com.malk.pro.guyuan.server.tencent.TXYConf;
+import com.malk.pro.guyuan.service.tencent.TXYInvoice;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.FilePath;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDService;
+import com.malk.utils.*;
+import com.spire.pdf.PdfCompressionLevel;
+import com.spire.pdf.PdfDocument;
+import com.spire.pdf.PdfPageBase;
+import com.spire.pdf.exporting.PdfImageInfo;
+import com.spire.pdf.graphics.PdfBitmap;
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import net.coobird.thumbnailator.Thumbnails;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.poi.util.IOUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 错误抛出与拦截详见CatchException
+ */
+@Slf4j
+@RestController
+@RequestMapping("/guyuan/")
+public class IVController {
+
+    @Autowired
+    private TXYInvoice txyInvoice;
+
+    @Autowired
+    private YDClient ydClient;
+
+    /// 优先获取字段, 新版本接口已支持字段返回
+    private String findValue(List<Map<String, String>> infos, String... names) {
+        for (String name : names) {
+            Optional optional = infos.stream().filter(info -> info.get("Name").equals(name)).findAny();
+            if (optional.isPresent()) {
+                return String.valueOf(((Map) optional.get()).get("Value"));
+            }
+        }
+        return "";
+    }
+
+    // 兼容历史配置, 格式 (谷元)
+    private String guyuanNameRepalce(String name) {
+        if (name.contains("谷元")) {
+            return UtilString.replaceBracketIsWhole(name);
+        } else {
+            return UtilString.replaceBracketIsSemiangle(name);
+        }
+    }
+
+    /// url压缩转base64
+    @SneakyThrows
+    private String imageUrlConvertBase64(String imageUrl) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        // scale(比例), outputQuality(质量)
+        Thumbnails.fromURLs(Arrays.asList(new URL(imageUrl))).scale(0.5f).outputQuality(0.25f).toOutputStream(out);
+        InputStream inputStream = new ByteArrayInputStream(out.toByteArray());
+        //转换为base64
+        byte[] bytes = IOUtils.toByteArray(inputStream);
+        return Base64.getEncoder().encodeToString(bytes);
+    }
+
+    @Autowired
+    private FilePath filePath;
+
+    /// PDF压缩转base64
+    @SneakyThrows
+    private String pdfUrlConvertBase64(String pdfUrl) {
+        String fileName = "tmp_" + new Date().getTime() + ".pdf";
+        // 下载文件
+        File file = UtilFile.mkdirIfNot(fileName, filePath.getPath().getTmp());
+        UtilHttp.doDownload(pdfUrl, file);
+
+        // PDF压缩
+        PdfDocument doc = new PdfDocument(); // 创建PdfDocument类的对象
+        doc.loadFromFile(file.getAbsolutePath()); // 加载PDF文档
+        doc.getFileInfo().setIncrementalUpdate(false); // 禁用增量更新
+        doc.setCompressionLevel(PdfCompressionLevel.Normal); // 将压缩级别设置为最佳
+
+        // 遍历文档页面
+        for (int i = 0; i < doc.getPages().getCount(); i++) {
+            PdfPageBase page = doc.getPages().get(i);  // 获取指定页面
+            PdfImageInfo[] images = page.getImagesInfo(); // 获取每个页面的图像信息集合
+            // 遍历集合中的所有项目
+            if (images != null && images.length > 0)
+                for (int j = 0; j < images.length; j++) {
+                    PdfImageInfo image = images[j];  // 获取指定图片
+                    PdfBitmap bp = new PdfBitmap(image.getImage());
+                    bp.setQuality(30); // 设置压缩质量
+                    page.replaceImage(j, bp); // 将原始图像替换为压缩图像
+                }
+        }
+        // 将结果文档保存至另一个PDF文档中: 覆盖
+        doc.saveToFile(file.getAbsolutePath());
+        doc.close();
+        // PDF转base64, 无需透出本地文件地址
+        String base64 = UtilFile.fileToBase64(file.getAbsolutePath());
+        // 删除临时PDF文件
+        UtilFile.deleteFile(file.getAbsolutePath());
+        return base64;
+    }
+
+    // prd 校验发票抬头, 购买方范围
+    private void validateBuyer(String BuyerName, String tips) {
+        List<String> corpNames = Arrays.asList(
+                "谷元(上海)文化科技有限责任公司",
+                "上海爱鱼文化传媒有限公司",
+                "上海渔米可禧文化传媒有限公司",
+                "上海巧豆文化传媒有限公司",
+                "钰鸿文化创意(上海)有限公司",
+                "上海攸元文化科技有限公司",
+                "上海盈田演出经纪有限公司",
+                "上海小蝠文化科技有限公司",
+                "沐田居海(厦门)文化传媒有限公司",
+                "北京元环文化科技有限责任公司",
+                "厦门攸元文化科技有限公司",
+                "厦门亨有文化科技有限公司",
+                "厦门银雀思汀文化传媒有限公司",
+                "上海渝泽信息科技有限公司",
+                "厦门神谷飞流影视传媒有限公司",
+                "厦门谷钛数字科技有限公司",
+                "上海观情科技有限公司",
+                "渔米可禧文化传媒(香港)有限公司",
+                "杭州渔米可禧文化传媒有限公司");
+        McException.assertAccessException(!corpNames.contains(BuyerName), tips + ", 购买方名称不合法!");
+    }
+
+    /**
+     * 混票识别 [新版本]
+     */
+    @PostMapping("invoice-iv2")
+    McR invoice_iv2(@RequestBody Map<String, String> data) throws TencentCloudSDKException {
+
+        McException.assertParamException_Null(data, "url");
+        String image = ydClient.convertTemporaryUrl(data.get("url"));
+        log.info("混票识别, 免登地址, {}", image);
+        // 非PDF, 且内存大于3M, 压缩后上传
+        if (UtilMap.getFloat(data, "size") > 3.0f && !UtilMap.getBoolean(data, "isPdf")) {
+            image = imageUrlConvertBase64(image);
+        }
+        if (UtilMap.getFloat(data, "size") > 6.0f && UtilMap.getBoolean(data, "isPdf")) {
+            image = pdfUrlConvertBase64(image);
+        }
+        List<Map> invoices = (List<Map>) txyInvoice.doRecognizeGeneralInvoice(image).get("MixedInvoiceItems");
+        List<McInvoiceDto> result = invoices.stream().map(item -> {
+            Map prop = UtilMap.getMap(UtilMap.getMap(item, "SingleInvoiceInfos"), UtilMap.getString(item, "SubType"));
+            // ppExt: 通用字段定义
+            McInvoiceDto invoiceDto = McInvoiceDto.builder()
+                    .name(UtilMap.getString(item, "SubTypeDescription"))
+                    .kindName(UtilMap.getString(item, "TypeDescription"))
+                    .kind(UtilMap.getInt(item, "Type"))
+                    .code(UtilMap.getString(prop, "Code"))
+                    .serial(UtilMap.getString(prop, "Number"))
+                    .date(UtilString.replaceDateZH_cn(UtilMap.getString(prop, "Date")))
+                    .checkCode(UtilMap.getString(prop, "CheckCode"))
+                    // ppExt: 多明细行时, 优先取值合计 [全电票返回了subTotal字段, 但值为空]
+                    .amount(UtilNumber.setBigDecimal(UtilMap.getString_first(prop, "SubTotal", "Total")))
+                    .tax(UtilNumber.setBigDecimal(UtilMap.getString_first(prop, "SubTax", "Tax")))
+                    .excludingTax(UtilNumber.setBigDecimal(UtilMap.getString(prop, "PretaxAmount")))
+                    .buyerName(guyuanNameRepalce(UtilMap.getString(prop, "Buyer")))
+                    // ppExt: 中央非税未返回税号官方说明: 非税发票理论是没有税号的,图片中属于信用代码
+                    .buyerTaxId(UtilMap.getString(prop, "BuyerTaxID"))
+                    .sellerName(guyuanNameRepalce(UtilMap.getString_first(prop, "Seller", "Issuer")))                            // 行程单: 填开单位
+                    .sellerTaxId(UtilMap.getString_first(prop, "SellerTaxID", "AgentCode"))                       // 行程单: 销售单位代号
+                    .passengerName(UtilMap.getString_first(prop, "Name", "UserName"))                             // 火车票, 行程单
+                    // 交通出行
+                    .seatType(UtilMap.getString(prop, "Seat"))
+                    .departureTime(UtilString.replaceDateZH_cn(UtilMap.getString(prop, "DateGetOn")) + " " + UtilMap.getString(prop, "TimeGetOn"))
+                    .departurePort(UtilMap.getString_first(prop, "StationGetOn", "Entrance", "Place"))            // 火车票: 出发车站, 过路过桥费: 入口, 出租车: 发票所在地
+                    .arrivePort(UtilMap.getString_first(prop, "StationGetOff", "Exit"))                           // 行程单, 火车票: 到达车站, 过路过桥费: 出口
+                    .trainNo(UtilMap.getString_first(prop, "TrainNumber", "LicensePlate"))                        // 火车票: 车次, 出租车: 车牌号
+                    .insuranceCosts(UtilNumber.setBigDecimal((UtilMap.getString(prop, "Insurance"))))               // 行程单: 保险费
+                    .fuelCosts(UtilNumber.setBigDecimal((UtilMap.getString(prop, "FuelSurcharge"))))                // 行程单: 燃油附加费
+                    .constructionCosts(UtilNumber.setBigDecimal((UtilMap.getString(prop, "AirDevelopmentFund"))))   // 行程单: 民航发展基金
+                    .build();
+            // ppExt: 机票行程单, 行程与座位信息在明细内
+            if ("机票行程单".equals(item.get("TypeDescription"))) {
+                Map flight = (Map) UtilMap.getList(prop, "FlightItems").get(0);
+                invoiceDto.setDepartureTime(UtilString.replaceDateZH_cn(UtilMap.getString(item, "DateGetOn")) + " " + UtilMap.getString(prop, "TimeGetOn"));
+                invoiceDto.setDeparturePort(UtilMap.getString(flight, "StationGetOn"));
+                invoiceDto.setArrivePort(UtilMap.getString(flight, "StationGetOff"));
+                invoiceDto.setSeatType(UtilMap.getString(flight, "Seat"));
+            }
+            if ("出租车发票".equals(item.get("TypeDescription"))) {
+                // 上下车时间
+                invoiceDto.setDepartureTime(UtilMap.getString(prop, "TimeGetOn") + " ~ " + UtilMap.getString(prop, "TimeGetOff"));
+            }
+            return invoiceDto;
+        }).collect(Collectors.toList());
+        return McR.success(McInvoiceDto.formatResponse(result));
+    }
+
+    /**
+     * 混票识别 [旧版本, 已废弃]
+     */
+    @PostMapping("invoice-iv")
+    McR invoice_iv(@RequestBody Map<String, String> data) throws TencentCloudSDKException {
+
+        McException.assertParamException_Null(data, "url");
+        String image = ydClient.convertTemporaryUrl(data.get("url"));
+        log.info("混票识别, 免登地址, {}", image);
+        // 非PDF, 且内存大于3M, 压缩后上传
+        if (UtilMap.getFloat(data, "size") > 3.0f && !UtilMap.getBoolean(data, "isPdf")) {
+            image = imageUrlConvertBase64(image);
+        }
+        if (UtilMap.getFloat(data, "size") > 6.0f && UtilMap.getBoolean(data, "isPdf")) {
+            image = pdfUrlConvertBase64(image);
+        }
+        // ppExt: 通用字段定义
+        List<Map> invoices = (List<Map>) txyInvoice.doMixedInvoiceOCR(image).get("MixedInvoiceItems");
+        List<McInvoiceDto> result = invoices.stream().map(item -> {
+            String kind = TXYConf.TYPE_INVOICE.get(item.get("Type").toString());
+            List<Map<String, String>> infos = (List<Map<String, String>>) item.get("SingleInvoiceInfos");
+
+            McInvoiceDto.assertSuccess(item, kind); // 响应断言
+            String invoiceName = findValue(infos, "发票名称");
+            if (kind.equals("全电发票")) {
+                kind = invoiceName.contains("增值税专用发票") ? "全电专用发票" : "全电普通发票";
+            }
+            if (kind.equals("增值税发票")) {
+                kind = invoiceName.contains("增值税专用发票") ? "增值税专用发票" : "增值税普通发票";
+                if (invoiceName.contains("增值税电子")) {
+                    kind = invoiceName.contains("专用发票") ? "增值税电子专用发票" : "增值税电子普通发票";
+                }
+            }
+            McInvoiceDto invoiceDto = McInvoiceDto.builder()
+                    .name(invoiceName)
+                    .kindName(kind)
+                    .kind(McInvoiceKind.getKindCode(kind))
+                    .code(findValue(infos, "发票代码", "票据代码")) // 发票, 非税发票
+                    // 储存唯一ID [发票, 火车票, 行程单]
+                    .serial(findValue(infos, "发票号码", "编号", "电子客票号码", "票据号码").replace("No", "")) // 发票, 非税发票
+                    .date(findValue(infos, "开票日期").replace("年", "-").replace("月", "-").replace("日", ""))
+                    .checkCode(findValue(infos, "校验码"))
+                    .amount(UtilNumber.replaceCurrencyCHYToDecimal(findValue(infos, "小写金额", "价税合计(小写)", "合计金额", "票价", "金额"))) // 发票, 全电票, 行程单, 火车票, 过路过桥费
+                    .excludingTax(UtilNumber.replaceCurrencyCHYToDecimal(findValue(infos, "合计金额", "金额", "票价", "小写金额"))) // [ppExt: 多明细行时, 优先取值合计] 行程单, 火车票, 定额发票
+                    .tax(UtilNumber.replaceCurrencyCHYToDecimal(findValue(infos, "合计税额"))) // 增值税发票
+                    .buyerName(guyuanNameRepalce(findValue(infos, "购买方名称", "交款人"))) // 发票, 非税发票
+                    .buyerTaxId(findValue(infos, "购买方识别号", "购买方统一社会信用代码/纳税人识别号", "交款人统一社会信用代码")) // 发票, 全电票, 非税发票
+                    .sellerName(guyuanNameRepalce(findValue(infos, "销售方名称", "填开单位"))) // 行程单
+                    .sellerTaxId(findValue(infos, "销售方识别号", "销售方统一社会信用代码/纳税人识别号", "销售单位代号")) // 发票, 全电票, 行程单
+                    .passengerName(findValue(infos, "旅客姓名", "姓名")) // 行程单, 火车票
+                    .seatType(findValue(infos, "座位等级", "席别")) // 行程单, 火车票
+                    .departurePort(findValue(infos, "始发地", "出发站", "入口")) // 行程单, 火车票, 过路过桥费
+                    .arrivePort(findValue(infos, "目的地", "到达站", "出口")) // 行程单, 火车票, 过路过桥费
+                    .trainNo(findValue(infos, "航班号", "车次", "车牌号")) // 行程单, 火车票, 出租车
+                    .insuranceCosts(UtilNumber.setBigDecimal((findValue(infos, "保险费")))) // 行程单
+                    .fuelCosts(UtilNumber.setBigDecimal((findValue(infos, "燃油附加费")))) // 行程单
+                    .constructionCosts(UtilNumber.setBigDecimal((findValue(infos, "民航发展基金")))) // 行程单
+                    .build();
+            // 价格不一致情况下, 通过合计返回
+            if (!UtilNumber.equalBigDecimal(invoiceDto.getAmount(), invoiceDto.getExcludingTax().add(invoiceDto.getTax()))) {
+                invoiceDto.setAmount(invoiceDto.getExcludingTax().add(invoiceDto.getTax()));
+            }
+            // 机票行程单
+            if (kind.equals(McInvoiceKind.JP.getDesc())) {
+                String date = findValue(infos, "日期").replace("年", "-").replace("月", "-").replace("日", " ");
+                invoiceDto.setDepartureTime(date + " " + findValue(infos, "时间"));
+            }
+            // 火车票
+            if (kind.equals(McInvoiceKind.HC.getDesc())) {
+                invoiceDto.setDepartureTime(findValue(infos, "出发时间").replace("年", "-").replace("月", "-").replace("日", " "));
+            }
+            // 出租车
+            if (kind.equals(McInvoiceKind.CZC.getDesc())) {
+                String date = findValue(infos, "日期").replace("年", "-").replace("月", "-").replace("日", " ");
+                invoiceDto.setDepartureTime(date + " " + findValue(infos, "上车"));
+            }
+            return invoiceDto;
+        }).collect(Collectors.toList());
+        return McR.success(McInvoiceDto.formatResponse(result));
+    }
+
+    /**
+     * 发票查重, 验真
+     */
+    @PostMapping("invoice-va")
+    McR invoice_va(@RequestBody Map data) {
+        McException.assertParamException_Null(data, "param");
+        List<McInvoiceDto> invoices = JSON.parseArray(JSON.toJSONString(data.get("param")), McInvoiceDto.class);
+
+        log.info("发票查重, 验真, {}", invoices);
+        invoices.forEach(UtilMc.consumerWithIndex((item, index) -> {
+
+            McInvoiceDto dto = (McInvoiceDto) item;
+            String invoiceNo = dto.getSerial(); // 唯一标识, 发票号码
+
+            String serial = "第【" + (index + 1) + "】张发票";
+            validateBuyer(dto.getBuyerName(), serial + "有疑问");
+            McException.assertAccessException(StringUtils.isBlank(invoiceNo), serial + ", 识别结果为空, 请检查!");
+            YDParam ydParam = YDParam.builder()
+                    .formUuid("FORM-W2A66Z910O9B3LP9C6IYUDPRVWY62DO0YHIILY")
+                    .searchFieldJson(JSON.toJSONString(UtilMap.map("radioField_liihyrtb, textField_liihyrt8", "否", invoiceNo)))
+                    .build();
+            List<String> idList = (List<String>) ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_search_form_id).getData();
+            if (idList.size() > 0) {
+                McException.exceptionAccess(serial + "已存在, 请勿重复提交!");
+            }
+            // prd 仅仅识别增值税普通发票
+            if (dto.getName().contains("普通发票")) {
+                String serialTips = serial + "有疑问";
+                try {
+                    // ppExt: 识别与验真后抬头对比 [全电票, 新版本识别接口, 返回名称为: 电子发票(普通发票) 不包含全电标识, 发类型为: 全电发票. 注意取值]
+                    Map rsp = txyInvoice.doVatInvoiceVerifyNew(dto.getKindName(), dto.getCode(), invoiceNo, dto.getDate(), String.valueOf(dto.getAmount()), dto.getCheckCode(), String.valueOf(dto.getExcludingTax()), serialTips);
+                    Map invoice = (Map) rsp.get("Invoice");
+                    McException.assertAccessException(!dto.getBuyerName().equals(guyuanNameRepalce(invoice.get("BuyerName").toString())), serialTips + ", 购买方名称不匹配!");
+                    McException.assertAccessException(!dto.getBuyerTaxId().equals(invoice.get("BuyerTaxCode")), serialTips + ", 购买方税号不匹配!");
+                    McException.assertAccessException(!dto.getSellerName().equals(guyuanNameRepalce(invoice.get("SellerName").toString())), serialTips + ", 销售方名称不匹配!");
+                    McException.assertAccessException(!dto.getSellerTaxId().equals(invoice.get("SellerTaxCode")), serialTips + ", 销售方税号不匹配!");
+                } catch (TencentCloudSDKException e) {
+                    log.error(e.getMessage(), e);
+                    // prd: 上传发票为假发票时,提示:该发票有疑问,请联系财务人员
+                    String message = e.getMessage();
+                    // ppExt: 已经是新版本接口, 过滤提示 [官方答复: 提示不会检测您是否使用的是新版,所有的用户都会提示, 忽略即可]
+                    if (message.contains("温馨提示")) {
+                        message = message.split("温馨提示")[0];
+                    }
+                    if (message.contains("发票不存在")) {
+                        message = "有疑问,请联系财务人员";
+                    }
+                    McException.exceptionAccess(serial + message);
+                }
+            }
+        }));
+        return McR.success();
+    }
+
+
+    @Autowired
+    private YDService ydService;
+
+    /**
+     * 发票状态更新: 服务注册
+     */
+    @PostMapping("invoice-up")
+    McR invoice_va(HttpServletRequest request) {
+        Map data = UtilServlet.getParamMap(request);
+        log.info("发票状态更新: 服务注册, {}", data);
+
+        String compId = UtilMap.getString(data, "compId");
+        String status = UtilMap.getString(data, "status");
+
+        // 读取关联表单
+        List<String> associationForm = (List<String>) JSON.parse(UtilMap.getString(data, "multiAssociation"));
+        List<String> formInstanceIds = new ArrayList<>();
+        for (String record : associationForm) {
+            // 解析关联表单
+            List<Map> associationData = (List<Map>) JSON.parse(record);
+            formInstanceIds.addAll(associationData.stream().map(form -> UtilMap.getString(form, "instanceId")).collect(Collectors.toList()));
+        }
+        // 宜搭批量更新
+        Map update = UtilMap.map(compId, status);
+        if (compId.equals("selectField_liihyrt6")) {
+            update.put("radioField_liw7rb2q", "否"); // 提交后, 更新是否退回标识为否
+        }
+
+        // prd 9.10 更新报销单, 关联到发票:: ppExt 宜搭服务注册, 提交规则系统默认字段 [详见 YDService]
+        ydService.operateData2(data, update, YDParam.builder()
+                .formUuid("FORM-W2A66Z910O9B3LP9C6IYUDPRVWY62DO0YHIILY")
+                .formInstanceIdList(formInstanceIds)
+                .updateFormDataJson(JSON.toJSONString(update))
+                .build(), YDConf.FORM_OPERATION.multi_update);
+
+        return McR.success();
+    }
+
+    /**
+     * 发票状态更新: 退回提交
+     */
+    @PostMapping("invoice-zy")
+    McR invoice_zy(@RequestBody Map data) {
+        log.info("发票状态更新: 退回提交, {}", data);
+
+        List<String> pre_ids = (List<String>) data.get("pre_ids"); // 释放修改前
+        List<String> cur_ids = (List<String>) data.get("cur_ids"); // 占用修改后
+
+        // [前端调用添加] 退回为监听宜搭dom事件, 先执行接口调用, 才会校验宜搭必填, 过滤无效调用
+        if (cur_ids.size() == 0) {
+            return McR.success();
+        }
+
+        Map pre_update = (Map) data.get("pre_update");
+        Map cur_update = (Map) data.get("cur_update");
+
+        // 宜搭批量更新
+        ydClient.operateData(YDParam.builder()
+                .formUuid("FORM-W2A66Z910O9B3LP9C6IYUDPRVWY62DO0YHIILY")
+                .formInstanceIdList(pre_ids)
+                .updateFormDataJson(JSON.toJSONString(pre_update))
+                .build(), YDConf.FORM_OPERATION.multi_update);
+
+        ydClient.operateData(YDParam.builder()
+                .formUuid("FORM-W2A66Z910O9B3LP9C6IYUDPRVWY62DO0YHIILY")
+                .formInstanceIdList(cur_ids)
+                .updateFormDataJson(JSON.toJSONString(cur_update))
+                .build(), YDConf.FORM_OPERATION.multi_update);
+
+        return McR.success();
+    }
+
+    @PostMapping("test")
+    McR test() {
+
+
+//        List<Map> process = (List<Map>) ydClient.queryData(YDParam.builder()
+//                .formUuid("W2A66Z910O9B3LP9C6IYUDPRVWY62DO0YHIILY")
+//                .formInstId("FINST-NGA66WA1FV4EB7QJC3OATA3EV8MK35Z9COEMLFR22")
+//                .build(), YDConf.FORM_QUERY.retrieve_id).getData();
+
+        List<Map> process = (List<Map>) ydClient.queryData(YDParam.builder()
+                .formUuid("FORM-0IA66C71F6NBAETREO8DE9SSN43D3YIZ0AYILC")
+                .searchFieldJson(JSON.toJSONString(UtilMap.map("textField_lmewsobs", "Y16668919W4E4FHQ6123ADDHB8XK3S709YEMLXWF")))
+                .build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
+
+        return McR.success();
+    }
+
+}

+ 526 - 0
src/main/java/com/malk/pro/guyuan/controller/NhIVController.java

@@ -0,0 +1,526 @@
+package com.malk.pro.guyuan.controller;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.malk.pro.guyuan.server.model.McInvoiceDto;
+import com.malk.pro.guyuan.server.model.McInvoiceKind;
+import com.malk.pro.guyuan.server.tencent.TXYConf;
+import com.malk.pro.guyuan.service.tencent.IvYdService;
+import com.malk.pro.guyuan.service.tencent.NhTXYInvoice;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.FilePath;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDService;
+import com.malk.utils.*;
+import com.spire.pdf.PdfCompressionLevel;
+import com.spire.pdf.PdfDocument;
+import com.spire.pdf.PdfPageBase;
+import com.spire.pdf.exporting.PdfImageInfo;
+import com.spire.pdf.graphics.PdfBitmap;
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import net.coobird.thumbnailator.Thumbnails;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.poi.util.IOUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 错误抛出与拦截详见CatchException
+ */
+@Slf4j
+@RestController
+@RequestMapping("/guyuan/nh/")
+public class NhIVController {
+
+    @Autowired
+    private NhTXYInvoice txyInvoice;
+
+    @Autowired
+    private YDClient ydClient;
+
+    private final static String APP_TYPE="APP_Y5KGBSIKJGG6ZQBTNKSZ";
+    private final static String SYSTEM_TOKEN="GFA66U91QQDMO11Y7N7YC7MA6U0M236VMM2YLBO11";
+
+    /// 优先获取字段, 新版本接口已支持字段返回
+    private String findValue(List<Map<String, String>> infos, String... names) {
+        for (String name : names) {
+            Optional optional = infos.stream().filter(info -> info.get("Name").equals(name)).findAny();
+            if (optional.isPresent()) {
+                return String.valueOf(((Map) optional.get()).get("Value"));
+            }
+        }
+        return "";
+    }
+
+    // 兼容历史配置, 格式 (谷元)
+    private String guyuanNameRepalce(String name) {
+        if (name.contains("谷元")) {
+            return UtilString.replaceBracketIsWhole(name);
+        } else {
+            return UtilString.replaceBracketIsSemiangle(name);
+        }
+    }
+
+    /// url压缩转base64
+    @SneakyThrows
+    private String imageUrlConvertBase64(String imageUrl) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        // scale(比例), outputQuality(质量)
+        Thumbnails.fromURLs(Arrays.asList(new URL(imageUrl))).scale(0.5f).outputQuality(0.25f).toOutputStream(out);
+        InputStream inputStream = new ByteArrayInputStream(out.toByteArray());
+        //转换为base64
+        byte[] bytes = IOUtils.toByteArray(inputStream);
+        return Base64.getEncoder().encodeToString(bytes);
+    }
+
+    @Autowired
+    private FilePath filePath;
+
+    /// PDF压缩转base64
+    @SneakyThrows
+    private String pdfUrlConvertBase64(String pdfUrl) {
+        String fileName = "tmp_" + new Date().getTime() + ".pdf";
+        // 下载文件
+        File file = UtilFile.mkdirIfNot(fileName, filePath.getPath().getTmp());
+        UtilHttp.doDownload(pdfUrl, file);
+
+        // PDF压缩
+        PdfDocument doc = new PdfDocument(); // 创建PdfDocument类的对象
+        doc.loadFromFile(file.getAbsolutePath()); // 加载PDF文档
+        doc.getFileInfo().setIncrementalUpdate(false); // 禁用增量更新
+        doc.setCompressionLevel(PdfCompressionLevel.Normal); // 将压缩级别设置为最佳
+
+        // 遍历文档页面
+        for (int i = 0; i < doc.getPages().getCount(); i++) {
+            PdfPageBase page = doc.getPages().get(i);  // 获取指定页面
+            PdfImageInfo[] images = page.getImagesInfo(); // 获取每个页面的图像信息集合
+            // 遍历集合中的所有项目
+            if (images != null && images.length > 0)
+                for (int j = 0; j < images.length; j++) {
+                    PdfImageInfo image = images[j];  // 获取指定图片
+                    PdfBitmap bp = new PdfBitmap(image.getImage());
+                    bp.setQuality(30); // 设置压缩质量
+                    page.replaceImage(j, bp); // 将原始图像替换为压缩图像
+                }
+        }
+        // 将结果文档保存至另一个PDF文档中: 覆盖
+        doc.saveToFile(file.getAbsolutePath());
+        doc.close();
+        // PDF转base64, 无需透出本地文件地址
+        String base64 = UtilFile.fileToBase64(file.getAbsolutePath());
+        // 删除临时PDF文件
+        UtilFile.deleteFile(file.getAbsolutePath());
+        return base64;
+    }
+
+    // prd 校验发票抬头, 购买方范围
+    private void validateBuyer(String BuyerName, String tips) {
+        List<String> corpNames = Arrays.asList(
+                "珠海金魁新能源科技有限公司",
+                "珠海能魁新能源科技有限公司",
+                "珠海创伟新能源有限公司",
+                "河南能辉绿电科技有限公司",
+                "河南省绿色生态新能源科技有限公司",
+                "贵州能辉智慧能源科技有限公司",
+                "上海能辉科技股份有限公司");
+        McException.assertAccessException(!corpNames.contains(BuyerName), tips + ", 购买方名称不合法!");
+    }
+
+    /**
+     * 混票识别 [新版本]
+     */
+    @PostMapping("invoice-iv2")
+    McR invoice_iv2(@RequestBody Map<String, String> data) throws TencentCloudSDKException {
+
+        McException.assertParamException_Null(data, "url");
+        String image = ydClient.convertTemporaryUrl(data.get("url"),6000);
+        log.info("混票识别, 免登地址, {}", image);
+        // 非PDF, 且内存大于3M, 压缩后上传
+        if (UtilMap.getFloat(data, "size") > 3.0f && !UtilMap.getBoolean(data, "isPdf")) {
+            image = imageUrlConvertBase64(image);
+        }
+        if (UtilMap.getFloat(data, "size") > 6.0f && UtilMap.getBoolean(data, "isPdf")) {
+            image = pdfUrlConvertBase64(image);
+        }
+        List<Map> invoices = (List<Map>) txyInvoice.doRecognizeGeneralInvoice(image).get("MixedInvoiceItems");
+        List<McInvoiceDto> result = invoices.stream().map(item -> {
+            Map prop = UtilMap.getMap(UtilMap.getMap(item, "SingleInvoiceInfos"), UtilMap.getString(item, "SubType"));
+
+            String kind = UtilMap.getString(item, "TypeDescription");
+            String invoiceName = UtilMap.getString(item, "SubTypeDescription");
+            if (kind.equals("全电发票")) {
+                kind = invoiceName.contains("专用发票") ? "全电专用发票" : "全电普通发票";
+            }
+            if (kind.equals("增值税发票")) {
+                kind = invoiceName.contains("增值税专用发票") ? "增值税专用发票" : "增值税普通发票";
+                if (invoiceName.contains("增值税电子")) {
+                    kind = invoiceName.contains("专用发票") ? "增值税电子专用发票" : "增值税电子普通发票";
+                }
+            }
+            // ppExt: 通用字段定义
+            McInvoiceDto invoiceDto = McInvoiceDto.builder()
+                    .name(UtilMap.getString(item, "SubTypeDescription"))
+                    .kindName(kind)
+                    .kind(UtilMap.getInt(item, "Type"))
+                    .code(UtilMap.getString(prop, "Code"))
+                    .serial(UtilMap.getString(prop, "Number"))
+                    .date(UtilString.replaceDateZH_cn(UtilMap.getString(prop, "Date")))
+                    .checkCode(UtilMap.getString(prop, "CheckCode"))
+                    // ppExt: 多明细行时, 优先取值合计 [全电票返回了subTotal字段, 但值为空]
+                    .amount(UtilNumber.setBigDecimal(UtilMap.getString_first(prop, "SubTotal", "Total")))
+                    .tax(UtilNumber.setBigDecimal(UtilMap.getString_first(prop, "SubTax", "Tax")))
+                    .excludingTax(UtilNumber.setBigDecimal(UtilMap.getString(prop, "PretaxAmount")))
+                    .buyerName(StringUtils.isBlank(guyuanNameRepalce(UtilMap.getString(prop, "Buyer")))?"上海能辉科技股份有限公司":guyuanNameRepalce(UtilMap.getString(prop, "Buyer")))
+                    // ppExt: 中央非税未返回税号官方说明: 非税发票理论是没有税号的,图片中属于信用代码
+                    .buyerTaxId(StringUtils.isBlank(UtilMap.getString(prop, "BuyerTaxID"))?"91310000685457643J": UtilMap.getString(prop, "BuyerTaxID"))
+                    .sellerName(guyuanNameRepalce(UtilMap.getString_first(prop, "Seller", "Issuer")))                            // 行程单: 填开单位
+                    .sellerTaxId(UtilMap.getString_first(prop, "SellerTaxID", "AgentCode"))                       // 行程单: 销售单位代号
+                    .passengerName(UtilMap.getString_first(prop, "Name", "UserName"))                             // 火车票, 行程单
+                    // 交通出行
+                    .seatType(UtilMap.getString(prop, "Seat"))
+                    .departureTime(UtilString.replaceDateZH_cn(UtilMap.getString(prop, "DateGetOn")) + " " + UtilMap.getString(prop, "TimeGetOn"))
+                    .departurePort(UtilMap.getString_first(prop, "StationGetOn", "Entrance", "Place"))            // 火车票: 出发车站, 过路过桥费: 入口, 出租车: 发票所在地
+                    .arrivePort(UtilMap.getString_first(prop, "StationGetOff", "Exit"))                           // 行程单, 火车票: 到达车站, 过路过桥费: 出口
+                    .trainNo(UtilMap.getString_first(prop, "TrainNumber", "LicensePlate"))                        // 火车票: 车次, 出租车: 车牌号
+                    .insuranceCosts(UtilNumber.setBigDecimal((UtilMap.getString(prop, "Insurance"))))               // 行程单: 保险费
+                    .fuelCosts(UtilNumber.setBigDecimal((UtilMap.getString(prop, "FuelSurcharge"))))                // 行程单: 燃油附加费
+                    .constructionCosts(UtilNumber.setBigDecimal((UtilMap.getString(prop, "AirDevelopmentFund"))))   // 行程单: 民航发展基金
+                    .build();
+            // ppExt: 机票行程单, 行程与座位信息在明细内
+            if ("机票行程单".equals(item.get("TypeDescription"))) {
+                Map flight = (Map) UtilMap.getList(prop, "FlightItems").get(0);
+                invoiceDto.setDepartureTime(UtilString.replaceDateZH_cn(UtilMap.getString(item, "DateGetOn")) + " " + UtilMap.getString(prop, "TimeGetOn"));
+                invoiceDto.setDeparturePort(UtilMap.getString(flight, "StationGetOn"));
+                invoiceDto.setArrivePort(UtilMap.getString(flight, "StationGetOff"));
+                invoiceDto.setSeatType(UtilMap.getString(flight, "Seat"));
+            }
+            if ("出租车发票".equals(item.get("TypeDescription"))) {
+                // 上下车时间
+                invoiceDto.setDepartureTime(UtilMap.getString(prop, "TimeGetOn") + " ~ " + UtilMap.getString(prop, "TimeGetOff"));
+            }
+            return invoiceDto;
+        }).collect(Collectors.toList());
+        return McR.success(McInvoiceDto.formatResponse(result));
+    }
+
+    /**
+     * 混票识别 [旧版本, 已废弃]
+     */
+    @PostMapping("invoice-iv")
+    McR invoice_iv(@RequestBody Map<String, String> data) throws TencentCloudSDKException {
+
+        McException.assertParamException_Null(data, "url");
+        String image = ydClient.convertTemporaryUrl(data.get("url"),6000);
+        log.info("混票识别, 免登地址, {}", image);
+        // 非PDF, 且内存大于3M, 压缩后上传
+        if (UtilMap.getFloat(data, "size") > 3.0f && !UtilMap.getBoolean(data, "isPdf")) {
+            image = imageUrlConvertBase64(image);
+        }
+        if (UtilMap.getFloat(data, "size") > 6.0f && UtilMap.getBoolean(data, "isPdf")) {
+            image = pdfUrlConvertBase64(image);
+        }
+        // ppExt: 通用字段定义
+        List<Map> invoices = (List<Map>) txyInvoice.doMixedInvoiceOCR(image).get("MixedInvoiceItems");
+        List<McInvoiceDto> result = invoices.stream().map(item -> {
+            String kind = TXYConf.TYPE_INVOICE.get(item.get("Type").toString());
+            List<Map<String, String>> infos = (List<Map<String, String>>) item.get("SingleInvoiceInfos");
+
+            McInvoiceDto.assertSuccess(item, kind); // 响应断言
+            String invoiceName = findValue(infos, "发票名称");
+            if (kind.equals("全电发票")) {
+                kind = invoiceName.contains("增值税专用发票") ? "全电专用发票" : "全电普通发票";
+            }
+            if (kind.equals("增值税发票")) {
+                kind = invoiceName.contains("增值税专用发票") ? "增值税专用发票" : "增值税普通发票";
+                if (invoiceName.contains("增值税电子")) {
+                    kind = invoiceName.contains("专用发票") ? "增值税电子专用发票" : "增值税电子普通发票";
+                }
+            }
+            McInvoiceDto invoiceDto = McInvoiceDto.builder()
+                    .name(invoiceName)
+                    .kindName(kind)
+                    .kind(McInvoiceKind.getKindCode(kind))
+                    .code(findValue(infos, "发票代码", "票据代码")) // 发票, 非税发票
+                    // 储存唯一ID [发票, 火车票, 行程单]
+                    .serial(findValue(infos, "发票号码", "编号", "电子客票号码", "票据号码").replace("No", "")) // 发票, 非税发票
+                    .date(findValue(infos, "开票日期").replace("年", "-").replace("月", "-").replace("日", ""))
+                    .checkCode(findValue(infos, "校验码"))
+                    .amount(UtilNumber.replaceCurrencyCHYToDecimal(findValue(infos, "小写金额", "价税合计(小写)", "合计金额", "票价", "金额"))) // 发票, 全电票, 行程单, 火车票, 过路过桥费
+                    .excludingTax(UtilNumber.replaceCurrencyCHYToDecimal(findValue(infos, "合计金额", "金额", "票价", "小写金额"))) // [ppExt: 多明细行时, 优先取值合计] 行程单, 火车票, 定额发票
+                    .tax(UtilNumber.replaceCurrencyCHYToDecimal(findValue(infos, "合计税额"))) // 增值税发票
+                    .buyerName(guyuanNameRepalce(findValue(infos, "购买方名称", "交款人"))) // 发票, 非税发票
+                    .buyerTaxId(findValue(infos, "购买方识别号", "购买方统一社会信用代码/纳税人识别号", "交款人统一社会信用代码")) // 发票, 全电票, 非税发票
+                    .sellerName(guyuanNameRepalce(findValue(infos, "销售方名称", "填开单位"))) // 行程单
+                    .sellerTaxId(findValue(infos, "销售方识别号", "销售方统一社会信用代码/纳税人识别号", "销售单位代号")) // 发票, 全电票, 行程单
+                    .passengerName(findValue(infos, "旅客姓名", "姓名")) // 行程单, 火车票
+                    .seatType(findValue(infos, "座位等级", "席别")) // 行程单, 火车票
+                    .departurePort(findValue(infos, "始发地", "出发站", "入口")) // 行程单, 火车票, 过路过桥费
+                    .arrivePort(findValue(infos, "目的地", "到达站", "出口")) // 行程单, 火车票, 过路过桥费
+                    .trainNo(findValue(infos, "航班号", "车次", "车牌号")) // 行程单, 火车票, 出租车
+                    .insuranceCosts(UtilNumber.setBigDecimal((findValue(infos, "保险费")))) // 行程单
+                    .fuelCosts(UtilNumber.setBigDecimal((findValue(infos, "燃油附加费")))) // 行程单
+                    .constructionCosts(UtilNumber.setBigDecimal((findValue(infos, "民航发展基金")))) // 行程单
+                    .build();
+            // 价格不一致情况下, 通过合计返回
+            if (!UtilNumber.equalBigDecimal(invoiceDto.getAmount(), invoiceDto.getExcludingTax().add(invoiceDto.getTax()))) {
+                invoiceDto.setAmount(invoiceDto.getExcludingTax().add(invoiceDto.getTax()));
+            }
+            // 机票行程单
+            if (kind.equals(McInvoiceKind.JP.getDesc())) {
+                String date = findValue(infos, "日期").replace("年", "-").replace("月", "-").replace("日", " ");
+                invoiceDto.setDepartureTime(date + " " + findValue(infos, "时间"));
+            }
+            // 火车票
+            if (kind.equals(McInvoiceKind.HC.getDesc())) {
+                invoiceDto.setDepartureTime(findValue(infos, "出发时间").replace("年", "-").replace("月", "-").replace("日", " "));
+            }
+            // 出租车
+            if (kind.equals(McInvoiceKind.CZC.getDesc())) {
+                String date = findValue(infos, "日期").replace("年", "-").replace("月", "-").replace("日", " ");
+                invoiceDto.setDepartureTime(date + " " + findValue(infos, "上车"));
+            }
+            return invoiceDto;
+        }).collect(Collectors.toList());
+        return McR.success(McInvoiceDto.formatResponse(result));
+    }
+
+    /**
+     * 发票查重, 验真
+     */
+    @PostMapping("invoice-va")
+    McR invoice_va(@RequestBody Map data) {
+        McException.assertParamException_Null(data, "param");
+        List<McInvoiceDto> invoices = JSON.parseArray(JSON.toJSONString(data.get("param")), McInvoiceDto.class);
+
+        log.info("发票查重, 验真, {}", invoices);
+        invoices.forEach(UtilMc.consumerWithIndex((item, index) -> {
+
+            McInvoiceDto dto = (McInvoiceDto) item;
+            String invoiceNo = dto.getSerial(); // 唯一标识, 发票号码
+
+            String serial = "第【" + (index + 1) + "】张发票";
+            validateBuyer(dto.getBuyerName(), serial + "有疑问");
+            McException.assertAccessException(StringUtils.isBlank(invoiceNo), serial + ", 识别结果为空, 请检查!");
+            YDParam ydParam = YDParam.builder().systemToken(SYSTEM_TOKEN).appType(APP_TYPE)
+                    .formUuid("FORM-442A54C312A64FCA9C1D19C7C1AD7314MXAJ")
+                    .searchFieldJson(JSON.toJSONString(UtilMap.map("radioField_liihyrtb, textField_liihyrt8", "否", invoiceNo)))
+                    .build();
+            List<String> idList = (List<String>) ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_search_form_id).getData();
+            if (idList.size() > 0) {
+                McException.exceptionAccess(serial + "已存在, 请勿重复提交!");
+            }
+            // prd 仅仅识别 报销 用途的发票
+            if (dto.getType().equals("报销") && !dto.getKindName().contains("车票") && !dto.getKindName().contains("车发票") && !dto.getKindName().contains("定额发票")) {
+                String serialTips = serial + "有疑问";
+                try {
+                    // ppExt: 识别与验真后抬头对比 [全电票, 新版本识别接口, 返回名称为: 电子发票(普通发票) 不包含全电标识, 发类型为: 全电发票. 注意取值]
+                    Map rsp = txyInvoice.doVatInvoiceVerifyNew(dto.getKindName(), dto.getCode(), invoiceNo, dto.getDate(), String.valueOf(dto.getAmount()), dto.getCheckCode(), String.valueOf(dto.getExcludingTax()), serialTips);
+                    Map invoice = (Map) rsp.get("Invoice");
+                    McException.assertAccessException(!dto.getBuyerName().equals(guyuanNameRepalce(invoice.get("BuyerName").toString())), serialTips + ", 购买方名称不匹配!");
+                    McException.assertAccessException(!dto.getBuyerTaxId().equals(invoice.get("BuyerTaxCode")), serialTips + ", 购买方税号不匹配!");
+                    McException.assertAccessException(!dto.getSellerName().equals(guyuanNameRepalce(invoice.get("SellerName").toString())), serialTips + ", 销售方名称不匹配!");
+                    McException.assertAccessException(!dto.getSellerTaxId().equals(invoice.get("SellerTaxCode")), serialTips + ", 销售方税号不匹配!");
+                } catch (TencentCloudSDKException e) {
+                    log.error(e.getMessage(), e);
+                    // prd: 上传发票为假发票时,提示:该发票有疑问,请联系财务人员
+                    String message = e.getMessage();
+                    // ppExt: 已经是新版本接口, 过滤提示 [官方答复: 提示不会检测您是否使用的是新版,所有的用户都会提示, 忽略即可]
+                    if (message.contains("温馨提示")) {
+                        message = message.split("温馨提示")[0];
+                    }
+                    if (message.contains("发票不存在")) {
+                        message = "有疑问,请联系财务人员";
+                    }
+                    McException.exceptionAccess(serial + message);
+                }
+            }
+        }));
+        return McR.success();
+    }
+
+
+    @Autowired
+    private YDService ydService;
+    @Autowired
+    private IvYdService ivYdService;
+
+    /**
+     * 发票状态更新: 服务注册
+     */
+    @PostMapping("invoice-up")
+    McR invoice_va(HttpServletRequest request) {
+        Map data = UtilServlet.getParamMap(request);
+        log.info("发票状态更新: 服务注册, {}", data);
+
+        String compId = UtilMap.getString(data, "compId");
+        String status = UtilMap.getString(data, "status");
+
+        // 读取关联表单
+        String formUUid="";
+        List<String> formInstanceIds = new ArrayList<>();
+        if(compId.equals("selectField_lzs0bpk2")){
+            // 采购表单
+            formUUid="FORM-B5A7B20013AE4CD09AD87FAB9A3E145FS3P6";
+            List<Map> associationData = (List<Map>) JSON.parse(UtilMap.getString(data, "multiAssociation"));
+            formInstanceIds.addAll(associationData.stream().map(form -> UtilMap.getString(form, "instanceId")).collect(Collectors.toList()));
+        }else{
+            formUUid="FORM-442A54C312A64FCA9C1D19C7C1AD7314MXAJ";
+            List<String> associationForm = (List<String>) JSON.parse(UtilMap.getString(data, "multiAssociation"));
+            for (String record : associationForm) {
+                // 解析关联表单
+                List<Map> associationData = (List<Map>) JSON.parse(record);
+                formInstanceIds.addAll(associationData.stream().map(form -> UtilMap.getString(form, "instanceId")).collect(Collectors.toList()));
+            }
+        }
+        // 宜搭批量更新
+        Map update = UtilMap.map(compId, status);
+        if (compId.equals("selectField_liihyrt6")) {
+            update.put("radioField_liw7rb2q", "否"); // 提交后, 更新是否退回标识为否
+        }
+
+        // prd 9.10 更新报销单, 关联到发票:: ppExt 宜搭服务注册, 提交规则系统默认字段 [详见 YDService]
+//        ydService.operateData3(data, update, YDParam.builder().systemToken(SYSTEM_TOKEN).appType(APP_TYPE)
+//                .formUuid(formUUid)
+//                .formInstanceIdList(formInstanceIds)
+//                .updateFormDataJson(JSON.toJSONString(update))
+//                .build(), YDConf.FORM_OPERATION.multi_update);
+        ivYdService.operateData(data, update, YDParam.builder().systemToken(SYSTEM_TOKEN).appType(APP_TYPE)
+                .formUuid(formUUid)
+                .formInstanceIdList(formInstanceIds)
+                .updateFormDataJson(JSON.toJSONString(update))
+                .build(), YDConf.FORM_OPERATION.multi_update);
+
+        return McR.success();
+    }
+
+    /**
+     * 发票状态更新: 退回提交
+     */
+    @PostMapping("invoice-zy")
+    McR invoice_zy(@RequestBody Map data) {
+        log.info("发票状态更新: 退回提交, {}", data);
+
+        List<String> pre_ids = (List<String>) data.get("pre_ids"); // 释放修改前
+        List<String> cur_ids = (List<String>) data.get("cur_ids"); // 占用修改后
+
+        // [前端调用添加] 退回为监听宜搭dom事件, 先执行接口调用, 才会校验宜搭必填, 过滤无效调用
+        if (cur_ids.size() == 0) {
+            return McR.success();
+        }
+
+        Map pre_update = (Map) data.get("pre_update");
+        Map cur_update = (Map) data.get("cur_update");
+
+        // 宜搭批量更新
+        ydClient.operateData(YDParam.builder().systemToken(SYSTEM_TOKEN).appType(APP_TYPE)
+                .formUuid("FORM-442A54C312A64FCA9C1D19C7C1AD7314MXAJ")
+                .formInstanceIdList(pre_ids)
+                .updateFormDataJson(JSON.toJSONString(pre_update))
+                .build(), YDConf.FORM_OPERATION.multi_update);
+
+        ydClient.operateData(YDParam.builder().systemToken(SYSTEM_TOKEN).appType(APP_TYPE)
+                .formUuid("FORM-442A54C312A64FCA9C1D19C7C1AD7314MXAJ")
+                .formInstanceIdList(cur_ids)
+                .updateFormDataJson(JSON.toJSONString(cur_update))
+                .build(), YDConf.FORM_OPERATION.multi_update);
+
+        return McR.success();
+    }
+
+    /**
+     * 全局查询(不匹配子表单)
+     */
+    @PostMapping("validate")
+    McR queryAll(HttpServletRequest request) {
+        Map<String, ?> param = UtilServlet.getParamMap(request);
+        log.info("全局查询(不匹配子表单), {}", param);
+        if (ObjectUtil.isNull(param.get("uniques"))) {
+            return McR.success();
+        }
+        McException.assertParamException_Null(param, "uniques", "formUuid", "compId");
+        // 容错 - 尾部分号的空格会被输入框忽略
+        String[] uniques = String.valueOf(param.get("uniques")).replace("; ", ";").split(";");
+        for (String val : uniques) {
+            // 查重校验: 单张发票唯一标识 + 审批已通过 / 审批中
+            List<Map> conditions = new ArrayList<>();
+            Map unique = new HashMap();
+            unique.put("key", param.get("compId"));
+            unique.put("value", val.split(": ")[1]);
+            unique.put("type", "TEXT");
+            unique.put("operator", "like");
+            unique.put("componentName", "TextField");
+            conditions.add(unique);
+
+            Map approve = new HashMap();
+            approve.put("key", "processApprovedResult");
+            approve.put("value", new String[]{"agree"});
+            approve.put("type", "ARRAY");
+            approve.put("operator", "in");
+            approve.put("componentName", "SelectField");
+            conditions.add(approve);
+            YDParam ydParam = YDParam.builder()
+                    .appType(APP_TYPE)
+                    .systemToken(SYSTEM_TOKEN)
+                    .formUuid(String.valueOf(param.get("formUuid")))
+                    .searchCondition(JSON.toJSONString(conditions))
+                    .build();
+            DDR_New ddr_new = ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_list);
+            log.info("审批通过匹配结果, {}, {}", ddr_new.getTotalCount(), ddr_new.getData());
+            if (ddr_new.getTotalCount() > 0) {
+                return McR.errorAccess("发票已被使用, 请勿重复提交!");
+            }
+
+            conditions.remove(approve);
+            Map status = new HashMap();
+            status.put("key", "processInstanceStatus");
+            status.put("value", new String[]{"RUNNING"});
+            status.put("type", "ARRAY");
+            status.put("operator", "in");
+            status.put("componentName", "SelectField");
+            conditions.add(status);
+            ydParam.setSearchCondition(JSON.toJSONString(conditions));
+            ddr_new = ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_list);
+            log.info("审批通过匹配结果, {}, {}", ddr_new.getTotalCount(), ddr_new.getData());
+            if (ddr_new.getTotalCount() > 0) {
+                return McR.errorAccess("发票已在流程中, 请勿重复提交!");
+            }
+        }
+        return McR.success();
+    }
+
+    @PostMapping("test")
+    McR test() {
+
+
+//        List<Map> process = (List<Map>) ydClient.queryData(YDParam.builder()
+//                .formUuid("442A54C312A64FCA9C1D19C7C1AD7314MXAJ")
+//                .formInstId("FINST-NGA66WA1FV4EB7QJC3OATA3EV8MK35Z9COEMLFR22")
+//                .build(), YDConf.FORM_QUERY.retrieve_id).getData();
+
+        List<Map> process = (List<Map>) ydClient.queryData(YDParam.builder().systemToken(SYSTEM_TOKEN).appType(APP_TYPE)
+                .formUuid("FORM-0IA66C71F6NBAETREO8DE9SSN43D3YIZ0AYILC")
+                .searchFieldJson(JSON.toJSONString(UtilMap.map("textField_lmewsobs", "Y16668919W4E4FHQ6123ADDHB8XK3S709YEMLXWF")))
+                .build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
+
+        return McR.success();
+    }
+
+}

+ 202 - 0
src/main/java/com/malk/pro/guyuan/controller/NhOaTLYController.java

@@ -0,0 +1,202 @@
+package com.malk.pro.guyuan.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McR;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Workflow;
+import com.malk.utils.UtilDateTime;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.map.HashedMap;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.*;
+
+/**
+ * 错误抛出与拦截详见 CatchException
+ */
+@Slf4j
+@RestController
+@RequestMapping("/guyuan/oa/")
+public class NhOaTLYController {
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private DDClient_Workflow ddClient_workflow;
+
+    /**
+     * 推送审批
+     */
+    @PostMapping("do-approve")
+    public McR doApprove(String processInstanceId, String type) {
+
+        log.info("推送审批, {} {}", type, processInstanceId);
+        Map formData=new HashMap();
+        // OA组件name, 匹配宜搭组件ID
+        formData.put("selectField_m0qg3qz0","审批中");
+        Map<String, String> compsId_main = UtilMap.map("出差事由, 出差天数, 外部人员, 出差备注, 出行人(同行人)", "textareaField_lygo9owv, numberField_lygo4abs, textField_m0naj26h, textareaField_lygo9ox1, employeeField_lygo9ox2");
+        if ("出差1".equals(type)) {
+            formData.put("selectField_m0naj267","出差批准及订票申请");
+        }else if ("出差2".equals(type)) {
+            formData.put("selectField_m0naj267","出差返程及回家订票申请");
+            compsId_main.put("已在外出差及驻地天数","numberField_m0naj26f");
+        }else{
+            return McR.error("201","类型错误!");
+        }
+
+        Map<String, String> compsId_itinerary = UtilMap.map("交通工具, 单程往返, 出发城市, 目的城市, 开始时间, 结束时间, 时长", "textField_lygo9oww, textField_lygo9owx, textField_lygo9owy, textField_lygo9owz, textField_lygvu31i, textField_lygvu31j, numberField_lygo9ox0");
+        compsId_main.put("出差明细", "tableField_lygo9owu"); // 子表组件
+        syncYD(processInstanceId, "FORM-BD5B7CCC28124B808B9DAD98F153CEDACB79", compsId_main, compsId_itinerary, "itinerary",formData);
+        return McR.success();
+    }
+
+    /**
+     * 推送审批
+     */
+    @GetMapping("approved")
+    public McR approved(String processInstanceId, String result) {
+
+        log.info("审批完成, {} {}", result, processInstanceId);
+        List<Map> formList = (List<Map>) ydClient.queryData(YDParam.builder()
+                .appType(APP_TYPE)
+                .systemToken(SYSTRM_TOKEN)
+                .searchFieldJson(JSON.toJSONString(UtilMap.map("textField_lyh4y3th",processInstanceId)))
+                .formUuid("FORM-BD5B7CCC28124B808B9DAD98F153CEDACB79")
+                .build(), YDConf.FORM_QUERY.retrieve_list).getData();
+        if(formList!=null&&formList.size()>0){
+            ydClient.operateData(YDParam.builder()
+                    .appType(APP_TYPE)
+                    .systemToken(SYSTRM_TOKEN)
+                    .formInstanceId(UtilMap.getString(formList.get(0),"formInstanceId"))
+                    .updateFormDataJson(JSON.toJSONString(UtilMap.map("selectField_m0qg3qz0",result)))
+                    .build(), YDConf.FORM_OPERATION.update);
+        }
+        return McR.success();
+    }
+
+    /// dingtalk
+    final String APP_EKY = "dingbde12wxlamjrfze4";
+    final String APP_SECRET = "h1jDsG2Bem2cjpVkxgHtUi_zF3wV8VGM6QJM19-FcnMLfJc4JmgOIQPQSg5yv3Tl";
+    /// aliwork
+    final String APP_TYPE = "APP_Y5KGBSIKJGG6ZQBTNKSZ";
+    final String SYSTRM_TOKEN = "GFA66U91QQDMO11Y7N7YC7MA6U0M236VMM2YLBO11";
+
+    /// 同步OA单据到宜搭
+    public void syncYD(String processInstanceId, String formUuid, Map<String, String> compsId_main, Map<String, String> compsId_itinerary, String compId_sub_oa,Map formData) {
+
+        String token = ddClient.getAccessToken(APP_EKY, APP_SECRET);
+
+        Map processData = ddClient_workflow.getProcessInstanceId(token, processInstanceId);
+        List<Map> formComponentValues = (List<Map>) processData.get("formComponentValues");
+
+        String userId = String.valueOf(processData.get("originatorUserId"));
+        long cDate = UtilDateTime.parse(UtilMap.getString(processData, "createTime"), "yyyy-MM-dd'T'HH:mm").getTime();
+        formData.putAll(UtilMap.map("employeeField_ltxqs53k, departmentSelectField_lu20ayky, departmentSelectField_m0naj26d, dateField_ltxqs53j, textField_lygnetw9", Arrays.asList(userId), Arrays.asList(processData.get("originatorDeptId")), Arrays.asList(processData.get("originatorDeptId")), cDate, UtilMap.getString(processData, "businessId")));
+
+        for (String name : compsId_main.keySet()) {
+            String compId = compsId_main.get(name);
+            // 判定是否子表 [宜搭]
+            if (compId.startsWith("tableField_")) {
+                List<Map> details = new ArrayList<>();
+                // 兼容明细组件, 存在多条情况 [加班跨天才有有明细]
+                Optional optional = formComponentValues.stream().filter(item -> compId_sub_oa.equals(item.get("bizAlias"))).findAny();
+                if (!optional.isPresent()) {
+                    continue;
+                }
+                String schedule = UtilMap.getString((Map) optional.get(), "value");
+                List<Map> itineraryList = ((List<Map>) JSON.parse(schedule));
+                // 循环明细数据
+                for (Map itinerary : itineraryList) {
+                    List<Map> rowValue = (List<Map>) itinerary.get("rowValue");
+                    Map rowData = new HashedMap();
+                    // 循环子表组件
+                    for (String subName : compsId_itinerary.keySet()) {
+                        log.info("子表字段, {}", subName);
+                        //  加班单跨天 [子表label为空]
+                        rowData.put(compsId_itinerary.get(subName), rowValue.stream().filter(item -> subName.equals(item.get("bizAlias")) || subName.equals(item.get("label"))).findAny().get().get("value"));
+                    }
+                    details.add(rowData);
+                }
+                formData.put(compId, details);
+                continue;
+            }
+            log.info("主表字段, {}", name);
+            // 请假套件: 开始时间 / 结束时间 / 时长 / 单位 / 请假类型
+            if ("DDHolidayField".equals(name)) {
+                Optional optional = formComponentValues.stream().filter(item -> "DDHolidayField".equals(item.get("componentType"))).findAny();
+                if (optional.isPresent()) {
+                    String[] ids = compId.split(" / ");
+                    List vas = (List) JSON.parse(UtilMap.getString((Map) optional.get(), "value"));
+                    for (int i = 0; i < ids.length; i++) {
+                        formData.put(ids[i], vas.get(i));
+                    }
+                }
+                continue;
+            }
+            Map formComp = formComponentValues.stream().filter(item -> name.equals(item.get("name"))).findAny().get();
+            Object value = formComp.get("value");
+            // 成员组件, 数据处理
+            if ("InnerContactField".equals(formComp.get("componentType")) && formComp.containsKey("value")) {
+                List<Map> empInfos = (List<Map>) JSON.parse(String.valueOf(formComp.get("extValue")));
+                List<String> emplsId = new ArrayList<>();
+                for (Map empInfo : empInfos) {
+                    emplsId.add(String.valueOf(empInfo.get("emplId")));
+                }
+                value = emplsId; // 成员多选
+            }
+            formData.put(compId, value);
+        }
+
+        // 用于审批回传
+        List<Map> tasks = UtilMap.getList(processData, "tasks");
+        formData.put("textField_lygvvyd9", tasks.get(0).get("taskId"));
+        formData.put("textField_lygvvyda", tasks.get(0).get("userId"));
+        formData.put("textField_lyh4y3th", processInstanceId);
+        formData.put("selectField_lyo1uao4", "否"); // 出差是否报销, 否
+        formData.put("selectField_lyo1zprd", "是"); // 同步存量数据, 否
+
+        log.info("审批数据, {}", JSON.toJSONString(formData));
+        ydClient.operateData(YDParam.builder()
+                .appType(APP_TYPE)
+                .systemToken(SYSTRM_TOKEN)
+                .formUuid(formUuid)
+                .formDataJson(JSON.toJSONString(formData))
+                .userId(userId)
+                .build(), YDConf.FORM_OPERATION.create);
+    }
+
+    /**
+     * 审批回调
+     */
+    @PostMapping("approved")
+    public McR approved(String processInstanceId, String userId, String taskId, String result) {
+        log.info("审批回调, {} {}", processInstanceId, result);
+        String accessToken = ddClient.getAccessToken(APP_EKY, APP_SECRET);
+//        ddClient_workflow.executeRunningApprove(accessToken, processInstanceId, userId, taskId, result, "", null);
+        return McR.success();
+    }
+
+    /**
+     * 删除/撤销
+     */
+    @PostMapping("terminate")
+    public McR terminate(String processInstanceId, String userId) {
+        log.info("删除/撤销, {} {}", processInstanceId, userId);
+
+        String accessToken = ddClient.getAccessToken(APP_EKY, APP_SECRET);
+        ddClient_workflow.terminateRunningApprove(accessToken, processInstanceId, true, "发起人撤销", userId);
+        return McR.success();
+    }
+}

+ 27 - 0
src/main/java/com/malk/pro/guyuan/filter/CatchException_YXY.java

@@ -0,0 +1,27 @@
+package com.malk.pro.guyuan.filter;
+
+import com.malk.filter.CatchException;
+import com.malk.server.common.McR;
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * 腾讯云 [通用拦截参考 CatchException]
+ */
+@Slf4j
+@RestControllerAdvice(annotations = RestController.class)
+public class CatchException_YXY extends CatchException {
+
+    /**
+     * 错误类抛出
+     */
+    @ExceptionHandler(TencentCloudSDKException.class)
+    public McR TencentCloudSDKException(TencentCloudSDKException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        // prd, 统一中文提示 return McR.errorVendor(e.getMessage(), "tencent");
+        return McR.errorVendor("发票识别异常!", "tencent");
+    }
+}

+ 219 - 0
src/main/java/com/malk/pro/guyuan/server/model/McInvoiceDto.java

@@ -0,0 +1,219 @@
+package com.malk.pro.guyuan.server.model;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.base.BaseDto;
+import com.malk.server.common.McException;
+import com.malk.server.common.McREnum;
+import com.malk.utils.UtilMap;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+/**
+ * ppExt: 新版本官方返回数据已结构化, 以官方为准
+ *
+ * @apiNote https://cloud.tencent.com/document/api/866/33527#AirTransport 数据结构
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class McInvoiceDto extends BaseDto {
+
+
+    /**************** 发票 ****************/
+
+    /**
+     * 发票名称 [类型全称]
+     */
+    private String name;
+
+    /**
+     * 发票用途 [供应商、报销]
+     */
+    private String type;
+
+    // 兼容: 不为空
+    public String getName() {
+        if (StringUtils.isBlank(name)) {
+            return kindName;
+        }
+        return name;
+    }
+
+    /**
+     * 发票类型 [类型简称]
+     * -
+     * ppExt: 全电票, 新版本识别接口, 返回名称为: 电子发票(普通发票) 不包含全电标识, 发类型为: 全电发票. 注意取值
+     */
+    private String kindName;
+
+    /**
+     * 发票类型 [编码]
+     */
+    private int kind;
+
+    /**
+     * 发票代码
+     */
+    private String code;
+
+    /**
+     * 发票号码
+     */
+    private String serial;
+
+    /**
+     * 开票日期 [yyyy-MM-dd]
+     */
+    private String date;
+
+    /**
+     * 校验码
+     */
+    private String checkCode;
+
+    /**
+     * 价税合计
+     */
+    private BigDecimal amount;
+
+    /**
+     * 不含税金额
+     */
+    private BigDecimal excludingTax;
+
+    /// 没有税额时, 税额可能没传递 [因此前提是不含税为空],不含税金额等于价税合计
+    public BigDecimal getExcludingTax() {
+        if (ObjectUtil.isNull((excludingTax)) && (ObjectUtil.isNull(tax) || BigDecimal.ZERO.compareTo(tax) == 0)) {
+            return amount;
+        }
+        return excludingTax;
+    }
+
+    /**
+     * 不含税金额
+     */
+    private BigDecimal tax;
+
+    /// 取值为null, 合计会异常
+    public BigDecimal getTax() {
+        if (ObjectUtil.isNull(tax)) {
+            return BigDecimal.ZERO;
+        }
+        return tax;
+    }
+
+    /**
+     * 购买方名称
+     */
+    private String buyerName;
+
+    /**
+     * 购买方税号
+     */
+    private String buyerTaxId;
+
+    /**
+     * 销售方名称
+     */
+    private String sellerName;
+
+    /**
+     * 销售方税号
+     */
+    private String sellerTaxId;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**************** 交通 ****************/
+
+    /**
+     * 乘客姓名
+     */
+    private String passengerName;
+
+    /**
+     * 座位类型
+     */
+    private String seatType;
+
+    /**
+     * 出发地
+     */
+    private String departurePort;
+
+    /**
+     * 到达地
+     */
+    private String arrivePort;
+
+    /**
+     * 出发时间 [yyyy-MM-dd HH:mm]
+     */
+    private String departureTime;
+
+    /**
+     * 车次编号/航班号
+     */
+    private String trainNo;
+
+    /**
+     * 保险费
+     */
+    private BigDecimal insuranceCosts;
+
+    /**
+     * 燃油附加费
+     */
+    private BigDecimal fuelCosts;
+
+    /**
+     * 民航发展基金
+     */
+    private BigDecimal constructionCosts;
+
+    /**************** 格式化 ****************/
+
+    /**
+     * 实例Map, 服务宜搭组件映射 [明细组件获取表头, 通过名称转换字段]
+     *
+     * @implSpec const headers = this.$("tableField_liv5f4d2").props.children[0].props.children.map(({ props: { label, fieldId } }) => ({ label, compId: fieldId }))
+     */
+    public static Map formatDtoLabelAndProp() {
+
+        Map data = UtilMap.map("发票名称, 发票类型, 发票代码, 发票号码, 开票日期, 校验码, 价税合计, 不含税金额, 税额", "name, kindName, code, serial, date, checkCode, amount, excludingTax, tax");
+        data.putAll(UtilMap.map("购买方名称, 购买方税号, 销售方名称, 销售方税号", "buyerName, buyerTaxId, sellerName, sellerTaxId"));
+        data.putAll(UtilMap.map("乘客姓名, 座位类型, 出发地, 到达地, 出发时间, 车次编号/航班号, 保险费, 燃油附加费, 民航发展基金", "passengerName, seatType, departurePort, arrivePort, departureTime, trainNo, insuranceCosts, fuelCosts, constructionCosts"));
+        return data;
+    }
+
+    /**
+     * 格式化返回
+     */
+    public static Map formatResponse(Object result) {
+        return UtilMap.map("result, dto", result, formatDtoLabelAndProp());
+    }
+
+    /**************** 返回值 ****************/
+
+    // 成功状态标记
+    private final static String SUC_CODE = "OK";
+
+    /**
+     * 断言错误信息
+     */
+    public static void assertSuccess(Map result, String kind) {
+        String code = UtilMap.getString(result, "Code");
+        McException.assertException(!SUC_CODE.equals(code), McREnum.VENDOR_ERROR.getCode(), kind, "tencent");
+    }
+}
+

+ 67 - 0
src/main/java/com/malk/pro/guyuan/server/model/McInvoiceKind.java

@@ -0,0 +1,67 @@
+package com.malk.pro.guyuan.server.model;
+
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+/**
+ * 定义返回值和对应状态的信息 [旧版本]
+ * ppExt: 新版本官方返回数据已结构化, 以官方为准
+ */
+@Deprecated
+@Slf4j
+public enum McInvoiceKind {
+
+    PP("增值税普通发票", 1),
+    ZP("增值税专用发票", 2),
+    DP("增值税电子普通发票", 3),
+    DZ("增值税电子专用发票", 4),
+    QP("全电普通发票", 5),
+    QZ("全电专用发票", 6),
+
+    HC("火车票", 11),
+    JP("机票行程单", 12),
+    CZC("出租车发票", 13),
+    DE("定额发票", 14),
+    GLGQ("过路过桥费发票", 15),
+
+    FS("非税发票", 21),
+    OT("其它特殊发票", 22),
+
+    UN("未知类型", -1);
+
+    @Getter
+    private String desc;
+    @Getter
+    private int code;
+
+    /**
+     * 根据code查找
+     */
+    public final static String getKindName(int code) {
+        Optional optional = Arrays.stream(McInvoiceKind.values()).filter(item -> item.code == code).findAny();
+        if (optional.isPresent()) {
+            return ((McInvoiceKind) optional.get()).desc;
+        }
+        return UN.getDesc();
+    }
+
+    /**
+     * 根据name查找
+     */
+    public final static int getKindCode(String name) {
+        Optional optional = Arrays.stream(McInvoiceKind.values()).filter(item -> item.desc.equals(name)).findAny();
+        //log.info("发票类型, {}", Arrays.stream(McInvoiceKind.values()).map(item -> item.desc).collect(Collectors.toList()));
+        if (optional.isPresent()) {
+            return ((McInvoiceKind) optional.get()).code;
+        }
+        return UN.getCode();
+    }
+
+    McInvoiceKind(String name, int code) {
+        this.desc = name;
+        this.code = code;
+    }
+}

+ 36 - 0
src/main/java/com/malk/pro/guyuan/server/tencent/TXYConf.java

@@ -0,0 +1,36 @@
+package com.malk.pro.guyuan.server.tencent;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+//@ConfigurationProperties(prefix = "tencent")
+public class TXYConf {
+
+    private String APPID="1309939821";
+
+    private String SecretId="AKID2uqoryukbO2XuBThuxzdEpnmnmoocuCH";
+
+    private String SecretKey="wnmgYHo8wrmjlldKoHnIkDZlqvrVDpOz";
+
+    private String Region="ap-shanghai";
+
+    /**
+     * 票据类型
+     * -
+     * 中央非税未返回税号官方说明: 非税发票理论是没有税号的,图片中那个属于信用代码 [两个版本接口均为返回]
+     * ppExt: 新版本官方返回数据已结构化, 以官方为准
+     */
+    @Deprecated
+    public static final Map<String, String> TYPE_INVOICE = UtilMap.map("-1, 0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 16",
+            "未知类型, 出租车发票, 定额发票, 火车票, 增值税发票, 机票行程单, 通用机打发票, 汽车票, 轮船票, 增值税发票(卷票) , 购车发票, 过路过桥费发票, 非税发票, 全电发票");
+
+}

+ 36 - 0
src/main/java/com/malk/pro/guyuan/server/tencent/TXYConfNh.java

@@ -0,0 +1,36 @@
+package com.malk.pro.guyuan.server.tencent;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+//@ConfigurationProperties(prefix = "tencentnh")
+public class TXYConfNh {
+
+    private String APPID="1326929507";
+
+    private String SecretId="AKID1kOzX2ZD61JYhwMkkes6UsQ1lgccdYWd";
+
+    private String SecretKey="EMQbjt7GLYwHFC4z9bNB49PfyALfuIqk";
+
+    private String Region="ap-shanghai";
+
+    /**
+     * 票据类型
+     * -
+     * 中央非税未返回税号官方说明: 非税发票理论是没有税号的,图片中那个属于信用代码 [两个版本接口均为返回]
+     * ppExt: 新版本官方返回数据已结构化, 以官方为准
+     */
+    @Deprecated
+    public static final Map<String, String> TYPE_INVOICE = UtilMap.map("-1, 0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 16",
+            "未知类型, 出租车发票, 定额发票, 火车票, 增值税发票, 机票行程单, 通用机打发票, 汽车票, 轮船票, 增值税发票(卷票) , 购车发票, 过路过桥费发票, 非税发票, 全电发票");
+
+}

+ 15 - 0
src/main/java/com/malk/pro/guyuan/service/tencent/IvYdService.java

@@ -0,0 +1,15 @@
+package com.malk.pro.guyuan.service.tencent;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+
+import java.util.Map;
+
+public interface IvYdService {
+
+    /**
+     * 操作数据 [异步] 能辉
+     */
+    Object operateData(Map data, Map update, YDParam param, YDConf.FORM_OPERATION type);
+
+}

+ 39 - 0
src/main/java/com/malk/pro/guyuan/service/tencent/NhTXYInvoice.java

@@ -0,0 +1,39 @@
+package com.malk.pro.guyuan.service.tencent;
+
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+
+import java.util.Map;
+
+public interface NhTXYInvoice {
+
+    /**
+     * 混贴票据识别 [将弃用, 直接返回识别数据, ppExt: 官方已停止更新, PDF多张异常]
+     *
+     * @param image 下载图片经Base64编码后不超过 7M。图片下载时间不超过 3 秒
+     */
+    @Deprecated
+    Map doMixedInvoiceOCR(String image) throws TencentCloudSDKException;
+
+    /**
+     * 通用票据识别(高级版)[返回结构化数据] ppExt: 新版本官方返回数据已结构化, 以官方为准
+     *
+     * @param image 下载图片经Base64编码后不超过 8M。图片下载时间不超过 3 秒
+     */
+    Map doRecognizeGeneralInvoice(String image) throws TencentCloudSDKException;
+
+    /**
+     * 发票验真[新版]
+     *
+     * @param invoiceKind         ppExt: 全电票, 新版本识别接口, 返回名称为: 电子发票(普通发票) 不包含全电标识, 发类型为: 全电发票. 注意取值
+     * @param invoiceCode         票代码(10或12 位),全电发票为空
+     * @param checkCode           校验码后 6 位,增值税普通发票、增值税电子普通发票、增值税普通发票(卷式)、增值税电子普通发票(通行费)时必填;
+     * @param excludingTax/amount 不含税金额,增值税专用发票、增值税电子专用发票、机动车销售统一发票、二手车销售统一发票、区块链发票时必填; 全电发票为价税合计(含税金额)
+     */
+    Map doVatInvoiceVerifyNew(String invoiceKind, String invoiceCode, String invoiceNo, String invoiceDate, String amount, String checkCode, String excludingTax, String tips) throws TencentCloudSDKException;
+
+    /**
+     * 名片识别
+     */
+    Map doBusinessCardOCR(String image) throws TencentCloudSDKException;
+
+}

+ 39 - 0
src/main/java/com/malk/pro/guyuan/service/tencent/TXYInvoice.java

@@ -0,0 +1,39 @@
+package com.malk.pro.guyuan.service.tencent;
+
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+
+import java.util.Map;
+
+public interface TXYInvoice {
+
+    /**
+     * 混贴票据识别 [将弃用, 直接返回识别数据, ppExt: 官方已停止更新, PDF多张异常]
+     *
+     * @param image 下载图片经Base64编码后不超过 7M。图片下载时间不超过 3 秒
+     */
+    @Deprecated
+    Map doMixedInvoiceOCR(String image) throws TencentCloudSDKException;
+
+    /**
+     * 通用票据识别(高级版)[返回结构化数据] ppExt: 新版本官方返回数据已结构化, 以官方为准
+     *
+     * @param image 下载图片经Base64编码后不超过 8M。图片下载时间不超过 3 秒
+     */
+    Map doRecognizeGeneralInvoice(String image) throws TencentCloudSDKException;
+
+    /**
+     * 发票验真[新版]
+     *
+     * @param invoiceKind         ppExt: 全电票, 新版本识别接口, 返回名称为: 电子发票(普通发票) 不包含全电标识, 发类型为: 全电发票. 注意取值
+     * @param invoiceCode         票代码(10或12 位),全电发票为空
+     * @param checkCode           校验码后 6 位,增值税普通发票、增值税电子普通发票、增值税普通发票(卷式)、增值税电子普通发票(通行费)时必填;
+     * @param excludingTax/amount 不含税金额,增值税专用发票、增值税电子专用发票、机动车销售统一发票、二手车销售统一发票、区块链发票时必填; 全电发票为价税合计(含税金额)
+     */
+    Map doVatInvoiceVerifyNew(String invoiceKind, String invoiceCode, String invoiceNo, String invoiceDate, String amount, String checkCode, String excludingTax, String tips) throws TencentCloudSDKException;
+
+    /**
+     * 名片识别
+     */
+    Map doBusinessCardOCR(String image) throws TencentCloudSDKException;
+
+}

+ 46 - 0
src/main/java/com/malk/pro/guyuan/service/tencent/impl/IvYdServiceImpl.java

@@ -0,0 +1,46 @@
+package com.malk.pro.guyuan.service.tencent.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.pro.guyuan.service.tencent.IvYdService;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.service.aliwork.YDClient;
+import com.malk.utils.UtilMap;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class IvYdServiceImpl implements IvYdService {
+
+    @Autowired
+    private YDClient ydClient;
+    @Autowired
+    private YDConf ydConf;
+
+    @Async
+    @SneakyThrows
+    @Override
+    public Object operateData(Map data, Map update, YDParam param, YDConf.FORM_OPERATION type) {
+        if (data.containsKey("aUuid")) {
+            Thread.sleep(3000);
+            List<Map> process = (List<Map>) ydClient.queryData(YDParam.builder().appType(param.getAppType()).systemToken(param.getSystemToken())
+                    .formUuid(data.get("aFormUuid").toString())
+                    .searchFieldJson(JSON.toJSONString(UtilMap.map("textField_lmewsobs", data.get("aUuid"))))
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
+
+            update.put(data.get("aCompId"),
+                    Arrays.asList(UtilMap.map("appType, formType, instanceId, formUuid, title",
+                            param.getAppType(), "process", process.get(0).get("formInstanceId"), data.get("aFormUuid"), process.get(0).get("title"))));
+            param.setUpdateFormDataJson(JSON.toJSONString(update));
+        }
+        return ydClient.operateData(param, type);
+    }
+}

+ 162 - 0
src/main/java/com/malk/pro/guyuan/service/tencent/impl/NhTXYImplInvoice.java

@@ -0,0 +1,162 @@
+package com.malk.pro.guyuan.service.tencent.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.pro.guyuan.server.tencent.TXYConfNh;
+import com.malk.pro.guyuan.service.tencent.NhTXYInvoice;
+import com.malk.server.common.McException;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilNumber;
+import com.tencentcloudapi.common.Credential;
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import com.tencentcloudapi.common.profile.ClientProfile;
+import com.tencentcloudapi.common.profile.HttpProfile;
+import com.tencentcloudapi.ocr.v20181119.OcrClient;
+import com.tencentcloudapi.ocr.v20181119.models.*;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Slf4j
+@Service
+public class NhTXYImplInvoice implements NhTXYInvoice {
+
+    @Autowired
+    private TXYConfNh txyConf;
+
+    // 创建请求描述
+    private ClientProfile doRequest(String endPoint) {
+
+        HttpProfile httpProfile = new HttpProfile();
+        httpProfile.setEndpoint(endPoint);
+        ClientProfile clientProfile = new ClientProfile();
+        clientProfile.setHttpProfile(httpProfile);
+        return clientProfile;
+    }
+
+    /**
+     * 混贴票据识别
+     *
+     * @apiNote https://cloud.tencent.com/document/product/866/37835
+     */
+    @Override
+    public Map doMixedInvoiceOCR(String image) throws TencentCloudSDKException {
+
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        MixedInvoiceOCRRequest req = new MixedInvoiceOCRRequest();
+        if (image.startsWith("http")) {
+            req.setImageUrl(image);
+        } else {
+            req.setImageBase64(image);
+        }
+        req.setReturnMultiplePage(true); // PDF多页识别, 仅支持返回文件前30页
+        //log.debug("请求参数, {}", JSON.toJSONString(req));
+        MixedInvoiceOCRResponse resp = client.MixedInvoiceOCR(req);
+        String result = MixedInvoiceOCRResponse.toJsonString(resp);
+        log.debug("请求响应, {}", result);
+        return (Map) JSON.parse(result);
+    }
+
+    /**
+     * 通用票据识别(高级版)
+     *
+     * @apiNote https://cloud.tencent.com/document/api/866/90802
+     */
+    @Override
+    public Map doRecognizeGeneralInvoice(String image) throws TencentCloudSDKException {
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        RecognizeGeneralInvoiceRequest req = new RecognizeGeneralInvoiceRequest();
+        if (image.startsWith("http")) {
+            req.setImageUrl(image);
+        } else {
+            req.setImageBase64(image);
+        }
+        req.setEnableMultiplePage(true); // PDF多页识别, 仅支持返回文件前30页
+        req.setEnableCutImage(false); // 返回切割图片base64
+        RecognizeGeneralInvoiceResponse resp = client.RecognizeGeneralInvoice(req);
+        String result = MixedInvoiceOCRResponse.toJsonString(resp);
+        log.debug("请求响应, {}", result);
+        return (Map) JSON.parse(result);
+    }
+
+    /**
+     * 发票验真[新版]
+     *
+     * @apiNote https://cloud.tencent.com/document/product/866/73674
+     */
+    @Override
+    public Map doVatInvoiceVerifyNew(String invoiceKind, String invoiceCode, String invoiceNo, String invoiceDate, String amount, String checkCode, String excludingTax, String tips) throws TencentCloudSDKException {
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        // 一些特殊发票校验码只有5位, 兼容
+        if (StringUtils.isNotBlank(checkCode) && checkCode.length() > 6) {
+            checkCode = checkCode.substring(checkCode.length() - 6);
+        }
+
+        VatInvoiceVerifyNewRequest req = new VatInvoiceVerifyNewRequest();
+        req.setInvoiceNo(invoiceNo);
+        req.setInvoiceDate(invoiceDate);
+        req.setInvoiceCode(invoiceCode);
+        req.setCheckCode(checkCode);
+        // ppExt: 全电票, 新版本识别接口, 返回名称为: 电子发票(普通发票) 不包含全电标识, 发类型为: 全电发票. 注意取值
+        if (invoiceKind.contains("全电")) {
+            // 全电票, 需要价税合计且无发票代码与校验码
+            req.setCheckCode(null);
+            req.setInvoiceCode(null);
+            req.setAmount(amount);
+        } else {
+            req.setAmount(null);
+            req.setAmount(excludingTax);
+        }
+        log.debug("发票验真, {}, {}", invoiceKind, JSON.toJSONString(req));
+        VatInvoiceVerifyNewResponse resp = client.VatInvoiceVerifyNew(req);
+        String result = VatInvoiceVerifyNewResponse.toJsonString(resp);
+        Map rsp = (Map) JSON.parse(result);
+
+        // 因全电票取值, 取值价税合计, 单独校验下金额
+        Map invoice = (Map) rsp.get("Invoice");
+        if (StringUtils.isBlank(tips)) {
+            tips = "发票有疑问";
+        }
+        log.debug("请求响应, {}", result);
+        McException.assertAccessException(!UtilNumber.equalBigDecimal(UtilMap.getString(invoice, "AmountWithTax"), amount), tips + ", 价税合计金额不匹配!");
+        // ppExt: 增值税卷票: 票面无税率, 税额. 但接口验证返回或本质上发票是有税率, 税额. 因此取消后置判断
+        McException.assertAccessException(!invoiceKind.contains("卷票") && !UtilNumber.equalBigDecimal(UtilMap.getString(invoice, "AmountWithoutTax"), excludingTax), tips + ", 不含税金额不匹配!");
+        return rsp;
+    }
+
+    /**
+     * 名片识别
+     *
+     * @apiNote https://console.cloud.tencent.com/api/explorer?Product=ocr&Version=2018-11-19&Action=BusinessCardOCR
+     */
+    @Override
+    public Map doBusinessCardOCR(String image) throws TencentCloudSDKException {
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        BusinessCardOCRRequest req = new BusinessCardOCRRequest();
+        if (image.startsWith("http")) {
+            req.setImageUrl(image);
+        } else {
+            req.setImageBase64(image);
+        }
+        req.setConfig(JSON.toJSONString(UtilMap.map("RetImageType", "PROPROCESS")));
+        //log.debug("请求参数, {}", JSON.toJSONString(req));
+        BusinessCardOCRResponse resp = client.BusinessCardOCR(req);
+        String result = BusinessCardOCRResponse.toJsonString(resp);
+        log.debug("请求响应, {}", result);
+        return (Map) JSON.parse(result);
+    }
+}

+ 162 - 0
src/main/java/com/malk/pro/guyuan/service/tencent/impl/TXYImplInvoice.java

@@ -0,0 +1,162 @@
+package com.malk.pro.guyuan.service.tencent.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.pro.guyuan.server.tencent.TXYConf;
+import com.malk.pro.guyuan.service.tencent.TXYInvoice;
+import com.malk.server.common.McException;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilNumber;
+import com.tencentcloudapi.common.Credential;
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import com.tencentcloudapi.common.profile.ClientProfile;
+import com.tencentcloudapi.common.profile.HttpProfile;
+import com.tencentcloudapi.ocr.v20181119.OcrClient;
+import com.tencentcloudapi.ocr.v20181119.models.*;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Slf4j
+@Service
+public class TXYImplInvoice implements TXYInvoice {
+
+    @Autowired
+    private TXYConf txyConf;
+
+    // 创建请求描述
+    private ClientProfile doRequest(String endPoint) {
+
+        HttpProfile httpProfile = new HttpProfile();
+        httpProfile.setEndpoint(endPoint);
+        ClientProfile clientProfile = new ClientProfile();
+        clientProfile.setHttpProfile(httpProfile);
+        return clientProfile;
+    }
+
+    /**
+     * 混贴票据识别
+     *
+     * @apiNote https://cloud.tencent.com/document/product/866/37835
+     */
+    @Override
+    public Map doMixedInvoiceOCR(String image) throws TencentCloudSDKException {
+
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        MixedInvoiceOCRRequest req = new MixedInvoiceOCRRequest();
+        if (image.startsWith("http")) {
+            req.setImageUrl(image);
+        } else {
+            req.setImageBase64(image);
+        }
+        req.setReturnMultiplePage(true); // PDF多页识别, 仅支持返回文件前30页
+        //log.debug("请求参数, {}", JSON.toJSONString(req));
+        MixedInvoiceOCRResponse resp = client.MixedInvoiceOCR(req);
+        String result = MixedInvoiceOCRResponse.toJsonString(resp);
+        log.debug("请求响应, {}", result);
+        return (Map) JSON.parse(result);
+    }
+
+    /**
+     * 通用票据识别(高级版)
+     *
+     * @apiNote https://cloud.tencent.com/document/api/866/90802
+     */
+    @Override
+    public Map doRecognizeGeneralInvoice(String image) throws TencentCloudSDKException {
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        RecognizeGeneralInvoiceRequest req = new RecognizeGeneralInvoiceRequest();
+        if (image.startsWith("http")) {
+            req.setImageUrl(image);
+        } else {
+            req.setImageBase64(image);
+        }
+        req.setEnableMultiplePage(true); // PDF多页识别, 仅支持返回文件前30页
+        req.setEnableCutImage(false); // 返回切割图片base64
+        RecognizeGeneralInvoiceResponse resp = client.RecognizeGeneralInvoice(req);
+        String result = MixedInvoiceOCRResponse.toJsonString(resp);
+        log.debug("请求响应, {}", result);
+        return (Map) JSON.parse(result);
+    }
+
+    /**
+     * 发票验真[新版]
+     *
+     * @apiNote https://cloud.tencent.com/document/product/866/73674
+     */
+    @Override
+    public Map doVatInvoiceVerifyNew(String invoiceKind, String invoiceCode, String invoiceNo, String invoiceDate, String amount, String checkCode, String excludingTax, String tips) throws TencentCloudSDKException {
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        // 一些特殊发票校验码只有5位, 兼容
+        if (StringUtils.isNotBlank(checkCode) && checkCode.length() > 6) {
+            checkCode = checkCode.substring(checkCode.length() - 6);
+        }
+
+        VatInvoiceVerifyNewRequest req = new VatInvoiceVerifyNewRequest();
+        req.setInvoiceNo(invoiceNo);
+        req.setInvoiceDate(invoiceDate);
+        req.setInvoiceCode(invoiceCode);
+        req.setCheckCode(checkCode);
+        // ppExt: 全电票, 新版本识别接口, 返回名称为: 电子发票(普通发票) 不包含全电标识, 发类型为: 全电发票. 注意取值
+        if (invoiceKind.contains("全电")) {
+            // 全电票, 需要价税合计且无发票代码与校验码
+            req.setCheckCode(null);
+            req.setInvoiceCode(null);
+            req.setAmount(amount);
+        } else {
+            req.setAmount(null);
+            req.setAmount(excludingTax);
+        }
+        log.debug("发票验真, {}, {}", invoiceKind, JSON.toJSONString(req));
+        VatInvoiceVerifyNewResponse resp = client.VatInvoiceVerifyNew(req);
+        String result = VatInvoiceVerifyNewResponse.toJsonString(resp);
+        Map rsp = (Map) JSON.parse(result);
+
+        // 因全电票取值, 取值价税合计, 单独校验下金额
+        Map invoice = (Map) rsp.get("Invoice");
+        if (StringUtils.isBlank(tips)) {
+            tips = "发票有疑问";
+        }
+        log.debug("请求响应, {}", result);
+        McException.assertAccessException(!UtilNumber.equalBigDecimal(UtilMap.getString(invoice, "AmountWithTax"), amount), tips + ", 价税合计金额不匹配!");
+        // ppExt: 增值税卷票: 票面无税率, 税额. 但接口验证返回或本质上发票是有税率, 税额. 因此取消后置判断
+        McException.assertAccessException(!invoiceKind.contains("卷票") && !UtilNumber.equalBigDecimal(UtilMap.getString(invoice, "AmountWithoutTax"), excludingTax), tips + ", 不含税金额不匹配!");
+        return rsp;
+    }
+
+    /**
+     * 名片识别
+     *
+     * @apiNote https://console.cloud.tencent.com/api/explorer?Product=ocr&Version=2018-11-19&Action=BusinessCardOCR
+     */
+    @Override
+    public Map doBusinessCardOCR(String image) throws TencentCloudSDKException {
+        Credential cred = new Credential(txyConf.getSecretId(), txyConf.getSecretKey());
+        ClientProfile clientProfile = doRequest("ocr.tencentcloudapi.com");
+        OcrClient client = new OcrClient(cred, txyConf.getRegion(), clientProfile);
+
+        BusinessCardOCRRequest req = new BusinessCardOCRRequest();
+        if (image.startsWith("http")) {
+            req.setImageUrl(image);
+        } else {
+            req.setImageBase64(image);
+        }
+        req.setConfig(JSON.toJSONString(UtilMap.map("RetImageType", "PROPROCESS")));
+        //log.debug("请求参数, {}", JSON.toJSONString(req));
+        BusinessCardOCRResponse resp = client.BusinessCardOCR(req);
+        String result = BusinessCardOCRResponse.toJsonString(resp);
+        log.debug("请求响应, {}", result);
+        return (Map) JSON.parse(result);
+    }
+}

+ 3 - 3
src/main/resources/application-dev.yml

@@ -1,7 +1,7 @@
 server:
-  port: 8113
+  port: 8200
   servlet:
-    context-path: /api
+    context-path: /pro
 
 spring:
   datasource:
@@ -13,7 +13,7 @@ enable:
   scheduling: false
 logging:
   config: classpath:logback-spring.xml
-  path: /home/server/demo/log/
+  path: /home/server/pro/log/
   level:
     com.malk.*: debug
 

File diff suppressed because it is too large
+ 15053 - 0
src/main/resources/static.mjs/mjs.js


File diff suppressed because it is too large
+ 1 - 0
src/main/resources/static.mjs/mjs.min.js


File diff suppressed because it is too large
+ 15056 - 0
src/main/resources/static.mjs/mjsnh.js