1、新增百度OCR

2、新增应收应付
This commit is contained in:
2026-08-18 01:41:37 +08:00
parent 6c74983707
commit 176035ab95
35 changed files with 2255 additions and 74 deletions
@@ -0,0 +1,59 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
*/
package org.springblade.transport.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 应收应付费用调整请求
*
* @author Chill
*/
@Data
@Schema(description = "应收应付费用调整请求")
public class ReceivablePayableAdjustFeeRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "应收应付明细ID")
private Long detailId;
@Schema(description = "调整原因")
private String adjustReason;
@Schema(description = "费用调整行")
private List<AdjustRow> rows;
@Data
@Schema(description = "费用调整行")
public static class AdjustRow implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "费用行ID")
private Long id;
@Schema(description = "运输量")
private BigDecimal transportQuantity;
@Schema(description = "里程")
private BigDecimal mileage;
@Schema(description = "运输费")
private BigDecimal freightAmount;
@Schema(description = "动态费用项目")
private Map<String, BigDecimal> feeItems;
}
}
@@ -46,6 +46,9 @@ public class ReceivablePayableGenerateRequest implements Serializable {
@Schema(description = "合同ID") @Schema(description = "合同ID")
private Long contractId; private Long contractId;
@Schema(description = "结算类型:receivable/payable")
private String settlementType;
@Schema(description = "计费方案ID") @Schema(description = "计费方案ID")
private String billingPlanId; private String billingPlanId;
@@ -28,6 +28,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.List; import java.util.List;
import java.math.BigDecimal;
/** /**
* 应收应付更新费用请求 * 应收应付更新费用请求
@@ -47,12 +48,21 @@ public class ReceivablePayableUpdateFeeRequest implements Serializable {
@Schema(description = "合同ID") @Schema(description = "合同ID")
private Long contractId; private Long contractId;
@Schema(description = "结算类型:receivable/payable")
private String settlementType;
@Schema(description = "计费方案ID") @Schema(description = "计费方案ID")
private String billingPlanId; private String billingPlanId;
@Schema(description = "调整原因") @Schema(description = "调整原因")
private String adjustReason; private String adjustReason;
@Schema(description = "手工调差金额,可正可负")
private BigDecimal adjustAmount;
@Schema(description = "手工调差费用项")
private String adjustFeeItem;
@Schema(description = "仅关闭") @Schema(description = "仅关闭")
private Boolean closeOnly; private Boolean closeOnly;
@@ -134,6 +134,9 @@ public class ContractManage extends TenantEntity {
@Schema(description = "计费信息开关") @Schema(description = "计费信息开关")
private Integer billingEnabled; private Integer billingEnabled;
@Schema(description = "费用生成模式:system系统生成,manual手动生成")
private String feeGenerationMode;
@Schema(description = "合同主文件JSON") @Schema(description = "合同主文件JSON")
private String contractFileJson; private String contractFileJson;
@@ -146,6 +149,15 @@ public class ContractManage extends TenantEntity {
@Schema(description = "结算生成规则JSON") @Schema(description = "结算生成规则JSON")
private String settlementRuleJson; private String settlementRuleJson;
@Schema(description = "预结算配置JSON")
private String preSettlementConfigJson;
@Schema(description = "正式结算配置JSON")
private String formalSettlementConfigJson;
@Schema(description = "付款比例设置JSON")
private String paymentRatioJson;
@Schema(description = "对账配置JSON") @Schema(description = "对账配置JSON")
private String reconciliationJson; private String reconciliationJson;
@@ -0,0 +1,60 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springblade.core.tenant.mp.TenantEntity;
import java.io.Serial;
/**
* 保险OCR识别模板实体类。
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("blade_insurance_ocr_template")
@Schema(description = "保险OCR识别模板")
public class InsuranceOcrTemplate extends TenantEntity {
@Serial
private static final long serialVersionUID = 1L;
/** 模板名称。 */
@Schema(description = "模板名称")
private String name;
/** 字段映射配置JSON。 */
@TableField("mapping_config")
@Schema(description = "字段映射配置JSON")
private String mappingConfig;
}
@@ -0,0 +1,59 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
/**
* 百度 OCR 识别结果。
*
* @author Chill
*/
@Data
@Schema(description = "百度OCR识别结果")
public class BaiduOcrResultVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 业务证件类型。 */
@Schema(description = "证件类型")
private String type;
/** 正副面,非卡证类型为空。 */
@Schema(description = "证件面,front为正面或主页,back为反面或副页")
private String side;
/** 百度 OCR 原始返回结果,包含 words_result 等字段。 */
@Schema(description = "百度OCR原始返回结果")
private Map<String, Object> result;
}
@@ -0,0 +1,59 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springblade.transport.pojo.entity.InsuranceOcrTemplate;
import java.io.Serial;
/**
* 保险OCR识别模板视图实体类。
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "保险OCR识别模板")
public class InsuranceOcrTemplateVO extends InsuranceOcrTemplate {
@Serial
private static final long serialVersionUID = 1L;
/** 创建人姓名。 */
@TableField(exist = false)
@Schema(description = "创建人姓名")
private String createUserName;
/** 更新人姓名。 */
@TableField(exist = false)
@Schema(description = "更新人姓名")
private String updateUserName;
}
@@ -11,6 +11,7 @@ import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.log.annotation.ApiLog; import org.springblade.core.log.annotation.ApiLog;
import org.springblade.core.tenant.annotation.NonDS; import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.FR;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.StringUtil;
import org.springblade.file.listener.FileEvent; import org.springblade.file.listener.FileEvent;
@@ -57,29 +58,29 @@ public class FileController extends BladeController {
@ApiLog("附件管理-批量上传") @ApiLog("附件管理-批量上传")
@Operation(summary = "OBS批量上传", description = "OBS批量上传,参数名:files") @Operation(summary = "OBS批量上传", description = "OBS批量上传,参数名:files")
@PostMapping("/ossUpload") @PostMapping("/ossUpload")
public R ossUpload(MultipartFile[] files, @RequestParam(value = "fileName", required = false) String fileName) { public FR ossUpload(MultipartFile[] files, @RequestParam(value = "fileName", required = false) String fileName) {
return attachmentService.batchOssUpload(files, fileName); return attachmentService.batchOssUpload(files, fileName);
} }
@Operation(summary = "上传", description = "上传,参数名:file") @Operation(summary = "上传", description = "上传,参数名:file")
@PostMapping("/upload") @PostMapping("/upload")
public R upload(MultipartFile file) { public FR upload(MultipartFile file) {
MultipartFile[] files = {file}; MultipartFile[] files = {file};
R<List<Attachment>> result = attachmentService.batchOssUpload(files, null); R<List<Attachment>> result = attachmentService.batchOssUpload(files, null);
List<Attachment> data = result.getData(); List<Attachment> data = result.getData();
if(data != null && !data.isEmpty())return R.data(data.get(0)); if(data != null && !data.isEmpty())return FR.data(data.get(0));
return R.fail("上传失败"); return FR.fail("上传失败");
} }
@Operation(summary = "获取附件,多个附件id用逗号分割", description = "获取附件,多个附件id用逗号分割") @Operation(summary = "获取附件,多个附件id用逗号分割", description = "获取附件,多个附件id用逗号分割")
@GetMapping("/getAttachment") @GetMapping("/getAttachment")
public R getAttachment(@RequestParam(value = "id") String id) { public FR getAttachment(@RequestParam(value = "id") String id) {
return R.data(attachmentService.getAttachment(id)); return FR.data(attachmentService.getAttachment(id));
} }
@Operation(summary = "获取文件url", description = "获取文件url,参数:objectKey") @Operation(summary = "获取文件url", description = "获取文件url,参数:objectKey")
@GetMapping("/getFileUrl") @GetMapping("/getFileUrl")
public R getFileUrl(@RequestParam(value = "objectKey") String objectKey, @RequestParam(value = "attachmentName", required = false) String attachmentName) { public FR getFileUrl(@RequestParam(value = "objectKey") String objectKey, @RequestParam(value = "attachmentName", required = false) String attachmentName) {
if (StringUtil.isBlank(attachmentName)) { if (StringUtil.isBlank(attachmentName)) {
// 附件名为空,查询附件名 // 附件名为空,查询附件名
Attachment attachment = attachmentService.getOne(Wrappers.<Attachment>lambdaQuery() Attachment attachment = attachmentService.getOne(Wrappers.<Attachment>lambdaQuery()
@@ -94,7 +95,7 @@ public class FileController extends BladeController {
attachmentName = attachmentName.replaceAll(",", "_"); attachmentName = attachmentName.replaceAll(",", "_");
} }
} }
return R.data(fileService.getFileUrl(objectKey, attachmentName, null)); return FR.data(fileService.getFileUrl(objectKey, attachmentName, null));
} }
/** /**
@@ -104,9 +105,9 @@ public class FileController extends BladeController {
*/ */
@Operation(summary = "获取wps文件预览url", description = "获取wps文件预览url") @Operation(summary = "获取wps文件预览url", description = "获取wps文件预览url")
@GetMapping("/getWpsFilePreviewUrl") @GetMapping("/getWpsFilePreviewUrl")
public R getWpsFilePreviewUrl(@NotNull(message = "附件id不能为空") @RequestParam(value = "attachmentId", required = false) String attachmentId, public FR getWpsFilePreviewUrl(@NotNull(message = "附件id不能为空") @RequestParam(value = "attachmentId", required = false) String attachmentId,
@NotBlank(message = "附件名称不能为空") @RequestParam(value = "attachmentName", required = false) String attachmentName) { @NotBlank(message = "附件名称不能为空") @RequestParam(value = "attachmentName", required = false) String attachmentName) {
return R.data(wpsService.getFilePreviewUrl(attachmentId, attachmentName)); return FR.data(wpsService.getFilePreviewUrl(attachmentId, attachmentName));
} }
/** /**
@@ -117,41 +118,41 @@ public class FileController extends BladeController {
*/ */
@Operation(summary = "获取wps文件编辑url", description = "获取wps文件编辑url") @Operation(summary = "获取wps文件编辑url", description = "获取wps文件编辑url")
@GetMapping("/getWpsFileEditUrl") @GetMapping("/getWpsFileEditUrl")
public R getWpsFileEditUrl(@NotNull(message = "附件id不能为空") @RequestParam(value = "attachmentId", required = false) String attachmentId, public FR getWpsFileEditUrl(@NotNull(message = "附件id不能为空") @RequestParam(value = "attachmentId", required = false) String attachmentId,
@NotBlank(message = "附件名称不能为空") @RequestParam(value = "attachmentName", required = false) String attachmentName) { @NotBlank(message = "附件名称不能为空") @RequestParam(value = "attachmentName", required = false) String attachmentName) {
return R.data(wpsService.getWpsFileEditUrl(attachmentId, attachmentName)); return FR.data(wpsService.getWpsFileEditUrl(attachmentId, attachmentName));
} }
@Operation(summary = "批量获取文件url", description = "批量获取文件url,参数:objectKey数组") @Operation(summary = "批量获取文件url", description = "批量获取文件url,参数:objectKey数组")
@PostMapping("/getFileUrls") @PostMapping("/getFileUrls")
public R getFileUrls(@RequestBody List<String> objectKeys) { public FR getFileUrls(@RequestBody List<String> objectKeys) {
return R.data(fileService.getFileUrls(objectKeys, null)); return FR.data(fileService.getFileUrls(objectKeys, null));
} }
@ApiLog("OCR识别-识别身份证") @ApiLog("OCR识别-识别身份证")
@Operation(summary = "识别身份证信息支持正反面", description = "参数:url") @Operation(summary = "识别身份证信息支持正反面", description = "参数:url")
@GetMapping("/recognitionIDCard") @GetMapping("/recognitionIDCard")
public R recognitionIDCard(@RequestParam(value = "url", required = false) String url, public FR recognitionIDCard(@RequestParam(value = "url", required = false) String url,
@RequestParam(value = "objectKey", required = false) String objectKey) { @RequestParam(value = "objectKey", required = false) String objectKey) {
String imageUrl = StringUtil.isNotBlank(url) ? url : objectKey; String imageUrl = StringUtil.isNotBlank(url) ? url : objectKey;
if (StringUtil.isBlank(imageUrl)) { if (StringUtil.isBlank(imageUrl)) {
return R.fail("图片地址不能为空"); return FR.fail("图片地址不能为空");
} }
if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) { if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) {
imageUrl = fileService.getFileUrl(imageUrl, null); imageUrl = fileService.getFileUrl(imageUrl, null);
} }
return R.data(ocrService.recognitionIDCard(List.of(imageUrl))); return FR.data(ocrService.recognitionIDCard(List.of(imageUrl)));
} }
@SentinelResource("ocr:batchCards") @SentinelResource("ocr:batchCards")
@ApiLog("OCR识别-识别车辆运输凭证(不知道类型)") @ApiLog("OCR识别-识别车辆运输凭证(不知道类型)")
@Operation(summary = "识别车辆运输凭证,不知道类型", description = "参数:objectKeylist") @Operation(summary = "识别车辆运输凭证,不知道类型", description = "参数:objectKeylist")
@PostMapping("/recognitionTransportCertificates") @PostMapping("/recognitionTransportCertificates")
public R<TransportCertificateVO> recognitionTransportCertificates( public FR<TransportCertificateVO> recognitionTransportCertificates(
@RequestBody List<Object> attachments, @RequestBody List<Object> attachments,
@RequestParam(value = "projectAbbreviation", required = false) String projectAbbreviation, @RequestParam(value = "projectAbbreviation", required = false) String projectAbbreviation,
@RequestParam(value = "code", required = false) String code) { @RequestParam(value = "code", required = false) String code) {
return R.data(ocrConvertService.recognitionTransportCertificate( return FR.data(ocrConvertService.recognitionTransportCertificate(
buildCertificateBatchRecognitionDTO(attachments, projectAbbreviation, code) buildCertificateBatchRecognitionDTO(attachments, projectAbbreviation, code)
)); ));
} }
@@ -26,6 +26,7 @@
package org.springblade.file.service; package org.springblade.file.service;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.core.tool.api.FR;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.file.pojo.entity.Attachment; import org.springblade.file.pojo.entity.Attachment;
import org.springblade.file.pojo.vo.AttachmentDetailVO; import org.springblade.file.pojo.vo.AttachmentDetailVO;
@@ -49,7 +50,7 @@ public interface IAttachmentService extends IService<Attachment> {
* @param fileName 文件名,如果 files只有1个,且fileName不为空,设置文件名为 fileName * @param fileName 文件名,如果 files只有1个,且fileName不为空,设置文件名为 fileName
* @return * @return
*/ */
R<List<Attachment>> batchOssUpload(MultipartFile[] files, String fileName); FR<List<Attachment>> batchOssUpload(MultipartFile[] files, String fileName);
/** /**
* 获取附件,多个附件id用逗号分割 * 获取附件,多个附件id用逗号分割
@@ -32,6 +32,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.api.FR;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.CollectionUtil; import org.springblade.core.tool.utils.CollectionUtil;
import org.springblade.core.tool.utils.SpringUtil; import org.springblade.core.tool.utils.SpringUtil;
@@ -78,7 +79,7 @@ public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, Attachm
private IAttachmentService self; private IAttachmentService self;
@Override @Override
public R<List<Attachment>> batchOssUpload(MultipartFile[] files, String overWriteFileName){ public FR<List<Attachment>> batchOssUpload(MultipartFile[] files, String overWriteFileName){
try { try {
List<Attachment> attachmentList = new ArrayList<>(); List<Attachment> attachmentList = new ArrayList<>();
// 文件数量为1个,且重写的文件名不为空,使用重写的文件名,给uniapp上传使用,uniapp上传的文件名不是原始文件名 // 文件数量为1个,且重写的文件名不为空,使用重写的文件名,给uniapp上传使用,uniapp上传的文件名不是原始文件名
@@ -98,11 +99,11 @@ public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, Attachm
} }
this.saveBatch(attachmentList); this.saveBatch(attachmentList);
log.info("OBS上传 successfully"); log.info("OBS上传 successfully");
return R.data(attachmentList,"OBS上传 successfully"); return FR.data(attachmentList,"OBS上传 successfully");
} catch (Exception e) { } catch (Exception e) {
log.error("OBS上传 failed,Exception", e); log.error("OBS上传 failed,Exception", e);
} }
return R.fail("OBS上传 failed"); return FR.fail("OBS上传 failed");
} }
@Override @Override
@@ -0,0 +1,119 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.api.R;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import org.springblade.transport.ocr.service.IBaiduOcrService;
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/**
* 百度 OCR 控制器。
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@Slf4j
@RequestMapping("/baidu-ocr")
@Tag(name = "百度OCR", description = "百度OCR证件识别")
public class BaiduOcrController extends BladeController {
private final IBaiduOcrService baiduOcrService;
/**
* 识别上传的图片。
*
* @param file 图片文件
* @param type 证件类型:id_card、business_license、vehicle_license、driving_license、road_transport_certificate、general
* @param side 正副面:front或back,适用于身份证、行驶证、驾驶证,默认front
* @return OCR结果
*/
@PostMapping(value = "/recognize", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ApiOperationSupport(order = 1)
@Operation(summary = "上传图片OCR识别", description = "支持身份证、营业执照、行驶证、驾驶证、道路运输证和通用文字识别")
public R<BaiduOcrResultVO> recognize(
@RequestPart("file") MultipartFile file,
@Parameter(description = "OCR证件类型", required = true) @RequestParam String type,
@Parameter(description = "正副面:front或back,适用于身份证、行驶证、驾驶证,默认front") @RequestParam(required = false) String side) {
if (file == null || file.isEmpty()) {
throw new ServiceException("OCR图片不能为空");
}
BaiduOcrType ocrType = resolveType(type);
if (file.getSize() > ocrType.getMaxRawSize()) {
throw new ServiceException("OCR图片过大,请压缩后重新上传");
}
try {
return R.data(baiduOcrService.recognize(ocrType, side, file.getBytes()));
} catch (IOException exception) {
log.error("读取OCR图片失败,type={}size={}", ocrType.name(), file.getSize(), exception);
throw new ServiceException("读取OCR图片失败");
}
}
/**
* 识别图片地址。
*
* @param imageUrl 图片地址
* @param type 证件类型
* @param side 正副面
* @return OCR结果
*/
@PostMapping("/recognize-url")
@ApiOperationSupport(order = 2)
@Operation(summary = "图片地址OCR识别", description = "图片地址必须是百度可访问的HTTP或HTTPS地址")
public R<BaiduOcrResultVO> recognizeUrl(
@Parameter(description = "图片地址", required = true) @RequestParam String imageUrl,
@Parameter(description = "OCR证件类型", required = true) @RequestParam String type,
@Parameter(description = "正副面:front或back,适用于身份证、行驶证、驾驶证,默认front") @RequestParam(required = false) String side) {
return R.data(baiduOcrService.recognizeUrl(resolveType(type), side, imageUrl));
}
private BaiduOcrType resolveType(String type) {
try {
return BaiduOcrType.from(type);
} catch (IllegalArgumentException exception) {
throw new ServiceException(exception.getMessage());
}
}
}
@@ -136,6 +136,12 @@ public class ContractManageController extends BladeController {
return R.status(contractManageService.startChange(id, changeContent, changeReason)); return R.status(contractManageService.startChange(id, changeContent, changeReason));
} }
@PostMapping("/submit-change")
@Operation(summary = "提交合同变更")
public R submitChange(@RequestBody ContractManage contractManage) {
return R.status(contractManageService.submitChange(contractManage));
}
@PostMapping("/terminate") @PostMapping("/terminate")
@ApiOperationSupport(order = 11) @ApiOperationSupport(order = 11)
@Operation(summary = "终止合同", description = "传入id和reason") @Operation(summary = "终止合同", description = "传入id和reason")
@@ -0,0 +1,98 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.InsuranceOcrTemplate;
import org.springblade.transport.pojo.vo.InsuranceOcrTemplateVO;
import org.springblade.transport.service.IInsuranceOcrTemplateService;
import org.springblade.transport.wrapper.InsuranceOcrTemplateWrapper;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 保险OCR识别模板控制器。
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@PreAuth(menu = "insurance_ocr_template")
@RequestMapping("/insurance-ocr-template")
@Tag(name = "保险OCR识别模板", description = "保险OCR识别模板")
public class InsuranceOcrTemplateController extends BladeController {
private final IInsuranceOcrTemplateService insuranceOcrTemplateService;
@GetMapping("/detail")
@ApiOperationSupport(order = 1)
@Operation(summary = "详情", description = "传入id")
public R<InsuranceOcrTemplateVO> detail(@Parameter(description = "主键", required = true) @RequestParam Long id) {
InsuranceOcrTemplate insuranceOcrTemplate = insuranceOcrTemplateService.getById(id);
if (insuranceOcrTemplate == null || insuranceOcrTemplate.getIsDeleted() == 1) {
throw new ServiceException("保险OCR识别模板不存在");
}
return R.data(InsuranceOcrTemplateWrapper.build().entityVO(insuranceOcrTemplate));
}
@GetMapping("/list")
@ApiOperationSupport(order = 2)
@Operation(summary = "分页", description = "传入insuranceOcrTemplate")
public R<IPage<InsuranceOcrTemplateVO>> list(InsuranceOcrTemplateVO insuranceOcrTemplate, Query query) {
return R.data(insuranceOcrTemplateService.selectInsuranceOcrTemplatePage(Condition.getPage(query), insuranceOcrTemplate));
}
@PostMapping("/submit")
@ApiOperationSupport(order = 3)
@Operation(summary = "新增或修改", description = "传入insuranceOcrTemplate")
public R submit(@RequestBody InsuranceOcrTemplate insuranceOcrTemplate) {
return R.status(insuranceOcrTemplateService.submit(insuranceOcrTemplate));
}
@PostMapping("/remove")
@ApiOperationSupport(order = 4)
@Operation(summary = "逻辑删除", description = "传入ids")
public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) {
return R.status(insuranceOcrTemplateService.deleteLogic(Func.toLongList(ids)));
}
}
@@ -166,7 +166,7 @@ public class InsuranceRecordController extends BladeController {
*/ */
@PostMapping("/recognize") @PostMapping("/recognize")
@ApiOperationSupport(order = 8) @ApiOperationSupport(order = 8)
@Operation(summary = "OCR识别保单", description = "上传保单图片或PDF") @Operation(summary = "OCR识别保单", description = "上传保单图片")
public R<InsuranceRecord> recognize(MultipartFile file, public R<InsuranceRecord> recognize(MultipartFile file,
@RequestParam(required = false) String vehicleType, @RequestParam(required = false) String vehicleType,
@RequestParam(required = false) String ocrTemplate) { @RequestParam(required = false) String ocrTemplate) {
@@ -35,6 +35,7 @@ import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.DateUtil;
import org.springblade.transport.pojo.dto.ReceivablePayableAdjustFeeRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableGenerateRequest; import org.springblade.transport.pojo.dto.ReceivablePayableGenerateRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableTransferRequest; import org.springblade.transport.pojo.dto.ReceivablePayableTransferRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableUpdateFeeRequest; import org.springblade.transport.pojo.dto.ReceivablePayableUpdateFeeRequest;
@@ -49,6 +50,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -94,8 +96,24 @@ public class ReceivablePayableDetailController extends BladeController {
return R.success("更新成功"); return R.success("更新成功");
} }
@GetMapping("/transfer-candidates") @GetMapping("/update-fee-contracts")
@ApiOperationSupport(order = 5) @ApiOperationSupport(order = 5)
@Operation(summary = "更新费用可选合同")
public R<List<Map<String, Object>>> updateFeeContracts(
@RequestParam(required = false) String settlementType) {
return R.data(detailService.updateFeeContracts(settlementType));
}
@PostMapping("/adjust-fee")
@ApiOperationSupport(order = 6)
@Operation(summary = "调整费用")
public R adjustFee(@RequestBody ReceivablePayableAdjustFeeRequest request) {
detailService.adjustFee(request);
return R.success("保存成功");
}
@GetMapping("/transfer-candidates")
@ApiOperationSupport(order = 7)
@Operation(summary = "转结算候选明细") @Operation(summary = "转结算候选明细")
public R<IPage<Map<String, Object>>> transferCandidates(Query query, public R<IPage<Map<String, Object>>> transferCandidates(Query query,
@RequestParam(required = false) String contractName, @RequestParam(required = false) String contractName,
@@ -0,0 +1,38 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.transport.pojo.entity.InsuranceOcrTemplate;
/**
* 保险OCR识别模板Mapper接口。
*
* @author Chill
*/
public interface InsuranceOcrTemplateMapper extends BaseMapper<InsuranceOcrTemplate> {
}
@@ -0,0 +1,55 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.ocr.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.net.http.HttpClient;
/**
* 百度 OCR HTTP 客户端配置。
*
* @author Chill
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(BaiduOcrProperties.class)
public class BaiduOcrConfiguration {
/**
* 创建百度 OCR HTTP 客户端。
*
* @param properties 百度 OCR 配置
* @return HTTP 客户端
*/
@Bean(name = "baiduOcrHttpClient")
public HttpClient baiduOcrHttpClient(BaiduOcrProperties properties) {
return HttpClient.newBuilder()
.connectTimeout(properties.getConnectTimeout())
.build();
}
}
@@ -0,0 +1,79 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.ocr.config;
import lombok.Data;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* 百度 OCR 配置。
*
* @author Chill
*/
@Data
@ConfigurationProperties(prefix = "baidu.ocr")
public class BaiduOcrProperties {
/**
* 是否启用百度 OCR。
*/
private boolean enabled = false;
/**
* 百度智能云应用 API Key。
*/
@ToString.Exclude
private String apiKey;
/**
* 百度智能云应用 Secret Key。
*/
@ToString.Exclude
private String secretKey;
/**
* 百度 OCR 服务地址。
*/
private String endpoint = "https://aip.baidubce.com";
/**
* 建立百度接口连接的超时时间。
*/
private Duration connectTimeout = Duration.ofSeconds(5);
/**
* 百度接口请求超时时间。
*/
private Duration requestTimeout = Duration.ofSeconds(30);
/**
* 提前刷新 access_token 的时间。
*/
private Duration tokenRefreshAdvance = Duration.ofMinutes(1);
}
@@ -0,0 +1,100 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.ocr.constant;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Locale;
/**
* 百度 OCR 支持的证件类型。
*
* @author Chill
*/
@Getter
@AllArgsConstructor
public enum BaiduOcrType {
/** 身份证。 */
ID_CARD("身份证", "/rest/2.0/ocr/v1/idcard", "id_card_side", 8 * 1024 * 1024, 8192),
/** 营业执照。 */
BUSINESS_LICENSE("营业执照", "/rest/2.0/ocr/v1/business_license", null, 10 * 1024 * 1024, 8192),
/** 行驶证。 */
VEHICLE_LICENSE("行驶证", "/rest/2.0/ocr/v1/vehicle_license", "vehicle_license_side", 4 * 1024 * 1024, 4096),
/** 驾驶证。 */
DRIVING_LICENSE("驾驶证", "/rest/2.0/ocr/v1/driving_license", "driving_license_side", 4 * 1024 * 1024, 4096),
/** 道路运输证。 */
ROAD_TRANSPORT_CERTIFICATE("道路运输证", "/rest/2.0/ocr/v1/road_transport_certificate", null, 4 * 1024 * 1024, 4096),
/** 通用文字识别(标准版)。 */
GENERAL("通用证件", "/rest/2.0/ocr/v1/general_basic", null, 8 * 1024 * 1024, 4096);
private final String description;
private final String path;
private final String sideParameter;
private final int maxEncodedSize;
private final int maxDimension;
/**
* 将请求参数转换为证件类型。
*
* @param value 类型名称、枚举名或常用别名
* @return 证件类型
*/
public static BaiduOcrType from(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("OCR证件类型不能为空");
}
String normalized = value.trim().replace('-', '_').replace(' ', '_').toUpperCase(Locale.ROOT);
return switch (normalized) {
case "ID_CARD", "IDCARD", "ID" -> ID_CARD;
case "BUSINESS_LICENSE", "BUSINESSLICENSE", "LICENSE", "营业执照" -> BUSINESS_LICENSE;
case "VEHICLE_LICENSE", "VEHICLELICENSE", "DRIVING_VEHICLE", "行驶证" -> VEHICLE_LICENSE;
case "DRIVING_LICENSE", "DRIVINGLICENSE", "驾驶证" -> DRIVING_LICENSE;
case "ROAD_TRANSPORT_CERTIFICATE", "ROADTRANSPORTCERTIFICATE", "ROAD_TRANSPORT", "道路运输证" -> ROAD_TRANSPORT_CERTIFICATE;
case "GENERAL", "GENERAL_BASIC", "COMMON", "COMMON_CARD", "通用证件", "通用文字识别" -> GENERAL;
default -> throw new IllegalArgumentException("不支持的OCR证件类型:" + value);
};
}
/**
* 判断该类型是否支持正副面参数。
*
* @return 是否支持正副面
*/
public boolean supportsSide() {
return sideParameter != null;
}
/**
* 获取上传图片原始大小的理论上限。
*
* @return 原始大小上限
*/
public long getMaxRawSize() {
return (long) maxEncodedSize * 3 / 4;
}
}
@@ -0,0 +1,57 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.ocr.service;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
/**
* 百度 OCR 服务。
*
* @author Chill
*/
public interface IBaiduOcrService {
/**
* 识别上传的图片。
*
* @param type 证件类型
* @param side 正副面
* @param image 图片二进制
* @return 识别结果
*/
BaiduOcrResultVO recognize(BaiduOcrType type, String side, byte[] image);
/**
* 识别图片地址。
*
* @param type 证件类型
* @param side 正副面
* @param imageUrl 图片地址
* @return 识别结果
*/
BaiduOcrResultVO recognizeUrl(BaiduOcrType type, String side, String imageUrl);
}
@@ -0,0 +1,356 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.ocr.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.transport.ocr.config.BaiduOcrProperties;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import org.springblade.transport.ocr.service.IBaiduOcrService;
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 百度 OCR 服务实现。
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BaiduOcrServiceImpl implements IBaiduOcrService {
private static final String TOKEN_PATH = "/oauth/2.0/token";
private static final String TOKEN_GRANT_TYPE = "client_credentials";
private static final int ACCESS_TOKEN_INVALID = 110;
private static final int ACCESS_TOKEN_EXPIRED = 111;
private static final int MIN_IMAGE_DIMENSION = 15;
private static final Set<String> SUPPORTED_IMAGE_FORMATS = Set.of("JPEG", "JPG", "PNG", "BMP");
private static final TypeReference<Map<String, Object>> RESULT_TYPE = new TypeReference<>() {
};
private final BaiduOcrProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final Object tokenMonitor = new Object();
private volatile AccessToken accessToken;
@Override
public BaiduOcrResultVO recognize(BaiduOcrType type, String side, byte[] image) {
validateType(type);
if (image == null || image.length == 0) {
throw new ServiceException("OCR图片不能为空");
}
if (image.length > type.getMaxRawSize()) {
throw new ServiceException("OCR图片过大,请压缩后重新上传");
}
validateImage(type, image);
String imageBase64 = Base64.getEncoder().encodeToString(image);
String encodedImage = URLEncoder.encode(imageBase64, StandardCharsets.UTF_8);
if (encodedImage.length() > type.getMaxEncodedSize()) {
throw new ServiceException(type.getDescription() + "图片经Base64和URL编码后不能超过"
+ (type.getMaxEncodedSize() / 1024 / 1024) + "M");
}
Map<String, String> form = new LinkedHashMap<>();
form.put("image", imageBase64);
return request(type, side, form);
}
@Override
public BaiduOcrResultVO recognizeUrl(BaiduOcrType type, String side, String imageUrl) {
validateType(type);
if (StringUtil.isBlank(imageUrl)) {
throw new ServiceException("OCR图片地址不能为空");
}
String normalizedUrl = imageUrl.trim();
validateImageUrl(normalizedUrl);
Map<String, String> form = new LinkedHashMap<>();
form.put("url", normalizedUrl);
return request(type, side, form);
}
private BaiduOcrResultVO request(BaiduOcrType type, String side, Map<String, String> form) {
validateType(type);
if (!properties.isEnabled()) {
throw new ServiceException("百度OCR服务未启用");
}
if (StringUtil.isBlank(properties.getApiKey()) || StringUtil.isBlank(properties.getSecretKey())) {
throw new ServiceException("百度OCR的API Key和Secret Key未配置");
}
String normalizedSide = normalizeSide(type, side);
if (normalizedSide != null) {
form.put(type.getSideParameter(), normalizedSide);
}
JsonNode result = sendOcrRequest(type, form);
int errorCode = result.path("error_code").asInt(0);
if (errorCode != 0) {
String message = result.path("error_msg").asText("未知错误");
log.warn("百度OCR识别失败,type={}side={}errorCode={}logId={}",
type.name(), normalizedSide, errorCode, result.path("log_id").asText(""));
throw new ServiceException("百度OCR识别失败(" + errorCode + "):" + message);
}
BaiduOcrResultVO response = new BaiduOcrResultVO();
response.setType(type.name());
response.setSide(normalizedSide);
response.setResult(objectMapper.convertValue(result, RESULT_TYPE));
return response;
}
private JsonNode sendOcrRequest(BaiduOcrType type, Map<String, String> form) {
String token = getAccessToken();
JsonNode result = executeOcrRequest(type, form, token);
if (isAccessTokenInvalid(result)) {
invalidateAccessToken(token);
log.warn("百度OCR access_token 已失效,重新获取后重试,type={}errorCode={}",
type.name(), result.path("error_code").asInt());
result = executeOcrRequest(type, form, getAccessToken());
}
return result;
}
private JsonNode executeOcrRequest(BaiduOcrType type, Map<String, String> form, String token) {
try {
URI requestUri = URI.create(buildEndpoint(type.getPath()) + "?access_token="
+ URLEncoder.encode(token, StandardCharsets.UTF_8));
HttpRequest request = HttpRequest.newBuilder(requestUri)
.timeout(properties.getRequestTimeout())
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(toFormBody(form), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
log.error("百度OCR接口响应异常,type={}, status={}", type.name(), response.statusCode());
throw new ServiceException("百度OCR接口调用失败,HTTP状态码:" + response.statusCode());
}
JsonNode result = objectMapper.readTree(response.body());
if (result == null || !result.isObject()) {
throw new ServiceException("百度OCR响应格式异常");
}
return result;
} catch (ServiceException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
log.error("调用百度OCR接口被中断,type={}", type.name(), exception);
throw new ServiceException("百度OCR接口调用被中断");
} catch (IllegalArgumentException exception) {
log.error("百度OCR接口地址配置不正确,type={}", type.name());
throw new ServiceException("百度OCR接口地址配置不正确");
} catch (IOException exception) {
log.error("调用百度OCR接口失败,type={}", type.name(), exception);
throw new ServiceException("百度OCR接口调用失败");
}
}
private boolean isAccessTokenInvalid(JsonNode result) {
int errorCode = result.path("error_code").asInt(0);
return errorCode == ACCESS_TOKEN_INVALID || errorCode == ACCESS_TOKEN_EXPIRED;
}
private void invalidateAccessToken(String rejectedToken) {
synchronized (tokenMonitor) {
if (accessToken != null && Objects.equals(accessToken.value(), rejectedToken)) {
accessToken = null;
}
}
}
private String getAccessToken() {
AccessToken currentToken = accessToken;
if (currentToken != null && currentToken.isValid(properties.getTokenRefreshAdvance())) {
return currentToken.value();
}
synchronized (tokenMonitor) {
currentToken = accessToken;
if (currentToken != null && currentToken.isValid(properties.getTokenRefreshAdvance())) {
return currentToken.value();
}
return requestAccessToken();
}
}
private String requestAccessToken() {
try {
String query = "grant_type=" + URLEncoder.encode(TOKEN_GRANT_TYPE, StandardCharsets.UTF_8)
+ "&client_id=" + URLEncoder.encode(properties.getApiKey(), StandardCharsets.UTF_8)
+ "&client_secret=" + URLEncoder.encode(properties.getSecretKey(), StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(buildEndpoint(TOKEN_PATH) + "?" + query))
.timeout(properties.getRequestTimeout())
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
JsonNode result = objectMapper.readTree(response.body());
if (result == null || !result.isObject()) {
throw new ServiceException("百度OCR鉴权响应格式异常");
}
String token = result.path("access_token").asText(null);
long expiresIn = result.path("expires_in").asLong(0);
if (response.statusCode() < 200 || response.statusCode() >= 300 || StringUtil.isBlank(token) || expiresIn <= 0) {
String error = result.path("error").asText("");
String message = result.path("error_description").asText("未知错误");
log.error("百度OCR鉴权失败,status={}error={}", response.statusCode(), error);
throw new ServiceException("百度OCR鉴权失败:" + message);
}
accessToken = new AccessToken(token, Instant.now().plusSeconds(expiresIn));
return token;
} catch (ServiceException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ServiceException("百度OCR鉴权请求被中断");
} catch (IllegalArgumentException exception) {
log.error("百度OCR鉴权地址配置不正确");
throw new ServiceException("百度OCR鉴权地址配置不正确");
} catch (IOException exception) {
log.error("获取百度OCR access_token 失败", exception);
throw new ServiceException("百度OCR鉴权失败");
}
}
private String normalizeSide(BaiduOcrType type, String side) {
if (StringUtil.isBlank(side)) {
return type.supportsSide() ? "front" : null;
}
if (!type.supportsSide()) {
throw new ServiceException(type.getDescription() + "不支持正副面参数");
}
String normalized = side.trim().toLowerCase(Locale.ROOT);
if ("front".equals(normalized) || "main".equals(normalized) || "正面".equals(normalized) || "主页".equals(normalized)) {
return "front";
}
if ("back".equals(normalized) || "side".equals(normalized) || "副面".equals(normalized) || "副页".equals(normalized) || "反面".equals(normalized)) {
return "back";
}
throw new ServiceException("OCR证件面参数只能是front或back");
}
private void validateType(BaiduOcrType type) {
if (type == null) {
throw new ServiceException("OCR证件类型不能为空");
}
}
private void validateImageUrl(String imageUrl) {
if (imageUrl.getBytes(StandardCharsets.UTF_8).length > 1024) {
throw new ServiceException("OCR图片地址长度不能超过1024字节");
}
try {
URI imageUri = URI.create(imageUrl);
String scheme = imageUri.getScheme();
if (StringUtil.isBlank(scheme) || StringUtil.isBlank(imageUri.getHost())
|| imageUri.getUserInfo() != null
|| !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
throw new ServiceException("OCR图片地址必须是有效的HTTP或HTTPS地址");
}
} catch (IllegalArgumentException exception) {
throw new ServiceException("OCR图片地址格式不正确");
}
}
private void validateImage(BaiduOcrType type, byte[] image) {
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(new ByteArrayInputStream(image))) {
if (imageInputStream == null) {
throw new ServiceException("OCR图片格式不正确");
}
Iterator<ImageReader> imageReaders = ImageIO.getImageReaders(imageInputStream);
if (!imageReaders.hasNext()) {
throw new ServiceException("OCR仅支持JPG、JPEG、PNG、BMP图片");
}
ImageReader imageReader = imageReaders.next();
try {
imageReader.setInput(imageInputStream, true, true);
String formatName = imageReader.getFormatName().toUpperCase(Locale.ROOT);
if (!SUPPORTED_IMAGE_FORMATS.contains(formatName)) {
throw new ServiceException("OCR仅支持JPG、JPEG、PNG、BMP图片");
}
int width = imageReader.getWidth(0);
int height = imageReader.getHeight(0);
if (Math.min(width, height) < MIN_IMAGE_DIMENSION || Math.max(width, height) > type.getMaxDimension()) {
throw new ServiceException(type.getDescription() + "图片最短边不能小于15px,最长边不能超过"
+ type.getMaxDimension() + "px");
}
} finally {
imageReader.dispose();
}
} catch (ServiceException exception) {
throw exception;
} catch (IOException exception) {
log.error("解析OCR图片失败,type={}size={}", type.name(), image.length, exception);
throw new ServiceException("OCR图片格式不正确");
}
}
private String toFormBody(Map<String, String> form) {
return form.entrySet().stream()
.map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + "="
+ URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
.reduce((left, right) -> left + "&" + right)
.orElse("");
}
private String buildEndpoint(String path) {
String endpoint = properties.getEndpoint();
if (StringUtil.isBlank(endpoint)) {
throw new ServiceException("百度OCR服务地址未配置");
}
return endpoint.replaceAll("/+$", "") + path;
}
private record AccessToken(String value, Instant expiresAt) {
private boolean isValid(Duration advance) {
Duration refreshAdvance = advance == null || advance.isNegative() ? Duration.ZERO : advance;
return StringUtil.isNotBlank(value) && expiresAt.isAfter(Instant.now().plus(refreshAdvance));
}
}
}
@@ -48,6 +48,7 @@ public interface IContractManageService extends BaseService<ContractManage> {
boolean reject(Long id); boolean reject(Long id);
boolean withdraw(Long id); boolean withdraw(Long id);
boolean startChange(Long id, String changeContent, String changeReason); boolean startChange(Long id, String changeContent, String changeReason);
boolean submitChange(ContractManage contractManage);
boolean updateAttachments(ContractManage contractManage); boolean updateAttachments(ContractManage contractManage);
boolean terminate(Long id, String reason); boolean terminate(Long id, String reason);
boolean removeDraft(String ids); boolean removeDraft(String ids);
@@ -0,0 +1,57 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.entity.InsuranceOcrTemplate;
import org.springblade.transport.pojo.vo.InsuranceOcrTemplateVO;
/**
* 保险OCR识别模板服务类。
*
* @author Chill
*/
public interface IInsuranceOcrTemplateService extends BaseService<InsuranceOcrTemplate> {
/**
* 分页查询模板。
*
* @param page 分页参数
* @param insuranceOcrTemplate 查询条件
* @return 模板分页
*/
IPage<InsuranceOcrTemplateVO> selectInsuranceOcrTemplatePage(IPage<InsuranceOcrTemplate> page, InsuranceOcrTemplateVO insuranceOcrTemplate);
/**
* 保存模板。
*
* @param insuranceOcrTemplate 模板
* @return 是否成功
*/
boolean submit(InsuranceOcrTemplate insuranceOcrTemplate);
}
@@ -24,6 +24,7 @@ package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService; import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.ReceivablePayableAdjustFeeRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableGenerateRequest; import org.springblade.transport.pojo.dto.ReceivablePayableGenerateRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableTransferRequest; import org.springblade.transport.pojo.dto.ReceivablePayableTransferRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableUpdateFeeRequest; import org.springblade.transport.pojo.dto.ReceivablePayableUpdateFeeRequest;
@@ -32,6 +33,7 @@ import org.springblade.transport.pojo.vo.ReceivablePayableChangeRecordVO;
import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO; import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO;
import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO; import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -49,6 +51,10 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
void updateFee(ReceivablePayableUpdateFeeRequest request); void updateFee(ReceivablePayableUpdateFeeRequest request);
List<Map<String, Object>> updateFeeContracts(String settlementType);
void adjustFee(ReceivablePayableAdjustFeeRequest request);
void transferSettlement(ReceivablePayableTransferRequest request); void transferSettlement(ReceivablePayableTransferRequest request);
IPage<Map<String, Object>> transferCandidates(IPage<?> page, String contractName, String batchNo, IPage<Map<String, Object>> transferCandidates(IPage<?> page, String contractName, String batchNo,
@@ -59,4 +65,7 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
ReceivablePayableFeeDetailVO generatePreview(IPage<?> page, ReceivablePayableGenerateRequest request); ReceivablePayableFeeDetailVO generatePreview(IPage<?> page, ReceivablePayableGenerateRequest request);
void generateFee(ReceivablePayableGenerateRequest request); void generateFee(ReceivablePayableGenerateRequest request);
/** 完成运单后按合同系统计费模式自动生成应收、应付明细。 */
void generateForCompletedWaybills(List<Long> waybillIds);
} }
@@ -44,6 +44,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -205,6 +206,25 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
return updateById(contractManage); return updateById(contractManage);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submitChange(ContractManage request) {
ContractManage source = loadExists(request.getId());
if (!Objects.equals(source.getContractStage(), STAGE_FORMAL)
|| !List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED, STATUS_CHANGE_REJECTED).contains(source.getApprovalStatus())) {
throw new ServiceException("当前合同状态不允许发起变更");
}
validatePaymentRatios(request.getPaymentRatioJson());
source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB());
source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat());
source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount());
source.setPaymentDays(request.getPaymentDays()); source.setRemark(request.getRemark()); source.setBillingEnabled(request.getBillingEnabled()); source.setFeeGenerationMode(request.getFeeGenerationMode());
source.setBillingPlanJson(request.getBillingPlanJson()); source.setSettlementRuleJson(request.getSettlementRuleJson()); source.setPreSettlementConfigJson(request.getPreSettlementConfigJson()); source.setFormalSettlementConfigJson(request.getFormalSettlementConfigJson()); source.setPaymentRatioJson(request.getPaymentRatioJson());
source.setContractFileJson(request.getContractFileJson()); source.setAttachmentsJson(request.getAttachmentsJson());
updateById(source);
return startChange(source.getId(), request.getChangeContent(), request.getChangeReason());
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public boolean updateAttachments(ContractManage contractManage) { public boolean updateAttachments(ContractManage contractManage) {
@@ -414,6 +434,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
contractManage.setAttachmentsJson(TransportBusinessSupport.trimToNull(contractManage.getAttachmentsJson())); contractManage.setAttachmentsJson(TransportBusinessSupport.trimToNull(contractManage.getAttachmentsJson()));
contractManage.setBillingPlanJson(TransportBusinessSupport.trimToNull(contractManage.getBillingPlanJson())); contractManage.setBillingPlanJson(TransportBusinessSupport.trimToNull(contractManage.getBillingPlanJson()));
contractManage.setSettlementRuleJson(TransportBusinessSupport.trimToNull(contractManage.getSettlementRuleJson())); contractManage.setSettlementRuleJson(TransportBusinessSupport.trimToNull(contractManage.getSettlementRuleJson()));
contractManage.setPaymentRatioJson(TransportBusinessSupport.trimToNull(contractManage.getPaymentRatioJson()));
contractManage.setReconciliationJson(TransportBusinessSupport.trimToNull(contractManage.getReconciliationJson())); contractManage.setReconciliationJson(TransportBusinessSupport.trimToNull(contractManage.getReconciliationJson()));
contractManage.setChangeRecordJson(TransportBusinessSupport.trimToNull(contractManage.getChangeRecordJson())); contractManage.setChangeRecordJson(TransportBusinessSupport.trimToNull(contractManage.getChangeRecordJson()));
normalizeOptionalIntegerFields(contractManage); normalizeOptionalIntegerFields(contractManage);
@@ -433,6 +454,29 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类别"); TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类别");
validateContractNameUnique(contractManage); validateContractNameUnique(contractManage);
TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "备注不能超过2000字"); TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "备注不能超过2000字");
validatePaymentRatios(contractManage.getPaymentRatioJson());
}
private void validatePaymentRatios(String paymentRatioJson) {
if (Func.isEmpty(paymentRatioJson)) return;
try {
Object parsed = JsonUtil.parse(paymentRatioJson, List.class);
if (!(parsed instanceof List<?> rows) || rows.isEmpty()) return;
BigDecimal total = BigDecimal.ZERO;
for (Object row : rows) {
if (row instanceof Map<?, ?> values && values.get("ratioLimit") != null
&& !String.valueOf(values.get("ratioLimit")).isBlank()) {
total = total.add(new BigDecimal(String.valueOf(values.get("ratioLimit"))));
}
}
if (total.compareTo(BigDecimal.valueOf(100)) != 0) {
throw new ServiceException("付款比例上限合计必须等于100%");
}
} catch (ServiceException exception) {
throw exception;
} catch (Exception exception) {
throw new ServiceException("付款比例设置格式不正确");
}
} }
private void validateContractNameUnique(ContractManage contractManage) { private void validateContractNameUnique(ContractManage contractManage) {
@@ -0,0 +1,154 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.transport.mapper.InsuranceOcrTemplateMapper;
import org.springblade.transport.pojo.entity.InsuranceOcrTemplate;
import org.springblade.transport.pojo.vo.InsuranceOcrTemplateVO;
import org.springblade.transport.service.IInsuranceOcrTemplateService;
import org.springblade.transport.wrapper.InsuranceOcrTemplateWrapper;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 保险OCR识别模板服务实现类。
*
* @author Chill
*/
@Service
public class InsuranceOcrTemplateServiceImpl extends BaseServiceImpl<InsuranceOcrTemplateMapper, InsuranceOcrTemplate> implements IInsuranceOcrTemplateService {
private static final int NAME_MAX_LENGTH = 100;
private static final int MAPPING_VALUE_MAX_LENGTH = 100;
private static final List<String> INSURANCE_FIELD_KEYS = List.of(
"保险类型", "保单号", "开始日期", "结束日期", "保额", "保费", "发票号", "开票日期", "备注"
);
@Override
public IPage<InsuranceOcrTemplateVO> selectInsuranceOcrTemplatePage(IPage<InsuranceOcrTemplate> page, InsuranceOcrTemplateVO insuranceOcrTemplate) {
LambdaQueryWrapper<InsuranceOcrTemplate> queryWrapper = Wrappers.<InsuranceOcrTemplate>lambdaQuery()
.eq(InsuranceOcrTemplate::getIsDeleted, 0)
.like(StringUtil.isNotBlank(insuranceOcrTemplate.getName()), InsuranceOcrTemplate::getName, insuranceOcrTemplate.getName())
.orderByDesc(InsuranceOcrTemplate::getCreateTime);
return InsuranceOcrTemplateWrapper.build().pageVO(page(page, queryWrapper));
}
@Override
public boolean submit(InsuranceOcrTemplate insuranceOcrTemplate) {
boolean created = insuranceOcrTemplate.getId() == null;
if (!created) {
InsuranceOcrTemplate oldTemplate = getById(insuranceOcrTemplate.getId());
if (oldTemplate == null || oldTemplate.getIsDeleted() == 1) {
throw new ServiceException("保险OCR识别模板不存在");
}
insuranceOcrTemplate.setTenantId(oldTemplate.getTenantId());
}
insuranceOcrTemplate.setName(trimToNull(insuranceOcrTemplate.getName()));
insuranceOcrTemplate.setMappingConfig(trimToNull(insuranceOcrTemplate.getMappingConfig()));
if (created && insuranceOcrTemplate.getStatus() == null) {
insuranceOcrTemplate.setStatus(1);
}
validate(insuranceOcrTemplate);
return saveOrUpdate(insuranceOcrTemplate);
}
private void validate(InsuranceOcrTemplate insuranceOcrTemplate) {
if (StringUtil.isBlank(insuranceOcrTemplate.getName())) {
throw new ServiceException("模板名称不能为空");
}
if (insuranceOcrTemplate.getName().length() > NAME_MAX_LENGTH) {
throw new ServiceException("模板名称不能超过100个字符");
}
Long nameCount = count(Wrappers.<InsuranceOcrTemplate>lambdaQuery()
.eq(InsuranceOcrTemplate::getIsDeleted, 0)
.eq(InsuranceOcrTemplate::getName, insuranceOcrTemplate.getName())
.ne(insuranceOcrTemplate.getId() != null, InsuranceOcrTemplate::getId, insuranceOcrTemplate.getId()));
if (nameCount > 0) {
throw new ServiceException("模板名称已存在");
}
validateMappingConfig(insuranceOcrTemplate.getMappingConfig());
}
private void validateMappingConfig(String mappingConfig) {
if (StringUtil.isBlank(mappingConfig)) {
throw new ServiceException("字段映射配置不能为空");
}
JSONArray mappingArray;
try {
mappingArray = JSON.parseArray(mappingConfig);
} catch (Exception exception) {
throw new ServiceException("字段映射配置格式不正确");
}
if (mappingArray == null || mappingArray.size() != INSURANCE_FIELD_KEYS.size()) {
throw new ServiceException("字段映射配置必须包含全部保险字段");
}
Set<String> mappingKeySet = new LinkedHashSet<>();
boolean hasMappingValue = false;
for (Object item : mappingArray) {
if (!(item instanceof JSONObject mapping)) {
throw new ServiceException("字段映射配置格式不正确");
}
String key = trimToNull(mapping.getString("key"));
String value = trimToNull(mapping.getString("value"));
if (!INSURANCE_FIELD_KEYS.contains(key)) {
throw new ServiceException("字段映射包含不支持的键名");
}
if (!mappingKeySet.add(key)) {
throw new ServiceException("字段映射键名不能重复");
}
if (value != null && value.length() > MAPPING_VALUE_MAX_LENGTH) {
throw new ServiceException("字段映射值不能超过100个字符");
}
hasMappingValue = hasMappingValue || value != null;
}
if (!mappingKeySet.containsAll(INSURANCE_FIELD_KEYS)) {
throw new ServiceException("字段映射配置必须包含全部保险字段");
}
if (!hasMappingValue) {
throw new ServiceException("请至少填写一个字段映射值");
}
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}
@@ -25,9 +25,13 @@
*/ */
package org.springblade.transport.service.impl; package org.springblade.transport.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.BeanUtil;
@@ -36,18 +40,30 @@ import org.springblade.system.cache.UserCache;
import org.springblade.transport.excel.InsuranceRecordExcel; import org.springblade.transport.excel.InsuranceRecordExcel;
import org.springblade.transport.excel.InsuranceRecordExportExcel; import org.springblade.transport.excel.InsuranceRecordExportExcel;
import org.springblade.transport.mapper.InsuranceRecordMapper; import org.springblade.transport.mapper.InsuranceRecordMapper;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import org.springblade.transport.ocr.service.IBaiduOcrService;
import org.springblade.transport.pojo.entity.InsuranceRecord; import org.springblade.transport.pojo.entity.InsuranceRecord;
import org.springblade.transport.pojo.entity.InsuranceOcrTemplate;
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
import org.springblade.transport.pojo.vo.InsuranceRecordVO; import org.springblade.transport.pojo.vo.InsuranceRecordVO;
import org.springblade.transport.service.IInsuranceOcrTemplateService;
import org.springblade.transport.service.IInsuranceRecordService; import org.springblade.transport.service.IInsuranceRecordService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* 保险记录 服务实现类 * 保险记录 服务实现类
@@ -55,6 +71,7 @@ import java.util.Set;
* @author Chill * @author Chill
*/ */
@Service @Service
@RequiredArgsConstructor
public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordMapper, InsuranceRecord> implements IInsuranceRecordService { public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordMapper, InsuranceRecord> implements IInsuranceRecordService {
private static final int VEHICLE_NO_MAX_LENGTH = 50; private static final int VEHICLE_NO_MAX_LENGTH = 50;
@@ -64,7 +81,11 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordM
private static final int OCR_TEMPLATE_MAX_LENGTH = 100; private static final int OCR_TEMPLATE_MAX_LENGTH = 100;
private static final int POLICY_FILE_MAX_LENGTH = 1000; private static final int POLICY_FILE_MAX_LENGTH = 1000;
private static final int REMARK_MAX_LENGTH = 200; private static final int REMARK_MAX_LENGTH = 200;
private static final Set<String> SUPPORT_FILE_TYPES = Set.of("jpg", "jpeg", "png", "pdf"); private static final Set<String> SUPPORT_FILE_TYPES = Set.of("jpg", "jpeg", "png", "bmp");
private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})[-/.年](\\d{1,2})[-/.月](\\d{1,2})日?");
private final IBaiduOcrService baiduOcrService;
private final IInsuranceOcrTemplateService insuranceOcrTemplateService;
@Override @Override
public IPage<InsuranceRecordVO> selectInsuranceRecordPage(IPage<InsuranceRecordVO> page, InsuranceRecordVO insuranceRecord) { public IPage<InsuranceRecordVO> selectInsuranceRecordPage(IPage<InsuranceRecordVO> page, InsuranceRecordVO insuranceRecord) {
@@ -116,10 +137,131 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordM
@Override @Override
public InsuranceRecord recognizePolicy(MultipartFile file, String vehicleType, String ocrTemplate) { public InsuranceRecord recognizePolicy(MultipartFile file, String vehicleType, String ocrTemplate) {
validatePolicyFile(file); validatePolicyFile(file);
String templateName = trimToNull(ocrTemplate);
if (templateName == null) {
throw new ServiceException("请选择OCR识别模板");
}
InsuranceOcrTemplate insuranceOcrTemplate = insuranceOcrTemplateService.getOne(Wrappers.<InsuranceOcrTemplate>lambdaQuery()
.eq(InsuranceOcrTemplate::getIsDeleted, 0)
.eq(InsuranceOcrTemplate::getStatus, 1)
.eq(InsuranceOcrTemplate::getName, templateName));
if (insuranceOcrTemplate == null) {
throw new ServiceException("OCR识别模板不存在或已停用");
}
InsuranceRecord insuranceRecord = new InsuranceRecord(); InsuranceRecord insuranceRecord = new InsuranceRecord();
insuranceRecord.setVehicleType(normalizeVehicleType(vehicleType)); insuranceRecord.setVehicleType(normalizeVehicleType(vehicleType));
insuranceRecord.setOcrTemplate(trimToNull(ocrTemplate)); insuranceRecord.setOcrTemplate(templateName);
throw new ServiceException("当前未配置OCR识别服务,请手动填写保单信息"); try {
BaiduOcrResultVO ocrResult = baiduOcrService.recognize(BaiduOcrType.GENERAL, null, file.getBytes());
applyTemplateMapping(insuranceRecord, insuranceOcrTemplate.getMappingConfig(), ocrResult.getResult());
return insuranceRecord;
} catch (IOException exception) {
throw new ServiceException("读取保单图片失败");
}
}
private void applyTemplateMapping(InsuranceRecord insuranceRecord, String mappingConfig, Map<String, Object> ocrResult) {
Map<String, String> mappingMap = parseMappingConfig(mappingConfig);
List<String> words = extractOcrWords(ocrResult);
insuranceRecord.setInsuranceType(findMappedValue(words, mappingMap.get("保险类型")));
insuranceRecord.setPolicyNo(findMappedValue(words, mappingMap.get("保单号")));
insuranceRecord.setStartDate(parseDate(findMappedValue(words, mappingMap.get("开始日期"))));
insuranceRecord.setEndDate(parseDate(findMappedValue(words, mappingMap.get("结束日期"))));
insuranceRecord.setInsuredAmount(parseAmount(findMappedValue(words, mappingMap.get("保额"))));
insuranceRecord.setPremium(parseAmount(findMappedValue(words, mappingMap.get("保费"))));
insuranceRecord.setInvoiceNo(findMappedValue(words, mappingMap.get("发票号")));
insuranceRecord.setInvoiceDate(parseDate(findMappedValue(words, mappingMap.get("开票日期"))));
insuranceRecord.setRemark(findMappedValue(words, mappingMap.get("备注")));
}
private Map<String, String> parseMappingConfig(String mappingConfig) {
try {
JSONArray mappingArray = JSON.parseArray(mappingConfig);
Map<String, String> mappingMap = new LinkedHashMap<>();
for (Object item : mappingArray) {
if (item instanceof JSONObject mapping) {
String key = trimToNull(mapping.getString("key"));
String value = trimToNull(mapping.getString("value"));
if (key != null && value != null) {
mappingMap.put(key, value);
}
}
}
return mappingMap;
} catch (Exception exception) {
throw new ServiceException("OCR识别模板字段映射配置格式不正确");
}
}
private List<String> extractOcrWords(Map<String, Object> ocrResult) {
if (ocrResult == null || ocrResult.isEmpty()) {
return List.of();
}
Object wordsResult = ocrResult.get("words_result");
if (wordsResult instanceof Collection<?> wordCollection) {
return wordCollection.stream().map(this::extractWord).filter(Objects::nonNull).toList();
}
if (wordsResult instanceof Map<?, ?> wordMap) {
return wordMap.values().stream().map(this::extractWord).filter(Objects::nonNull).toList();
}
return List.of();
}
private String extractWord(Object wordItem) {
if (wordItem instanceof Map<?, ?> wordMap) {
Object words = wordMap.get("words");
return words == null ? null : trimToNull(String.valueOf(words));
}
return wordItem == null ? null : trimToNull(String.valueOf(wordItem));
}
private String findMappedValue(List<String> words, String mappingValue) {
if (mappingValue == null || words.isEmpty()) {
return null;
}
Pattern standalonePattern = Pattern.compile("^" + Pattern.quote(mappingValue) + "\\s*[:]?$");
Pattern inlinePattern = Pattern.compile(Pattern.quote(mappingValue) + "\\s*[:]?\\s*(.+)$");
for (int index = 0; index < words.size(); index++) {
String word = words.get(index).trim();
Matcher inlineMatcher = inlinePattern.matcher(word);
if (inlineMatcher.find()) {
return trimToNull(inlineMatcher.group(1));
}
if (standalonePattern.matcher(word).matches() && index + 1 < words.size()) {
return trimToNull(words.get(index + 1));
}
}
return null;
}
private LocalDate parseDate(String value) {
if (value == null) {
return null;
}
Matcher matcher = DATE_PATTERN.matcher(value);
if (!matcher.find()) {
return null;
}
try {
return LocalDate.of(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)));
} catch (RuntimeException exception) {
return null;
}
}
private BigDecimal parseAmount(String value) {
if (value == null) {
return null;
}
String amount = value.replaceAll("[^\\d.]", "");
if (amount.isBlank()) {
return null;
}
try {
return new BigDecimal(amount);
} catch (NumberFormatException exception) {
return null;
}
} }
private void prepare(InsuranceRecord insuranceRecord) { private void prepare(InsuranceRecord insuranceRecord) {
@@ -186,7 +328,7 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordM
private void validatePolicyFile(MultipartFile file) { private void validatePolicyFile(MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new ServiceException("请上传保单图片或PDF文件"); throw new ServiceException("请上传保单图片");
} }
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
if (Func.isEmpty(filename) || !filename.contains(".")) { if (Func.isEmpty(filename) || !filename.contains(".")) {
@@ -194,7 +336,7 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordM
} }
String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
if (!SUPPORT_FILE_TYPES.contains(extension)) { if (!SUPPORT_FILE_TYPES.contains(extension)) {
throw new ServiceException("仅支持JPG、PNG、PDF文件"); throw new ServiceException("仅支持JPG、PNG、BMP文件");
} }
} }
@@ -36,10 +36,12 @@ import org.springblade.system.cache.UserCache;
import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper; import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper;
import org.springblade.transport.mapper.ReceivablePayableChangeRecordMapper; import org.springblade.transport.mapper.ReceivablePayableChangeRecordMapper;
import org.springblade.transport.mapper.ReceivablePayableDetailMapper; import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
import org.springblade.transport.pojo.dto.ReceivablePayableAdjustFeeRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableGenerateRequest; import org.springblade.transport.pojo.dto.ReceivablePayableGenerateRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableTransferRequest; import org.springblade.transport.pojo.dto.ReceivablePayableTransferRequest;
import org.springblade.transport.pojo.dto.ReceivablePayableUpdateFeeRequest; import org.springblade.transport.pojo.dto.ReceivablePayableUpdateFeeRequest;
import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CommonAddress;
import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee; import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee;
import org.springblade.transport.pojo.entity.ReceivablePayableChangeRecord; import org.springblade.transport.pojo.entity.ReceivablePayableChangeRecord;
import org.springblade.transport.pojo.entity.ReceivablePayableDetail; import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
@@ -49,11 +51,13 @@ import org.springblade.transport.pojo.vo.ReceivablePayableChangeRecordVO;
import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO; import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO;
import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO; import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO;
import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.ICommonAddressService;
import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IWaybillService; import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.wrapper.ReceivablePayableDetailWrapper; import org.springblade.transport.wrapper.ReceivablePayableDetailWrapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
@@ -61,12 +65,15 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.Optional;
/** /**
* 应收应付明细服务实现类 * 应收应付明细服务实现类
@@ -74,6 +81,7 @@ import java.util.Set;
* @author Chill * @author Chill
*/ */
@Service @Service
@Slf4j
public class ReceivablePayableDetailServiceImpl public class ReceivablePayableDetailServiceImpl
extends BaseServiceImpl<ReceivablePayableDetailMapper, ReceivablePayableDetail> extends BaseServiceImpl<ReceivablePayableDetailMapper, ReceivablePayableDetail>
implements IReceivablePayableDetailService { implements IReceivablePayableDetailService {
@@ -82,15 +90,18 @@ public class ReceivablePayableDetailServiceImpl
private final ReceivablePayableChangeRecordMapper changeRecordMapper; private final ReceivablePayableChangeRecordMapper changeRecordMapper;
private final IWaybillService waybillService; private final IWaybillService waybillService;
private final IContractManageService contractManageService; private final IContractManageService contractManageService;
private final ICommonAddressService commonAddressService;
public ReceivablePayableDetailServiceImpl(ReceivablePayableCargoFeeMapper cargoFeeMapper, public ReceivablePayableDetailServiceImpl(ReceivablePayableCargoFeeMapper cargoFeeMapper,
ReceivablePayableChangeRecordMapper changeRecordMapper, ReceivablePayableChangeRecordMapper changeRecordMapper,
IWaybillService waybillService, IWaybillService waybillService,
IContractManageService contractManageService) { IContractManageService contractManageService,
ICommonAddressService commonAddressService) {
this.cargoFeeMapper = cargoFeeMapper; this.cargoFeeMapper = cargoFeeMapper;
this.changeRecordMapper = changeRecordMapper; this.changeRecordMapper = changeRecordMapper;
this.waybillService = waybillService; this.waybillService = waybillService;
this.contractManageService = contractManageService; this.contractManageService = contractManageService;
this.commonAddressService = commonAddressService;
} }
@Override @Override
@@ -105,7 +116,11 @@ public class ReceivablePayableDetailServiceImpl
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()) .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())
.eq(ReceivablePayableCargoFee::getIsDeleted, 0) .eq(ReceivablePayableCargoFee::getIsDeleted, 0)
.orderByAsc(ReceivablePayableCargoFee::getCreateTime)); .orderByAsc(ReceivablePayableCargoFee::getCreateTime));
return buildFeeDetail(rows); ReceivablePayableFeeDetailVO result = buildFeeDetail(rows);
LinkedHashSet<String> feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId()));
feeItemNames.addAll(result.getFeeItemNames());
result.setFeeItemNames(new ArrayList<>(feeItemNames));
return result;
} }
@Override @Override
@@ -125,11 +140,11 @@ public class ReceivablePayableDetailServiceImpl
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateFee(ReceivablePayableUpdateFeeRequest request) { public void updateFee(ReceivablePayableUpdateFeeRequest request) {
if (Boolean.TRUE.equals(request.getCloseOnly())) { if (Boolean.TRUE.equals(request.getCloseOnly())) {
closeDetails(request.getIds()); closeDetails(request.getIds(), request.getSettlementType());
return; return;
} }
if (Func.isEmpty(request.getIds()) && Func.isEmpty(request.getContractId())) { if (Func.isEmpty(request.getContractId())) {
throw new ServiceException("请选择需要更新费用明细或合同"); throw new ServiceException("请选择需要更新费用合同");
} }
List<ReceivablePayableDetail> details = list(buildUpdateQuery(request)); List<ReceivablePayableDetail> details = list(buildUpdateQuery(request));
if (Func.isEmpty(details)) { if (Func.isEmpty(details)) {
@@ -140,12 +155,107 @@ public class ReceivablePayableDetailServiceImpl
continue; continue;
} }
BigDecimal before = money(detail.getTotalAmount()); BigDecimal before = money(detail.getTotalAmount());
rebuildDetailFee(detail); rebuildDetailFee(detail, request.getBillingPlanId());
if (request.getAdjustAmount() != null && request.getAdjustAmount().compareTo(BigDecimal.ZERO) != 0) {
applyManualAdjustment(detail, request.getAdjustAmount(), request.getAdjustFeeItem(), request.getAdjustReason());
}
saveChangeRecord(detail, "【费用合计】从[" + formatMoney(before) + "]调整为[" + formatMoney(detail.getTotalAmount()) + "]", saveChangeRecord(detail, "【费用合计】从[" + formatMoney(before) + "]调整为[" + formatMoney(detail.getTotalAmount()) + "]",
request.getAdjustReason()); request.getAdjustReason());
} }
} }
@Override
public List<Map<String, Object>> updateFeeContracts(String settlementType) {
List<Long> contractIds = list(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.select(ReceivablePayableDetail::getContractId)
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.eq(ReceivablePayableDetail::getSettlementStatus, "pending")
.eq(Func.isNotEmpty(settlementType), ReceivablePayableDetail::getSettlementType,
settlementType(settlementType))
.isNotNull(ReceivablePayableDetail::getContractId)
.groupBy(ReceivablePayableDetail::getContractId))
.stream().map(ReceivablePayableDetail::getContractId).toList();
if (Func.isEmpty(contractIds)) {
return List.of();
}
Map<Long, ContractManage> contracts = contractManageService.listByIds(contractIds).stream()
.collect(java.util.stream.Collectors.toMap(ContractManage::getId, contract -> contract));
return contractIds.stream().map(contracts::get).filter(Objects::nonNull).map(contract -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", contract.getId());
item.put("contractNo", contract.getContractNo());
item.put("contractName", contract.getContractName());
item.put("billingPlanJson", contract.getBillingPlanJson());
return item;
}).toList();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void adjustFee(ReceivablePayableAdjustFeeRequest request) {
if (request == null || request.getDetailId() == null || Func.isEmpty(request.getRows())) {
throw new ServiceException("费用调整数据不能为空");
}
ReceivablePayableDetail detail = getExisting(request.getDetailId());
if (!"pending".equals(detail.getSettlementStatus())) {
throw new ServiceException("仅待结算明细允许调整");
}
List<ReceivablePayableCargoFee> existingRows = cargoFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId())
.eq(ReceivablePayableCargoFee::getIsDeleted, 0));
Map<Long, ReceivablePayableCargoFee> existingMap = existingRows.stream()
.collect(java.util.stream.Collectors.toMap(ReceivablePayableCargoFee::getId, row -> row));
if (request.getRows().size() != existingRows.size()) {
throw new ServiceException("费用调整行数据不完整");
}
Set<String> allowedFeeItems = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId()));
existingRows.forEach(row -> allowedFeeItems.addAll(parseMap(row.getFeeItemsJson()).keySet()));
List<String> changes = new ArrayList<>();
for (ReceivablePayableAdjustFeeRequest.AdjustRow adjusted : request.getRows()) {
ReceivablePayableCargoFee existing = existingMap.get(adjusted.getId());
if (existing == null) {
throw new ServiceException("存在无效的费用调整行");
}
validateNonNegative(adjusted.getTransportQuantity(), "运输量");
validateNonNegative(adjusted.getMileage(), "里程");
validateNonNegative(adjusted.getFreightAmount(), "运输费");
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
if (adjusted.getFeeItems() != null) {
adjusted.getFeeItems().forEach((name, amount) -> {
if (!allowedFeeItems.contains(name)) {
throw new ServiceException("费用项目不存在:" + name);
}
validateNonNegative(amount, name);
feeItems.put(name, money(amount));
});
}
appendChange(changes, "计费数量", existing.getTransportQuantity(), adjusted.getTransportQuantity());
appendChange(changes, "里程", existing.getMileage(), adjusted.getMileage());
appendChange(changes, "运输费", existing.getFreightAmount(), adjusted.getFreightAmount());
Map<String, Object> oldFeeItems = parseMap(existing.getFeeItemsJson());
for (String name : allowedFeeItems) {
appendChange(changes, name, decimal(oldFeeItems.get(name)), money(feeItems.get(name)));
}
BigDecimal freightAmount = money(adjusted.getFreightAmount());
BigDecimal afterAmount = adjustedAfterAmount(freightAmount, feeItems);
existing.setTransportQuantity(money(adjusted.getTransportQuantity()));
existing.setMileage(money(adjusted.getMileage()));
existing.setFreightAmount(freightAmount);
existing.setFeeItemsJson(JsonUtil.toJson(feeItems));
existing.setAdjustAmount(afterAmount.subtract(money(existing.getOriginalAmount())));
existing.setAfterAmount(afterAmount);
cargoFeeMapper.updateById(existing);
}
if (changes.isEmpty()) {
throw new ServiceException("未修改任何费用数据");
}
for (int i = 0; i < changes.size(); i++) {
saveChangeRecord(detail, changes.get(i), request.getAdjustReason(), String.format("%04d", i + 1));
}
refreshAdjustedDetail(detail, existingRows);
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void transferSettlement(ReceivablePayableTransferRequest request) { public void transferSettlement(ReceivablePayableTransferRequest request) {
@@ -203,11 +313,10 @@ public class ReceivablePayableDetailServiceImpl
public ReceivablePayableFeeDetailVO generatePreview(IPage<?> page, ReceivablePayableGenerateRequest request) { public ReceivablePayableFeeDetailVO generatePreview(IPage<?> page, ReceivablePayableGenerateRequest request) {
validateGenerateRequest(request, true); validateGenerateRequest(request, true);
List<Waybill> waybills = waybillService.list(buildWaybillQuery(request)); List<Waybill> waybills = waybillService.list(buildWaybillQuery(request));
ContractManage contract = contractManageService.getById(request.getContractId());
List<ReceivablePayableCargoFee> fees = waybills.stream() List<ReceivablePayableCargoFee> fees = waybills.stream()
.skip((page.getCurrent() - 1) * page.getSize()) .skip((page.getCurrent() - 1) * page.getSize()).limit(page.getSize())
.limit(page.getSize()) .flatMap(waybill -> calculatedFees(waybill, contract, request.getBillingPlanId()).stream()).toList();
.map(waybill -> buildCargoFee(null, waybill))
.toList();
ReceivablePayableFeeDetailVO vo = buildFeeDetail(fees); ReceivablePayableFeeDetailVO vo = buildFeeDetail(fees);
vo.setTotal((long) waybills.size()); vo.setTotal((long) waybills.size());
return vo; return vo;
@@ -223,16 +332,123 @@ public class ReceivablePayableDetailServiceImpl
} }
ContractManage contract = contractManageService.getById(request.getContractId()); ContractManage contract = contractManageService.getById(request.getContractId());
for (Waybill waybill : waybills) { for (Waybill waybill : waybills) {
if (existsByWaybill(waybill.getId())) { if (existsByWaybill(waybill.getId(), settlementType(request.getSettlementType()))) {
continue; continue;
} }
ReceivablePayableDetail detail = buildDetail(waybill, contract); ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), settlementType(request.getSettlementType()));
save(detail); save(detail);
ReceivablePayableCargoFee cargoFee = buildCargoFee(detail.getId(), waybill); calculatedFees(waybill, contract, request.getBillingPlanId()).forEach(fee -> {
cargoFeeMapper.insert(cargoFee); fee.setDetailId(detail.getId());
cargoFeeMapper.insert(fee);
});
} }
} }
@Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
public void generateForCompletedWaybills(List<Long> waybillIds) {
if (Func.isEmpty(waybillIds)) return;
for (Long waybillId : waybillIds) {
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Func.isEmpty(waybill.getContractId())) continue;
ContractManage contract = contractManageService.getById(waybill.getContractId());
if (contract == null || !isSystemGeneration(contract)) continue;
try {
String planId = matchedPlanId(waybill, contract);
if (Func.isEmpty(planId)) continue;
List<ReceivablePayableCargoFee> matchedFees = calculatedFees(waybill, contract, planId, true);
if (matchedFees.isEmpty()) continue;
for (String settlementType : List.of("payable", "receivable")) {
if (existsByWaybill(waybill.getId(), settlementType)) continue;
ReceivablePayableDetail detail = buildDetail(waybill, contract, settlementType, matchedFees);
save(detail);
for (ReceivablePayableCargoFee fee : matchedFees) {
ReceivablePayableCargoFee copy = BeanUtil.copyProperties(fee, ReceivablePayableCargoFee.class);
copy.setId(null);
copy.setDetailId(detail.getId());
cargoFeeMapper.insert(copy);
}
}
} catch (Exception exception) {
log.error("自动生成运单费用明细失败,waybillId:{}, waybillNo:{}, contractId:{}, failureReason:{}",
waybill.getId(), waybill.getWaybillNo(), waybill.getContractId(), exception.getMessage(), exception);
if (exception instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new RuntimeException(exception);
}
}
}
private boolean isSystemGeneration(ContractManage contract) {
if (Func.isNotEmpty(contract.getFeeGenerationMode())) {
return "system".equalsIgnoreCase(contract.getFeeGenerationMode()) || "系统生成".equals(contract.getFeeGenerationMode());
}
return !Integer.valueOf(0).equals(contract.getBillingEnabled());
}
private String matchedPlanId(Waybill waybill, ContractManage contract) {
List<Map<String, Object>> plans = parseList(contract.getBillingPlanJson());
if (plans.isEmpty()) return null;
Map<String, Object> plan = plans.stream().filter(item -> Boolean.TRUE.equals(item.get("defaultPlan"))).findFirst()
.orElse(plans.get(plans.size() - 1));
if (!(plan.get("rules") instanceof List<?> rules)) return null;
boolean matched = rules.stream().anyMatch(value -> value instanceof Map<?, ?> raw && matchesRule(raw, waybill));
if (!matched) return null;
return "__matched__";
}
private boolean matchesRule(Map<?, ?> raw, Waybill waybill) {
Object conditionValue = raw.get("matchCondition");
if (!(conditionValue instanceof Map<?, ?> condition)) return true;
return matchesLocation(condition, "origin", waybill.getDepartureAddressId(), waybill.getDepartureName(), waybill.getDepartureAddress())
&& matchesLocation(condition, "destination", waybill.getArrivalAddressId(), waybill.getArrivalName(), waybill.getArrivalAddress())
&& matchesCondition(condition.get("cargoType"), waybill.getCargoType());
}
private boolean matchesLocation(Map<?, ?> condition, String location, Long addressId, String addressName,
String detailAddress) {
Object expectedName = condition.get(location);
Object expectedCode = condition.get(location + "Code");
if (isBlank(expectedName) && isBlank(expectedCode)) return true;
String actualCode = resolveRegionCode(addressId);
if (!isBlank(expectedCode) && !isBlank(actualCode)) {
return matchesCondition(expectedCode, actualCode);
}
return matchesCondition(expectedName, addressName)
|| matchesCondition(expectedName, detailAddress);
}
private String resolveRegionCode(Long addressId) {
if (addressId == null) return "";
try {
CommonAddress address = commonAddressService.getById(addressId);
return address == null || address.getRegionCode() == null ? "" : address.getRegionCode().trim();
} catch (Exception exception) {
log.warn("解析运单行政区编码失败,addressId:{}", addressId, exception);
return "";
}
}
private boolean isBlank(Object value) {
return value == null || String.valueOf(value).trim().isEmpty();
}
private boolean matchesCondition(Object expected, String actual) {
if (expected == null || String.valueOf(expected).isBlank()) return true;
if (expected instanceof List<?> values) {
return values.stream().anyMatch(value -> matchesCondition(value, actual));
}
String expectedText = String.valueOf(expected).trim();
String actualText = String.valueOf(actual == null ? "" : actual).trim();
if (expectedText.isEmpty() || actualText.isEmpty()) return false;
if (expectedText.equals(actualText) || actualText.contains(expectedText) || expectedText.contains(actualText)) {
return true;
}
return false;
}
private LambdaQueryWrapper<ReceivablePayableDetail> buildQuery(ReceivablePayableDetailVO query) { private LambdaQueryWrapper<ReceivablePayableDetail> buildQuery(ReceivablePayableDetailVO query) {
LambdaQueryWrapper<ReceivablePayableDetail> wrapper = Wrappers.<ReceivablePayableDetail>lambdaQuery() LambdaQueryWrapper<ReceivablePayableDetail> wrapper = Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getIsDeleted, 0) .eq(ReceivablePayableDetail::getIsDeleted, 0)
@@ -251,6 +467,7 @@ public class ReceivablePayableDetailServiceImpl
.like(Func.isNotEmpty(query.getBatchNo()), ReceivablePayableDetail::getBatchNo, query.getBatchNo()) .like(Func.isNotEmpty(query.getBatchNo()), ReceivablePayableDetail::getBatchNo, query.getBatchNo())
.like(Func.isNotEmpty(query.getVehicleNo()), ReceivablePayableDetail::getVehicleNo, query.getVehicleNo()) .like(Func.isNotEmpty(query.getVehicleNo()), ReceivablePayableDetail::getVehicleNo, query.getVehicleNo())
.eq(Func.isNotEmpty(query.getSettlementStatus()), ReceivablePayableDetail::getSettlementStatus, query.getSettlementStatus()); .eq(Func.isNotEmpty(query.getSettlementStatus()), ReceivablePayableDetail::getSettlementStatus, query.getSettlementStatus());
wrapper.eq(Func.isNotEmpty(query.getSettlementType()), ReceivablePayableDetail::getSettlementType, query.getSettlementType());
return wrapper.orderByDesc(ReceivablePayableDetail::getCreateTime); return wrapper.orderByDesc(ReceivablePayableDetail::getCreateTime);
} }
@@ -258,12 +475,10 @@ public class ReceivablePayableDetailServiceImpl
LambdaQueryWrapper<ReceivablePayableDetail> wrapper = Wrappers.<ReceivablePayableDetail>lambdaQuery() LambdaQueryWrapper<ReceivablePayableDetail> wrapper = Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getIsDeleted, 0) .eq(ReceivablePayableDetail::getIsDeleted, 0)
.eq(ReceivablePayableDetail::getSettlementStatus, "pending"); .eq(ReceivablePayableDetail::getSettlementStatus, "pending");
if (Func.isNotEmpty(request.getIds())) { if (Func.isNotEmpty(request.getSettlementType())) {
wrapper.in(ReceivablePayableDetail::getId, request.getIds()); wrapper.eq(ReceivablePayableDetail::getSettlementType, settlementType(request.getSettlementType()));
} }
if (Func.isNotEmpty(request.getContractId())) {
wrapper.eq(ReceivablePayableDetail::getContractId, request.getContractId()); wrapper.eq(ReceivablePayableDetail::getContractId, request.getContractId());
}
return wrapper; return wrapper;
} }
@@ -272,7 +487,8 @@ public class ReceivablePayableDetailServiceImpl
.eq(Waybill::getIsDeleted, 0) .eq(Waybill::getIsDeleted, 0)
.eq(Waybill::getContractId, request.getContractId()) .eq(Waybill::getContractId, request.getContractId())
.eq(Waybill::getBusinessStatus, "completed") .eq(Waybill::getBusinessStatus, "completed")
.notInSql(Waybill::getId, "select waybill_id from blade_receivable_payable_detail where is_deleted = 0"); .notInSql(Waybill::getId, "select waybill_id from blade_receivable_payable_detail where is_deleted = 0"
+ (Func.isNotEmpty(request.getSettlementType()) ? " and settlement_type = '" + settlementType(request.getSettlementType()) + "'" : ""));
if (Func.isNotEmpty(request.getBatchNo())) { if (Func.isNotEmpty(request.getBatchNo())) {
wrapper.like(Waybill::getBatchNo, request.getBatchNo()); wrapper.like(Waybill::getBatchNo, request.getBatchNo());
} }
@@ -288,20 +504,29 @@ public class ReceivablePayableDetailServiceImpl
return wrapper.orderByDesc(Waybill::getCreateTime); return wrapper.orderByDesc(Waybill::getCreateTime);
} }
private ReceivablePayableDetail buildDetail(Waybill waybill, ContractManage contract) { private ReceivablePayableDetail buildDetail(Waybill waybill, ContractManage contract, String billingPlanId, String settlementType) {
ReceivablePayableCargoFee cargoFee = buildCargoFee(null, waybill); List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId);
return buildDetail(waybill, contract, settlementType, fees);
}
private ReceivablePayableDetail buildDetail(Waybill waybill, ContractManage contract, String settlementType, List<ReceivablePayableCargoFee> fees) {
BigDecimal freight = fees.stream().filter(this::isFreight).map(ReceivablePayableCargoFee::getAfterAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal total = fees.stream().map(ReceivablePayableCargoFee::getAfterAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal other = total.subtract(freight).setScale(2, RoundingMode.HALF_UP);
ReceivablePayableDetail detail = new ReceivablePayableDetail(); ReceivablePayableDetail detail = new ReceivablePayableDetail();
detail.setDocumentNo(nextDocumentNo()); detail.setDocumentNo(nextDocumentNo());
detail.setSettlementType("receivable"); detail.setSettlementType(settlementType);
detail.setProjectId(waybill.getProjectId()); detail.setProjectId(waybill.getProjectId());
detail.setProjectName(waybill.getProjectName()); detail.setProjectName(waybill.getProjectName());
detail.setDeptId(waybill.getDeptId()); detail.setDeptId(waybill.getDeptId());
detail.setDeptName(waybill.getDeptName()); detail.setDeptName(waybill.getDeptName());
detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate()); detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate());
detail.setCustomerName(waybill.getCustomerName()); detail.setCustomerName("payable".equals(settlementType)
detail.setContractId(waybill.getContractId()); ? (contract == null ? waybill.getCarrierName() : contract.getPartyA())
: (contract == null ? waybill.getCustomerName() : contract.getPartyB()));
detail.setContractId(contract == null ? waybill.getContractId() : contract.getId());
detail.setContractNo(contract == null ? null : contract.getContractNo()); detail.setContractNo(contract == null ? null : contract.getContractNo());
detail.setContractName(waybill.getContractName()); detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName());
detail.setSourceType("系统生成"); detail.setSourceType("系统生成");
detail.setWaybillId(waybill.getId()); detail.setWaybillId(waybill.getId());
detail.setWaybillNo(waybill.getWaybillNo()); detail.setWaybillNo(waybill.getWaybillNo());
@@ -315,11 +540,12 @@ public class ReceivablePayableDetailServiceImpl
detail.setBatchNo(waybill.getBatchNo()); detail.setBatchNo(waybill.getBatchNo());
detail.setUnitPrice(waybill.getUnitPrice()); detail.setUnitPrice(waybill.getUnitPrice());
detail.setCurrency("RMB"); detail.setCurrency("RMB");
detail.setFreightAmount(cargoFee.getFreightAmount()); detail.setFreightAmount(freight);
detail.setOtherFeeAmount(waybill.getOtherFeeTotal()); detail.setOtherFeeAmount(other);
detail.setTotalAmount(cargoFee.getAfterAmount()); detail.setTotalAmount(total);
detail.setSettlementStatus("pending"); detail.setSettlementStatus("pending");
detail.setFeeItemsJson(cargoFee.getFeeItemsJson()); detail.setFeeItemsJson(JsonUtil.toJson(fees.stream().collect(HashMap<String, BigDecimal>::new,
(map, fee) -> map.put(fee.getCargoName(), fee.getAfterAmount()), HashMap::putAll)));
return detail; return detail;
} }
@@ -358,6 +584,133 @@ public class ReceivablePayableDetailServiceImpl
return cargoFee; return cargoFee;
} }
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId) {
return calculatedFees(waybill, contract, planId, false);
}
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) {
List<Map<String, Object>> plans = parseList(contract == null ? null : contract.getBillingPlanJson());
Map<String, Object> plan = "__matched__".equals(planId) ? plans.stream().filter(this::isDefaultPlan).findFirst().orElseGet(() -> plans.isEmpty() ? null : plans.get(plans.size() - 1)) : plans.stream().filter(item -> Objects.equals(stringValue(item, "id"), planId)
|| Objects.equals(stringValue(item, "planId"), planId)).findFirst()
.orElseGet(() -> plans.stream().filter(this::isDefaultPlan).findFirst().orElse(null));
if (plan == null || !(plan.get("rules") instanceof List<?>)) return matchOnly ? List.of() : List.of(buildCargoFee(null, waybill));
List<ReceivablePayableCargoFee> result = new ArrayList<>();
int line = 1;
for (Object value : (List<?>) plan.get("rules")) {
if (!(value instanceof Map<?, ?> raw)) continue;
Map<String, Object> rule = new LinkedHashMap<>();
raw.forEach((key, item) -> rule.put(String.valueOf(key), item));
if (matchOnly && !matchesRule(raw, waybill)) continue;
BigDecimal amount = calculateRule(rule, waybill);
if (amount == null) continue;
ReceivablePayableCargoFee fee = new ReceivablePayableCargoFee();
fee.setWaybillId(waybill.getId()); fee.setLineNo(String.format("%04d", line++));
fee.setCargoName(stringValue(rule, "feeItem", "费用")); fee.setCargoType(waybill.getCargoType());
fee.setBillingFactor(stringValue(rule, "billingElement", "")); fee.setBillingType(stringValue(rule, "billingType", ""));
fee.setTransportQuantity(measure(rule, waybill)); fee.setQuantityUnit(stringValue(rule, "billingUnit", waybill.getQuantityUnit()));
fee.setPriceUnit(stringValue(rule, "billingUnit", waybill.getPriceUnit())); fee.setUnitPrice(decimal(rule.get("unitPrice")));
fee.setMileage(waybill.getMileage()); fee.setFreightAmount(isFreightRule(rule) ? amount : BigDecimal.ZERO);
fee.setFeeItemsJson(JsonUtil.toJson(Map.of(fee.getCargoName(), amount)));
fee.setOriginalAmount(amount); fee.setAdjustAmount(BigDecimal.ZERO); fee.setAfterAmount(amount); fee.setRemark(stringValue(rule, "remark", waybill.getRemark()));
result.add(fee);
}
return result.isEmpty() && !matchOnly ? List.of(buildCargoFee(null, waybill)) : result;
}
private boolean isDefaultPlan(Map<String, Object> plan) {
Object value = plan.get("defaultPlan");
return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(String.valueOf(value));
}
private BigDecimal calculateRule(Map<String, Object> rule, Waybill waybill) {
String element = stringValue(rule, "billingElement", ""); String type = stringValue(rule, "billingType", "");
BigDecimal base = measure(rule, waybill); BigDecimal unit = decimal(rule.get("unitPrice"));
if ("按重量".equals(element) || "按吨·公里".equals(element)) {
BigDecimal minimum = decimal(rule.get("minimumBillingWeight"));
if (minimum.signum() > 0 && "按重量".equals(element)) base = base.max(minimum);
if (minimum.signum() > 0 && "按吨·公里".equals(element)) base = base.divide(money(waybill.getQuantity()).max(BigDecimal.ONE), 6, RoundingMode.HALF_UP).multiply(minimum).multiply(money(waybill.getMileage()));
}
if ("固定一口价".equals(type)) return unit.setScale(2, RoundingMode.HALF_UP);
List<Map<String, Object>> ranges = ranges(rule);
if (ranges.isEmpty() || "固定单价".equals(type)) return base.multiply(unit).setScale(2, RoundingMode.HALF_UP);
if (type.contains("区间") && type.contains("一口价")) return range(ranges, base).map(r -> decimal(r.get("unitPrice")).signum() == 0 ? unit : decimal(r.get("unitPrice"))).orElse(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
if ("区间单价".equals(type)) { BigDecimal rangeBase = base; return range(ranges, rangeBase).map(r -> decimal(r.get("unitPrice")).multiply(rangeBase)).orElse(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP); }
if ("阶梯单价".equals(type)) {
BigDecimal total = BigDecimal.ZERO, previous = BigDecimal.ZERO;
for (Map<String, Object> r : ranges.stream().sorted(Comparator.comparing(x -> decimal(x.get("lowerLimit")))).toList()) {
BigDecimal upper = decimal(r.get("upperLimit")); BigDecimal part = base.min(upper).subtract(previous).max(BigDecimal.ZERO);
total = total.add(part.multiply(decimal(r.get("unitPrice")).signum() == 0 ? unit : decimal(r.get("unitPrice")))); previous = upper;
if (base.compareTo(upper) <= 0) break;
}
return total.setScale(2, RoundingMode.HALF_UP);
}
return base.multiply(unit).setScale(2, RoundingMode.HALF_UP);
}
private boolean isFreight(ReceivablePayableCargoFee fee) {
return fee.getFreightAmount() != null && fee.getFreightAmount().compareTo(BigDecimal.ZERO) > 0;
}
private boolean isFreightRule(Map<String, Object> rule) {
String type = stringValue(rule, "feeType", "") + stringValue(rule, "feeItem", "");
return type.contains("运费") || type.contains("运输费");
}
private BigDecimal measure(Map<String, Object> rule, Waybill waybill) {
return switch (stringValue(rule, "billingElement", "按重量")) {
case "按体积" -> volume(waybill);
case "按车辆", "固定金额(整单一口价)" -> BigDecimal.ONE;
case "按里程" -> money(waybill.getMileage());
case "按吨·公里" -> money(waybill.getQuantity()).multiply(money(waybill.getMileage()));
case "按数量" -> money(waybill.getQuantity());
default -> money(waybill.getQuantity());
};
}
private BigDecimal volume(Waybill waybill) {
Map<String, Object> goods = parseMap(waybill.getGoodsJson());
BigDecimal value = decimal(goods.get("volume"));
if (value.signum() == 0) value = decimal(goods.get("cargoVolume"));
return value;
}
private List<Map<String, Object>> ranges(Map<String, Object> rule) {
Object source = rule.get("limitRanges");
if (!(source instanceof List<?>)) { Map<String, Object> fallback = new LinkedHashMap<>(); fallback.put("lowerLimit", rule.get("lowerLimit")); fallback.put("upperLimit", rule.get("upperLimit")); fallback.put("unitPrice", rule.get("unitPrice")); source = List.of(fallback); }
List<Map<String, Object>> result = new ArrayList<>();
for (Object value : (List<?>) source) if (value instanceof Map<?, ?> raw) {
Map<String, Object> item = new LinkedHashMap<>(); raw.forEach((key, val) -> item.put(String.valueOf(key), val));
if (item.get("lowerLimit") != null && item.get("upperLimit") != null) result.add(item);
}
return result;
}
private Optional<Map<String, Object>> range(List<Map<String, Object>> ranges, BigDecimal value) {
List<Map<String, Object>> sorted = ranges.stream().sorted(Comparator.comparing(x -> decimal(x.get("lowerLimit")))).toList();
for (int i = 0; i < sorted.size(); i++) {
Map<String, Object> r = sorted.get(i);
boolean upperMatched = i == sorted.size() - 1 ? value.compareTo(decimal(r.get("upperLimit"))) <= 0 : value.compareTo(decimal(r.get("upperLimit"))) < 0;
if (value.compareTo(decimal(r.get("lowerLimit"))) >= 0 && upperMatched) return Optional.of(r);
}
return Optional.empty();
}
private List<Map<String, Object>> parseList(String json) {
if (Func.isEmpty(json)) return List.of();
try {
Object parsed = JsonUtil.parse(json, List.class);
if (parsed instanceof List<?> list) return list.stream().filter(Map.class::isInstance).map(item -> {
Map<String, Object> result = new LinkedHashMap<>(); ((Map<?, ?>) item).forEach((k, v) -> result.put(String.valueOf(k), v)); return result;
}).toList();
} catch (Exception ignored) { }
return List.of();
}
private String stringValue(Map<String, Object> map, String key) { return stringValue(map, key, ""); }
private String stringValue(Map<String, Object> map, String key, String fallback) {
Object value = map.get(key); return value == null || String.valueOf(value).isBlank() ? fallback : String.valueOf(value);
}
private ReceivablePayableFeeDetailVO buildFeeDetail(List<ReceivablePayableCargoFee> rows) { private ReceivablePayableFeeDetailVO buildFeeDetail(List<ReceivablePayableCargoFee> rows) {
Set<String> feeItemNames = new LinkedHashSet<>(); Set<String> feeItemNames = new LinkedHashSet<>();
List<ReceivablePayableCargoFeeVO> records = rows.stream().map(row -> { List<ReceivablePayableCargoFeeVO> records = rows.stream().map(row -> {
@@ -379,26 +732,93 @@ public class ReceivablePayableDetailServiceImpl
return vo; return vo;
} }
private void rebuildDetailFee(ReceivablePayableDetail detail) { private List<String> contractFeeItemNames(Long contractId) {
if (contractId == null) return List.of();
ContractManage contract = contractManageService.getById(contractId);
if (contract == null) return List.of();
LinkedHashSet<String> names = new LinkedHashSet<>();
for (Map<String, Object> plan : parseList(contract.getBillingPlanJson())) {
if (!(plan.get("rules") instanceof List<?> rules)) continue;
for (Object value : rules) {
if (!(value instanceof Map<?, ?> raw)) continue;
Object feeItem = raw.get("feeItem");
if (!isBlank(feeItem)) names.add(String.valueOf(feeItem));
}
}
return new ArrayList<>(names);
}
private void validateNonNegative(BigDecimal value, String field) {
if (value != null && value.compareTo(BigDecimal.ZERO) < 0) {
throw new ServiceException(field + "不能小于0");
}
}
private BigDecimal adjustedAfterAmount(BigDecimal freightAmount, Map<String, BigDecimal> feeItems) {
BigDecimal feeItemTotal = feeItems.values().stream().map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
boolean containsFreight = feeItems.keySet().stream().anyMatch(this::isFreightFeeItem);
return (containsFreight ? feeItemTotal : freightAmount.add(feeItemTotal)).setScale(2, RoundingMode.HALF_UP);
}
private boolean isFreightFeeItem(String name) {
return name != null && (name.contains("运费") || name.contains("运输费"));
}
private void appendChange(List<String> changes, String field, BigDecimal before, BigDecimal after) {
BigDecimal oldValue = money(before);
BigDecimal newValue = money(after);
if (oldValue.compareTo(newValue) != 0) {
changes.add("" + field + "】从[" + formatValue(oldValue) + "]调整为[" + formatValue(newValue) + "]");
}
}
private String formatValue(BigDecimal value) {
return money(value).stripTrailingZeros().toPlainString();
}
private void refreshAdjustedDetail(ReceivablePayableDetail detail, List<ReceivablePayableCargoFee> rows) {
BigDecimal freight = rows.stream().map(row -> money(row.getFreightAmount())).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal total = rows.stream().map(row -> money(row.getAfterAmount())).reduce(BigDecimal.ZERO, BigDecimal::add);
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
rows.forEach(row -> parseMap(row.getFeeItemsJson()).forEach((name, value) ->
feeItems.merge(name, decimal(value), BigDecimal::add)));
if (!rows.isEmpty()) {
detail.setTransportQuantity(rows.get(0).getTransportQuantity());
detail.setMileage(rows.get(0).getMileage());
}
detail.setFreightAmount(freight);
detail.setOtherFeeAmount(total.subtract(freight));
detail.setTotalAmount(total);
detail.setFeeItemsJson(JsonUtil.toJson(feeItems));
updateById(detail);
}
private void rebuildDetailFee(ReceivablePayableDetail detail, String billingPlanId) {
Waybill waybill = waybillService.getById(detail.getWaybillId()); Waybill waybill = waybillService.getById(detail.getWaybillId());
if (waybill == null) { if (waybill == null) {
throw new ServiceException("关联运单不存在"); throw new ServiceException("关联运单不存在");
} }
ReceivablePayableCargoFee cargoFee = buildCargoFee(detail.getId(), waybill); ContractManage contract = contractManageService.getById(detail.getContractId());
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId);
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
cargoFeeMapper.insert(cargoFee); fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); });
detail.setFreightAmount(cargoFee.getFreightAmount()); BigDecimal freight = fees.stream().filter(this::isFreight).map(ReceivablePayableCargoFee::getAfterAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
detail.setOtherFeeAmount(money(waybill.getOtherFeeTotal())); BigDecimal total = fees.stream().map(ReceivablePayableCargoFee::getAfterAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
detail.setTotalAmount(cargoFee.getAfterAmount()); detail.setFreightAmount(freight);
detail.setFeeItemsJson(cargoFee.getFeeItemsJson()); detail.setOtherFeeAmount(total.subtract(freight));
detail.setTotalAmount(total);
detail.setFeeItemsJson(JsonUtil.toJson(fees.stream().collect(HashMap<String, BigDecimal>::new, (map, fee) -> map.put(fee.getCargoName(), fee.getAfterAmount()), HashMap::putAll)));
updateById(detail); updateById(detail);
} }
private void closeDetails(List<Long> ids) { private void closeDetails(List<Long> ids, String settlementType) {
if (Func.isEmpty(ids)) { if (Func.isEmpty(ids)) {
throw new ServiceException("请选择需要关闭的明细"); throw new ServiceException("请选择需要关闭的明细");
} }
for (ReceivablePayableDetail detail : listByIds(ids)) { for (ReceivablePayableDetail detail : listByIds(ids)) {
if (Func.isNotEmpty(settlementType) && !Objects.equals(detail.getSettlementType(), settlementType(settlementType))) {
throw new ServiceException("费用明细结算类型不匹配");
}
if (!"pending".equals(detail.getSettlementStatus())) { if (!"pending".equals(detail.getSettlementStatus())) {
throw new ServiceException("仅待结算明细允许关闭"); throw new ServiceException("仅待结算明细允许关闭");
} }
@@ -408,9 +828,13 @@ public class ReceivablePayableDetailServiceImpl
} }
private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason) { private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason) {
saveChangeRecord(detail, content, reason, "0001");
}
private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo) {
ReceivablePayableChangeRecord record = new ReceivablePayableChangeRecord(); ReceivablePayableChangeRecord record = new ReceivablePayableChangeRecord();
record.setDetailId(detail.getId()); record.setDetailId(detail.getId());
record.setLineNo("0001"); record.setLineNo(lineNo);
record.setCargoName(detail.getCargoName()); record.setCargoName(detail.getCargoName());
record.setChangeContent(content); record.setChangeContent(content);
record.setAdjustUser(AuthUtil.getUserId()); record.setAdjustUser(AuthUtil.getUserId());
@@ -420,6 +844,16 @@ public class ReceivablePayableDetailServiceImpl
changeRecordMapper.insert(record); changeRecordMapper.insert(record);
} }
private void applyManualAdjustment(ReceivablePayableDetail detail, BigDecimal amount, String feeItem, String reason) {
ReceivablePayableCargoFee fee = new ReceivablePayableCargoFee();
fee.setDetailId(detail.getId()); fee.setWaybillId(detail.getWaybillId()); fee.setLineNo("ADJ-" + System.currentTimeMillis());
fee.setCargoName(Func.isEmpty(feeItem) ? "手工调差" : feeItem); fee.setBillingFactor("手工调整"); fee.setBillingType("手工调差");
fee.setOriginalAmount(BigDecimal.ZERO); fee.setAdjustAmount(money(amount)); fee.setAfterAmount(money(amount)); fee.setFeeItemsJson(JsonUtil.toJson(Map.of(fee.getCargoName(), money(amount)))); fee.setRemark(reason);
cargoFeeMapper.insert(fee);
detail.setOtherFeeAmount(money(detail.getOtherFeeAmount()).add(money(amount)));
detail.setTotalAmount(money(detail.getTotalAmount()).add(money(amount))); updateById(detail);
}
private ReceivablePayableDetail getExisting(Long id) { private ReceivablePayableDetail getExisting(Long id) {
ReceivablePayableDetail detail = getById(id); ReceivablePayableDetail detail = getById(id);
if (detail == null || Objects.equals(detail.getIsDeleted(), 1)) { if (detail == null || Objects.equals(detail.getIsDeleted(), 1)) {
@@ -428,12 +862,21 @@ public class ReceivablePayableDetailServiceImpl
return detail; return detail;
} }
private boolean existsByWaybill(Long waybillId) { private boolean existsByWaybill(Long waybillId, String settlementType) {
return count(Wrappers.<ReceivablePayableDetail>lambdaQuery() return count(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getWaybillId, waybillId) .eq(ReceivablePayableDetail::getWaybillId, waybillId)
.eq(ReceivablePayableDetail::getSettlementType, settlementType)
.eq(ReceivablePayableDetail::getIsDeleted, 0)) > 0; .eq(ReceivablePayableDetail::getIsDeleted, 0)) > 0;
} }
private String settlementType(String value) {
if (Func.isEmpty(value)) return "receivable";
if (!List.of("receivable", "payable").contains(value)) {
throw new ServiceException("结算类型不正确");
}
return value;
}
private void validateGenerateRequest(ReceivablePayableGenerateRequest request, boolean requireWaybill) { private void validateGenerateRequest(ReceivablePayableGenerateRequest request, boolean requireWaybill) {
if (Func.isEmpty(request.getContractId())) { if (Func.isEmpty(request.getContractId())) {
throw new ServiceException("请选择运单合同"); throw new ServiceException("请选择运单合同");
@@ -441,6 +884,7 @@ public class ReceivablePayableDetailServiceImpl
if (Func.isEmpty(request.getBillingPlanId())) { if (Func.isEmpty(request.getBillingPlanId())) {
throw new ServiceException("请选择计费方案"); throw new ServiceException("请选择计费方案");
} }
settlementType(request.getSettlementType());
if (requireWaybill && Func.isEmpty(request.getWaybillIds())) { if (requireWaybill && Func.isEmpty(request.getWaybillIds())) {
throw new ServiceException("请选择需要生成费用的运单"); throw new ServiceException("请选择需要生成费用的运单");
} }
@@ -42,11 +42,13 @@ import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IWaybillService; import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.wrapper.WaybillWrapper; import org.springblade.transport.wrapper.WaybillWrapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
@@ -62,6 +64,7 @@ import java.util.stream.Collectors;
* @author Chill * @author Chill
*/ */
@Service @Service
@Slf4j
public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill> implements IWaybillService { public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill> implements IWaybillService {
private static final String STATUS_DRAFT = "draft"; private static final String STATUS_DRAFT = "draft";
@@ -69,6 +72,10 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@jakarta.annotation.Resource @jakarta.annotation.Resource
private ILoadingManageService loadingManageService; private ILoadingManageService loadingManageService;
@jakarta.annotation.Resource
@org.springframework.context.annotation.Lazy
private IReceivablePayableDetailService receivablePayableDetailService;
@Override @Override
public IPage<WaybillVO> selectWaybillPage(IPage<Waybill> page, WaybillVO waybill) { public IPage<WaybillVO> selectWaybillPage(IPage<Waybill> page, WaybillVO waybill) {
IPage<Waybill> entityPage = page(page, buildQuery(waybill)); IPage<Waybill> entityPage = page(page, buildQuery(waybill));
@@ -284,7 +291,11 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
throw new ServiceException("当前状态不允许完成"); throw new ServiceException("当前状态不允许完成");
} }
waybill.setBusinessStatus("completed"); waybill.setBusinessStatus("completed");
return updateById(waybill); boolean updated = updateById(waybill);
if (updated) {
receivablePayableDetailService.generateForCompletedWaybills(List.of(waybill.getId()));
}
return updated;
} }
@Override @Override
@@ -300,6 +311,8 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
complete(waybill.getId()); complete(waybill.getId());
result.setSuccessCount(result.getSuccessCount() + 1); result.setSuccessCount(result.getSuccessCount() + 1);
} catch (Exception exception) { } catch (Exception exception) {
log.error("批量完成运单失败,waybillId:{}, waybillNo:{}, failureReason:{}",
waybill.getId(), waybill.getWaybillNo(), exception.getMessage(), exception);
result.setSkippedCount(result.getSkippedCount() + 1); result.setSkippedCount(result.getSkippedCount() + 1);
result.getSkippedCodes().add(waybill.getWaybillNo()); result.getSkippedCodes().add(waybill.getWaybillNo());
} }
@@ -0,0 +1,55 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.wrapper;
import org.springblade.core.mp.support.BaseEntityWrapper;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.pojo.entity.InsuranceOcrTemplate;
import org.springblade.transport.pojo.vo.InsuranceOcrTemplateVO;
import java.util.Objects;
/**
* 保险OCR识别模板包装类。
*
* @author Chill
*/
public class InsuranceOcrTemplateWrapper extends BaseEntityWrapper<InsuranceOcrTemplate, InsuranceOcrTemplateVO> {
public static InsuranceOcrTemplateWrapper build() {
return new InsuranceOcrTemplateWrapper();
}
@Override
public InsuranceOcrTemplateVO entityVO(InsuranceOcrTemplate insuranceOcrTemplate) {
InsuranceOcrTemplateVO insuranceOcrTemplateVO = Objects.requireNonNull(BeanUtil.copyProperties(insuranceOcrTemplate, InsuranceOcrTemplateVO.class));
insuranceOcrTemplateVO.setCreateUserName(UserCache.getUserRealName(insuranceOcrTemplate.getCreateUser()));
insuranceOcrTemplateVO.setUpdateUserName(UserCache.getUserRealName(insuranceOcrTemplate.getUpdateUser()));
return insuranceOcrTemplateVO;
}
}
+11
View File
@@ -94,6 +94,17 @@ thirdParty:
# MK开放接口地址 # MK开放接口地址
baseUrl: http://127.0.0.1:8080 baseUrl: http://127.0.0.1:8080
#百度OCR配置,API Key和Secret Key请通过环境变量注入
baidu:
ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
api-key: ${BAIDU_OCR_API_KEY:}
secret-key: ${BAIDU_OCR_SECRET_KEY:}
endpoint: ${BAIDU_OCR_ENDPOINT:https://aip.baidubce.com}
connect-timeout: 5s
request-timeout: 30s
token-refresh-advance: 1m
powerjob: powerjob:
worker: worker:
+11
View File
@@ -66,6 +66,17 @@ thirdParty:
# MK开放接口地址 # MK开放接口地址
baseUrl: http://127.0.0.1:8080 baseUrl: http://127.0.0.1:8080
#百度OCR配置,API Key和Secret Key请通过环境变量注入
baidu:
ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
api-key: ${BAIDU_OCR_API_KEY:}
secret-key: ${BAIDU_OCR_SECRET_KEY:}
endpoint: ${BAIDU_OCR_ENDPOINT:https://aip.baidubce.com}
connect-timeout: 5s
request-timeout: 30s
token-refresh-advance: 1m
powerjob: powerjob:
worker: worker:
server-address: 172.16.203.228:7700 server-address: 172.16.203.228:7700
+11
View File
@@ -43,3 +43,14 @@ blade:
url: jdbc:mysql://192.168.0.188:3306/bladex?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true url: jdbc:mysql://192.168.0.188:3306/bladex?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: root username: root
password: root password: root
#百度OCR配置,API Key和Secret Key请通过环境变量注入
baidu:
ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
api-key: ${BAIDU_OCR_API_KEY:}
secret-key: ${BAIDU_OCR_SECRET_KEY:}
endpoint: ${BAIDU_OCR_ENDPOINT:https://aip.baidubce.com}
connect-timeout: 5s
request-timeout: 30s
token-refresh-advance: 1m
@@ -0,0 +1,6 @@
-- 合同费用生成、结算配置及付款比例设置
ALTER TABLE blade_contract_manage
ADD COLUMN fee_generation_mode varchar(20) DEFAULT 'system' COMMENT '费用生成模式:system系统生成,manual手动生成' AFTER billing_enabled,
ADD COLUMN pre_settlement_config_json text DEFAULT NULL COMMENT '预结算配置JSON' AFTER settlement_rule_json,
ADD COLUMN formal_settlement_config_json text DEFAULT NULL COMMENT '正式结算配置JSON' AFTER pre_settlement_config_json,
ADD COLUMN payment_ratio_json text DEFAULT NULL COMMENT '付款比例设置JSON' AFTER formal_settlement_config_json;
@@ -0,0 +1,32 @@
-- ----------------------------
-- Table structure for blade_insurance_ocr_template
-- ----------------------------
DROP TABLE IF EXISTS `blade_insurance_ocr_template`;
CREATE TABLE `blade_insurance_ocr_template` (
`id` bigint(20) NOT NULL COMMENT '主键',
`tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '模板名称',
`mapping_config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '字段映射配置JSON',
`create_user` bigint(20) DEFAULT NULL COMMENT '创建人',
`create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_user` bigint(20) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`status` int(11) NOT NULL DEFAULT '1' COMMENT '状态',
`is_deleted` int(11) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_insurance_ocr_template_name` (`tenant_id`, `name`) USING BTREE,
KEY `idx_insurance_ocr_template_status` (`status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='保险OCR识别模板';
-- ----------------------------
-- Menu data for insurance OCR template
-- parent_id:基础配置 1164733399668962201
-- ----------------------------
INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES
(2086000000000000001, 1164733399668962201, 'insurance_ocr_template', '保险OCR识别模板', 'insurance_ocr_template', '/base/insurance-ocr-template', 'iconfont iconicon_doc', 90, 1, 0, 1, NULL, '', 0),
(2086000000000000002, 2086000000000000001, 'insurance_ocr_template_add', '新增', 'insurance_ocr_template_add', '', '', 1, 2, 0, 1, NULL, '', 0),
(2086000000000000003, 2086000000000000001, 'insurance_ocr_template_edit', '编辑', 'insurance_ocr_template_edit', '', '', 2, 2, 0, 1, NULL, '', 0),
(2086000000000000004, 2086000000000000001, 'insurance_ocr_template_delete', '删除', 'insurance_ocr_template_delete', '', '', 3, 2, 0, 1, NULL, '', 0),
(2086000000000000005, 2086000000000000001, 'insurance_ocr_template_view', '查看', 'insurance_ocr_template_view', '', '', 4, 2, 0, 1, NULL, '', 0),
(2086000000000000006, 2086000000000000001, 'insurance_ocr_template_list', '列表', 'insurance_ocr_template_list', '/blade-transport/insurance-ocr-template/list', '', 5, 2, 0, 1, NULL, '', 0);