diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableAdjustFeeRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableAdjustFeeRequest.java new file mode 100644 index 0000000..e434b2a --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableAdjustFeeRequest.java @@ -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 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 feeItems; + } +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableGenerateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableGenerateRequest.java index 02dffaf..882bb36 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableGenerateRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableGenerateRequest.java @@ -46,6 +46,9 @@ public class ReceivablePayableGenerateRequest implements Serializable { @Schema(description = "合同ID") private Long contractId; + @Schema(description = "结算类型:receivable/payable") + private String settlementType; + @Schema(description = "计费方案ID") private String billingPlanId; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableUpdateFeeRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableUpdateFeeRequest.java index 0c1d3ea..9268eb2 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableUpdateFeeRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableUpdateFeeRequest.java @@ -28,6 +28,7 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; import java.util.List; +import java.math.BigDecimal; /** * 应收应付更新费用请求 @@ -47,12 +48,21 @@ public class ReceivablePayableUpdateFeeRequest implements Serializable { @Schema(description = "合同ID") private Long contractId; + @Schema(description = "结算类型:receivable/payable") + private String settlementType; + @Schema(description = "计费方案ID") private String billingPlanId; @Schema(description = "调整原因") private String adjustReason; + @Schema(description = "手工调差金额,可正可负") + private BigDecimal adjustAmount; + + @Schema(description = "手工调差费用项") + private String adjustFeeItem; + @Schema(description = "仅关闭") private Boolean closeOnly; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java index 5ee249e..55325a5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java @@ -134,6 +134,9 @@ public class ContractManage extends TenantEntity { @Schema(description = "计费信息开关") private Integer billingEnabled; + @Schema(description = "费用生成模式:system系统生成,manual手动生成") + private String feeGenerationMode; + @Schema(description = "合同主文件JSON") private String contractFileJson; @@ -146,6 +149,15 @@ public class ContractManage extends TenantEntity { @Schema(description = "结算生成规则JSON") private String settlementRuleJson; + @Schema(description = "预结算配置JSON") + private String preSettlementConfigJson; + + @Schema(description = "正式结算配置JSON") + private String formalSettlementConfigJson; + + @Schema(description = "付款比例设置JSON") + private String paymentRatioJson; + @Schema(description = "对账配置JSON") private String reconciliationJson; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InsuranceOcrTemplate.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InsuranceOcrTemplate.java new file mode 100644 index 0000000..6fe108d --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InsuranceOcrTemplate.java @@ -0,0 +1,60 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java new file mode 100644 index 0000000..d22af13 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java @@ -0,0 +1,59 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 result; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InsuranceOcrTemplateVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InsuranceOcrTemplateVO.java new file mode 100644 index 0000000..4c0c3c6 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InsuranceOcrTemplateVO.java @@ -0,0 +1,59 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +} diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileController.java b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileController.java index 37cab5d..5ac37ae 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileController.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileController.java @@ -11,6 +11,7 @@ import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.log.annotation.ApiLog; 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.utils.StringUtil; import org.springblade.file.listener.FileEvent; @@ -57,29 +58,29 @@ public class FileController extends BladeController { @ApiLog("附件管理-批量上传") @Operation(summary = "OBS批量上传", description = "OBS批量上传,参数名:files") @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); } @Operation(summary = "上传", description = "上传,参数名:file") @PostMapping("/upload") - public R upload(MultipartFile file) { + public FR upload(MultipartFile file) { MultipartFile[] files = {file}; R> result = attachmentService.batchOssUpload(files, null); List data = result.getData(); - if(data != null && !data.isEmpty())return R.data(data.get(0)); - return R.fail("上传失败"); + if(data != null && !data.isEmpty())return FR.data(data.get(0)); + return FR.fail("上传失败"); } @Operation(summary = "获取附件,多个附件id用逗号分割", description = "获取附件,多个附件id用逗号分割") @GetMapping("/getAttachment") - public R getAttachment(@RequestParam(value = "id") String id) { - return R.data(attachmentService.getAttachment(id)); + public FR getAttachment(@RequestParam(value = "id") String id) { + return FR.data(attachmentService.getAttachment(id)); } @Operation(summary = "获取文件url", description = "获取文件url,参数:objectKey") @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)) { // 附件名为空,查询附件名 Attachment attachment = attachmentService.getOne(Wrappers.lambdaQuery() @@ -94,7 +95,7 @@ public class FileController extends BladeController { 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") @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) { - 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") @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) { - return R.data(wpsService.getWpsFileEditUrl(attachmentId, attachmentName)); + return FR.data(wpsService.getWpsFileEditUrl(attachmentId, attachmentName)); } @Operation(summary = "批量获取文件url", description = "批量获取文件url,参数:objectKey数组") @PostMapping("/getFileUrls") - public R getFileUrls(@RequestBody List objectKeys) { - return R.data(fileService.getFileUrls(objectKeys, null)); + public FR getFileUrls(@RequestBody List objectKeys) { + return FR.data(fileService.getFileUrls(objectKeys, null)); } @ApiLog("OCR识别-识别身份证") @Operation(summary = "识别身份证信息支持正反面", description = "参数:url") @GetMapping("/recognitionIDCard") - public R recognitionIDCard(@RequestParam(value = "url", required = false) String url, - @RequestParam(value = "objectKey", required = false) String objectKey) { + public FR recognitionIDCard(@RequestParam(value = "url", required = false) String url, + @RequestParam(value = "objectKey", required = false) String objectKey) { String imageUrl = StringUtil.isNotBlank(url) ? url : objectKey; if (StringUtil.isBlank(imageUrl)) { - return R.fail("图片地址不能为空"); + return FR.fail("图片地址不能为空"); } if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) { imageUrl = fileService.getFileUrl(imageUrl, null); } - return R.data(ocrService.recognitionIDCard(List.of(imageUrl))); + return FR.data(ocrService.recognitionIDCard(List.of(imageUrl))); } @SentinelResource("ocr:batchCards") @ApiLog("OCR识别-识别车辆运输凭证(不知道类型)") @Operation(summary = "识别车辆运输凭证,不知道类型", description = "参数:objectKeylist") @PostMapping("/recognitionTransportCertificates") - public R recognitionTransportCertificates( + public FR recognitionTransportCertificates( @RequestBody List attachments, @RequestParam(value = "projectAbbreviation", required = false) String projectAbbreviation, @RequestParam(value = "code", required = false) String code) { - return R.data(ocrConvertService.recognitionTransportCertificate( + return FR.data(ocrConvertService.recognitionTransportCertificate( buildCertificateBatchRecognitionDTO(attachments, projectAbbreviation, code) )); } diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/IAttachmentService.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/IAttachmentService.java index 0a54e01..d423a13 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/IAttachmentService.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/IAttachmentService.java @@ -26,6 +26,7 @@ package org.springblade.file.service; import com.baomidou.mybatisplus.extension.service.IService; +import org.springblade.core.tool.api.FR; import org.springblade.core.tool.api.R; import org.springblade.file.pojo.entity.Attachment; import org.springblade.file.pojo.vo.AttachmentDetailVO; @@ -49,7 +50,7 @@ public interface IAttachmentService extends IService { * @param fileName 文件名,如果 files只有1个,且fileName不为空,设置文件名为 fileName * @return */ - R> batchOssUpload(MultipartFile[] files, String fileName); + FR> batchOssUpload(MultipartFile[] files, String fileName); /** * 获取附件,多个附件id用逗号分割 diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/AttachmentServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/AttachmentServiceImpl.java index 67bd5fb..4a5195e 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/AttachmentServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/AttachmentServiceImpl.java @@ -32,6 +32,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; 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.utils.CollectionUtil; import org.springblade.core.tool.utils.SpringUtil; @@ -78,7 +79,7 @@ public class AttachmentServiceImpl extends ServiceImpl> batchOssUpload(MultipartFile[] files, String overWriteFileName){ + public FR> batchOssUpload(MultipartFile[] files, String overWriteFileName){ try { List attachmentList = new ArrayList<>(); // 文件数量为1个,且重写的文件名不为空,使用重写的文件名,给uniapp上传使用,uniapp上传的文件名不是原始文件名 @@ -98,11 +99,11 @@ public class AttachmentServiceImpl extends ServiceImpl + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 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 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()); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java index 4b8ba6c..412d674 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java @@ -136,6 +136,12 @@ public class ContractManageController extends BladeController { 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") @ApiOperationSupport(order = 11) @Operation(summary = "终止合同", description = "传入id和reason") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InsuranceOcrTemplateController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InsuranceOcrTemplateController.java new file mode 100644 index 0000000..100dd40 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InsuranceOcrTemplateController.java @@ -0,0 +1,98 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 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> 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))); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InsuranceRecordController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InsuranceRecordController.java index 6c0d3c3..4dc3153 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InsuranceRecordController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InsuranceRecordController.java @@ -166,7 +166,7 @@ public class InsuranceRecordController extends BladeController { */ @PostMapping("/recognize") @ApiOperationSupport(order = 8) - @Operation(summary = "OCR识别保单", description = "上传保单图片或PDF") + @Operation(summary = "OCR识别保单", description = "上传保单图片") public R recognize(MultipartFile file, @RequestParam(required = false) String vehicleType, @RequestParam(required = false) String ocrTemplate) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java index 24aea17..dfa2720 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java @@ -35,6 +35,7 @@ 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.DateUtil; +import org.springblade.transport.pojo.dto.ReceivablePayableAdjustFeeRequest; import org.springblade.transport.pojo.dto.ReceivablePayableGenerateRequest; import org.springblade.transport.pojo.dto.ReceivablePayableTransferRequest; 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.RestController; +import java.util.List; import java.util.Map; /** @@ -94,8 +96,24 @@ public class ReceivablePayableDetailController extends BladeController { return R.success("更新成功"); } - @GetMapping("/transfer-candidates") + @GetMapping("/update-fee-contracts") @ApiOperationSupport(order = 5) + @Operation(summary = "更新费用可选合同") + public R>> 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 = "转结算候选明细") public R>> transferCandidates(Query query, @RequestParam(required = false) String contractName, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InsuranceOcrTemplateMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InsuranceOcrTemplateMapper.java new file mode 100644 index 0000000..7984fed --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InsuranceOcrTemplateMapper.java @@ -0,0 +1,38 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 { + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/config/BaiduOcrConfiguration.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/config/BaiduOcrConfiguration.java new file mode 100644 index 0000000..bc3c9de --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/config/BaiduOcrConfiguration.java @@ -0,0 +1,55 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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(); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/config/BaiduOcrProperties.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/config/BaiduOcrProperties.java new file mode 100644 index 0000000..8cc4529 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/config/BaiduOcrProperties.java @@ -0,0 +1,79 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/constant/BaiduOcrType.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/constant/BaiduOcrType.java new file mode 100644 index 0000000..8384508 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/constant/BaiduOcrType.java @@ -0,0 +1,100 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IBaiduOcrService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IBaiduOcrService.java new file mode 100644 index 0000000..5b1c1c0 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IBaiduOcrService.java @@ -0,0 +1,57 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java new file mode 100644 index 0000000..3a60627 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java @@ -0,0 +1,356 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 SUPPORTED_IMAGE_FORMATS = Set.of("JPEG", "JPG", "PNG", "BMP"); + private static final TypeReference> 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 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 form = new LinkedHashMap<>(); + form.put("url", normalizedUrl); + return request(type, side, form); + } + + private BaiduOcrResultVO request(BaiduOcrType type, String side, Map 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 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 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 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 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 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 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)); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java index 801ce23..a1299d8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java @@ -48,6 +48,7 @@ public interface IContractManageService extends BaseService { boolean reject(Long id); boolean withdraw(Long id); boolean startChange(Long id, String changeContent, String changeReason); + boolean submitChange(ContractManage contractManage); boolean updateAttachments(ContractManage contractManage); boolean terminate(Long id, String reason); boolean removeDraft(String ids); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInsuranceOcrTemplateService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInsuranceOcrTemplateService.java new file mode 100644 index 0000000..937c086 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInsuranceOcrTemplateService.java @@ -0,0 +1,57 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 { + + /** + * 分页查询模板。 + * + * @param page 分页参数 + * @param insuranceOcrTemplate 查询条件 + * @return 模板分页 + */ + IPage selectInsuranceOcrTemplatePage(IPage page, InsuranceOcrTemplateVO insuranceOcrTemplate); + + /** + * 保存模板。 + * + * @param insuranceOcrTemplate 模板 + * @return 是否成功 + */ + boolean submit(InsuranceOcrTemplate insuranceOcrTemplate); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java index f085d7c..a500178 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java @@ -24,6 +24,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; 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.ReceivablePayableTransferRequest; 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.ReceivablePayableFeeDetailVO; +import java.util.List; import java.util.Map; /** @@ -49,6 +51,10 @@ public interface IReceivablePayableDetailService extends BaseService> updateFeeContracts(String settlementType); + + void adjustFee(ReceivablePayableAdjustFeeRequest request); + void transferSettlement(ReceivablePayableTransferRequest request); IPage> transferCandidates(IPage page, String contractName, String batchNo, @@ -59,4 +65,7 @@ public interface IReceivablePayableDetailService extends BaseService page, ReceivablePayableGenerateRequest request); void generateFee(ReceivablePayableGenerateRequest request); + + /** 完成运单后按合同系统计费模式自动生成应收、应付明细。 */ + void generateForCompletedWaybills(List waybillIds); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java index d61c6ce..21eefe7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java @@ -44,6 +44,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -205,6 +206,25 @@ public class ContractManageServiceImpl extends BaseServiceImpl 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) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceOcrTemplateServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceOcrTemplateServiceImpl.java new file mode 100644 index 0000000..92b83f5 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceOcrTemplateServiceImpl.java @@ -0,0 +1,154 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 implements IInsuranceOcrTemplateService { + + private static final int NAME_MAX_LENGTH = 100; + private static final int MAPPING_VALUE_MAX_LENGTH = 100; + private static final List INSURANCE_FIELD_KEYS = List.of( + "保险类型", "保单号", "开始日期", "结束日期", "保额", "保费", "发票号", "开票日期", "备注" + ); + + @Override + public IPage selectInsuranceOcrTemplatePage(IPage page, InsuranceOcrTemplateVO insuranceOcrTemplate) { + LambdaQueryWrapper queryWrapper = Wrappers.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.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 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(); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java index b262ddc..a6efd6e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java @@ -25,9 +25,13 @@ */ 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.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; 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.InsuranceRecordExportExcel; 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.InsuranceOcrTemplate; +import org.springblade.transport.pojo.vo.BaiduOcrResultVO; import org.springblade.transport.pojo.vo.InsuranceRecordVO; +import org.springblade.transport.service.IInsuranceOcrTemplateService; import org.springblade.transport.service.IInsuranceRecordService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collection; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 保险记录 服务实现类 @@ -55,6 +71,7 @@ import java.util.Set; * @author Chill */ @Service +@RequiredArgsConstructor public class InsuranceRecordServiceImpl extends BaseServiceImpl implements IInsuranceRecordService { private static final int VEHICLE_NO_MAX_LENGTH = 50; @@ -64,7 +81,11 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl SUPPORT_FILE_TYPES = Set.of("jpg", "jpeg", "png", "pdf"); + private static final Set 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 public IPage selectInsuranceRecordPage(IPage page, InsuranceRecordVO insuranceRecord) { @@ -116,10 +137,131 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpllambdaQuery() + .eq(InsuranceOcrTemplate::getIsDeleted, 0) + .eq(InsuranceOcrTemplate::getStatus, 1) + .eq(InsuranceOcrTemplate::getName, templateName)); + if (insuranceOcrTemplate == null) { + throw new ServiceException("OCR识别模板不存在或已停用"); + } InsuranceRecord insuranceRecord = new InsuranceRecord(); insuranceRecord.setVehicleType(normalizeVehicleType(vehicleType)); - insuranceRecord.setOcrTemplate(trimToNull(ocrTemplate)); - throw new ServiceException("当前未配置OCR识别服务,请手动填写保单信息"); + insuranceRecord.setOcrTemplate(templateName); + 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 ocrResult) { + Map mappingMap = parseMappingConfig(mappingConfig); + List 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 parseMappingConfig(String mappingConfig) { + try { + JSONArray mappingArray = JSON.parseArray(mappingConfig); + Map 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 extractOcrWords(Map 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 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) { @@ -186,7 +328,7 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl implements IReceivablePayableDetailService { @@ -82,15 +90,18 @@ public class ReceivablePayableDetailServiceImpl private final ReceivablePayableChangeRecordMapper changeRecordMapper; private final IWaybillService waybillService; private final IContractManageService contractManageService; + private final ICommonAddressService commonAddressService; public ReceivablePayableDetailServiceImpl(ReceivablePayableCargoFeeMapper cargoFeeMapper, - ReceivablePayableChangeRecordMapper changeRecordMapper, - IWaybillService waybillService, - IContractManageService contractManageService) { + ReceivablePayableChangeRecordMapper changeRecordMapper, + IWaybillService waybillService, + IContractManageService contractManageService, + ICommonAddressService commonAddressService) { this.cargoFeeMapper = cargoFeeMapper; this.changeRecordMapper = changeRecordMapper; this.waybillService = waybillService; this.contractManageService = contractManageService; + this.commonAddressService = commonAddressService; } @Override @@ -105,7 +116,11 @@ public class ReceivablePayableDetailServiceImpl .eq(ReceivablePayableCargoFee::getDetailId, detail.getId()) .eq(ReceivablePayableCargoFee::getIsDeleted, 0) .orderByAsc(ReceivablePayableCargoFee::getCreateTime)); - return buildFeeDetail(rows); + ReceivablePayableFeeDetailVO result = buildFeeDetail(rows); + LinkedHashSet feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId())); + feeItemNames.addAll(result.getFeeItemNames()); + result.setFeeItemNames(new ArrayList<>(feeItemNames)); + return result; } @Override @@ -125,11 +140,11 @@ public class ReceivablePayableDetailServiceImpl @Transactional(rollbackFor = Exception.class) public void updateFee(ReceivablePayableUpdateFeeRequest request) { if (Boolean.TRUE.equals(request.getCloseOnly())) { - closeDetails(request.getIds()); + closeDetails(request.getIds(), request.getSettlementType()); return; } - if (Func.isEmpty(request.getIds()) && Func.isEmpty(request.getContractId())) { - throw new ServiceException("请选择需要更新的费用明细或合同"); + if (Func.isEmpty(request.getContractId())) { + throw new ServiceException("请选择需要更新费用的合同"); } List details = list(buildUpdateQuery(request)); if (Func.isEmpty(details)) { @@ -140,12 +155,107 @@ public class ReceivablePayableDetailServiceImpl continue; } 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()) + "]", request.getAdjustReason()); } } + @Override + public List> updateFeeContracts(String settlementType) { + List contractIds = list(Wrappers.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 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 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 existingRows = cargoFeeMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceivablePayableCargoFee::getDetailId, detail.getId()) + .eq(ReceivablePayableCargoFee::getIsDeleted, 0)); + Map existingMap = existingRows.stream() + .collect(java.util.stream.Collectors.toMap(ReceivablePayableCargoFee::getId, row -> row)); + if (request.getRows().size() != existingRows.size()) { + throw new ServiceException("费用调整行数据不完整"); + } + Set allowedFeeItems = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId())); + existingRows.forEach(row -> allowedFeeItems.addAll(parseMap(row.getFeeItemsJson()).keySet())); + List 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 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 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 @Transactional(rollbackFor = Exception.class) public void transferSettlement(ReceivablePayableTransferRequest request) { @@ -203,11 +313,10 @@ public class ReceivablePayableDetailServiceImpl public ReceivablePayableFeeDetailVO generatePreview(IPage page, ReceivablePayableGenerateRequest request) { validateGenerateRequest(request, true); List waybills = waybillService.list(buildWaybillQuery(request)); + ContractManage contract = contractManageService.getById(request.getContractId()); List fees = waybills.stream() - .skip((page.getCurrent() - 1) * page.getSize()) - .limit(page.getSize()) - .map(waybill -> buildCargoFee(null, waybill)) - .toList(); + .skip((page.getCurrent() - 1) * page.getSize()).limit(page.getSize()) + .flatMap(waybill -> calculatedFees(waybill, contract, request.getBillingPlanId()).stream()).toList(); ReceivablePayableFeeDetailVO vo = buildFeeDetail(fees); vo.setTotal((long) waybills.size()); return vo; @@ -223,16 +332,123 @@ public class ReceivablePayableDetailServiceImpl } ContractManage contract = contractManageService.getById(request.getContractId()); for (Waybill waybill : waybills) { - if (existsByWaybill(waybill.getId())) { + if (existsByWaybill(waybill.getId(), settlementType(request.getSettlementType()))) { continue; } - ReceivablePayableDetail detail = buildDetail(waybill, contract); + ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), settlementType(request.getSettlementType())); save(detail); - ReceivablePayableCargoFee cargoFee = buildCargoFee(detail.getId(), waybill); - cargoFeeMapper.insert(cargoFee); + calculatedFees(waybill, contract, request.getBillingPlanId()).forEach(fee -> { + fee.setDetailId(detail.getId()); + cargoFeeMapper.insert(fee); + }); } } + @Override + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW, + rollbackFor = Exception.class) + public void generateForCompletedWaybills(List 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 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> plans = parseList(contract.getBillingPlanJson()); + if (plans.isEmpty()) return null; + Map 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 buildQuery(ReceivablePayableDetailVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ReceivablePayableDetail::getIsDeleted, 0) @@ -251,6 +467,7 @@ public class ReceivablePayableDetailServiceImpl .like(Func.isNotEmpty(query.getBatchNo()), ReceivablePayableDetail::getBatchNo, query.getBatchNo()) .like(Func.isNotEmpty(query.getVehicleNo()), ReceivablePayableDetail::getVehicleNo, query.getVehicleNo()) .eq(Func.isNotEmpty(query.getSettlementStatus()), ReceivablePayableDetail::getSettlementStatus, query.getSettlementStatus()); + wrapper.eq(Func.isNotEmpty(query.getSettlementType()), ReceivablePayableDetail::getSettlementType, query.getSettlementType()); return wrapper.orderByDesc(ReceivablePayableDetail::getCreateTime); } @@ -258,12 +475,10 @@ public class ReceivablePayableDetailServiceImpl LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ReceivablePayableDetail::getIsDeleted, 0) .eq(ReceivablePayableDetail::getSettlementStatus, "pending"); - if (Func.isNotEmpty(request.getIds())) { - wrapper.in(ReceivablePayableDetail::getId, request.getIds()); - } - if (Func.isNotEmpty(request.getContractId())) { - wrapper.eq(ReceivablePayableDetail::getContractId, request.getContractId()); + if (Func.isNotEmpty(request.getSettlementType())) { + wrapper.eq(ReceivablePayableDetail::getSettlementType, settlementType(request.getSettlementType())); } + wrapper.eq(ReceivablePayableDetail::getContractId, request.getContractId()); return wrapper; } @@ -272,7 +487,8 @@ public class ReceivablePayableDetailServiceImpl .eq(Waybill::getIsDeleted, 0) .eq(Waybill::getContractId, request.getContractId()) .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())) { wrapper.like(Waybill::getBatchNo, request.getBatchNo()); } @@ -288,20 +504,29 @@ public class ReceivablePayableDetailServiceImpl return wrapper.orderByDesc(Waybill::getCreateTime); } - private ReceivablePayableDetail buildDetail(Waybill waybill, ContractManage contract) { - ReceivablePayableCargoFee cargoFee = buildCargoFee(null, waybill); + private ReceivablePayableDetail buildDetail(Waybill waybill, ContractManage contract, String billingPlanId, String settlementType) { + List fees = calculatedFees(waybill, contract, billingPlanId); + return buildDetail(waybill, contract, settlementType, fees); + } + + private ReceivablePayableDetail buildDetail(Waybill waybill, ContractManage contract, String settlementType, List 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(); detail.setDocumentNo(nextDocumentNo()); - detail.setSettlementType("receivable"); + detail.setSettlementType(settlementType); detail.setProjectId(waybill.getProjectId()); detail.setProjectName(waybill.getProjectName()); detail.setDeptId(waybill.getDeptId()); detail.setDeptName(waybill.getDeptName()); detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate()); - detail.setCustomerName(waybill.getCustomerName()); - detail.setContractId(waybill.getContractId()); + detail.setCustomerName("payable".equals(settlementType) + ? (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.setContractName(waybill.getContractName()); + detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName()); detail.setSourceType("系统生成"); detail.setWaybillId(waybill.getId()); detail.setWaybillNo(waybill.getWaybillNo()); @@ -315,11 +540,12 @@ public class ReceivablePayableDetailServiceImpl detail.setBatchNo(waybill.getBatchNo()); detail.setUnitPrice(waybill.getUnitPrice()); detail.setCurrency("RMB"); - detail.setFreightAmount(cargoFee.getFreightAmount()); - detail.setOtherFeeAmount(waybill.getOtherFeeTotal()); - detail.setTotalAmount(cargoFee.getAfterAmount()); + detail.setFreightAmount(freight); + detail.setOtherFeeAmount(other); + detail.setTotalAmount(total); detail.setSettlementStatus("pending"); - detail.setFeeItemsJson(cargoFee.getFeeItemsJson()); + detail.setFeeItemsJson(JsonUtil.toJson(fees.stream().collect(HashMap::new, + (map, fee) -> map.put(fee.getCargoName(), fee.getAfterAmount()), HashMap::putAll))); return detail; } @@ -358,6 +584,133 @@ public class ReceivablePayableDetailServiceImpl return cargoFee; } + private List calculatedFees(Waybill waybill, ContractManage contract, String planId) { + return calculatedFees(waybill, contract, planId, false); + } + + private List calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) { + List> plans = parseList(contract == null ? null : contract.getBillingPlanJson()); + Map 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 result = new ArrayList<>(); + int line = 1; + for (Object value : (List) plan.get("rules")) { + if (!(value instanceof Map raw)) continue; + Map 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 plan) { + Object value = plan.get("defaultPlan"); + return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(String.valueOf(value)); + } + + private BigDecimal calculateRule(Map 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> 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 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 rule) { + String type = stringValue(rule, "feeType", "") + stringValue(rule, "feeItem", ""); + return type.contains("运费") || type.contains("运输费"); + } + + private BigDecimal measure(Map 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 goods = parseMap(waybill.getGoodsJson()); + BigDecimal value = decimal(goods.get("volume")); + if (value.signum() == 0) value = decimal(goods.get("cargoVolume")); + return value; + } + + private List> ranges(Map rule) { + Object source = rule.get("limitRanges"); + if (!(source instanceof List)) { Map 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> result = new ArrayList<>(); + for (Object value : (List) source) if (value instanceof Map raw) { + Map 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> range(List> ranges, BigDecimal value) { + List> sorted = ranges.stream().sorted(Comparator.comparing(x -> decimal(x.get("lowerLimit")))).toList(); + for (int i = 0; i < sorted.size(); i++) { + Map 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> 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 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 map, String key) { return stringValue(map, key, ""); } + private String stringValue(Map 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 rows) { Set feeItemNames = new LinkedHashSet<>(); List records = rows.stream().map(row -> { @@ -379,26 +732,93 @@ public class ReceivablePayableDetailServiceImpl return vo; } - private void rebuildDetailFee(ReceivablePayableDetail detail) { + private List contractFeeItemNames(Long contractId) { + if (contractId == null) return List.of(); + ContractManage contract = contractManageService.getById(contractId); + if (contract == null) return List.of(); + LinkedHashSet names = new LinkedHashSet<>(); + for (Map 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 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 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 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 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()); if (waybill == null) { throw new ServiceException("关联运单不存在"); } - ReceivablePayableCargoFee cargoFee = buildCargoFee(detail.getId(), waybill); + ContractManage contract = contractManageService.getById(detail.getContractId()); + List fees = calculatedFees(waybill, contract, billingPlanId); cargoFeeMapper.delete(Wrappers.lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); - cargoFeeMapper.insert(cargoFee); - detail.setFreightAmount(cargoFee.getFreightAmount()); - detail.setOtherFeeAmount(money(waybill.getOtherFeeTotal())); - detail.setTotalAmount(cargoFee.getAfterAmount()); - detail.setFeeItemsJson(cargoFee.getFeeItemsJson()); + fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); }); + 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); + detail.setFreightAmount(freight); + detail.setOtherFeeAmount(total.subtract(freight)); + detail.setTotalAmount(total); + detail.setFeeItemsJson(JsonUtil.toJson(fees.stream().collect(HashMap::new, (map, fee) -> map.put(fee.getCargoName(), fee.getAfterAmount()), HashMap::putAll))); updateById(detail); } - private void closeDetails(List ids) { + private void closeDetails(List ids, String settlementType) { if (Func.isEmpty(ids)) { throw new ServiceException("请选择需要关闭的明细"); } for (ReceivablePayableDetail detail : listByIds(ids)) { + if (Func.isNotEmpty(settlementType) && !Objects.equals(detail.getSettlementType(), settlementType(settlementType))) { + throw new ServiceException("费用明细结算类型不匹配"); + } if (!"pending".equals(detail.getSettlementStatus())) { throw new ServiceException("仅待结算明细允许关闭"); } @@ -408,9 +828,13 @@ public class ReceivablePayableDetailServiceImpl } 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(); record.setDetailId(detail.getId()); - record.setLineNo("0001"); + record.setLineNo(lineNo); record.setCargoName(detail.getCargoName()); record.setChangeContent(content); record.setAdjustUser(AuthUtil.getUserId()); @@ -420,6 +844,16 @@ public class ReceivablePayableDetailServiceImpl 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) { ReceivablePayableDetail detail = getById(id); if (detail == null || Objects.equals(detail.getIsDeleted(), 1)) { @@ -428,12 +862,21 @@ public class ReceivablePayableDetailServiceImpl return detail; } - private boolean existsByWaybill(Long waybillId) { + private boolean existsByWaybill(Long waybillId, String settlementType) { return count(Wrappers.lambdaQuery() .eq(ReceivablePayableDetail::getWaybillId, waybillId) + .eq(ReceivablePayableDetail::getSettlementType, settlementType) .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) { if (Func.isEmpty(request.getContractId())) { throw new ServiceException("请选择运单合同"); @@ -441,6 +884,7 @@ public class ReceivablePayableDetailServiceImpl if (Func.isEmpty(request.getBillingPlanId())) { throw new ServiceException("请选择计费方案"); } + settlementType(request.getSettlementType()); if (requireWaybill && Func.isEmpty(request.getWaybillIds())) { throw new ServiceException("请选择需要生成费用的运单"); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 66fb490..60b3984 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -42,11 +42,13 @@ import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.wrapper.WaybillWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; import java.math.BigDecimal; import java.util.ArrayList; @@ -62,6 +64,7 @@ import java.util.stream.Collectors; * @author Chill */ @Service +@Slf4j public class WaybillServiceImpl extends BaseServiceImpl implements IWaybillService { private static final String STATUS_DRAFT = "draft"; @@ -69,6 +72,10 @@ public class WaybillServiceImpl extends BaseServiceImpl @jakarta.annotation.Resource private ILoadingManageService loadingManageService; + @jakarta.annotation.Resource + @org.springframework.context.annotation.Lazy + private IReceivablePayableDetailService receivablePayableDetailService; + @Override public IPage selectWaybillPage(IPage page, WaybillVO waybill) { IPage entityPage = page(page, buildQuery(waybill)); @@ -284,7 +291,11 @@ public class WaybillServiceImpl extends BaseServiceImpl throw new ServiceException("当前状态不允许完成"); } waybill.setBusinessStatus("completed"); - return updateById(waybill); + boolean updated = updateById(waybill); + if (updated) { + receivablePayableDetailService.generateForCompletedWaybills(List.of(waybill.getId())); + } + return updated; } @Override @@ -300,6 +311,8 @@ public class WaybillServiceImpl extends BaseServiceImpl complete(waybill.getId()); result.setSuccessCount(result.getSuccessCount() + 1); } catch (Exception exception) { + log.error("批量完成运单失败,waybillId:{}, waybillNo:{}, failureReason:{}", + waybill.getId(), waybill.getWaybillNo(), exception.getMessage(), exception); result.setSkippedCount(result.getSkippedCount() + 1); result.getSkippedCodes().add(waybill.getWaybillNo()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InsuranceOcrTemplateWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InsuranceOcrTemplateWrapper.java new file mode 100644 index 0000000..b351993 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InsuranceOcrTemplateWrapper.java @@ -0,0 +1,55 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 { + + 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; + } + +} diff --git a/doc/nacos/blade-dev.yaml b/doc/nacos/blade-dev.yaml index ed1dfe1..1fcdc23 100644 --- a/doc/nacos/blade-dev.yaml +++ b/doc/nacos/blade-dev.yaml @@ -94,6 +94,17 @@ thirdParty: # MK开放接口地址 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: worker: diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index 5fb7e2f..d8f6404 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -66,6 +66,17 @@ thirdParty: # MK开放接口地址 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: worker: server-address: 172.16.203.228:7700 diff --git a/doc/nacos/blade-test.yaml b/doc/nacos/blade-test.yaml index 4223a91..2839eb5 100644 --- a/doc/nacos/blade-test.yaml +++ b/doc/nacos/blade-test.yaml @@ -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 username: 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 diff --git a/doc/sql/transport/blade_contract_manage_fee_config_20260817.sql b/doc/sql/transport/blade_contract_manage_fee_config_20260817.sql new file mode 100644 index 0000000..fedd8ec --- /dev/null +++ b/doc/sql/transport/blade_contract_manage_fee_config_20260817.sql @@ -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; diff --git a/doc/sql/transport/blade_insurance_ocr_template.sql b/doc/sql/transport/blade_insurance_ocr_template.sql new file mode 100644 index 0000000..8bd1f7f --- /dev/null +++ b/doc/sql/transport/blade_insurance_ocr_template.sql @@ -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);