diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/cache/SysCache.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/cache/SysCache.java index 75ce8ed9..70d5d881 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/cache/SysCache.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/cache/SysCache.java @@ -65,6 +65,7 @@ public class SysCache { private static final String ROLE_NAMES_ID = "roleNames:id:"; private static final String ROLE_ALIAS_ID = "roleAlias:id:"; private static final String ROLE_ALIASES_ID = "roleAliases:id:"; + private static final String BUSINESS_DEPT_CONFIG = "businessDeptConfig:platformDept:"; public static final String TENANT_ID = "tenant:id:"; public static final String TENANT_TENANT_ID = "tenant:tenantId:"; public static final String TENANT_PACKAGE_ID = "tenant:packageId:"; @@ -381,4 +382,21 @@ public class SysCache { }, Boolean.FALSE); } + /** + * 按平台公司 + 业务部门获取业务部门配置 + * + * @param platformCompanyId 平台公司ID + * @param businessDeptId 业务部门ID + * @return 业务部门配置 + */ + public static BusinessDeptConfig getBusinessDeptConfig(Long platformCompanyId, Long businessDeptId) { + if (platformCompanyId == null || businessDeptId == null) { + return null; + } + return CacheUtil.get(SYS_CACHE, BUSINESS_DEPT_CONFIG, platformCompanyId + StringPool.DASH + businessDeptId, () -> { + R result = getSysClient().getBusinessDeptConfig(platformCompanyId, businessDeptId); + return result.getData(); + }); + } + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java index b55af34f..06c2f08a 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java @@ -76,6 +76,7 @@ public interface ISysClient { String MEASUREMENT_UNITS = API_PREFIX + "/measurement-units"; String PERMISSIONS = API_PREFIX + "/permissions"; String INVOICE_ITEMS = API_PREFIX + "/invoice-items"; + String BUSINESS_DEPT_CONFIG = API_PREFIX + "/business-dept-config"; /** * 获取菜单 @@ -345,4 +346,15 @@ public interface ISysClient { @GetMapping(INVOICE_ITEMS) R> getInvoiceItems(); + /** + * 按平台公司 + 业务部门获取业务部门配置 + * + * @param platformCompanyId 平台公司ID + * @param businessDeptId 业务部门ID + * @return 业务部门配置 + */ + @GetMapping(BUSINESS_DEPT_CONFIG) + R getBusinessDeptConfig(@RequestParam("platformCompanyId") Long platformCompanyId, + @RequestParam("businessDeptId") Long businessDeptId); + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java index bfb7d123..1579cab4 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java @@ -189,4 +189,9 @@ public class ISysClientFallback implements ISysClient { return R.fail("获取数据失败"); } + @Override + public R getBusinessDeptConfig(Long platformCompanyId, Long businessDeptId) { + return R.fail("获取数据失败"); + } + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/BusinessDeptConfig.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/BusinessDeptConfig.java new file mode 100644 index 00000000..18d08659 --- /dev/null +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/BusinessDeptConfig.java @@ -0,0 +1,87 @@ +/** + * 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.system.pojo.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +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; + +/** + * 业务部门配置实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_business_dept_config") +@Schema(description = "业务部门配置") +public class BusinessDeptConfig extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "平台公司ID") + private Long platformCompanyId; + + @Schema(description = "平台公司名称") + private String platformCompanyName; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "业务部门ID") + private Long businessDeptId; + + @Schema(description = "业务部门名称") + private String businessDeptName; + + @Schema(description = "合同编码前缀") + private String contractCodePrefix; + + @Schema(description = "初始流水号") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Integer initialSerial; + + @Schema(description = "金蝶公司代码") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String kingdeeCompanyCode; + + @Schema(description = "金蝶部门代码") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String kingdeeDeptCode; + + @Schema(description = "备注") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String remark; + +} diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java index 0446ece9..a024a6f8 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java @@ -203,14 +203,18 @@ public class Dept extends TenantEntity { /** * 是否平台公司:0否,1是 + * ALWAYS:保证「否(0)」也能更新落库,避免被字段策略跳过 */ @Schema(description = "是否平台公司:0否,1是") + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Integer isPlatformCompany; /** * 是否业务部门:0否,1是 + * ALWAYS:保证「否(0)」也能更新落库,避免被字段策略跳过 */ @Schema(description = "是否业务部门:0否,1是") + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Integer isBusinessDept; /** diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/BusinessDeptConfigVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/BusinessDeptConfigVO.java new file mode 100644 index 00000000..a81cbdea --- /dev/null +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/BusinessDeptConfigVO.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.system.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.system.pojo.entity.BusinessDeptConfig; + +import java.io.Serial; + +/** + * 业务部门配置视图实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "业务部门配置") +public class BusinessDeptConfigVO extends BusinessDeptConfig { + + @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-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RoleDataScopeVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RoleDataScopeVO.java index 1656f7ae..78eb5307 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RoleDataScopeVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RoleDataScopeVO.java @@ -67,6 +67,18 @@ public class RoleDataScopeVO implements Serializable { return scope; } + /** + * 无组织可见(未配置数据权限时的默认结果) + */ + public static RoleDataScopeVO none() { + RoleDataScopeVO scope = new RoleDataScopeVO(); + scope.setScopeType(RoleDataScopeType.CUSTOM.getCode()); + scope.setAllDept(false); + scope.setOwnCreate(false); + scope.setDeptIds(Collections.emptyList()); + return scope; + } + public static RoleDataScopeVO ownCreate() { RoleDataScopeVO scope = new RoleDataScopeVO(); scope.setScopeType(RoleDataScopeType.OWN_CREATE.getCode()); diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractChange.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractChange.java index 936ec485..f87dd1c3 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractChange.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractChange.java @@ -103,6 +103,18 @@ public class ContractChange extends TenantEntity { @Schema(description = "乙方") private String partyB; + @Schema(description = "甲方客商明细JSON") + private String partyAJson; + + @Schema(description = "乙方客商明细JSON") + private String partyBJson; + + @Schema(description = "丙方客商明细JSON") + private String partyCJson; + + @Schema(description = "丙方") + private String partyC; + @Schema(description = "开始日期") private LocalDate startDate; @@ -204,12 +216,6 @@ public class ContractChange extends TenantEntity { @Schema(description = "其他事项说明") private String remark; - @Schema(description = "合规客商风险等级(展示用)") - private String hcsRiskLevel; - - @Schema(description = "合规客商黑名单状态(展示用)") - private String hcsBlackStatus; - @Schema(description = "审批状态") private String approvalStatus; 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 655ae234..fb35ad1f 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 @@ -67,6 +67,12 @@ public class ContractManage extends TenantEntity { @Schema(description = "合同负责部门") private String organizationName; + @Schema(description = "平台公司ID") + private Long platformCompanyId; + + @Schema(description = "平台公司") + private String platformCompanyName; + @Schema(description = "合同类别") private String contractCategory; @@ -79,6 +85,18 @@ public class ContractManage extends TenantEntity { @Schema(description = "乙方") private String partyB; + @Schema(description = "甲方客商明细JSON") + private String partyAJson; + + @Schema(description = "乙方客商明细JSON") + private String partyBJson; + + @Schema(description = "丙方客商明细JSON") + private String partyCJson; + + @Schema(description = "丙方") + private String partyC; + @Schema(description = "开始日期") private LocalDate startDate; @@ -240,15 +258,6 @@ public class ContractManage extends TenantEntity { @Schema(description = "合规履约计划ID(C103-9 返回 fdExeplanId)") private String hcsExeplanId; - @Schema(description = "合规客商风险等级(C101-1,甲方/乙方取高)") - private String hcsRiskLevel; - - @Schema(description = "合规客商黑名单状态(C101-1,甲方/乙方取是)") - private String hcsBlackStatus; - - @Schema(description = "合规风险校验时间") - private LocalDateTime hcsRiskTime; - @Schema(description = "合规特批流程ID(C103-8 返回)") private String hcsSpecFdId; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerArchive.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerArchive.java index 242d0f65..e35d1051 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerArchive.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerArchive.java @@ -52,6 +52,9 @@ public class CustomerArchive extends TenantEntity { @Schema(description = "客商编号") private String customerCode; + @Schema(description = "供应商编码(金蝶A901-1供应商查询返回number,建档/同步时落库)") + private String supplierCode; + @Schema(description = "客商简称") private String shortName; @@ -182,4 +185,19 @@ public class CustomerArchive extends TenantEntity { @Schema(description = "金蝶推送时间") private LocalDateTime kingdeeSyncTime; + @Schema(description = "合规客商风险等级(C101-1,每日定时刷新):0=低风险,1=中风险,2=高风险") + private String hcsRiskLevel; + + @Schema(description = "合规客商黑名单状态(C101-1,每日定时刷新):yes=是,no=否,handing=处理中") + private String kingdeeBlackStatus; + + @Schema(description = "合规客商风险最近查询时间(C101-1)") + private LocalDateTime hcsRiskTime; + + @Schema(description = "金蝶黑名单失信类别(A801-5 awrp_combofield_title,缺失兜底 awrp_combofield)") + private String kingdeeBlackCategory; + + @Schema(description = "金蝶黑名单列入事由(A801-5 awrp_textareafield)") + private String kingdeeBlackReason; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java index 20c42775..2a2b36ca 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java @@ -160,6 +160,18 @@ public class FormalSettlement extends TenantEntity { * 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败 */ private String kingdeeSyncStatus; + /** + * 金蝶推送失败原因(仅 failed 态展示;重推成功转 synced 后残留原因不再回显) + */ + private String kingdeeFailReason; + /** + * 金蝶冲销状态:unwrittenoff-未冲销 writtenoff-已冲销(null=未推送金蝶或未校验) + */ + private String kingdeeWriteOffStatus; + /** + * 金蝶冲销状态确认时间(定时回写/预检回写时间) + */ + private LocalDateTime kingdeeWriteOffTime; /** * 附件JSON */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java index 60ede7e1..6b85f353 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java @@ -167,6 +167,10 @@ public class InvoiceApplication extends TenantEntity { * 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败 invoiced-开票完成(终态) */ private String kingdeeStatus; + /** + * 金蝶推送失败原因(仅 failed 态展示;重推成功转 synced 后残留原因不再回显) + */ + private String kingdeeFailReason; /** * 同步金蝶时间 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java index 8e145070..be6896cc 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java @@ -77,4 +77,11 @@ public class InvoiceApplicationInvoice extends TenantEntity { @Schema(description = "A303-5 返回原文JSON留档") private String rawJson; + /** 履约开票记录ID(C108-1 返回;非空即已推送,扫描据此幂等跳过) */ + private String hcsPerfFdId; + /** 履约开票记录推送失败重试次数 */ + private Integer hcsPerfRetryCount; + /** 履约开票记录最近推送失败原因(成功后清空) */ + private String hcsPerfSyncMsg; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceiptSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceiptSettlement.java index 4f19d2d5..dc4be0bb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceiptSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceiptSettlement.java @@ -55,4 +55,10 @@ public class InvoiceReceiptSettlement extends TenantEntity { private BigDecimal settlementAmount; private BigDecimal receivedInvoiceAmount; private BigDecimal allocatedInvoiceAmount; + /** 履约收票记录ID(C108-1 返回;非空即已推送,扫描据此幂等跳过) */ + private String hcsPerfFdId; + /** 履约收票记录推送失败重试次数 */ + private Integer hcsPerfRetryCount; + /** 履约收票记录最近推送失败原因(成功后清空) */ + private String hcsPerfSyncMsg; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java index 533394ae..0f5a0a04 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java @@ -52,68 +52,96 @@ public class KingdeeReceiptFlow extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 金蝶认领公告id(A301-2 返回 id,认领推送 A301-3 必填) + * 金蝶认领公告id(历史 A301-2 同步遗留,A301-1 口径不再使用,恒为空) */ @JsonSerialize(using = ToStringSerializer.class) private Long billId; /** - * 金蝶付款人id(A301-2 公告对应往来单位 id,认领推送 A301-3 用) + * 金蝶付款人id(历史 A301-2 同步遗留,A301-1 口径不再使用,恒为空) */ private String payerId; /** - * 公告认领状态(A301-2 claimstatus:0未认领/1已认领) + * 公告认领状态(历史 A301-2 同步遗留,A301-1 口径不再使用,恒为空) */ private String kingdeeClaimStatus; /** - * 我已认领(A301-2 claimme) + * 我已认领(历史 A301-2 同步遗留,A301-1 口径不再使用,恒为空) */ private Boolean claimMe; /** - * 未认领金额(A301-2 unclaimamount) + * 未匹配收款金额(A301-1 unmatchamountrec,金蝶参考展示,不参与认领额度) */ - private BigDecimal unclaimedAmount; + private BigDecimal unmatchAmountRec; /** - * 手续费(A301-2 fee) + * 已匹配收款金额(A301-1 matchamountrec) + */ + private BigDecimal matchAmountRec; + /** + * 已匹配付款金额(A301-1 matchamountpay) + */ + private BigDecimal matchAmountPay; + /** + * 未匹配付款金额(A301-1 unmatchamountpay) + */ + private BigDecimal unmatchAmountPay; + /** + * 已结算金额(A301-1 settleamount) + */ + private BigDecimal settleAmount; + /** + * 未结算金额(A301-1 unsettleamount) + */ + private BigDecimal unsettleAmount; + /** + * 手续费(A301-1 fee) */ private BigDecimal fee; /** - * 收款人(A301-2 recpayee) + * 收款人(A301-1 收款公司名称 org_name) */ private String payeeName; /** - * 收款通知单号(A301-2 billno) + * 收款人(公司).编码(A301-1 org_number) + */ + private String orgNumber; + /** + * 核算组织.编码(A301-1 openorg_number) + */ + private String openOrgNumber; + /** + * 金蝶收款单号(A301-1 billno) */ private String receiptNoticeNo; /** - * 付款人名称(A301-2 recviewpayer) + * 付款人名称(A301-1 payername) */ private String payerName; /** - * 收款金额(A301-2 reamount) + * 收款金额(A301-1 actrecamt) */ private BigDecimal receiptAmount; /** - * 对方单位名称(A301-2 oppunit) + * 对方单位名称(A301-1 付款人名称) */ private String counterpartyName; /** - * 对方银行账号(A301-2 oppbanknumber) + * 对方银行账号(A301-1 付款账户 payeracctbanknum) */ private String counterpartyAccount; /** - * 对方开户行(A301-2 oppbank) + * 对方开户行(A301-1 付款银行 payerbankname) */ private String counterpartyBank; /** - * 摘要(A301-2 description) + * 摘要(A301-1 txt_description) */ private String summary; /** - * 交易日期(A301-2 tradetime) + * 交易日期(A301-1 业务日期 bizdate) */ private LocalDateTime transactionTime; /** - * 明细流水号(A301-2 detailid,回退 tradedetailno / bankdetailno,幂等键) + * 明细流水号(收款单号 billno,幂等键;C104-2 按此定位合规侧收款流水) */ private String detailSerialNo; /** @@ -125,27 +153,27 @@ public class KingdeeReceiptFlow extends TenantEntity { */ private String claimStatus; /** - * 推送状态(none未推送/pushed已推送/failed推送失败;已推送为终态闸门,不允许二次推送) + * 推送状态(none未推送/pushed已推送/failed推送失败;历史 A301-3 推送遗留,A301-1 口径下恒为 none,仅存量的已推送数据保留展示) */ private String pushStatus; /** - * 最近一次推送失败原因(成功后清空) + * 最近一次推送失败原因(历史 A301-3 推送遗留) */ private String pushFailReason; /** - * 首次推送金蝶成功时间 + * 首次推送金蝶成功时间(历史 A301-3 推送遗留) */ private LocalDateTime pushedTime; /** - * 金蝶推送来源单据编码(分摊结算单号下划线拼接,推送时生成落库;分摊不变则重试编码不变,A301-1 回写匹配键) + * 金蝶推送来源单据编码(历史 A301-3 推送遗留,A301-1 口径不再生成) */ private String awrpSrcCode; /** - * 金蝶收款单号(推送响应回填 / A301-1 回写推进) + * 金蝶收款单号(A301-1 同步落库 billno) */ private String kingdeeBillNo; /** - * 金蝶回写状态(none未推送/pushed已推送/approved审核通过/received已收款/failed失败) + * 金蝶单据状态(A301-1 billstatus 映射:none未推送/pushed已推送/approved审核通过/received已收款/failed失败,同步时随单据刷新) */ private String kingdeeBillStatus; /** diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java index 1a1283de..90129764 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java @@ -86,6 +86,12 @@ public class LoadingManage extends TenantEntity { @Schema(description = "承运商合同ID") private Long carrierContractId; + @Schema(description = "承运客商明细行ID") + private String carrierPartyId; + + @Schema(description = "承运客商角色:B/C") + private String carrierPartyRole; + @Schema(description = "发货地址") private String departureAddress; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java index e4021397..f8f3bb81 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java @@ -117,6 +117,8 @@ public class PaymentApplication extends TenantEntity { private String kingdeeBillId; /** 金蝶同步状态:unsynced 未生成 / synced 已生成 / paying 付款中 / paid 已付款 / closed 已关闭 / failed 生成失败 */ private String kingdeeStatus; + /** 金蝶推送失败原因(仅 failed 态展示;重推成功转 synced 后残留原因不再回显) */ + private String kingdeeFailReason; /** 附件信息JSON */ private String attachmentsJson; /** 备注 */ @@ -141,6 +143,8 @@ public class PaymentApplication extends TenantEntity { private String hcsSpecFdId; /** 特批状态:none/need/pushing/processing/approved/rejected */ private String hcsSpecStatus; + /** 合规付款特批结果附件ID(paymentLegal/result 回调 fileUrl,C103-5 下载入参、金蝶附件联带用) */ + private String hcsSpecAttachFdId; /** 履约记录ID(C108-1 返回,C108-2 更新必传) */ private String hcsPerfFdId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaimHcsRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaimHcsRecord.java new file mode 100644 index 00000000..265eb54e --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaimHcsRecord.java @@ -0,0 +1,96 @@ +/** + * 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.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +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; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 收款认领合规推送记录实体类 + *

+ * C107-1 认领记录推送结果落库(认领主单 × 合同一条):成功持久化合规侧 fdId 供 C107-3 作废引用, + * 失败落推送标记与原因(仅记标记,不做定时重试)。 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_receipt_claim_hcs_record") +@Schema(description = "收款认领合规推送记录") +public class ReceiptClaimHcsRecord extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 认领主单id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long receiptClaimId; + /** + * 收款流水id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long receiptFlowId; + /** + * 合同id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long contractId; + /** + * 合同编号 + */ + private String contractNo; + /** + * 该认领主单分摊到本合同的认领金额 + */ + private BigDecimal claimAmount; + /** + * 合规侧认领记录fdId(C107-1 返回,C107-3 作废必填引用) + */ + private String hcsClaimRecordFdId; + /** + * 推送状态(success推送成功/failed推送失败/voided已作废) + */ + private String pushStatus; + /** + * 推送失败原因(成功后清空) + */ + private String failReason; + /** + * 推送成功时间 + */ + private LocalDateTime pushedTime; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java index 888fcce8..700c7b88 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java @@ -30,6 +30,8 @@ public class SettlementAdjustment extends TenantEntity { private String currentNode; private String currentProcessor; private String kingdeeSyncStatus; + /** 金蝶推送失败原因(随结算单推送失败落库,仅 failed 态展示) */ + private String kingdeeFailReason; /** * 金蝶单据号(未推送场景回填原结算单金蝶单号;重推场景回填新单金蝶单号) */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java index a3157095..bd85768e 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java @@ -136,6 +136,12 @@ public class Waybill extends TenantEntity { @Schema(description = "承运商合同ID") private Long carrierContractId; + @Schema(description = "承运客商明细行ID") + private String carrierPartyId; + + @Schema(description = "承运客商角色:B/C") + private String carrierPartyRole; + @Schema(description = "司机ID") private Long driverId; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BusinessLicenseOcrVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BusinessLicenseOcrVO.java new file mode 100644 index 00000000..2b7452e8 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BusinessLicenseOcrVO.java @@ -0,0 +1,54 @@ +/** + * 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.LinkedHashMap; +import java.util.Map; + +/** + * 营业执照 OCR 识别结果。 + * + * @author Chill + */ +@Data +@Schema(description = "营业执照OCR识别结果") +public class BusinessLicenseOcrVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 按第三方返回 key 聚合的字段,例如 uscc、company_name、legal_name。 + */ + @Schema(description = "识别字段,键为第三方 OCR 的 key") + private Map fields = new LinkedHashMap<>(); + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractChangeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractChangeVO.java index 086d498e..f43cbb7a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractChangeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractChangeVO.java @@ -67,4 +67,12 @@ public class ContractChangeVO extends ContractChange { @Schema(description = "同步状态名称") private String syncStatusName; + @TableField(exist = false) + @Schema(description = "客商风险等级(甲乙双方关联客商档案实时组装,等级取高):0=低风险,1=中风险,2=高风险") + private String hcsRiskLevel; + + @TableField(exist = false) + @Schema(description = "客商黑名单状态(甲乙双方关联客商档案实时组装,任一方金蝶黑名单命中记是):yes/no") + private String kingdeeBlackStatus; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java index 11009240..46002d22 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java @@ -64,7 +64,7 @@ public class ContractManageVO extends ContractManage { private Boolean hcsSynced; @TableField(exist = false) - @Schema(description = "生效类型筛选") + @Schema(description = "合同状态筛选") private String effectiveType; @TableField(exist = false) @@ -87,4 +87,16 @@ public class ContractManageVO extends ContractManage { @Schema(description = "审核状态名称") private String approvalStatusName; + @TableField(exist = false) + @Schema(description = "客商风险等级(甲乙双方关联客商档案实时组装,等级取高):0=低风险,1=中风险,2=高风险") + private String hcsRiskLevel; + + @TableField(exist = false) + @Schema(description = "客商黑名单状态(甲乙双方关联客商档案实时组装,任一方金蝶黑名单命中记是):yes/no") + private String kingdeeBlackStatus; + + @TableField(exist = false) + @Schema(description = "客商风险明细文案(甲乙双方风险信息合并,列表悬浮展示,与客商列表/表单实时提示同口径)") + private String hcsRiskDetail; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java index 5e924aec..807d5a72 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java @@ -106,4 +106,12 @@ public class CustomerArchiveVO extends CustomerArchive { @Schema(description = "已付金额合计(元)") private BigDecimal usedFundLimit; + @TableField(exist = false) + @Schema(description = "客商风险筛选值:blacklist黑名单/high高风险/middle中风险/low低风险") + private String customerRisk; + + @TableField(exist = false) + @Schema(description = "客商风险明细(后台组装:合同系统风险等级+金蝶黑名单失信类别/列入事由)") + private String hcsRiskDetail; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java index da93615f..1a247302 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java @@ -63,6 +63,9 @@ public class CustomerRiskVO implements Serializable { @Schema(description = "风险等级名称(服务端转义:黑名单/高风险/中风险/低风险,黑名单优先;无等级为空)") private String riskLevelName; + @Schema(description = "是否命中金蝶黑名单(A801-5,黑名单展示口径;红字细分由前端按等级+本标记翻译)") + private Boolean kingdeeBlackHit; + @Schema(description = "是否高风险/黑名单客商(外部客商专用,内部组织恒为 false)") private Boolean risky; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationSheetVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationSheetVO.java index 0b0dd2f8..0b35cd16 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationSheetVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationSheetVO.java @@ -26,6 +26,7 @@ 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.InvoiceApplicationLine; @@ -44,4 +45,8 @@ import java.util.List; public class InvoiceApplicationSheetVO extends InvoiceApplicationSheet { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) private List lines; + @Schema(description = "开票状态名称(按金蝶回写的发票结果实时推导:已开票/未开票)") + @TableField(exist = false) private String invoiceStatusName; + @Schema(description = "金蝶开票结果备注(本张发票经 A303-5 回写的发票号、开票日期、价税合计汇总)") + @TableField(exist = false) private String kingdeeResultRemark; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/LoadingCarrierContractVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/LoadingCarrierContractVO.java index 22a40b87..76417029 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/LoadingCarrierContractVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/LoadingCarrierContractVO.java @@ -55,4 +55,17 @@ public class LoadingCarrierContractVO implements Serializable { @Schema(description = "承运商名称") private String carrierName; + @Schema(description = "承运客商明细行ID") + private String carrierPartyId; + + @Schema(description = "承运客商角色:B/C") + private String carrierPartyRole; + + @Schema(description = "统一信用代码") + private String creditCode; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "客商档案ID") + private Long customerId; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderCarrierVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderCarrierVO.java index 1ce202dd..f040a9ae 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderCarrierVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderCarrierVO.java @@ -34,4 +34,17 @@ public class MasterOrderCarrierVO implements Serializable { @Schema(description = "承运商名称") private String carrierName; + @Schema(description = "承运客商明细行ID") + private String carrierPartyId; + + @Schema(description = "承运客商角色:B/C") + private String carrierPartyRole; + + @Schema(description = "统一信用代码") + private String creditCode; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "客商档案ID") + private Long customerId; + } diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java index b181e1f5..aeb72901 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java @@ -35,6 +35,9 @@ import org.springblade.core.tool.utils.StringUtil; import org.springblade.system.pojo.entity.User; import org.springblade.system.feign.IUserClient; +import java.util.Collections; +import java.util.List; + import static org.springblade.core.cache.constant.CacheConstant.USER_CACHE; import static org.springblade.core.launch.constant.FlowConstant.TASK_USR_PREFIX; @@ -47,6 +50,7 @@ import static org.springblade.core.launch.constant.FlowConstant.TASK_USR_PREFIX; public class UserCache { private static final String USER_CACHE_ID = "user:id:"; private static final String USER_CACHE_ACCOUNT = "user:account:"; + private static final String USER_CACHE_MANAGE_DEPT = "user:manage-dept:"; private static IUserClient userClient; @@ -89,6 +93,26 @@ public class UserCache { } } + /** + * 获取用户「数据管理权限」组织(含下级展开) + * + * @param userId 用户主键 + * @return 可管理组织 ID 集合 + */ + @SuppressWarnings("unchecked") + public static List getManageDeptIds(Long userId) { + if (userId == null || userId <= 0) { + return Collections.emptyList(); + } + try { + List deptIdList = CacheUtil.get(USER_CACHE, USER_CACHE_MANAGE_DEPT, userId, () -> loadManageDeptIds(userId)); + return deptIdList == null ? Collections.emptyList() : deptIdList; + } catch (RuntimeException exception) { + log.warn("用户数据管理组织缓存读取失败,改为直连查询, userId={}", userId, exception); + return loadManageDeptIds(userId); + } + } + /** * 获取用户姓名 * @@ -153,6 +177,14 @@ public class UserCache { return result.getData(); } + private static List loadManageDeptIds(Long userId) { + R> result = getUserClient().manageDeptIds(userId); + if (result == null || !result.isSuccess() || result.getData() == null) { + return Collections.emptyList(); + } + return result.getData(); + } + private static void clearBrokenUserCache() { CacheUtil.clear(USER_CACHE); CacheUtil.clear(USER_CACHE, Boolean.FALSE); diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IUserClient.java index 50be69e0..534d7437 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IUserClient.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IUserClient.java @@ -37,6 +37,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import java.util.List; + /** * User Feign接口类 * @@ -62,6 +64,7 @@ public interface IUserClient { String REGISTER_USER = API_PREFIX + "/register-user"; String REMOVE_USER = API_PREFIX + "/remove-user"; String BIND_WX_MINI_OPENID = API_PREFIX + "/bind-wx-mini-openid"; + String MANAGE_DEPT_IDS = API_PREFIX + "/manage-dept-ids"; /** * 获取用户信息 @@ -72,6 +75,15 @@ public interface IUserClient { @GetMapping(USER_BY_ID) R userInfoById(@RequestParam("userId") Long userId); + /** + * 解析用户「数据管理权限」组织(含下级) + * + * @param userId 用户主键 + * @return 可管理组织 ID 集合 + */ + @GetMapping(MANAGE_DEPT_IDS) + R> manageDeptIds(@RequestParam("userId") Long userId); + /** * 根据账号获取用户信息 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/BusinessDeptConfigController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/BusinessDeptConfigController.java new file mode 100644 index 00000000..d351b7e6 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/BusinessDeptConfigController.java @@ -0,0 +1,133 @@ +/** + * 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.system.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 jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +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.tenant.annotation.NonDS; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.entity.BusinessDeptConfig; +import org.springblade.system.pojo.vo.BusinessDeptConfigVO; +import org.springblade.system.service.IBusinessDeptConfigService; +import org.springblade.system.wrapper.BusinessDeptConfigWrapper; +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; + +/** + * 业务部门配置控制器 + * + * @author Chill + */ +@NonDS +@RestController +@AllArgsConstructor +@PreAuth(menu = "business_dept_config") +@RequestMapping("/business-dept-config") +@Tag(name = "业务部门配置", description = "业务部门配置") +public class BusinessDeptConfigController extends BladeController { + + private final IBusinessDeptConfigService businessDeptConfigService; + + /** + * 详情 + */ + @GetMapping("/detail") + @ApiOperationSupport(order = 1) + @Operation(summary = "详情", description = "传入businessDeptConfig") + public R detail(BusinessDeptConfig businessDeptConfig) { + if (Func.isEmpty(businessDeptConfig.getId())) { + return R.fail("主键不能为空"); + } + BusinessDeptConfig detail = businessDeptConfigService.getById(businessDeptConfig.getId()); + if (Func.isEmpty(detail)) { + return R.fail("业务部门配置不存在"); + } + return R.data(BusinessDeptConfigWrapper.build().entityVO(detail)); + } + + /** + * 分页 + */ + @GetMapping("/list") + @ApiOperationSupport(order = 2) + @Operation(summary = "分页", description = "传入businessDeptConfig") + public R> list(BusinessDeptConfigVO businessDeptConfig, Query query) { + IPage pages = businessDeptConfigService.selectBusinessDeptConfigPage( + Condition.getPage(query), businessDeptConfig); + return R.data(pages); + } + + /** + * 新增或修改 + */ + @PostMapping("/submit") + @ApiOperationSupport(order = 3) + @Operation(summary = "新增或修改", description = "传入businessDeptConfig") + public R submit(@Valid @RequestBody BusinessDeptConfig businessDeptConfig) { + return R.status(businessDeptConfigService.submit(businessDeptConfig)); + } + + /** + * 删除 + */ + @PostMapping("/remove") + @ApiOperationSupport(order = 4) + @Operation(summary = "逻辑删除", description = "传入ids") + public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) { + return R.status(businessDeptConfigService.deleteLogic(Func.toLongList(ids))); + } + + /** + * 按平台公司 + 业务部门查询 + */ + @GetMapping("/by-platform-dept") + @ApiOperationSupport(order = 5) + @Operation(summary = "按平台公司与业务部门查询", description = "传入platformCompanyId、businessDeptId") + public R byPlatformDept( + @Parameter(description = "平台公司ID", required = true) @RequestParam Long platformCompanyId, + @Parameter(description = "业务部门ID", required = true) @RequestParam Long businessDeptId) { + BusinessDeptConfig detail = businessDeptConfigService.getByPlatformAndDept(platformCompanyId, businessDeptId); + if (detail == null) { + return R.data(null); + } + return R.data(BusinessDeptConfigWrapper.build().entityVO(detail)); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java index eafb7b30..0c4a3256 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java @@ -95,7 +95,9 @@ public class DeptController extends BladeController { @Parameters({ @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "isPlatformCompany", description = "是否平台公司", in = ParameterIn.QUERY, schema = @Schema(type = "integer")), + @Parameter(name = "isBusinessDept", description = "是否业务部门", in = ParameterIn.QUERY, schema = @Schema(type = "integer")) }) @ApiOperationSupport(order = 2) @Operation(summary = "列表", description = "传入dept") @@ -111,7 +113,9 @@ public class DeptController extends BladeController { @Parameters({ @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "isPlatformCompany", description = "是否平台公司", in = ParameterIn.QUERY, schema = @Schema(type = "integer")), + @Parameter(name = "isBusinessDept", description = "是否业务部门", in = ParameterIn.QUERY, schema = @Schema(type = "integer")) }) @ApiOperationSupport(order = 3) @Operation(summary = "懒加载列表", description = "传入dept") @@ -122,8 +126,11 @@ public class DeptController extends BladeController { /** * 获取部门树形结构 + *

+ * 登录即可调用;数据范围由角色「数据权限」中 {@code dept_tree}(树形结构)单独配置, + * 未配置时返回空树(默认不展示)。 */ - @PreAuth(menu = "dept") + @PreAuth(AuthConstant.PERMIT_ALL) @GetMapping("/tree") @ApiOperationSupport(order = 4) @Operation(summary = "树形结构", description = "树形结构") @@ -135,8 +142,9 @@ public class DeptController extends BladeController { * 懒加载获取部门树形结构 *

* tenantId 由 Service 层完整决策:超管入参为空表示查全部,非超管强制覆盖为会话租户。 + * 数据范围与 {@link #tree} 相同,由 {@code dept_tree} 单独配置,未配置时返回空树。 */ - @PreAuth(menu = "dept") + @PreAuth(AuthConstant.PERMIT_ALL) @GetMapping("/lazy-tree") @ApiOperationSupport(order = 5) @Operation(summary = "懒加载树形结构", description = "树形结构") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java index 2dabbf04..e1167b0e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -70,14 +70,14 @@ public interface UserConvert { @Mapping(source = "lastname", target = "realName") @Mapping(source = "mobile", target = "phone") @Mapping(source = "email", target = "email") - @Mapping(source = "loginid", target = "oaLoginId") + @Mapping(target = "oaLoginId", ignore = true) @Mapping(source = "modified", target = "oaModified") User baseConvert(OAPersonResponse person); @Mapping(source = "subcompanyname", target = "companyName") @Mapping(source = "departmentname", target = "deptName") @Mapping(source = "id", target = "oaId") - @Mapping(source = "loginid", target = "oaLoginId") + @Mapping(target = "oaLoginId", ignore = true) UserDept userDeptConvert(OAPersonResponse person); /** @@ -88,6 +88,7 @@ public interface UserConvert { */ default User person2user(OAPersonResponse person, String defaultPassword) { User user = baseConvert(person); + user.setOaLoginId(resolveOaLoginId(person)); user.setAccount(resolveAccount(person)); // OA 可能返回多号(如 133.../137...),只保留首个号码再入库加密 user.setPhone(normalizeMobile(user.getPhone())); @@ -106,6 +107,22 @@ public interface UserConvert { return user; } + /** + * 解析 OA 登录名:优先工号 workcode,其次 loginid + */ + default String resolveOaLoginId(OAPersonResponse person) { + if (person == null) { + return null; + } + if (StringUtils.isNotBlank(person.getWorkcode())) { + return person.getWorkcode().trim(); + } + if (StringUtils.isNotBlank(person.getLoginid())) { + return person.getLoginid().trim(); + } + return null; + } + /** * 规范化手机号:去空白,多号时取第一个 */ @@ -124,7 +141,7 @@ public interface UserConvert { } /** - * 解析本系统登录账号:优先 OA loginid,其次工号,最后手机号 + * 解析本系统登录账号:优先工号 workcode,其次 OA loginid,最后手机号 * * @param person OA人员 * @return 账号,无法识别时返回 null @@ -133,12 +150,12 @@ public interface UserConvert { if (person == null) { return null; } - if (StringUtils.isNotBlank(person.getLoginid())) { - return person.getLoginid().trim(); - } if (StringUtils.isNotBlank(person.getWorkcode())) { return person.getWorkcode().trim(); } + if (StringUtils.isNotBlank(person.getLoginid())) { + return person.getLoginid().trim(); + } if (StringUtils.isNotBlank(person.getMobile())) { return person.getMobile().trim(); } @@ -148,7 +165,7 @@ public interface UserConvert { /** * oa人员转本系统用户部门 * @param person - * @param userMap OA登录名与用户id + * @param userMap 同步键(W:工号)与用户id * @return */ default UserDept person2userDept(OAPersonResponse person, Map userMap) { @@ -162,9 +179,11 @@ public interface UserConvert { userDept.setStatus(status); // 排序 userDept.setSort(OAUtils.parseInt(person.getDsporder())); - // 用户id,按 OA 登录名关联本批已新增或更新的用户 - String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid()); - userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId)); + // 用户id,按工号同步键关联本批已新增或更新的用户 + String oaLoginId = resolveOaLoginId(person); + userDept.setOaLoginId(oaLoginId); + String workcode = person == null ? null : StringUtils.trimToNull(person.getWorkcode()); + userDept.setUserId(workcode == null ? null : userMap.get("W:" + workcode)); userDept.setSyncTime(new Date()); return userDept; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java index a4cf9718..40900821 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java @@ -69,6 +69,7 @@ public class SysClient implements ISysClient { private final ICargoTypeService cargoTypeService; private final IMeasurementUnitService measurementUnitService; private final IInvoiceItemService invoiceItemService; + private final IBusinessDeptConfigService businessDeptConfigService; @Override @GetMapping(MENU) @@ -257,4 +258,10 @@ public class SysClient implements ISysClient { .orderByAsc(InvoiceItem::getCategoryName, InvoiceItem::getShortName))); } + @Override + @GetMapping(BUSINESS_DEPT_CONFIG) + public R getBusinessDeptConfig(Long platformCompanyId, Long businessDeptId) { + return R.data(businessDeptConfigService.getByPlatformAndDept(platformCompanyId, businessDeptId)); + } + } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/UserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/UserClient.java index f25da255..1a0cfc9b 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/UserClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/UserClient.java @@ -38,9 +38,12 @@ import org.springblade.system.service.IUserService; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Hidden; +import java.util.List; + /** * 用户服务Feign实现类 * @@ -60,6 +63,12 @@ public class UserClient implements IUserClient { return R.data(service.getById(userId)); } + @Override + @GetMapping(MANAGE_DEPT_IDS) + public R> manageDeptIds(@RequestParam("userId") Long userId) { + return R.data(service.resolveManageDeptIds(userId)); + } + @Override @GetMapping(USER_BY_ACCOUNT) public R userByAccount(String tenantId, String account) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/BusinessDeptConfigMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/BusinessDeptConfigMapper.java new file mode 100644 index 00000000..5c66af76 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/BusinessDeptConfigMapper.java @@ -0,0 +1,53 @@ +/** + * 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.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.apache.ibatis.annotations.Param; +import org.springblade.system.pojo.entity.BusinessDeptConfig; +import org.springblade.system.pojo.vo.BusinessDeptConfigVO; + +import java.util.List; + +/** + * 业务部门配置 Mapper 接口 + * + * @author Chill + */ +public interface BusinessDeptConfigMapper extends BaseMapper { + + /** + * 自定义分页 + * + * @param page 分页 + * @param businessDeptConfig 查询条件 + * @return 分页数据 + */ + List selectBusinessDeptConfigPage(IPage page, + @Param("businessDeptConfig") BusinessDeptConfigVO businessDeptConfig); + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/BusinessDeptConfigMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/BusinessDeptConfigMapper.xml new file mode 100644 index 00000000..a954b418 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/BusinessDeptConfigMapper.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml index 1f38b0fd..c1242385 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml @@ -90,6 +90,12 @@ and dept.full_name like concat(concat('%', #{param3.fullName}),'%') + + and dept.is_platform_company = #{param3.isPlatformCompany} + + + and dept.is_business_dept = #{param3.isBusinessDept} + ORDER BY dept.sort diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IBusinessDeptConfigService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IBusinessDeptConfigService.java new file mode 100644 index 00000000..2d305adf --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IBusinessDeptConfigService.java @@ -0,0 +1,66 @@ +/** + * 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.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.springblade.core.mp.base.BaseService; +import org.springblade.system.pojo.entity.BusinessDeptConfig; +import org.springblade.system.pojo.vo.BusinessDeptConfigVO; + +/** + * 业务部门配置服务类 + * + * @author Chill + */ +public interface IBusinessDeptConfigService extends BaseService { + + /** + * 自定义分页 + * + * @param page 分页 + * @param businessDeptConfig 查询条件 + * @return 分页数据 + */ + IPage selectBusinessDeptConfigPage(IPage page, BusinessDeptConfigVO businessDeptConfig); + + /** + * 新增或修改 + * + * @param businessDeptConfig 配置 + * @return 是否成功 + */ + boolean submit(BusinessDeptConfig businessDeptConfig); + + /** + * 按平台公司 + 业务部门查询配置 + * + * @param platformCompanyId 平台公司ID + * @param businessDeptId 业务部门ID + * @return 配置,不存在返回 null + */ + BusinessDeptConfig getByPlatformAndDept(Long platformCompanyId, Long businessDeptId); + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IRoleMenuDataScopeService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IRoleMenuDataScopeService.java index 93cb33d2..17349a22 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IRoleMenuDataScopeService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IRoleMenuDataScopeService.java @@ -66,6 +66,16 @@ public interface IRoleMenuDataScopeService extends BaseService roleIds, String menuCode, List userDeptIds); + /** + * 按菜单编码解析角色数据权限;菜单不存在或未配置时返回空权限(不回落为全部组织) + * + * @param roleIds 角色ID集合 + * @param menuCode 菜单编码 + * @param userDeptIds 用户所属组织 + * @return 解析结果 + */ + RoleDataScopeVO resolveByMenuCodeOrEmpty(List roleIds, String menuCode, List userDeptIds); + /** * 合并用户全部角色菜单配置为最宽数据权限 * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java index fedecc77..e4160480 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java @@ -257,6 +257,14 @@ public interface IUserService extends BaseService { */ boolean removeUser(String userIds); + /** + * 解析用户「数据管理权限」组织(含下级展开) + * + * @param userId 用户主键 + * @return 可管理组织 ID 集合;无配置时返回空列表 + */ + List resolveManageDeptIds(Long userId); + /** * 导入用户数据(仅超管 + Excel 显式指定时允许跨租户,否则归属当前会话租户) * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/BusinessDeptConfigServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/BusinessDeptConfigServiceImpl.java new file mode 100644 index 00000000..6c520b2e --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/BusinessDeptConfigServiceImpl.java @@ -0,0 +1,208 @@ +/** + * 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.system.service.impl; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springblade.core.cache.utils.CacheUtil; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.mapper.BusinessDeptConfigMapper; +import org.springblade.system.pojo.entity.BusinessDeptConfig; +import org.springblade.system.pojo.entity.Dept; +import org.springblade.system.pojo.vo.BusinessDeptConfigVO; +import org.springblade.system.service.IBusinessDeptConfigService; +import org.springblade.system.service.IDeptService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import java.util.Objects; + +import static org.springblade.core.cache.constant.CacheConstant.SYS_CACHE; + +/** + * 业务部门配置服务实现类 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class BusinessDeptConfigServiceImpl extends BaseServiceImpl + implements IBusinessDeptConfigService { + + private static final int PREFIX_MAX_LENGTH = 64; + private static final int CODE_MAX_LENGTH = 64; + private static final int NAME_MAX_LENGTH = 100; + private static final int REMARK_MAX_LENGTH = 200; + private static final int SERIAL_MAX = 999; + + private final IDeptService deptService; + + @Override + public IPage selectBusinessDeptConfigPage(IPage page, + BusinessDeptConfigVO businessDeptConfig) { + return page.setRecords(baseMapper.selectBusinessDeptConfigPage(page, businessDeptConfig)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean submit(BusinessDeptConfig businessDeptConfig) { + prepare(businessDeptConfig); + validate(businessDeptConfig); + boolean success = saveOrUpdate(businessDeptConfig); + if (success) { + CacheUtil.clear(SYS_CACHE); + CacheUtil.clear(SYS_CACHE, Boolean.FALSE); + } + return success; + } + + @Override + public BusinessDeptConfig getByPlatformAndDept(Long platformCompanyId, Long businessDeptId) { + if (Func.isEmpty(platformCompanyId) || Func.isEmpty(businessDeptId)) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(BusinessDeptConfig::getPlatformCompanyId, platformCompanyId) + .eq(BusinessDeptConfig::getBusinessDeptId, businessDeptId) + .eq(BusinessDeptConfig::getIsDeleted, 0) + .last("LIMIT 1"), false); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteLogic(java.util.List ids) { + boolean success = super.deleteLogic(ids); + if (success) { + CacheUtil.clear(SYS_CACHE); + CacheUtil.clear(SYS_CACHE, Boolean.FALSE); + } + return success; + } + + private void prepare(BusinessDeptConfig businessDeptConfig) { + Dept platformCompany = requirePlatformCompany(businessDeptConfig.getPlatformCompanyId()); + Dept businessDept = requireBusinessDept(businessDeptConfig.getBusinessDeptId()); + businessDeptConfig.setPlatformCompanyName(platformCompany.getDeptName()); + businessDeptConfig.setBusinessDeptName(businessDept.getDeptName()); + businessDeptConfig.setContractCodePrefix(trimToEmpty(businessDeptConfig.getContractCodePrefix())); + businessDeptConfig.setKingdeeDeptCode(trimToNull(businessDeptConfig.getKingdeeDeptCode())); + businessDeptConfig.setRemark(trimToNull(businessDeptConfig.getRemark())); + // 金蝶公司代码始终关联平台公司组织中配置的金蝶代码 + businessDeptConfig.setKingdeeCompanyCode(trimToNull(platformCompany.getKingdeeDeptCode())); + if (Func.isEmpty(businessDeptConfig.getStatus())) { + businessDeptConfig.setStatus(1); + } + } + + private void validate(BusinessDeptConfig businessDeptConfig) { + if (Func.isEmpty(businessDeptConfig.getPlatformCompanyId())) { + throw new ServiceException("请选择平台公司"); + } + if (Func.isEmpty(businessDeptConfig.getBusinessDeptId())) { + throw new ServiceException("请选择业务部门"); + } + if (Func.isEmpty(businessDeptConfig.getContractCodePrefix())) { + throw new ServiceException("请输入合同编码前缀"); + } + if (businessDeptConfig.getContractCodePrefix().length() > PREFIX_MAX_LENGTH) { + throw new ServiceException("合同编码前缀不能超过64字"); + } + if (Func.isNotEmpty(businessDeptConfig.getPlatformCompanyName()) + && businessDeptConfig.getPlatformCompanyName().length() > NAME_MAX_LENGTH) { + throw new ServiceException("平台公司名称不能超过100字"); + } + if (Func.isNotEmpty(businessDeptConfig.getBusinessDeptName()) + && businessDeptConfig.getBusinessDeptName().length() > NAME_MAX_LENGTH) { + throw new ServiceException("业务部门名称不能超过100字"); + } + if (Func.isNotEmpty(businessDeptConfig.getKingdeeCompanyCode()) + && businessDeptConfig.getKingdeeCompanyCode().length() > CODE_MAX_LENGTH) { + throw new ServiceException("金蝶公司代码不能超过64字"); + } + if (Func.isNotEmpty(businessDeptConfig.getKingdeeDeptCode()) + && businessDeptConfig.getKingdeeDeptCode().length() > CODE_MAX_LENGTH) { + throw new ServiceException("金蝶部门代码不能超过64字"); + } + if (Func.isNotEmpty(businessDeptConfig.getRemark()) + && businessDeptConfig.getRemark().length() > REMARK_MAX_LENGTH) { + throw new ServiceException("备注不能超过200字"); + } + Integer initialSerial = businessDeptConfig.getInitialSerial(); + if (initialSerial != null && (initialSerial < 1 || initialSerial > SERIAL_MAX)) { + throw new ServiceException("初始流水号必须在1到999之间"); + } + boolean duplicated = count(Wrappers.lambdaQuery() + .eq(BusinessDeptConfig::getPlatformCompanyId, businessDeptConfig.getPlatformCompanyId()) + .eq(BusinessDeptConfig::getBusinessDeptId, businessDeptConfig.getBusinessDeptId()) + .eq(BusinessDeptConfig::getIsDeleted, 0) + .ne(Func.isNotEmpty(businessDeptConfig.getId()), BusinessDeptConfig::getId, businessDeptConfig.getId())) > 0; + if (duplicated) { + throw new ServiceException("该平台公司与业务部门的配置已存在,不能重复添加"); + } + } + + private Dept requirePlatformCompany(Long platformCompanyId) { + if (Func.isEmpty(platformCompanyId)) { + throw new ServiceException("请选择平台公司"); + } + Dept dept = deptService.getById(platformCompanyId); + if (dept == null || Objects.equals(dept.getIsDeleted(), 1)) { + throw new ServiceException("平台公司不存在"); + } + if (!Objects.equals(dept.getIsPlatformCompany(), 1)) { + throw new ServiceException("所选组织不是平台公司"); + } + return dept; + } + + private Dept requireBusinessDept(Long businessDeptId) { + if (Func.isEmpty(businessDeptId)) { + throw new ServiceException("请选择业务部门"); + } + Dept dept = deptService.getById(businessDeptId); + if (dept == null || Objects.equals(dept.getIsDeleted(), 1)) { + throw new ServiceException("业务部门不存在"); + } + if (!Objects.equals(dept.getIsBusinessDept(), 1)) { + throw new ServiceException("所选组织不是业务部门"); + } + return dept; + } + + private String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private String trimToNull(String value) { + String text = trimToEmpty(value); + return text.isEmpty() ? null : text; + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index a211a169..2b2f485e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -224,13 +224,18 @@ public class DeptServiceImpl extends ServiceImpl implements ID .toList(); } + /** + * 组织树专用数据权限菜单编码(角色「数据权限」中单独配置;未配置时组织树默认不展示)。 + */ + public static final String MENU_DEPT_TREE = "dept_tree"; + /** * 解析当前登录用户的组织树数据权限。 *

- * 超管或角色菜单数据权限最宽合并为「全部组织」时不过滤; - * 其余按角色配置的组织并集过滤。仅「本人创建」时按用户所属组织及下级展示组织树。 + * 仅读取 {@link #MENU_DEPT_TREE}「树形结构」菜单的数据权限配置,不再合并其他业务菜单; + * 超管不过滤;未配置该菜单数据权限时返回空树(默认不显示)。 * - * @return 过滤上下文;无法识别用户时按空权限处理 + * @return 过滤上下文;无法识别用户或未配置时按空权限处理 */ private DeptDataScopeFilter resolveDeptDataScopeFilter() { if (AuthUtil.isAdministrator()) { @@ -246,8 +251,11 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (Func.isNotEmpty(user) && Func.isNotEmpty(user.getDeptId())) { userDeptIds = Func.toLongList(user.getDeptId()); } - RoleDataScopeVO scope = roleMenuDataScopeService.resolveWidest(roleIds, userDeptIds); - if (scope == null || scope.isAllDept()) { + RoleDataScopeVO scope = roleMenuDataScopeService.resolveByMenuCodeOrEmpty(roleIds, MENU_DEPT_TREE, userDeptIds); + if (scope == null) { + return DeptDataScopeFilter.empty(); + } + if (scope.isAllDept()) { return DeptDataScopeFilter.all(); } LinkedHashSet allowedDeptIds = new LinkedHashSet<>(); @@ -665,12 +673,9 @@ public class DeptServiceImpl extends ServiceImpl implements ID dept.setAncestors(parent.getAncestors() + StringPool.COMMA + dept.getParentId()); } dept.setIsDeleted(BladeConstant.DB_NOT_DELETED); - if (dept.getIsPlatformCompany() == null) { - dept.setIsPlatformCompany(0); - } - if (dept.getIsBusinessDept() == null) { - dept.setIsBusinessDept(0); - } + // 统一规范为 0/1,避免前端传空串、布尔或其它类型导致更新未生效 + dept.setIsPlatformCompany(normalizeYesNoFlag(dept.getIsPlatformCompany())); + dept.setIsBusinessDept(normalizeYesNoFlag(dept.getIsBusinessDept())); // 排序未填时默认 1 if (dept.getSort() == null) { dept.setSort(1); @@ -679,9 +684,24 @@ public class DeptServiceImpl extends ServiceImpl implements ID throw new ServiceException("租户ID不能为空"); } validateDeptCategory(dept); - validateDeptCode(dept, parent); + validateDeptCode(dept); validateContractInitialSerial(dept); - return saveOrUpdate(dept); + boolean saved = saveOrUpdate(dept); + if (saved && Func.isNotEmpty(dept.getId())) { + // 显式回写两个业务标志,避免部分更新路径漏改 + this.update(Wrappers.lambdaUpdate() + .set(Dept::getIsPlatformCompany, dept.getIsPlatformCompany()) + .set(Dept::getIsBusinessDept, dept.getIsBusinessDept()) + .eq(Dept::getId, dept.getId())); + } + return saved; + } + + /** + * 将是否类标志规范为 0/1;空值按 0 处理。 + */ + private Integer normalizeYesNoFlag(Integer flag) { + return Integer.valueOf(1).equals(flag) ? 1 : 0; } @Override @@ -881,9 +901,9 @@ public class DeptServiceImpl extends ServiceImpl implements ID } } - private void validateDeptCode(Dept dept, Dept parent) { + private void validateDeptCode(Dept dept) { String deptCode = dept.getDeptCode(); - // 组织编码非必填,为空时存 null,避免唯一索引冲突 + // 组织编码非必填,为空时存 null,避免唯一索引冲突;不再校验 GWWL/上级编码+三位数字格式 if (StringUtil.isBlank(deptCode)) { dept.setDeptCode(null); return; @@ -892,26 +912,6 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (deptCode.length() > 30) { throw new ServiceException("组织编码不能超过30个字符"); } - // 顶级组织不依赖上级编码 - if (parent == null) { - this.ensureDeptCodeUnique(dept, deptCode); - dept.setDeptCode(deptCode); - return; - } - if (StringUtil.isBlank(parent.getDeptCode())) { - throw new ServiceException("请选择已配置组织编码的上级组织"); - } - // 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字 - String expectedParentCode = parent.getDeptCode(); - if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { - expectedParentCode = OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE; - } else if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory()) - && StringUtil.isNotBlank(dept.getBelongCompanyCode())) { - expectedParentCode = dept.getBelongCompanyCode(); - } - if (!org.springblade.system.util.DeptCodeUtil.matchesChildCode(expectedParentCode, deptCode)) { - throw new ServiceException("组织编码格式应为:" + expectedParentCode + "三位数字(不足补零,中间不加-)"); - } this.ensureDeptCodeUnique(dept, deptCode); dept.setDeptCode(deptCode); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index 21d62c4a..3d40bc8e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -278,9 +278,7 @@ public class OASyncServiceImpl implements IOASyncService { OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons); OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex); if (startTime == null) { - oaUserListSyncHelper.removeOaUsersAbsentFrom( - oaUserListSyncHelper.extractOaLoginIds(oaPersons), - oaUserListSyncHelper.extractWorkcodes(oaPersons)); + oaUserListSyncHelper.removeOaUsersAbsentFrom(oaUserListSyncHelper.extractWorkcodes(oaPersons)); } CacheUtil.clear(USER_CACHE); CacheUtil.clear(SYS_CACHE); @@ -300,7 +298,7 @@ public class OASyncServiceImpl implements IOASyncService { private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); - // 全量同步首页开启原始数据收集,并开始累积登录名,最后一页再删除本地多余人员 + // 全量同步首页开启原始数据收集,并开始累积工号,最后一页再删除本地多余人员 if (!incremental && pageNo == 1) { oaSyncRawDumpHelper.beginFullDump(); oaUserListSyncHelper.beginFullSyncReconcile(); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index d4c89dc2..a99147a6 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -87,10 +87,8 @@ public class OaUserListSyncHelper { private static final String OA_PERSON_STATUS_RESIGNED = "5"; /** - * 全量同步跨页累积的 OA 登录名。分页接口每页独立事务,删除必须等最后一页再执行。 + * 全量同步跨页累积的 OA 工号。分页接口每页独立事务,删除必须等最后一页再执行。 */ - private final Set fullSyncLoginIds = ConcurrentHashMap.newKeySet(); - private final Set fullSyncWorkcodes = ConcurrentHashMap.newKeySet(); private volatile boolean fullSyncCollecting; @@ -409,16 +407,15 @@ public class OaUserListSyncHelper { } /** - * 全量同步从第一页开始收集 OA 登录名,最后一页再删除本地多余的 is_oa=2 人员。 + * 全量同步从第一页开始收集 OA 工号,最后一页再删除本地多余的 is_oa=2 人员。 */ public synchronized void beginFullSyncReconcile() { - this.fullSyncLoginIds.clear(); this.fullSyncWorkcodes.clear(); this.fullSyncCollecting = true; } /** - * 累积本页 OA 人员登录名,供全量同步结束后比对删除。 + * 累积本页 OA 人员工号,供全量同步结束后比对删除。 * * @param oaPersons 本页 OA 人员 */ @@ -426,49 +423,26 @@ public class OaUserListSyncHelper { if (!this.fullSyncCollecting || CollectionUtil.isEmpty(oaPersons)) { return; } - this.fullSyncLoginIds.addAll(this.extractOaLoginIds(oaPersons)); this.fullSyncWorkcodes.addAll(this.extractWorkcodes(oaPersons)); } /** - * 全量同步结束后,删除登录名不在本次 OA 数据中的 is_oa=2 人员。 + * 全量同步结束后,删除工号不在本次 OA 数据中的 is_oa=2 人员。 * - * @return 删除人数;未开始收集或登录名集合为空时返回 0,避免误删全部 OA 人员 + * @return 删除人数;未开始收集或工号集合为空时返回 0,避免误删全部 OA 人员 */ public synchronized int finishFullSyncReconcile() { if (!this.fullSyncCollecting) { return 0; } this.fullSyncCollecting = false; - Set oaLoginIds = new LinkedHashSet<>(this.fullSyncLoginIds); Set workcodes = new LinkedHashSet<>(this.fullSyncWorkcodes); - this.fullSyncLoginIds.clear(); this.fullSyncWorkcodes.clear(); - return this.removeOaUsersAbsentFrom(oaLoginIds, workcodes); + return this.removeOaUsersAbsentFrom(workcodes); } /** - * 提取仍在职的 OA 人员登录名。离职人员不计入,全量同步结束时会删掉对应本地账号。 - * - * @param oaPersons OA人员 - * @return 去重后的登录名 - */ - public Set extractOaLoginIds(List oaPersons) { - Set oaLoginIds = new LinkedHashSet<>(); - if (CollectionUtil.isEmpty(oaPersons)) { - return oaLoginIds; - } - for (OAPersonResponse oaPerson : oaPersons) { - String oaLoginId = this.resolveOaLoginId(oaPerson); - if (oaLoginId != null && !this.isOaResigned(oaPerson)) { - oaLoginIds.add(oaLoginId); - } - } - return oaLoginIds; - } - - /** - * 提取仍在职人员的工号。没有登录名时靠工号保留本地账号。 + * 提取仍在职人员的工号。离职人员不计入,全量同步结束时会删掉对应本地账号。 * * @param oaPersons OA人员 * @return 去重后的工号 @@ -488,25 +462,21 @@ public class OaUserListSyncHelper { } /** - * 删除当前租户中 is_oa=2、且既不在本次登录名里、也不是无登录名工号人员的账号。 - * 登录名和工号都没收集到时不删除。当前登录账号即使不在同步数据中也保留。 + * 删除当前租户中 is_oa=2、且工号不在本次同步工号集合中的账号。 + * 未收集到工号时不删除。当前登录账号即使不在同步数据中也保留。 * - * @param oaLoginIds 本次全量同步出现过的 OA 登录名 * @param workcodes 本次全量同步出现过的工号 * @return 删除人数 */ - public int removeOaUsersAbsentFrom(Set oaLoginIds, Set workcodes) { - boolean noLoginIds = CollectionUtil.isEmpty(oaLoginIds); - boolean noWorkcodes = CollectionUtil.isEmpty(workcodes); - if (noLoginIds && noWorkcodes) { - ComposeLogUtil.getLastLog().warn("全量同步未收集到OA登录名和工号,跳过删除本地 is_oa=2 人员"); + public int removeOaUsersAbsentFrom(Set workcodes) { + if (CollectionUtil.isEmpty(workcodes)) { + ComposeLogUtil.getLastLog().warn("全量同步未收集到OA工号,跳过删除本地 is_oa=2 人员"); return 0; } - Set loginIdSet = noLoginIds ? Set.of() : oaLoginIds; - Set workcodeSet = noWorkcodes ? Set.of() : workcodes; + Set workcodeSet = workcodes; String tenantId = this.resolveTenantId(); List oaUsers = this.userService.list(Wrappers.lambdaQuery() - .select(User::getId, User::getOaLoginId, User::getCode) + .select(User::getId, User::getCode) .eq(User::getTenantId, tenantId) .eq(User::getIsOa, CommonConstant.YES)); Long currentUserId = AuthUtil.getUserId(); @@ -519,11 +489,8 @@ public class OaUserListSyncHelper { ComposeLogUtil.getLastLog().warn("全量同步跳过删除当前登录账号 userId={}", oaUser.getId()); continue; } - String oaLoginId = StringUtils.trimToNull(oaUser.getOaLoginId()); String workcode = StringUtils.trimToNull(oaUser.getCode()); - boolean keptByLoginId = oaLoginId != null && loginIdSet.contains(oaLoginId); - boolean keptByWorkcode = oaLoginId == null && workcode != null && workcodeSet.contains(workcode); - if (!keptByLoginId && !keptByWorkcode) { + if (workcode == null || !workcodeSet.contains(workcode)) { removeIds.add(oaUser.getId()); } } @@ -535,54 +502,13 @@ public class OaUserListSyncHelper { } /** - * 按 OA 登录名删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。 - * - * @param tenantId 租户 - * @param oaLoginIds 离职人员登录名 - * @return 删除人数 - */ - private int removeOaUsersByLoginIds(String tenantId, Collection oaLoginIds) { - if (CollectionUtil.isEmpty(oaLoginIds)) { - return 0; - } - List oaLoginIdList = oaLoginIds.stream() - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - if (oaLoginIdList.isEmpty()) { - return 0; - } - Long currentUserId = AuthUtil.getUserId(); - List removeIds = new ArrayList<>(); - for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { - List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size())); - this.userService.list(Wrappers.lambdaQuery() - .select(User::getId, User::getOaLoginId) - .eq(User::getTenantId, tenantId) - .eq(User::getIsOa, CommonConstant.YES) - .in(User::getOaLoginId, oaLoginIdBatch)) - .forEach(oaUser -> { - if (oaUser.getId() == null) { - return; - } - if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) { - ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId()); - return; - } - removeIds.add(oaUser.getId()); - }); - } - return this.deleteOaUsers(removeIds, "同步删除OA离职人员"); - } - - /** - * 删除没有登录名、且工号属于离职人员的 is_oa=2 账号。 + * 按工号删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。 * * @param tenantId 租户 * @param workcodes 离职人员工号 * @return 删除人数 */ - private int removeOaUsersByBlankLoginWorkcodes(String tenantId, Collection workcodes) { + private int removeOaUsersByWorkcodes(String tenantId, Collection workcodes) { if (CollectionUtil.isEmpty(workcodes)) { return 0; } @@ -598,11 +524,10 @@ public class OaUserListSyncHelper { for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { List workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size())); this.userService.list(Wrappers.lambdaQuery() - .select(User::getId, User::getOaLoginId, User::getCode) + .select(User::getId, User::getCode) .eq(User::getTenantId, tenantId) .eq(User::getIsOa, CommonConstant.YES) - .in(User::getCode, workcodeBatch) - .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, ""))) + .in(User::getCode, workcodeBatch)) .forEach(oaUser -> { if (oaUser.getId() == null) { return; @@ -614,7 +539,7 @@ public class OaUserListSyncHelper { removeIds.add(oaUser.getId()); }); } - return this.deleteOaUsers(removeIds, "同步删除无登录名的OA离职人员"); + return this.deleteOaUsers(removeIds, "同步删除OA离职人员"); } /** @@ -645,7 +570,8 @@ public class OaUserListSyncHelper { } /** - * 同步人员并绑定到三级部门。有登录名按登录名更新,没有登录名但有工号则按工号更新且不回填登录名。离职人员直接删除。 + * 同步人员并绑定到三级部门。按工号 workcode(对应本地 code)匹配已有用户; + * OA登录名仍优先填 workcode,其次 loginid。无工号则跳过。离职人员按工号删除。 * * @param oaPersons OA人员 * @param orgIndex 组织索引 @@ -656,7 +582,6 @@ public class OaUserListSyncHelper { return new PersonSyncCount(0, 0, 0); } Map uniquePersonMap = new LinkedHashMap<>(); - Set resignedLoginIds = new LinkedHashSet<>(); Set resignedWorkcodes = new LinkedHashSet<>(); int skippedCount = 0; for (OAPersonResponse oaPerson : oaPersons) { @@ -665,49 +590,36 @@ public class OaUserListSyncHelper { skippedCount++; continue; } - String oaLoginId = this.resolveOaLoginId(oaPerson); String workcode = this.resolveWorkcode(oaPerson); if (this.isOaResigned(oaPerson)) { uniquePersonMap.remove(syncKey); - if (oaLoginId != null) { - resignedLoginIds.add(oaLoginId); - } else if (workcode != null) { + if (workcode != null) { resignedWorkcodes.add(workcode); } continue; } - if (oaLoginId != null) { - resignedLoginIds.remove(oaLoginId); - } else if (workcode != null) { + if (workcode != null) { resignedWorkcodes.remove(workcode); } uniquePersonMap.put(syncKey, oaPerson); } String tenantId = resolveTenantId(); - int removedCount = this.removeOaUsersByLoginIds(tenantId, resignedLoginIds) - + this.removeOaUsersByBlankLoginWorkcodes(tenantId, resignedWorkcodes); + int removedCount = this.removeOaUsersByWorkcodes(tenantId, resignedWorkcodes); if (uniquePersonMap.isEmpty()) { - if (resignedLoginIds.isEmpty() && resignedWorkcodes.isEmpty()) { - ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名和工号,跳过人员同步"); + if (resignedWorkcodes.isEmpty()) { + ComposeLogUtil.getLastLog().warn("OA人员均缺少工号,跳过人员同步"); } return new PersonSyncCount(0, skippedCount, removedCount); } String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); - Set oaLoginIds = new LinkedHashSet<>(); Set workcodes = new LinkedHashSet<>(); for (OAPersonResponse oaPerson : uniquePersonMap.values()) { - String oaLoginId = this.resolveOaLoginId(oaPerson); - if (oaLoginId != null) { - oaLoginIds.add(oaLoginId); - continue; - } String workcode = this.resolveWorkcode(oaPerson); if (workcode != null) { workcodes.add(workcode); } } - Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, oaLoginIds); - Map existingByWorkcode = this.loadUsersByBlankLoginWorkcode(tenantId, workcodes); + Map existingByWorkcode = this.loadUsersByWorkcode(tenantId, workcodes); List users = new ArrayList<>(); Map userMap = new HashMap<>(); Set existsUserIds = new HashSet<>(); @@ -717,7 +629,7 @@ public class OaUserListSyncHelper { String workcode = this.resolveWorkcode(oaPerson); User user = this.userConvert.person2user(oaPerson, defaultPassword); user.setTenantId(tenantId); - User existingUser = oaLoginId != null ? existingByOaLoginId.get(oaLoginId) : existingByWorkcode.get(workcode); + User existingUser = workcode == null ? null : existingByWorkcode.get(workcode); if (oaLoginId != null) { user.setOaLoginId(oaLoginId); user.setAccount(oaLoginId); @@ -757,7 +669,7 @@ public class OaUserListSyncHelper { ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size()); userService.updateBatchById(updateUsers); } - ComposeLogUtil.getLastLog().info("缺少登录名和工号已跳过的人员:{}", skippedCount); + ComposeLogUtil.getLastLog().info("缺少工号已跳过的人员:{}", skippedCount); int syncedCount = addUsers.size() + updateUsers.size(); if (userMap.isEmpty()) { return new PersonSyncCount(0, skippedCount, removedCount); @@ -882,50 +794,9 @@ public class OaUserListSyncHelper { } /** - * 按 OA 登录名加载已有 is_oa=2 用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。 + * 按工号(User.code)加载已有 is_oa=2 用户。同一工号有多条时保留较早的一条,后续同步都更新这一条。 */ - private Map loadUsersByOaLoginId(String tenantId, Collection oaLoginIds) { - Map existingByOaLoginId = new HashMap<>(); - if (CollectionUtil.isEmpty(oaLoginIds)) { - return existingByOaLoginId; - } - List oaLoginIdList = oaLoginIds.stream() - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { - List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size())); - userService.list(Wrappers.lambdaQuery() - .eq(User::getTenantId, tenantId) - .eq(User::getIsOa, CommonConstant.YES) - .in(User::getOaLoginId, oaLoginIdBatch)) - .forEach(user -> { - String oaLoginId = StringUtils.trimToNull(user.getOaLoginId()); - if (oaLoginId == null) { - return; - } - User previous = existingByOaLoginId.get(oaLoginId); - if (previous == null) { - existingByOaLoginId.put(oaLoginId, user); - return; - } - if (user.getId() != null && previous.getId() != null && user.getId() < previous.getId()) { - ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}", - oaLoginId, user.getId(), previous.getId()); - existingByOaLoginId.put(oaLoginId, user); - return; - } - ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}", - oaLoginId, previous.getId(), user.getId()); - }); - } - return existingByOaLoginId; - } - - /** - * 按工号加载没有登录名的 is_oa=2 用户。同一工号有多条时保留较早的一条。 - */ - private Map loadUsersByBlankLoginWorkcode(String tenantId, Collection workcodes) { + private Map loadUsersByWorkcode(String tenantId, Collection workcodes) { Map existingByWorkcode = new HashMap<>(); if (CollectionUtil.isEmpty(workcodes)) { return existingByWorkcode; @@ -939,26 +810,41 @@ public class OaUserListSyncHelper { this.userService.list(Wrappers.lambdaQuery() .eq(User::getTenantId, tenantId) .eq(User::getIsOa, CommonConstant.YES) - .in(User::getCode, workcodeBatch) - .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, ""))) + .in(User::getCode, workcodeBatch)) .forEach(user -> { String workcode = StringUtils.trimToNull(user.getCode()); if (workcode == null) { return; } User previous = existingByWorkcode.get(workcode); - if (previous == null || (user.getId() != null && previous.getId() != null && user.getId() < previous.getId())) { + if (previous == null) { existingByWorkcode.put(workcode, user); + return; } + if (user.getId() != null && previous.getId() != null && user.getId() < previous.getId()) { + ComposeLogUtil.getLastLog().warn("OA工号重复,保留较早用户 workcode={} keepUserId={} dropUserId={}", + workcode, user.getId(), previous.getId()); + existingByWorkcode.put(workcode, user); + return; + } + ComposeLogUtil.getLastLog().warn("OA工号重复,保留较早用户 workcode={} keepUserId={} dropUserId={}", + workcode, previous.getId(), user.getId()); }); } return existingByWorkcode; } + /** + * OA登录名:优先 workcode,其次 loginid。仅用于填充 oaLoginId/account,不参与匹配。 + */ private String resolveOaLoginId(OAPersonResponse oaPerson) { if (oaPerson == null) { return null; } + String workcode = StringUtils.trimToNull(oaPerson.getWorkcode()); + if (workcode != null) { + return workcode; + } return StringUtils.trimToNull(oaPerson.getLoginid()); } @@ -970,13 +856,9 @@ public class OaUserListSyncHelper { } /** - * 有登录名用登录名,没有登录名但有工号用工号。两者都没有则不同步。 + * 同步去重与匹配键:仅使用工号。无工号则不同步。 */ private String resolveSyncKey(OAPersonResponse oaPerson) { - String oaLoginId = this.resolveOaLoginId(oaPerson); - if (oaLoginId != null) { - return "L:" + oaLoginId; - } String workcode = this.resolveWorkcode(oaPerson); if (workcode != null) { return "W:" + workcode; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RoleMenuDataScopeServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RoleMenuDataScopeServiceImpl.java index 0314afc9..04d62d5b 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RoleMenuDataScopeServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RoleMenuDataScopeServiceImpl.java @@ -184,20 +184,44 @@ public class RoleMenuDataScopeServiceImpl extends BaseServiceImpllambdaQuery() - .eq(Menu::getCode, menuCode) - .eq(Menu::getIsDeleted, 0) - .last("limit 1")); + Menu menu = findMenuByCode(menuCode); if (Func.isEmpty(menu)) { return RoleDataScopeVO.all(); } - List scopeList = list(Wrappers.lambdaQuery() - .in(RoleMenuDataScope::getRoleId, roleIds) - .eq(RoleMenuDataScope::getMenuId, menu.getId()) - .eq(RoleMenuDataScope::getIsDeleted, 0)); + List scopeList = listScopes(roleIds, menu.getId()); return mergeScopes(scopeList, userDeptIds); } + @Override + public RoleDataScopeVO resolveByMenuCodeOrEmpty(List roleIds, String menuCode, List userDeptIds) { + if (Func.isEmpty(roleIds) || Func.isBlank(menuCode)) { + return RoleDataScopeVO.none(); + } + Menu menu = findMenuByCode(menuCode); + if (Func.isEmpty(menu)) { + return RoleDataScopeVO.none(); + } + List scopeList = listScopes(roleIds, menu.getId()); + if (Func.isEmpty(scopeList)) { + return RoleDataScopeVO.none(); + } + return mergeScopes(scopeList, userDeptIds); + } + + private Menu findMenuByCode(String menuCode) { + return menuMapper.selectOne(Wrappers.

lambdaQuery() + .eq(Menu::getCode, menuCode) + .eq(Menu::getIsDeleted, 0) + .last("limit 1")); + } + + private List listScopes(List roleIds, Long menuId) { + return list(Wrappers.lambdaQuery() + .in(RoleMenuDataScope::getRoleId, roleIds) + .eq(RoleMenuDataScope::getMenuId, menuId) + .eq(RoleMenuDataScope::getIsDeleted, 0)); + } + @Override public RoleDataScopeVO resolveWidest(List roleIds, List userDeptIds) { if (Func.isEmpty(roleIds)) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java index 67ef69fb..67122919 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java @@ -86,6 +86,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -663,6 +664,7 @@ public class UserServiceImpl extends BaseServiceImpl implement boolean tempUser = this.deleteLogic(Func.toLongList(userIds)); boolean tempUserDept = userDeptService.remove(Wrappers.lambdaQuery().in(UserDept::getUserId, Func.toLongList(userIds))); if (tempUser && tempUserDept) { + userDataScopeMapper.delete(Wrappers.lambdaQuery().in(UserDataScope::getUserId, idList)); UserOauth userOauth = new UserOauth(); userOauth.delete(Wrappers.lambdaQuery().in(UserOauth::getUserId, Func.toLongList(userIds))); UserWeb userWeb = new UserWeb(); @@ -672,6 +674,7 @@ public class UserServiceImpl extends BaseServiceImpl implement UserOther userOther = new UserOther(); userOther.delete(Wrappers.lambdaQuery().in(UserOther::getUserId, Func.toLongList(userIds))); CacheUtil.clear(USER_CACHE); + CacheUtil.clear(USER_CACHE, Boolean.FALSE); return true; } else { throw new ServiceException("删除用户失败!"); @@ -1119,8 +1122,10 @@ public class UserServiceImpl extends BaseServiceImpl implement if (user.getPersonCategory() == null) { user.setPersonCategory(1); } - // 组织/客商数据权限已迁移至角色菜单配置,用户侧字段仅保留兼容默认值,不再参与过滤。 - if (user.getDataScopeRange() == null) { + // 有「数据管理权限」组织时标记为自定义;否则仅补兼容默认值(业务过滤以角色权限 ∪ 用户管理组织为准) + if (Func.isNotEmpty(user.getDataScopeDeptIds())) { + user.setDataScopeRange(3); + } else if (user.getDataScopeRange() == null) { user.setDataScopeRange(2); } if (user.getDataLevelRange() == null) { @@ -1141,8 +1146,33 @@ public class UserServiceImpl extends BaseServiceImpl implement return null; } + /** + * 保存用户「数据管理权限」组织;dataScopeDeptIds 为 null 时保留原配置(兼容 IAM/同步等未传该字段的调用)。 + */ private boolean submitUserScopes(User user) { - // 用户级组织/客商数据权限已停用,改由角色菜单数据权限配置。 + if (user == null || user.getId() == null) { + return true; + } + if (user.getDataScopeDeptIds() == null) { + return true; + } + userDataScopeMapper.delete(Wrappers.lambdaQuery().eq(UserDataScope::getUserId, user.getId())); + List deptIdList = user.getDataScopeDeptIds().stream() + .filter(Objects::nonNull) + .filter(deptId -> deptId > 0) + .distinct() + .toList(); + for (Long deptId : deptIdList) { + UserDataScope scope = new UserDataScope(); + scope.setUserId(user.getId()); + scope.setDeptId(deptId); + userDataScopeMapper.insert(scope); + } + this.update(Wrappers.lambdaUpdate() + .set(User::getDataScopeRange, deptIdList.isEmpty() ? 2 : 3) + .eq(User::getId, user.getId())); + CacheUtil.clear(USER_CACHE); + CacheUtil.clear(USER_CACHE, Boolean.FALSE); return true; } @@ -1150,11 +1180,46 @@ public class UserServiceImpl extends BaseServiceImpl implement if (user == null || user.getId() == null) { return user; } - user.setDataScopeDeptIds(Collections.emptyList()); + List deptIdList = userDataScopeMapper.selectList(Wrappers.lambdaQuery() + .eq(UserDataScope::getUserId, user.getId())) + .stream() + .map(UserDataScope::getDeptId) + .filter(Objects::nonNull) + .distinct() + .toList(); + user.setDataScopeDeptIds(deptIdList); user.setCustomerIds(Collections.emptyList()); return user; } + @Override + public List resolveManageDeptIds(Long userId) { + if (Func.isEmpty(userId) || userId <= 0) { + return Collections.emptyList(); + } + List baseDeptIds = userDataScopeMapper.selectList(Wrappers.lambdaQuery() + .eq(UserDataScope::getUserId, userId)) + .stream() + .map(UserDataScope::getDeptId) + .filter(Objects::nonNull) + .filter(deptId -> deptId > 0) + .distinct() + .toList(); + if (Func.isEmpty(baseDeptIds)) { + return Collections.emptyList(); + } + LinkedHashSet allowedDeptIds = new LinkedHashSet<>(); + for (Long deptId : baseDeptIds) { + List childIds = SysCache.getDeptChildIds(deptId); + if (Func.isNotEmpty(childIds)) { + allowedDeptIds.addAll(childIds); + } else { + allowedDeptIds.add(deptId); + } + } + return List.copyOf(allowedDeptIds); + } + /** * 统一的租户范围过滤:超管放行,其他用户强制限定为当前会话租户。 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/BusinessDeptConfigWrapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/BusinessDeptConfigWrapper.java new file mode 100644 index 00000000..e2f8e077 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/BusinessDeptConfigWrapper.java @@ -0,0 +1,56 @@ +/** + * 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.system.wrapper; + +import org.springblade.core.mp.support.BaseEntityWrapper; +import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.system.cache.UserCache; +import org.springblade.system.pojo.entity.BusinessDeptConfig; +import org.springblade.system.pojo.vo.BusinessDeptConfigVO; + +import java.util.Objects; + +/** + * 业务部门配置包装类 + * + * @author Chill + */ +public class BusinessDeptConfigWrapper extends BaseEntityWrapper { + + public static BusinessDeptConfigWrapper build() { + return new BusinessDeptConfigWrapper(); + } + + @Override + public BusinessDeptConfigVO entityVO(BusinessDeptConfig businessDeptConfig) { + BusinessDeptConfigVO businessDeptConfigVO = Objects.requireNonNull( + BeanUtil.copyProperties(businessDeptConfig, BusinessDeptConfigVO.class)); + businessDeptConfigVO.setCreateUserName(UserCache.getUserRealName(businessDeptConfig.getCreateUser())); + businessDeptConfigVO.setUpdateUserName(UserCache.getUserRealName(businessDeptConfig.getUpdateUser())); + return businessDeptConfigVO; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java index 5fe089fe..635207fb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java @@ -71,9 +71,9 @@ public class BfiProperties { private ReceivedInvoice receivedInvoice = new ReceivedInvoice(); /** - * 收款认领公告查询配置(A301-2) + * 收款流水同步配置(A301-1 收款单查询) */ - private ClaimAnnounce claimAnnounce = new ClaimAnnounce(); + private RecBillSync recBillSync = new RecBillSync(); /** * 付款结果定时回写配置(A103-2/A103-4,总开关同时控制应收勾稽、开票结果回写) @@ -85,11 +85,6 @@ public class BfiProperties { */ private ReceivedInvoiceResult receivedInvoiceResult = new ReceivedInvoiceResult(); - /** - * 收款认领推送配置(A301-3) - */ - private ReceiptClaimPush receiptClaimPush = new ReceiptClaimPush(); - /** * 全票池发票定时同步配置(A801-1) */ @@ -115,6 +110,11 @@ public class BfiProperties { */ private ArResult arResult = new ArResult(); + /** + * 金蝶冲销状态回写配置(A102-4/A302-4) + */ + private WriteOffResult writeOffResult = new WriteOffResult(); + /** * 金蝶单据锁定补偿配置 */ @@ -197,20 +197,15 @@ public class BfiProperties { } /** - * 收款认领公告查询配置项(A301-2) + * 收款流水同步配置项(A301-1 收款单查询) */ @Data - public static class ClaimAnnounce { + public static class RecBillSync { /** - * 查询时间窗口(月数),默认 6(近半年) + * 同步业务日期窗口天数,默认 60 */ - private int months = 6; - - /** - * 认领人(claimusers):配置后公告查询固定使用该值,不再按当前登录用户姓名查询 - */ - private String claimusers; + private int days = 60; } /** @@ -257,6 +252,11 @@ public class BfiProperties { * 回写 single-flight 锁租约秒数,须大于最坏一轮耗时(分批远程查询 + 逐单回写) */ private long lockLeaseSeconds = 1800; + + /** + * A103-4 付款处理查询业务日期窗口天数,默认 60 + */ + private int queryWindowDays = 60; } /** @@ -271,23 +271,6 @@ public class BfiProperties { private boolean syncEnabled = false; } - /** - * 收款认领推送配置项(A301-3) - */ - @Data - public static class ReceiptClaimPush { - - /** - * 推送任务开关,默认关闭(联调稳定后开启) - */ - private boolean syncEnabled = false; - - /** - * 推送 single-flight 锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告 + 逐条推送) - */ - private long lockLeaseSeconds = 1800; - } - /** * 全票池发票同步配置项(A801-1) */ @@ -374,6 +357,24 @@ public class BfiProperties { private long lockLeaseSeconds = 1800; } + /** + * 金蝶冲销状态回写配置项(A102-4/A302-4):定时回写开关、防重键批量查询与结算单批量更新的分批大小, + * 防止单批过大超出金蝶查询接口与批量落库承载能力 + */ + @Data + public static class WriteOffResult { + + /** + * 冲销状态回写任务开关,默认关闭(安全兜底),由 Nacos 按环境显式开启 + */ + private boolean syncEnabled = false; + + /** + * 冲销状态批量查询/批量更新分批大小,默认 1000 + */ + private int queryBatchSize = 1000; + } + /** * 金蝶单据锁定补偿配置项 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsContractFieldConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsContractFieldConstant.java index 798c77d1..952d96f9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsContractFieldConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsContractFieldConstant.java @@ -52,6 +52,22 @@ public final class HcsContractFieldConstant { */ public static final String FIELD_CONTRACT_NO = "CONTRACT_NO"; + /** + * 业务合同号(fd 模板字段,值同合同编号 contractNo) + */ + public static final String FIELD_FD_BUS_CONTRACT_NUMBER = "fdBusContractNumber"; + + /** + * 归档状态(fd 模板字段):toarchive=待归档; archiving=归档中; archived=已归档; archiveerror=归档异常; + * TMS 侧仅在双章归档文件上传(归档状态=已归档)后传 toarchive,其余状态由合规侧自行流转,未归档不传 + */ + public static final String FIELD_FD_ARCHIVE_STATUS = "fdArchiveStatus"; + + /** + * 归档状态取值:待归档(fdArchiveStatus,双章归档文件上传后 TMS 固定传此值) + */ + public static final String ARCHIVE_STATUS_TO_ARCHIVE = "toarchive"; + /** * 合同标题(fd 模板字段) */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsSyncConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsSyncConstant.java index 42eaaf09..ce920802 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsSyncConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsSyncConstant.java @@ -25,6 +25,8 @@ */ package org.springblade.transport.constant; +import java.util.List; + /** * HCS(合同合规系统)同步常量(TMS 业务侧) *

@@ -175,6 +177,36 @@ public final class HcsSyncConstant { */ public static final String CUSTOMER_KIND_INTERNAL = "internal"; + /** + * C101-1 黑名单状态:是(客商档案/合同客商风险展示与组装用) + */ + public static final String BLACK_STATUS_YES = "yes"; + + /** + * C101-1 黑名单状态:否 + */ + public static final String BLACK_STATUS_NO = "no"; + + /** + * 合同审核通过状态集合(首推/变更通过,客商风险定时刷新扫描口径与 HcsPushTask 一致) + */ + public static final List CONTRACT_APPROVED_STATUSES = List.of("approved", "change_approved"); + + /** + * 合同已终止当前节点展示名(客商风险定时刷新排除已终止合同) + */ + public static final String CONTRACT_TERMINATED_NODE = "已终止"; + + /** + * 客商风险等级业务字典编码(dictKey=C101-1 风险等级 0/1/2,dictValue=低风险/中风险/高风险) + */ + public static final String RISK_LEVEL_DICT_CODE = "risk_level"; + + /** + * 客商风险定时刷新批量(200 客商/批:批内黑名单一次查询 + C101-1 逐个查询,查完立即提交数据库,批间互不影响) + */ + public static final int CUSTOMER_RISK_REFRESH_BATCH = 200; + /** * 合同附件类型字段名(与前端附件元数据 fileType 字段对齐,值取业务字典 contract_attachment_type) */ @@ -190,6 +222,16 @@ public final class HcsSyncConstant { */ public static final String PAYMENT_ATTACH_TYPE_SPECIAL = "special_approval"; + /** + * 付款申请附件类型值:合规特批结果附件(paymentLegal/result 回传展示行标记,详情展示用,不参与特批附件提交校验) + */ + public static final String PAYMENT_ATTACH_TYPE_SPEC_RESULT = "付款特批流程附件"; + + /** + * 合规特批结果附件统一业务名(金蝶上送与前端展示用,上传时按 C103-5 响应头补扩展名) + */ + public static final String PAYMENT_SPEC_ATTACH_NAME = "付款特批流程附件"; + /** * 附件类型:用印附件(双章归档文件,取值沿用 ContractManageServiceImpl#FILE_TYPE_SEAL_ARCHIVE) */ @@ -212,6 +254,12 @@ public final class HcsSyncConstant { */ public static final String CONTRACT_ATTACH_KEY_OTHER = "eopCdraftOther"; + /** + * TMS 合同归档状态取值:已归档(ContractManage#archiveStatus,双章归档文件上传后由审核通过逻辑维护; + * 与合规 fdArchiveStatus 的 archived 不是同一含义,TMS 已归档对应合规 toarchive 待归档) + */ + public static final String TMS_ARCHIVE_STATUS_ARCHIVED = "已归档"; + /** * 特批审核挂起节点展示名(currentNode,特批发起后挂起展示,结果回传后恢复) */ @@ -240,15 +288,37 @@ public final class HcsSyncConstant { public static final String ROW_OP_DELETE = "delete"; /** - * 履约类型:付款 + * 履约类型:付款(C103-9 履约计划行) */ public static final String PERFORM_TYPE_PAYMENT = "payment"; /** - * 履约类型:收款 + * 履约类型:收款(C103-9 履约计划行) */ public static final String PERFORM_TYPE_COLLECTION = "collection"; + // ==================== 履约记录类型(C108 fd_paln_type,按四场景传中文) ==================== + + /** + * 履约记录类型:付款 + */ + public static final String PERF_TYPE_PAYMENT = "付款"; + + /** + * 履约记录类型:收款 + */ + public static final String PERF_TYPE_COLLECTION = "收款"; + + /** + * 履约记录类型:开票 + */ + public static final String PERF_TYPE_INVOICE = "开票"; + + /** + * 履约记录类型:收票 + */ + public static final String PERF_TYPE_INVOICE_RECEIPT = "收票"; + /** * 款项性质:进度款 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java index b1c45f44..052a50e1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java @@ -171,6 +171,12 @@ public final class KingdeeBfiConstant { /** 金蝶同步状态:生成失败(推送异常/金蝶返回失败,可重推)。 */ public static final String KINGDEE_STATUS_FAILED = "failed"; + /** 金蝶冲销状态:未冲销(旧应付/应收单在金蝶尚未冲销)。 */ + public static final String KINGDEE_WRITEOFF_UNWRITTEN = "unwrittenoff"; + + /** 金蝶冲销状态:已冲销(旧应付/应收单已在金蝶冲销,部分冲销视同已冲销)。 */ + public static final String KINGDEE_WRITEOFF_WRITTEN = "writtenoff"; + /** 收票单推送状态:未推送(新建单据默认,票池绑定层的 kingdee_status 与此无关)。 */ public static final String RECEIVED_PUSH_UNSYNCED = "unsynced"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java index 93f278b8..60a36c71 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java @@ -61,6 +61,9 @@ public final class TransportBusiConstant { /** 审批状态:已作废(终态)。 */ public static final String VOIDED = "voided"; + /** 正式结算单调整标识:已调整(结算调整重推后的归档原单,金额已作废,不可再用于下游业务)。 */ + public static final String SETTLEMENT_ADJUST_STATUS_ADJUSTED = "adjusted"; + /** * 「未分配组织」筛选项的哨兵值,不是真实组织名。 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java index c94cc068..56c682b0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java @@ -36,7 +36,9 @@ 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.ocr.service.IBusinessLicenseOcrService; import org.springblade.transport.pojo.vo.BaiduOcrResultVO; +import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -60,6 +62,7 @@ import java.io.IOException; public class BaiduOcrController extends BladeController { private final IBaiduOcrService baiduOcrService; + private final IBusinessLicenseOcrService businessLicenseOcrService; /** * 识别上传的图片。 @@ -109,6 +112,20 @@ public class BaiduOcrController extends BladeController { return R.data(baiduOcrService.recognizeUrl(resolveType(type), side, imageUrl)); } + /** + * 营业执照图片地址识别(第三方 /businessLicense)。 + * + * @param imageUrl 图片地址 + * @return 识别结果 + */ + @PostMapping("/business-license") + @ApiOperationSupport(order = 3) + @Operation(summary = "营业执照OCR识别", description = "按图片地址调用第三方营业执照识别接口") + public R recognizeBusinessLicense( + @Parameter(description = "图片地址", required = true) @RequestParam String imageUrl) { + return R.data(businessLicenseOcrService.recognizeUrl(imageUrl)); + } + private BaiduOcrType resolveType(String type) { try { return BaiduOcrType.from(type); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractChangeController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractChangeController.java index 89ddf9e0..219be8a1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractChangeController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractChangeController.java @@ -33,6 +33,7 @@ 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.transport.constant.HcsSyncConstant; import org.springblade.transport.pojo.entity.ContractChange; import org.springblade.transport.pojo.vo.ContractChangeVO; import org.springblade.transport.service.IContractChangeService; @@ -110,17 +111,12 @@ public class ContractChangeController extends BladeController { hcsImmediatePushSupport.pushContractAndWait(contractId); String syncFailure = HcsImmediatePushSupport.pollSyncFailure(); if (syncFailure != null) { - ContractChange contractChange = contractChangeService.getById(id); - if (contractChange != null) { - contractChange.setSyncStatus("failed"); - contractChangeService.updateById(contractChange); - } + contractChangeService.writeBackSyncStatus(contractId, HcsSyncConstant.HCS_STATUS_FAILED); return R.data(Boolean.FALSE, "审批通过,但合规推送失败:" + syncFailure); } - ContractChange contractChange = contractChangeService.getById(id); - if (contractChange != null) { - contractChange.setSyncStatus("synced"); - contractChangeService.updateById(contractChange); + // 即时推送未开启时(开关关闭不触发推送)不标记已推送,待推送状态由定时扫描任务推送后闭环 + if (hcsImmediatePushSupport.isImmediateEnabled()) { + contractChangeService.writeBackSyncStatus(contractId, HcsSyncConstant.HCS_STATUS_SYNCED); } return R.success("审批通过"); } 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 364dbcc9..eb0584db 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 @@ -188,10 +188,11 @@ public class ContractManageController extends BladeController { @GetMapping("/next-contract-no") @ApiOperationSupport(order = 16) - @Operation(summary = "预览下一合同编号", description = "按合同负责部门编码前缀+年份+三位流水预览") + @Operation(summary = "预览下一合同编号", description = "按平台公司+业务部门配置的前缀+年份+三位流水预览") public R nextContractNo( - @Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId) { - return R.data(contractManageService.nextContractNo(organizationId)); + @Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId, + @Parameter(description = "平台公司ID", required = true) @RequestParam Long platformCompanyId) { + return R.data(contractManageService.nextContractNo(organizationId, platformCompanyId)); } @GetMapping("/export-contract-manage") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java index b0580c5a..c2c0b7ab 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java @@ -38,6 +38,7 @@ 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.secure.utils.AuthUtil; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; @@ -198,6 +199,36 @@ public class CustomerArchiveController extends BladeController { return R.data(customerArchiveService.syncKingdee(id)); } + /** + * 金蝶供应商编码查询(A901-1,新增客商填写信用代码后即时回显用) + */ + @GetMapping("/query-supplier-code") + @ApiOperationSupport(order = 12) + @Operation(summary = "金蝶供应商编码查询", description = "A901-1 按统一社会信用代码查询金蝶供应商档案取编码,查不到返回空") + public R querySupplierCode(@Parameter(description = "统一社会信用代码", required = true) @RequestParam String creditCode) { + return R.data(customerArchiveService.querySupplierCode(creditCode)); + } + + /** + * 金蝶开户行查询(A905-3,收款信息填写开户行失焦精确匹配联行号用) + */ + @GetMapping("/query-bank") + @ApiOperationSupport(order = 12) + @Operation(summary = "金蝶开户行查询", description = "A905-3 按行名精确匹配金蝶行名行号档案,返回行名与联行号,查不到返回空") + public R queryBank(@Parameter(description = "开户行名称", required = true) @RequestParam String bankName) { + return R.data(customerArchiveService.queryKingdeeBank(bankName)); + } + + /** + * 金蝶联行号校验(A905-3,收款信息保存前校验联行号真实存在用) + */ + @GetMapping("/query-bank-by-number") + @ApiOperationSupport(order = 12) + @Operation(summary = "金蝶联行号校验", description = "A905-3 按行号精确匹配金蝶行名行号档案,查不到返回空,接口异常返回错误") + public R queryBankByNumber(@Parameter(description = "联行号", required = true) @RequestParam String cnapsCode) { + return R.data(customerArchiveService.queryKingdeeBankByNumber(cnapsCode)); + } + /** * 评分明细模板 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java index e192d772..0a6722c1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java @@ -38,6 +38,7 @@ import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; +import org.springblade.hcs.config.HcsProperties; import org.springblade.hcs.dto.HcsContractLegalResultDTO; import org.springblade.hcs.dto.HcsCurrencyVO; import org.springblade.hcs.dto.HcsLegalResultDTO; @@ -70,7 +71,9 @@ import java.io.OutputStream; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * HCS 合规推送端点(设计文档 §5.0 手动入口) @@ -99,6 +102,20 @@ public class HcsSyncController { private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport; private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport; private final HcsMasterDataSupport hcsMasterDataSupport; + private final HcsProperties hcsProperties; + + /** + * 推送开关配置(供项目管理/合同管理列表控制「推送合规」「批量推送合规」显隐) + */ + @GetMapping("/push-config") + @ApiOperationSupport(order = 0) + @Operation(summary = "查询HCS推送开关", description = "返回 thirdParty.hcs.push.immediate-enabled,前端据此隐藏即时推送入口") + public R> pushConfig() { + boolean immediateEnabled = hcsProperties.getPush() != null && hcsProperties.getPush().isImmediateEnabled(); + Map config = new HashMap<>(2); + config.put("immediateEnabled", immediateEnabled); + return R.data(config); + } /** * 单条重推(bizType:project=项目立项 / contract=合同 / payment=付款申请 / invoice-application=开票申请 / invoice-receipt=收票单) @@ -130,8 +147,9 @@ public class HcsSyncController { case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsPushTask.pushUnsyncedProjects(); case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsPushTask.pushUnsyncedContracts(); case HcsSyncConstant.BIZ_TYPE_PAYMENT -> hcsPushTask.pushUnsyncedPayments(); - case HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION -> hcsPushTask.pushUnsyncedInvoiceApplications(); - case HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT -> hcsPushTask.pushUnsyncedInvoiceReceipts(); + // 开票/收票走组合动作(C111/C109 + 履约记录 C108-1),与定时任务行为一致 + case HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION -> hcsPushTask.syncHcsInvoiceApplications(); + case HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT -> hcsPushTask.syncHcsInvoiceReceipts(); default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType); }; return R.data(result); @@ -215,13 +233,16 @@ public class HcsSyncController { /** * 客商风险等级查询(C101-1,展示用,前端接入设计 §3.3;强制校验仍在推送/付款链路): - * 内部组织类型客商默认无风险不调合规,外部客商高风险/黑名单返回提示文案 + * 内部组织类型客商默认无风险不调合规,外部客商高风险/黑名单返回提示文案; + * creditCode/customerName 供新增客商(档案未落库)查金蝶黑名单、拼装提示名称兜底,档案存在时以档案为准 */ @GetMapping("/risk") @ApiOperationSupport(order = 10) - @Operation(summary = "客商风险等级查询", description = "C101-1 按客商编号查询风险等级与黑名单状态(展示用),内部客商默认无风险,高风险/黑名单返回风险提示文案,结果留痕") - public R risk(@Parameter(description = "客商编号", required = true) @RequestParam String customerCode) { - return R.data(hcsCreditCheckSupport.queryCustomerRisk(customerCode)); + @Operation(summary = "客商风险等级查询", description = "C101-1 按客商编号查询风险等级与黑名单状态(展示用),内部客商默认无风险,高风险/黑名单返回风险提示文案,结果留痕;creditCode/customerName 为新增客商场景兜底入参") + public R risk(@Parameter(description = "客商编号", required = true) @RequestParam String customerCode, + @Parameter(description = "统一社会信用代码(新增客商档案未落库时兜底查金蝶黑名单)") @RequestParam(required = false) String creditCode, + @Parameter(description = "客商名称(新增客商档案未落库时提示文案名称兜底)") @RequestParam(required = false) String customerName) { + return R.data(hcsCreditCheckSupport.queryCustomerRisk(customerCode, creditCode, customerName)); } /** @@ -261,13 +282,14 @@ public class HcsSyncController { */ @GetMapping("/attachment/download") @ApiOperationSupport(order = 12) - @Operation(summary = "合规附件下载", description = "C103-5 代理:按合规附件ID 下载文件流,name 参数可选用于指定下载文件名") + @Operation(summary = "合规附件下载", description = "C103-5 代理:按合规附件ID 下载文件流,name 参数可选用于指定下载文件名,inline=true 时内嵌预览(pdf 浏览器直接打开)") public void downloadAttachment(@Parameter(description = "合规附件ID", required = true) @RequestParam String fdId, @Parameter(description = "下载文件名(可选)") @RequestParam(required = false) String name, HttpServletResponse response) throws Exception { String fileName = StringUtil.isBlank(name) ? "hcs-attachment-" + fdId : name; response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); - response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20")); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + + URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20")); try (Response hcsResponse = hcsAttachmentSupport.downloadRaw(fdId); InputStream inputStream = hcsResponse.body().asInputStream(); OutputStream outputStream = response.getOutputStream()) { @@ -276,6 +298,19 @@ public class HcsSyncController { } } + /** + * 合同附件下载(路径式文件名,url 以 .pdf 等后缀结尾):前端附件组件按 url 路径后缀识别附件类型, + * query 传 fdId;行为与 /attachment/download 完全一致,强制下载不做内嵌预览 + */ + @GetMapping("/attachment/download/{name}") + @ApiOperationSupport(order = 12) + @Operation(summary = "合规附件下载(路径式文件名)", description = "C103-5 代理:路径携带文件名(含后缀,前端附件类型识别用),fdId 必传,文件流 attachment 下载") + public void downloadAttachmentWithPath(@Parameter(description = "下载文件名(含后缀)", required = true) @PathVariable String name, + @Parameter(description = "合规附件ID", required = true) @RequestParam String fdId, + HttpServletResponse response) throws Exception { + this.downloadAttachment(fdId, name, response); + } + /** * 合同特批结果人工确认(§6-U1 过渡方案:与合规推送等效) */ @@ -307,7 +342,7 @@ public class HcsSyncController { *

* 免登免授权同合同接口(blade.secure 放行); * iPaaS 网关注册映射:/gwzh/HTHG/paymentLegal/result → /hcs/paymentLegal/result; - * 报文 {sourceSystem, requestId=TMS付款申请ID, status=APPROVED/REJECTED, reason}, + * 报文 {sourceSystem, requestId=合规特批流程ID(hcs_spec_fd_id), status=APPROVED/REJECTED, reason}, * approved 恢复"审批通过"放行,rejected 联动付款审批驳回走重新提交闭环。 */ @PostMapping("/paymentLegal/result") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectChangeController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectChangeController.java index c697286c..7bfdf1e6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectChangeController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectChangeController.java @@ -33,6 +33,7 @@ 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.transport.constant.HcsSyncConstant; import org.springblade.transport.pojo.entity.ProjectChange; import org.springblade.transport.pojo.vo.ProjectChangeVO; import org.springblade.transport.service.IProjectChangeService; @@ -110,17 +111,12 @@ public class ProjectChangeController extends BladeController { hcsImmediatePushSupport.pushProjectAndWait(projectId); String syncFailure = HcsImmediatePushSupport.pollSyncFailure(); if (syncFailure != null) { - ProjectChange projectChange = projectChangeService.getById(id); - if (projectChange != null) { - projectChange.setSyncStatus("failed"); - projectChangeService.updateById(projectChange); - } + projectChangeService.writeBackSyncStatus(projectId, HcsSyncConstant.HCS_STATUS_FAILED); return R.data(Boolean.FALSE, "审批通过,但合规推送失败:" + syncFailure); } - ProjectChange projectChange = projectChangeService.getById(id); - if (projectChange != null) { - projectChange.setSyncStatus("synced"); - projectChangeService.updateById(projectChange); + // 即时推送未开启时(开关关闭不触发推送)不标记已推送,待推送状态由定时扫描任务推送后闭环 + if (hcsImmediatePushSupport.isImmediateEnabled()) { + projectChangeService.writeBackSyncStatus(projectId, HcsSyncConstant.HCS_STATUS_SYNCED); } return R.success("审批通过"); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java index 3db05ad5..3b7b80fc 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java @@ -83,16 +83,9 @@ public class ReceiptClaimRecordController extends BladeController { @PostMapping("/void") @ApiOperationSupport(order = 4) - @Operation(summary = "作废认领记录并生成金蝶认领冲单") + @Operation(summary = "作废认领记录(作废后同步合同合规系统)") public R voidClaim(@RequestParam Long id) { receiptClaimRecordService.voidClaim(id); return R.success("作废成功"); } - - @PostMapping("/query-kingdee-result") - @ApiOperationSupport(order = 5) - @Operation(summary = "查询金蝶认领结果(A301-1 收款单查询回写)") - public R queryKingdeeResult() { - return R.data(receiptClaimRecordService.refreshKingdeeResult()); - } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java index 1bbee361..b1fc3b21 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java @@ -35,7 +35,6 @@ 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.transport.pojo.dto.ReceiptClaimPushResult; import org.springblade.transport.pojo.dto.ReceiptClaimRequest; import org.springblade.transport.pojo.vo.ReceiptFlowVO; import org.springblade.transport.service.IReceiptFlowService; @@ -95,16 +94,8 @@ public class ReceiptFlowController extends BladeController { @PostMapping("/sync") @ApiOperationSupport(order = 5) - @Operation(summary = "手动同步金蝶收款流水(A301-2 收款认领公告查询)") + @Operation(summary = "手动同步金蝶收款流水(A301-1 收款单查询)") public R sync() { return R.data(receiptFlowService.sync()); } - - @PostMapping("/push-kingdee") - @ApiOperationSupport(order = 6) - @Operation(summary = "推送金蝶收款认领(A301-3 收款认领结果推送,仅认领完成流水可推送)") - public R pushKingdee(@RequestParam Long flowId) { - ReceiptClaimPushResult result = receiptFlowService.pushClaimToKingdee(flowId); - return result.isSuccess() ? R.data(result) : R.fail(result.getMessage()); - } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java index 2e9fdbc0..11eccfd6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java @@ -66,6 +66,8 @@ public class ContractManageExcel implements Serializable { private String partyA; @ExcelProperty("乙方") private String partyB; + @ExcelProperty("丙方") + private String partyC; @ExcelProperty("开始日期") private LocalDate startDate; @ExcelProperty("结束日期") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java index ebe4eec2..8c25f3f1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java @@ -51,6 +51,9 @@ public class CustomerArchiveExcel implements Serializable { @ExcelProperty("客商编号") private String customerCode; + @ExcelProperty("供应商编码") + private String supplierCode; + @ExcelProperty("客商简称") private String shortName; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml index 2c04f248..077b5a75 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml @@ -54,6 +54,11 @@ + + + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeReceiptFlowMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeReceiptFlowMapper.java index 7d69772c..0e6b654d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeReceiptFlowMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeReceiptFlowMapper.java @@ -27,8 +27,11 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; +import java.util.List; + /** * 金蝶收款流水镜像 Mapper 接口 * @@ -36,4 +39,13 @@ import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; */ @Mapper public interface KingdeeReceiptFlowMapper extends BaseMapper { + + /** + * 组织管理(blade_dept)中的平台公司金蝶组织编码列表(是否平台公司=是 且 已维护金蝶编码) + * + * @return 平台公司金蝶组织编码 + */ + @Select("SELECT kingdee_dept_code FROM blade_dept WHERE is_platform_company = 1 " + + "AND kingdee_dept_code IS NOT NULL AND kingdee_dept_code != '' AND is_deleted = 0") + List selectPlatformOrgNumbers(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimHcsRecordMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimHcsRecordMapper.java new file mode 100644 index 00000000..feac846d --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimHcsRecordMapper.java @@ -0,0 +1,39 @@ +/** + * 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.apache.ibatis.annotations.Mapper; +import org.springblade.transport.pojo.entity.ReceiptClaimHcsRecord; + +/** + * 收款认领合规推送记录 Mapper 接口 + * + * @author Chill + */ +@Mapper +public interface ReceiptClaimHcsRecordMapper extends BaseMapper { +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IBusinessLicenseOcrService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IBusinessLicenseOcrService.java new file mode 100644 index 00000000..dcb8a4bf --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IBusinessLicenseOcrService.java @@ -0,0 +1,45 @@ +/** + * 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.pojo.vo.BusinessLicenseOcrVO; + +/** + * 营业执照 OCR 服务。 + * + * @author Chill + */ +public interface IBusinessLicenseOcrService { + + /** + * 根据图片地址识别营业执照。 + * + * @param imageUrl 图片地址 + * @return 识别结果 + */ + BusinessLicenseOcrVO recognizeUrl(String imageUrl); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BusinessLicenseOcrServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BusinessLicenseOcrServiceImpl.java new file mode 100644 index 00000000..332efc39 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BusinessLicenseOcrServiceImpl.java @@ -0,0 +1,189 @@ +/** + * 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.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.transport.ocr.service.IBusinessLicenseOcrService; +import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +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.util.LinkedHashMap; +import java.util.Map; + +/** + * 营业执照 OCR 服务实现,调用第三方 /businessLicense 接口。 + * + * @author Chill + */ +@Slf4j +@Service +public class BusinessLicenseOcrServiceImpl implements IBusinessLicenseOcrService { + + private static final String PATH = "/businessLicense"; + private static final String DATA_TYPE_URL = "url"; + private static final int SUCCESS_CODE = 0; + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + /** + * 营业执照 OCR 服务地址,Nacos:thirdParty.ocr.businessLicenseBaseUrl + */ + @Value("${thirdParty.ocr.businessLicenseBaseUrl:http://124.71.22.123:8888}") + private String businessLicenseBaseUrl; + + public BusinessLicenseOcrServiceImpl(ObjectMapper objectMapper, + @Qualifier("baiduOcrHttpClient") HttpClient httpClient) { + this.objectMapper = objectMapper; + this.httpClient = httpClient; + } + + @Override + public BusinessLicenseOcrVO recognizeUrl(String imageUrl) { + if (StringUtil.isBlank(imageUrl)) { + throw new ServiceException("OCR图片地址不能为空"); + } + String normalizedUrl = imageUrl.trim(); + validateImageUrl(normalizedUrl); + if (StringUtil.isBlank(businessLicenseBaseUrl)) { + throw new ServiceException("营业执照OCR服务地址未配置(thirdParty.ocr.businessLicenseBaseUrl)"); + } + String requestUrl = businessLicenseBaseUrl.replaceAll("/+$", "") + PATH; + Map body = new LinkedHashMap<>(); + body.put("img_path", normalizedUrl); + body.put("data_type", DATA_TYPE_URL); + JsonNode response = sendRequest(requestUrl, body); + int code = response.path("code").asInt(Integer.MIN_VALUE); + if (code != SUCCESS_CODE) { + String message = firstNonBlank(response.path("msg").asText(null), response.path("message").asText(null), "未知错误"); + log.warn("营业执照OCR识别失败,code={},msg={},imageUrl={}", code, message, normalizedUrl); + throw new ServiceException("营业执照OCR识别失败:" + message); + } + BusinessLicenseOcrVO result = new BusinessLicenseOcrVO(); + result.setFields(extractFields(response.path("data"))); + return result; + } + + private JsonNode sendRequest(String requestUrl, Map body) { + try { + String jsonBody = objectMapper.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder(URI.create(requestUrl)) + .timeout(Duration.ofSeconds(60)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, 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接口响应异常,status={},url={}", response.statusCode(), requestUrl); + 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接口被中断,url={}", requestUrl, exception); + throw new ServiceException("营业执照OCR接口调用被中断"); + } catch (IllegalArgumentException exception) { + log.error("营业执照OCR接口地址配置不正确,url={}", requestUrl); + throw new ServiceException("营业执照OCR接口地址配置不正确"); + } catch (IOException exception) { + log.error("调用营业执照OCR接口失败,url={}", requestUrl, exception); + throw new ServiceException("营业执照OCR接口调用失败"); + } + } + + private Map extractFields(JsonNode dataNode) { + Map fields = new LinkedHashMap<>(); + if (dataNode == null || !dataNode.isArray()) { + return fields; + } + for (JsonNode item : dataNode) { + if (item == null || !item.isObject()) { + continue; + } + String key = item.path("key").asText(null); + String value = item.path("value").asText(null); + if (StringUtil.isBlank(key) || value == null) { + continue; + } + String trimmed = value.trim(); + if (StringUtil.isBlank(trimmed)) { + continue; + } + fields.put(key.trim(), trimmed); + } + return fields; + } + + 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 String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (StringUtil.isNotBlank(value)) { + return value; + } + } + return null; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractChangeService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractChangeService.java index 8dab1de0..f715bcf7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractChangeService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractChangeService.java @@ -72,4 +72,13 @@ public interface IContractChangeService extends BaseService { */ boolean markSyncStatus(String ids, String syncStatus); + /** + * 按原合同回写最近一条待同步变更单的同步状态(审批即时推送/定时任务推送后调用) + * + * @param contractId 原合同ID + * @param syncStatus 同步状态(synced/failed) + * @return 是否回写成功(无待同步变更单时返回 false) + */ + boolean writeBackSyncStatus(Long contractId, String syncStatus); + } 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 d4f481b9..7597ecb9 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 @@ -75,12 +75,13 @@ public interface IContractManageService extends BaseService { List exportContractManage(ContractManageVO contractManage, String ids); /** - * 预览下一合同编号(按合同负责部门前缀 + 年份 + 三位流水) + * 预览下一合同编号(按平台公司 + 业务部门配置的前缀 + 年份 + 三位流水) * - * @param organizationId 合同负责部门ID + * @param organizationId 合同负责部门(业务部门)ID + * @param platformCompanyId 平台公司ID * @return 预览编号 */ - String nextContractNo(Long organizationId); + String nextContractNo(Long organizationId, Long platformCompanyId); /** * 合同是否可用于生成结算单:已审批、未终止,且临时合同仍在临时效力期内。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java index e50f1a47..c608c664 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java @@ -25,6 +25,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO; import org.springblade.transport.excel.CustomerArchiveExcel; import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.vo.CustomerArchiveVO; @@ -196,6 +197,30 @@ public interface ICustomerArchiveService extends BaseService { */ CustomerArchive syncKingdee(Long id); + /** + * A901-1 按统一社会信用代码查询金蝶供应商编码(新增客商填写信用代码后即时回显用),查不到返回 null + * + * @param unifiedCreditCode 统一社会信用代码 + * @return 金蝶供应商编码 + */ + String querySupplierCode(String unifiedCreditCode); + + /** + * A905-3 按行名精确匹配金蝶行名行号档案(收款信息填写开户行后失焦回显联行号用),查不到返回 null + * + * @param bankName 开户行名称(行名全称) + * @return 匹配到的行名行号档案,含联行号 + */ + BfiBankVO queryKingdeeBank(String bankName); + + /** + * A905-3 按行号精确匹配金蝶行名行号档案(收款信息保存前校验联行号真实存在用),查不到返回 null,接口异常上抛由全局异常处理返回 + * + * @param cnapsCode 联行号(行号) + * @return 匹配到的行名行号档案 + */ + BfiBankVO queryKingdeeBankByNumber(String cnapsCode); + /** * 推送失败状态落库(独立事务):推送主事务已回滚,失败状态与原因单独落库供页面展示与手动重推 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java index daf1d429..34c5649b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java @@ -80,6 +80,23 @@ public interface IFormalSettlementService extends BaseService String retryKingdeeFailed(); String syncKingdeeArResult(); + + /** + * 金蝶冲销状态定时回写:扫描已同步金蝶的结算单,按防重键批量查询 A102-4/A302-4 的 + * hadwrittenoff(部分冲销视同已冲销),回写冲销状态供结算单列表展示 + * + * @return 执行摘要 + */ + String syncKingdeeWriteOffResult(); + + /** + * 单笔刷新金蝶冲销状态(选单预检/重推闸门实时查询后回写,状态未变化不落库) + * + * @param settlementId 正式结算单id + * @param writtenOff 金蝶侧应付/应收单是否已冲销 + */ + void refreshKingdeeWriteOff(Long settlementId, boolean writtenOff); + List detailFees(Long detailId); void adjustDetail(PreSettlementDetailAdjustRequest request); void refreshPaymentSummary(Long settlementId); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java index e68ec0c0..c55c874f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java @@ -72,12 +72,13 @@ public interface IPaymentApplicationService extends BaseService { boolean removeDraft(String ids); + /** + * 按原项目回写最近一条待同步变更单的同步状态(审批即时推送/定时任务推送后调用) + * + * @param projectId 原项目ID + * @param syncStatus 同步状态(synced/failed) + * @return 是否回写成功(无待同步变更单时返回 false) + */ + boolean writeBackSyncStatus(Long projectId, String syncStatus); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java index efd68227..38eb870f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java @@ -46,11 +46,4 @@ public interface IReceiptClaimRecordService extends BaseService { void updateAttachments(ReceiptClaimAttachmentsRequest request); void voidClaim(Long id); - - /** - * A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进金蝶单据状态 - * - * @return 回写条数 - */ - int refreshKingdeeResult(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java index 89586f79..4985b5ba 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java @@ -27,7 +27,6 @@ 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.ReceiptClaimPushResult; import org.springblade.transport.pojo.dto.ReceiptClaimRequest; import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; import org.springblade.transport.pojo.vo.ReceiptFlowVO; @@ -53,25 +52,9 @@ public interface IReceiptFlowService extends BaseService { Long claim(ReceiptClaimRequest request); /** - * 手动同步金蝶收款流水:直连 A301-2 收款认领公告查询,按明细流水号幂等 upsert 收款流水表 + * 手动同步金蝶收款流水:直连 A301-1 收款单查询(收款处理查询),按流水分录明细流水号幂等 upsert 收款流水表 * * @return 本次同步条数 */ int sync(); - - /** - * 手动推送金蝶(A301-3 收款认领结果推送):认领完成且未推送过的流水才可推送, - * 推送前以当前用户姓名重查 A301-2 最新公告对账。与定时任务共用同一推送实现。 - * - * @param flowId 收款流水id - * @return 推送结果(失败不抛异常,原因随结果返回) - */ - ReceiptClaimPushResult pushClaimToKingdee(Long flowId); - - /** - * 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。 - * - * @return 执行摘要 - */ - String pushClaimByTask(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java index b8f1e083..b882244c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java @@ -310,7 +310,7 @@ public class CommonAddressServiceImpl extends BaseServiceImpl selectContractChangePage(IPage page, ContractChangeVO contractChange) { IPage entityPage = page(page, buildQuery(contractChange).orderByDesc(ContractChange::getCreateTime)); IPage voPage = ContractChangeWrapper.build().pageVO(entityPage); voPage.getRecords().forEach(this::fillReadonly); + this.fillContractRisk(voPage.getRecords()); return voPage; } @@ -90,6 +95,7 @@ public class ContractChangeServiceImpl extends BaseServiceImpl records) { + if (Func.isEmpty(records)) { + return; + } + java.util.Set partyNames = new java.util.HashSet<>(); + records.forEach(change -> { + if (Func.isNotEmpty(change.getPartyA())) { + partyNames.add(change.getPartyA().trim()); + } + if (Func.isNotEmpty(change.getPartyB())) { + partyNames.add(change.getPartyB().trim()); + } + }); + java.util.Map riskMap = this.hcsCreditCheckSupport.queryArchiveRiskMap(partyNames); + for (ContractChangeVO change : records) { + CustomerArchive partyARisk = Func.isEmpty(change.getPartyA()) ? null : riskMap.get(change.getPartyA().trim()); + CustomerArchive partyBRisk = Func.isEmpty(change.getPartyB()) ? null : riskMap.get(change.getPartyB().trim()); + change.setHcsRiskLevel(HcsCreditCheckSupport.higherRiskLevel( + partyARisk == null ? null : partyARisk.getHcsRiskLevel(), + partyBRisk == null ? null : partyBRisk.getHcsRiskLevel())); + change.setKingdeeBlackStatus( + HcsCreditCheckSupport.isKingdeeBlackHit(partyARisk) || HcsCreditCheckSupport.isKingdeeBlackHit(partyBRisk) + ? HcsSyncConstant.BLACK_STATUS_YES : HcsSyncConstant.BLACK_STATUS_NO); + } + } @Override @Transactional(rollbackFor = Exception.class) public ContractChangeVO createFromContract(Long contractId, String changeType) { @@ -190,7 +226,11 @@ public class ContractChangeServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(ContractChange::getContractId, contractId) + .eq(ContractChange::getApprovalStatus, STATUS_APPROVED) + .in(ContractChange::getSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED)) + .set(ContractChange::getSyncStatus, syncStatus) + .last("order by approved_time desc limit 1")); + } + private LambdaQueryWrapper buildQuery(ContractChangeVO contractChange) { TransportBusinessSupport.validateAllDept(contractChange.getAllDept(), "合同变更"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(ContractChange::getIsDeleted, 0); @@ -263,9 +317,12 @@ public class ContractChangeServiceImpl extends BaseServiceImpl wrapper.eq(ContractChange::getOrganizationId, currentDeptId) - .or().eq(ContractChange::getCreateUser, currentUserId) - .or().eq(ContractChange::getHandlerUserId, currentUserId)); + queryWrapper.and(wrapper -> { + wrapper.eq(ContractChange::getOrganizationId, currentDeptId) + .or().eq(ContractChange::getCreateUser, currentUserId) + .or().eq(ContractChange::getHandlerUserId, currentUserId); + TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId)); + }); } else if (Func.isNotEmpty(contractChange.getOrganizationId())) { queryWrapper.eq(ContractChange::getOrganizationId, contractChange.getOrganizationId()); } @@ -305,7 +362,20 @@ public class ContractChangeServiceImpl extends BaseServiceImpl entityPage = page(page, buildQuery(contractManage)); IPage voPage = ContractManageWrapper.build().pageVO(entityPage); voPage.getRecords().forEach(this::fillReadonly); + this.fillContractRisk(voPage.getRecords()); return voPage; } @@ -115,6 +122,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl records) { + if (Func.isEmpty(records)) { + return; + } + Set partyNames = new HashSet<>(); + records.forEach(contract -> { + if (Func.isNotEmpty(contract.getPartyA())) { + partyNames.add(contract.getPartyA().trim()); + } + if (Func.isNotEmpty(contract.getPartyB())) { + partyNames.add(contract.getPartyB().trim()); + } + }); + Map riskMap = this.hcsCreditCheckSupport.queryArchiveRiskMap(partyNames); + for (ContractManageVO contract : records) { + CustomerArchive partyARisk = Func.isEmpty(contract.getPartyA()) ? null : riskMap.get(contract.getPartyA().trim()); + CustomerArchive partyBRisk = Func.isEmpty(contract.getPartyB()) ? null : riskMap.get(contract.getPartyB().trim()); + contract.setHcsRiskLevel(HcsCreditCheckSupport.higherRiskLevel( + partyARisk == null ? null : partyARisk.getHcsRiskLevel(), + partyBRisk == null ? null : partyBRisk.getHcsRiskLevel())); + contract.setKingdeeBlackStatus( + HcsCreditCheckSupport.isKingdeeBlackHit(partyARisk) || HcsCreditCheckSupport.isKingdeeBlackHit(partyBRisk) + ? HcsSyncConstant.BLACK_STATUS_YES : HcsSyncConstant.BLACK_STATUS_NO); + // 悬浮明细与客商列表/表单实时提示同口径(buildRiskDetail 统一组装),有风险信息的甲乙方段拼接展示 + String partyADetail = partyARisk == null ? "" + : HcsCreditCheckSupport.buildRiskDetail(contract.getPartyA().trim(), partyARisk.getHcsRiskLevel(), + partyARisk.getKingdeeBlackCategory(), partyARisk.getKingdeeBlackReason()); + String partyBDetail = partyBRisk == null ? "" + : HcsCreditCheckSupport.buildRiskDetail(contract.getPartyB().trim(), partyBRisk.getHcsRiskLevel(), + partyBRisk.getKingdeeBlackCategory(), partyBRisk.getKingdeeBlackReason()); + contract.setHcsRiskDetail(Stream.of(partyADetail, partyBDetail) + .filter(Func::isNotEmpty).collect(Collectors.joining(";"))); + } + } + @Override @Transactional(rollbackFor = Exception.class) public boolean saveDraft(ContractManage contractManage) { @@ -403,7 +450,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl wrapper.eq(ContractManage::getOrganizationId, currentDeptId) - .or().eq(ContractManage::getCreateUser, currentUserId) - .or().eq(ContractManage::getHandlerUserId, currentUserId)); + queryWrapper.and(wrapper -> { + wrapper.eq(ContractManage::getOrganizationId, currentDeptId) + .or().eq(ContractManage::getCreateUser, currentUserId) + .or().eq(ContractManage::getHandlerUserId, currentUserId); + TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId)); + }); } else if (Func.isNotEmpty(contractManage.getOrganizationId())) { queryWrapper.eq(ContractManage::getOrganizationId, contractManage.getOrganizationId()); } @@ -632,6 +704,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl queryWrapper, String partyName) { + if (Func.isEmpty(partyName)) { + return; + } + queryWrapper.and(wrapper -> wrapper.like(ContractManage::getPartyA, partyName) + .or().like(ContractManage::getPartyB, partyName) + .or().like(ContractManage::getPartyC, partyName)); + } + private void applyEffectiveType(LambdaQueryWrapper queryWrapper, String effectiveType) { if (Func.isEmpty(effectiveType)) { return; @@ -686,7 +771,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl wrapper2.ne(ContractManage::getContractStage, STAGE_TEMPORARY) .lt(ContractManage::getEndDate, today))); - default -> throw new ServiceException("无效的生效类型筛选条件"); + default -> throw new ServiceException("无效的合同状态筛选条件"); } } @@ -723,6 +808,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl latestList = list(Wrappers.lambdaQuery() .select(ContractManage::getContractNo) .eq(ContractManage::getIsDeleted, 0) - .eq(ContractManage::getOrganizationId, organizationId) .likeRight(ContractManage::getContractNo, head) .orderByDesc(ContractManage::getContractNo) - .last("LIMIT 50")); + .last("LIMIT 200")); Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$"); - // 当年尚无匹配编号时,从组织配置的初始流水号起编;否则在已有最大流水号上加1 - int next = resolveContractInitialSerial(dept); + int initialSerial = resolveContractInitialSerial(config); + int maxUsed = 0; + boolean hasMatched = false; for (ContractManage item : latestList) { String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo()); if (Func.isEmpty(contractNo)) { @@ -839,22 +937,24 @@ public class ContractManageServiceImpl extends BaseServiceImpl 999) { throw new ServiceException("当前部门本年合同流水号已达上限999"); } return head + String.format("%03d", next) + "号"; } - private int resolveContractInitialSerial(Dept dept) { - Integer serial = dept.getContractInitialSerial(); + private int resolveContractInitialSerial(BusinessDeptConfig config) { + Integer serial = config == null ? null : config.getInitialSerial(); if (serial == null || serial < 1) { return 1; } if (serial > 999) { - throw new ServiceException("组织机构中配置的合同初始流水号不能超过999"); + throw new ServiceException("业务部门配置的合同初始流水号不能超过999"); } return serial; } @@ -877,6 +977,29 @@ public class ContractManageServiceImpl extends BaseServiceImpl originalKeys = new HashSet<>(); + for (Object item : parseJsonArray(originalJson)) { + originalKeys.add(attachmentKey(item)); + } + List newSealFiles = new ArrayList<>(); + for (Object item : parseJsonArray(requestJson)) { + if (originalKeys.contains(attachmentKey(item))) { + continue; + } + if (item instanceof Map map) { + Object fileType = map.get("fileType"); + if (fileType != null && FILE_TYPE_SEAL_ARCHIVE.equals(String.valueOf(fileType).trim())) { + newSealFiles.add(item); + } + } + } + return newSealFiles.isEmpty() ? null : JsonUtil.toJson(newSealFiles); + } + @SuppressWarnings("unchecked") private String markContractFilesApproved(String contractFileJson) { List files = parseJsonArray(contractFileJson); @@ -948,8 +1071,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl plans)) { throw new ServiceException("计费方案设置格式不正确"); } - Map defaultPlanCount = new HashMap<>(); for (Object value : plans) { if (!(value instanceof Map plan)) { throw new ServiceException("计费方案设置格式不正确"); } - String transportMode = trimValue(plan.get("transportMode")); - if (isDefaultBillingPlan(plan)) { - int count = defaultPlanCount.merge(transportMode, 1, Integer::sum); - if (count > 1) { - throw new ServiceException("同一运输方式仅支持配置一个默认计费方案"); - } - } + // 一个客商对应一个方案,不再校验「默认方案」 validateBillingMatchConditions(plan); validateBillingRuleTaxRates(plan); } @@ -1090,15 +1209,6 @@ public class ContractManageServiceImpl extends BaseServiceImpl plan) { - Object value = plan.get("defaultPlan"); - return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(String.valueOf(value)) || "1".equals(String.valueOf(value)); - } - - private String trimValue(Object value) { - return value == null ? null : TransportBusinessSupport.trimToNull(String.valueOf(value)); - } - private void validatePaymentRatios(String paymentRatioJson) { if (Func.isEmpty(paymentRatioJson)) return; try { @@ -1147,8 +1257,9 @@ public class ContractManageServiceImpl extends BaseServiceImpl(beforeData.keySet())); record.put("beforeData", beforeData); record.put("afterData", afterData); } @@ -1295,11 +1408,64 @@ public class ContractManageServiceImpl extends BaseServiceImpl beforeData, + Map afterData) { + if (beforeData == null || afterData == null || beforeData.isEmpty()) { + return TransportBusinessSupport.trimToNull(contractManage.getChangeContent()); + } + return beforeData.entrySet().stream() + .map(entry -> "将【" + contractChangeFieldLabel(entry.getKey()) + "】由【" + formatChangeRecordValue(entry.getValue()) + + "】变更为【" + formatChangeRecordValue(afterData.get(entry.getKey())) + "】") + .collect(Collectors.joining("、")); + } + + /** + * 合同变更快照字段中文名(change_record_json 展示用,与前端变更字段标签一致) + */ + private static final Map CONTRACT_CHANGE_FIELD_LABELS = Map.ofEntries( + Map.entry("contractNo", "合同编号"), Map.entry("contractName", "合同名称"), + Map.entry("partyB", "乙方"), Map.entry("startDate", "开始日期"), + Map.entry("endDate", "结束日期"), Map.entry("contractFormat", "合同格式"), + Map.entry("settlementMode", "结算方式"), Map.entry("legalSealFlag", "是否需要加盖法人章"), + Map.entry("copyCount", "一式份数"), Map.entry("settlementCurrency", "结算币种"), + Map.entry("invoiceCycle", "开票周期(天)"), Map.entry("paymentDays", "回款账期(天)"), + Map.entry("contractAmount", "合同金额"), Map.entry("templateFlag", "是否范本"), + Map.entry("originalContractNo", "原件合同编号"), Map.entry("electronicSealFlag", "是否电子章"), + Map.entry("mainContent", "合同主要内容"), Map.entry("remark", "其他事项说明"), + Map.entry("feeGenerationMode", "费用生成模式"), Map.entry("billingPlanJson", "计费方案"), + Map.entry("settlementRuleJson", "结算生成规则"), Map.entry("preSettlementConfigJson", "预结算配置"), + Map.entry("formalSettlementConfigJson", "正式结算配置"), Map.entry("paymentRatioJson", "付款比例设置"), + Map.entry("contractFileJson", "合同主文件"), Map.entry("attachmentsJson", "其它附件"), + Map.entry("changeAttachmentsJson", "变更附件") + ); + + private String contractChangeFieldLabel(String field) { + return CONTRACT_CHANGE_FIELD_LABELS.getOrDefault(field, field); + } + + private String formatChangeRecordValue(Object value) { + if (value == null || (value instanceof String text && text.isBlank())) { + return "空"; + } + String text = String.valueOf(value); + // 计费方案/附件等长 JSON 文本截断,保证变更内容可读 + return text.length() > 100 ? text.substring(0, 100) + "…" : text; + } + private Map buildContractChangeSnapshot(ContractManage contractManage) { Map data = new LinkedHashMap<>(); data.put("contractNo", contractManage.getContractNo()); data.put("contractName", contractManage.getContractName()); + data.put("partyA", contractManage.getPartyA()); data.put("partyB", contractManage.getPartyB()); + data.put("partyC", contractManage.getPartyC()); + data.put("partyAJson", contractManage.getPartyAJson()); + data.put("partyBJson", contractManage.getPartyBJson()); + data.put("partyCJson", contractManage.getPartyCJson()); data.put("startDate", contractManage.getStartDate()); data.put("endDate", contractManage.getEndDate()); data.put("contractFormat", contractManage.getContractFormat()); @@ -1321,9 +1487,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl records = baseMapper.selectCustomerArchivePage(page, customer, currentScopeUserId()); records.forEach(record -> record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser()))); fillFundUseRisk(records); + this.fillRiskDetail(records); return page.setRecords(records); } @@ -276,6 +286,12 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl page = bfiService.querySupplier(param); + List rows = page == null ? null : page.getRows(); + return rows == null || rows.isEmpty() ? null : rows.get(0).getNumber(); + } catch (Exception exception) { + log.warn("A901-1供应商查询金蝶客商编码失败,unifiedCreditCode={}", unifiedCreditCode, exception); + return null; + } + } + + /** + * 供应商编码落库:A901-1 按统一社会信用代码查金蝶供应商档案取 number,查到才覆盖(查不到/接口异常保留原值,不影响同步主流程) + */ + private void fillSupplierCode(CustomerArchive customer, CustomerArchive after) { + String supplierCode = this.queryKingdeeSupplierNumber(customer.getUnifiedCreditCode()); + if (Func.isNotEmpty(supplierCode)) { + after.setSupplierCode(supplierCode); + } + } + + @Override + public String querySupplierCode(String unifiedCreditCode) { + return this.queryKingdeeSupplierNumber(unifiedCreditCode); + } + + /** + * A905-3 按行名查询金蝶行名行号档案:收款信息填写开户行失焦时实时精确匹配取联行号, + * 接口异常返回 null 不阻断录入,由前端提示未查询到 + */ + @Override + public BfiBankVO queryKingdeeBank(String bankName) { + if (Func.isEmpty(bankName)) { + return null; + } + try { + BfiBankQueryDTO param = new BfiBankQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + param.setStatus(BfiConstant.BILL_STATUS_AUDITED); + param.setEnable(BfiConstant.ENABLE_ENABLED); + param.setName(List.of(bankName.trim())); + BfiPageVO page = bfiService.queryBank(param); + List rows = page == null ? null : page.getRows(); + if (rows == null || rows.isEmpty()) { + return null; + } + // 金蝶侧按行名过滤若为模糊匹配,本地再按全称精确比对取第一条 + String trimmedName = bankName.trim(); + return rows.stream() + .filter(bank -> trimmedName.equals(bank.getName())) + .findFirst() + .orElse(null); + } catch (Exception exception) { + log.warn("A905-3行名行号查询失败,bankName={}", bankName, exception); + return null; + } + } + + /** + * A905-3 按行号查询金蝶行名行号档案:收款信息保存前校验联行号真实存在,查不到返回 null 由前端拦截保存; + * 与 {@link #queryKingdeeBank(String)} 不同,接口异常不上抛吞掉而是直接抛出,便于前端区分「查不到」与「校验失败」 + */ + @Override + public BfiBankVO queryKingdeeBankByNumber(String cnapsCode) { + if (Func.isEmpty(cnapsCode)) { + return null; + } + BfiBankQueryDTO param = new BfiBankQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + param.setStatus(BfiConstant.BILL_STATUS_AUDITED); + param.setEnable(BfiConstant.ENABLE_ENABLED); + param.setNumber(List.of(cnapsCode.trim())); + BfiPageVO page = bfiService.queryBank(param); + List rows = page == null ? null : page.getRows(); + if (rows == null || rows.isEmpty()) { + return null; + } + // 金蝶侧按行号过滤若为模糊匹配,本地再按编码精确比对取第一条 + String trimmedCode = cnapsCode.trim(); + return rows.stream() + .filter(bank -> trimmedCode.equals(bank.getNumber())) + .findFirst() + .orElse(null); + } + /** * 金蝶已存在档案时,客商编号(customer_code)采纳金蝶客商编码,后续业务接口沿用客商编号即拿到金蝶编码; * 金蝶编码为空或与当前编号一致时无需处理;同租户下已被其他客商占用时跳过回填,避免违反 uk_customer_archive_code 唯一约束 @@ -766,10 +882,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl records) { + for (CustomerArchiveVO record : records) { + String customerName = Func.isEmpty(record.getFullName()) ? record.getCustomerCode() : record.getFullName(); + record.setHcsRiskDetail(HcsCreditCheckSupport.buildRiskDetail(customerName, + record.getHcsRiskLevel(), + record.getKingdeeBlackCategory(), record.getKingdeeBlackReason())); + } + } + /** * 资金使用情况 = 付款申请中「付款方=当前客商」且审核通过的已付金额合计; * 资金使用额度风险 = 合计已付金额 / 客商最大资金使用额度。 @@ -2152,6 +2282,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl payerNames = new HashSet<>(); records.forEach(customer -> { if (Func.isNotEmpty(customer.getFullName())) { @@ -2206,6 +2337,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl imp driver.setDrivingLicenseBack(trimToNull(driver.getDrivingLicenseBack())); driver.setQualificationFront(trimToNull(driver.getQualificationFront())); driver.setQualificationBack(trimToNull(driver.getQualificationBack())); + if (driver.getIdCardLongTerm() == null) { + driver.setIdCardLongTerm(DEFAULT_FALSE); + } if (driver.getDrivingLicenseLongTerm() == null) { driver.setDrivingLicenseLongTerm(DEFAULT_FALSE); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 09872fc0..7900ef9d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -155,6 +155,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaUpdate() .eq(FormalSettlement::getId, settlementId) - .set(FormalSettlement::getAdjustStatus, "adjusted")); + .set(FormalSettlement::getAdjustStatus, TransportBusiConstant.SETTLEMENT_ADJUST_STATUS_ADJUSTED)); kingdeeBillLockService.unlockByAdjustRepush(KingdeeLockConstant.SOURCE_SETTLEMENT, settlementId, AuthUtil.getUserName()); saveChange(settlementId, "结算单基本信息", null, "调整", "结算调整单" + Func.toStr(adjustmentNo) + "重推生成新单" + Func.toStr(newSettlementNo) + ",原单标记已调整", null); @@ -866,20 +869,24 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 500 ? failReason.substring(0, 500) : failReason; + // 条件更新:仅审批通过且未同步的单据落"生成失败"状态与失败原因,未审批/已同步/已删除单据自动跳过保持原状态 int updated = baseMapper.update(null, Wrappers.lambdaUpdate() .eq(FormalSettlement::getId, id) .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .ne(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED) - .set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + .set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .set(FormalSettlement::getKingdeeFailReason, columnReason)); if (updated > 0) { - // 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed 供「推送金蝶」重试 + // 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed + 失败原因供「推送金蝶」重试 settlementAdjustmentMapper.update(null, Wrappers.lambdaUpdate() .eq(SettlementAdjustment::getFormalSettlementId, id) .eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED) - .set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + .set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .set(SettlementAdjustment::getKingdeeFailReason, columnReason)); // 失败原因写变更记录留档(推送事务已回滚,此处独立事务);原因过长截断留档,不抛错 - String failReason = Func.toStrWithEmpty(reason, "未知异常"); saveChange(id, "结算单基本信息", null, "调整", "同步金蝶失败:" + (failReason.length() > 200 ? failReason.substring(0, 200) : failReason), null); } @@ -1134,6 +1141,113 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl分批与事务口径同 {@link #executeArResultSync()}:结算明细分批 in 查询、远程整批查询消除逐单 N+1, + * 仅回写状态有变化的结算单并 updateBatchById 批量落库;整轮任务不包外层事务,按防重键幂等可重跑。 + * + * @return 执行摘要 + */ + private String executeWriteOffSync() { + long startMillis = System.currentTimeMillis(); + // 已同步金蝶的结算单才在金蝶存在旧应付/应收单,未同步/生成失败不查询(前端展示 '-'); + // 已冲销为终态不再复查,仅扫未冲销单据(存量 NULL 由 06 脚本回填默认未冲销) + List settlements = list(Wrappers.lambdaQuery() + .eq(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED) + .eq(FormalSettlement::getKingdeeWriteOffStatus, KingdeeBfiConstant.KINGDEE_WRITEOFF_UNWRITTEN) + .orderByAsc(FormalSettlement::getId)); + if (Func.isEmpty(settlements)) { + log.info("金蝶冲销状态回写:无待查未冲销结算单"); + return "金蝶冲销状态回写:无待查未冲销结算单"; + } + log.info("金蝶冲销状态回写:本轮扫描待查未冲销结算单{}笔", settlements.size()); + // 结算明细分批一次查全(消除逐单查询 N+1),按结算单 id 分组后按结算类型组装防重键 + List settlementIds = settlements.stream().map(FormalSettlement::getId).toList(); + Map> detailsBySettlement = new HashMap<>(); + kingdeeApBillSyncSupport.batchForeach(settlementIds, AR_RESULT_DETAIL_QUERY_BATCH, batch -> + detailsBySettlement.putAll(detailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, batch) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)))); + Map apSettlementBySrcCode = new LinkedHashMap<>(); + Map arSettlementBySrcCode = new LinkedHashMap<>(); + for (FormalSettlement settlement : settlements) { + List details = detailsBySettlement.getOrDefault(settlement.getId(), List.of()); + if (Func.isEmpty(details)) { + continue; + } + if ("receivable".equals(settlement.getSettlementType())) { + String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details); + if (Func.isNotBlank(srcCode)) { + arSettlementBySrcCode.putIfAbsent(srcCode, settlement); + } + } else { + String srcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details); + if (Func.isNotBlank(srcCode)) { + apSettlementBySrcCode.putIfAbsent(srcCode, settlement); + } + } + } + // A102-4/A302-4 整批一次查询,冲销状态有变化的才落库(updateBatchById 批量更新,避免逐单 update) + log.info("金蝶冲销状态回写:防重键组装完成,应付{}个、应收{}个,按批次大小{}分批查询", + apSettlementBySrcCode.size(), arSettlementBySrcCode.size(), bfiProperties.getWriteOffResult().getQueryBatchSize()); + Map apWriteOffFlags = kingdeeApBillSyncSupport.queryApBillWriteOffFlags(new ArrayList<>(apSettlementBySrcCode.keySet())); + Map arWriteOffFlags = kingdeeArBillSyncSupport.queryArBillWriteOffFlags(new ArrayList<>(arSettlementBySrcCode.keySet())); + // 金蝶未返回的键按未冲销处理,返回数与组装数差异即金蝶侧查不到单据的规模,留痕便于对账 + log.info("金蝶冲销状态批量查询完成:应付键返回{}个、应收键返回{}个,未返回键按未冲销处理", + apWriteOffFlags.size(), arWriteOffFlags.size()); + List toUpdateSettlements = new ArrayList<>(); + apSettlementBySrcCode.forEach((srcCode, settlement) -> + this.applyWriteOffFlag(settlement, apWriteOffFlags.get(srcCode), toUpdateSettlements)); + arSettlementBySrcCode.forEach((srcCode, settlement) -> + this.applyWriteOffFlag(settlement, arWriteOffFlags.get(srcCode), toUpdateSettlements)); + if (Func.isNotEmpty(toUpdateSettlements)) { + // 批量落库按同一分批大小拆分,避免单批过大 + updateBatchById(toUpdateSettlements, bfiProperties.getWriteOffResult().getQueryBatchSize()); + log.info("金蝶冲销状态回写:批量落库完成,本批更新{}笔", toUpdateSettlements.size()); + } + String summary = "金蝶冲销状态回写完成:待查未冲销结算单" + (apSettlementBySrcCode.size() + arSettlementBySrcCode.size()) + + "笔,回写" + toUpdateSettlements.size() + "笔"; + log.info("{},耗时{}ms", summary, System.currentTimeMillis() - startMillis); + return summary; + } + + /** 应用单笔冲销查询结果:与本地冲销状态不一致时标记更新(查不到金蝶单据按未冲销处理),一致跳过 */ + private void applyWriteOffFlag(FormalSettlement settlement, Boolean writtenOff, List toUpdateSettlements) { + String status = Boolean.TRUE.equals(writtenOff) ? KingdeeBfiConstant.KINGDEE_WRITEOFF_WRITTEN : KingdeeBfiConstant.KINGDEE_WRITEOFF_UNWRITTEN; + if (status.equals(settlement.getKingdeeWriteOffStatus())) { + return; + } + settlement.setKingdeeWriteOffStatus(status); + settlement.setKingdeeWriteOffTime(LocalDateTime.now()); + toUpdateSettlements.add(settlement); + log.info("金蝶冲销状态回写:正式结算单[{}],{}", settlement.getFormalSettlementNo(), status); + } + @Override @Transactional(rollbackFor = Exception.class) public String applyPayment(FormalSettlementPaymentRequest request) { @@ -1248,6 +1362,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementPayment::getFormalSettlementId, settlement.getId()) .eq(FormalSettlementPayment::getIsDeleted, 0) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index 7b74830e..6dabb44e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -138,6 +138,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl sheets = sheetMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo)); + List invoices = invoiceApplicationInvoiceMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id) + .orderByAsc(InvoiceApplicationInvoice::getCreateTime)); + vo.setInvoices(invoices); + // 每张发票的金蝶开票结果:按张次金蝶单号归集,查看页展示开票状态与结果备注 + Map> invoicesBySerialNo = invoices.stream() + .filter(invoice -> Func.isNotBlank(invoice.getSerialNo())) + .collect(Collectors.groupingBy(invoice -> Func.toStr(invoice.getSerialNo()).trim())); vo.setSheets(sheets.stream().map(sheet -> { InvoiceApplicationSheetVO sheetVO = Objects.requireNonNull(BeanUtil.copyProperties(sheet, InvoiceApplicationSheetVO.class)); sheetVO.setLines(lineMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationLine::getInvoiceSheetId, sheet.getId()).orderByAsc(InvoiceApplicationLine::getLineNo))); + List sheetInvoices = + invoicesBySerialNo.get(Func.toStr(sheet.getKingdeeBillNo()).trim()); + sheetVO.setInvoiceStatusName(Func.isEmpty(sheetInvoices) ? INVOICE_STATUS_UNINVOICED : INVOICE_STATUS_INVOICED); + sheetVO.setKingdeeResultRemark(Func.isEmpty(sheetInvoices) ? "" : kingdeeResultRemark(sheetInvoices)); return sheetVO; }).toList()); vo.setDetails(applicationDetailMapper.selectList(Wrappers.lambdaQuery() @@ -262,9 +278,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(InvoiceApplicationRecord::getInvoiceApplicationId, id) .orderByAsc(InvoiceApplicationRecord::getCreateTime))); - vo.setInvoices(invoiceApplicationInvoiceMapper.selectList(Wrappers.lambdaQuery() - .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id) - .orderByAsc(InvoiceApplicationInvoice::getCreateTime))); return vo; } @@ -639,17 +652,19 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaUpdate() .eq(InvoiceApplication::getId, id) .eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED) .notIn(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, KingdeeBfiConstant.KINGDEE_STATUS_INVOICED) - .set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + .set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .set(InvoiceApplication::getKingdeeFailReason, failReason)); if (updated > 0) { // 失败原因写操作流水留档(推送事务已回滚,此处独立事务) record(id, "sync", "同步金蝶失败", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, - truncateReason(reason), null); + failReason, null); } } @@ -818,13 +833,19 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl savedBillNos = new ArrayList<>(); @@ -909,9 +930,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl items) { BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ==================== - // 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码 + // 【必填】组织.编码:合同平台公司金蝶组织编码 param.setOrgidNumber(orgNumber); - // 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源) + // 【必填】申请部门.编码:合同平台公司下业务部门配置的金蝶部门代码 param.setAwrpOrgfieldNumber(deptNumber); // 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号) param.setBillno(billNo); @@ -965,8 +986,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl applicationBySerialNo = new LinkedHashMap<>(); + Map> serialNosByApplicationId = new LinkedHashMap<>(); applications.forEach(application -> { if (Func.isNotBlank(application.getKingdeeBillNo())) { // 多张发票时申请单金蝶单号逗号拼接存储,拆分为逐张单号分别归集回写 for (String serialNo : application.getKingdeeBillNo().split(",")) { if (Func.isNotBlank(serialNo)) { applicationBySerialNo.putIfAbsent(serialNo.trim(), application); + serialNosByApplicationId.computeIfAbsent(application.getId(), key -> new ArrayList<>()).add(serialNo.trim()); } } } }); + // 已有开票结果的单号归集(含历史已落库),全部张次齐备才推进开票完成 + Set serialNosWithResult = new LinkedHashSet<>(); Map sellerCache = new LinkedHashMap<>(); Map> serialNosBySellerTaxNo = new LinkedHashMap<>(); int unresolved = 0; @@ -1204,7 +1230,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl> group : serialNosBySellerTaxNo.entrySet()) { List groupSerialNos = group.getValue(); for (int from = 0; from < groupSerialNos.size(); from += INVOICE_RESULT_QUERY_BATCH) { @@ -1225,10 +1250,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl existingInvoiceMap = batchQueryExistingInvoices(batch); log.debug("金蝶开票结果回写:批次已存在发票记录数={}", existingInvoiceMap.size()); - // 收集待插入的发票记录、待补文件地址的已有发票记录和待更新的开票申请 + // 已落库记录对应的单号视为已有开票结果(跨批次幂等归集) + existingInvoiceMap.values().forEach(record -> serialNosWithResult.add(Func.toStr(record.getSerialNo()).trim())); + // 收集待插入的发票记录和待补文件地址的已有发票记录 List toInsertInvoices = new ArrayList<>(); List toPatchInvoices = new ArrayList<>(); - List toUpdateApplications = new ArrayList<>(); for (BfiInvoiceApplyOpenApiBillVO bill : result.getData()) { String billNo = bill.getBillNo(); InvoiceApplication application = applicationBySerialNo.get(billNo); @@ -1270,13 +1296,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl 0) { invoiceWritten += inserted; - // 查到发票:开票申请单推进开票完成(终态) - application.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_INVOICED); - // HCS 开票登记待更新:扫描任务凭 hcs_fd_id 走 C111-3 回填票据日期(D12) - application.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); - toUpdateApplications.add(application); - invoicedCount++; - log.info("金蝶开票结果回写:开票申请[{}]状态推进为invoiced,本次写入发票{}张", application.getApplicationNo(), inserted); + serialNosWithResult.add(billNo); + log.info("金蝶开票结果回写:单据编号[{}]写入发票{}张", billNo, inserted); } } // 批量插入发票记录 @@ -1289,17 +1310,29 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl toCompleteApplications = new ArrayList<>(); + for (InvoiceApplication application : applications) { + List applicationSerialNos = serialNosByApplicationId.get(application.getId()); + if (Func.isEmpty(applicationSerialNos) || !serialNosWithResult.containsAll(applicationSerialNos)) { + continue; + } + application.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_INVOICED); + // HCS 开票登记待更新:扫描任务凭 hcs_fd_id 走 C111-3 回填票据日期(D12) + application.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); + toCompleteApplications.add(application); + } + int invoicedCount = toCompleteApplications.size(); + if (!toCompleteApplications.isEmpty()) { + updateBatchById(toCompleteApplications); + log.info("金蝶开票结果回写:批量推进开票完成{}笔", invoicedCount); + } log.info("金蝶开票结果回写完成:待查开票申请{}笔(销方税号无法解析跳过{}笔),回写发票{}张(开票完成{}笔,失败批次{}个)", applications.size(), unresolved, invoiceWritten, invoicedCount, failed); return "金蝶开票结果回写完成:待查开票申请" + applications.size() + "笔(销方税号无法解析跳过" + unresolved @@ -1321,6 +1354,27 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl left)); } + /** + * 汇总本张发票经 A303-5 回写的金蝶开票结果备注(发票号、开票日期、价税合计) + * + * @param sheetInvoices 本张发票的金蝶发票结果 + * @return 结果备注 + */ + private String kingdeeResultRemark(List sheetInvoices) { + return sheetInvoices.stream() + .map(invoice -> { + StringBuilder remark = new StringBuilder("发票号").append(Func.toStr(invoice.getInvoiceNo())); + if (invoice.getInvoiceDate() != null) { + remark.append(",开票日期").append(invoice.getInvoiceDate().toLocalDate()); + } + if (invoice.getInvoiceAmount() != null) { + remark.append(",价税合计").append(invoice.getInvoiceAmount()); + } + return remark.toString(); + }) + .collect(Collectors.joining(";")); + } + /** * 将 A303-5 返回的发票文件地址补齐到已落库记录(仅补空字段,不覆盖已有值) * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 33a64fbd..de5b6cbe 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -50,6 +50,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.constant.KingdeeAttachmentConstant; import org.springblade.transport.config.BfiProperties; @@ -1304,11 +1305,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛, - * 单片 500 条以内 1 页即短页终止) + * 同步单个票种的时间窗口:翻页与触顶二分续扫统一交给 {@link BfiUtil#forEachWindowSlice}, + * 页级客商单位过滤、镜像批量 upsert、已登记票跟随补绑在页回调中完成 * - * @param sliceStart 切片起点(含) - * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) - * @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享) - * @param stats 整轮统计累加器 + * @param windowStart 窗口起点(含) + * @param windowEnd 窗口终点(含) + * @param invoiceType 发票种类编码,null 不限 + * @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享) + * @param stats 整轮统计累加器 */ - private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, + private void syncPoolWindow(LocalDateTime windowStart, LocalDateTime windowEnd, String invoiceType, Set customerTaxNos, SyncStats stats) { - // 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏 - if (!sliceStart.isBefore(sliceEnd)) { - log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd); - return; - } - boolean truncated = false; - for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) { - List rows = this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo); - if (rows.isEmpty()) { - break; - } - stats.pageCount++; - stats.pulledCount += rows.size(); - // 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像; - // 短页判定用原始条数,过滤后的空页不能提前终止翻页 + stats.sliceCount += BfiUtil.forEachWindowSlice("金蝶全票池同步", windowStart, windowEnd, + POOL_PAGE_SIZE, MAX_POOL_PAGES, + (sliceStart, sliceEnd, pageNo) -> this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo), + rows -> this.handlePoolPage(rows, customerTaxNos, stats)); + } + + /** + * 票池同步单页处理:客商单位过滤 + 镜像批量 upsert + 已登记票跟随补绑。 + * 客商过滤只影响入镜像行数,翻页节奏由 BfiUtil 按原始行数判断,不受过滤影响 + * + * @param rows 本页拉取的票池发票(原始行) + * @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭 + * @param stats 整轮统计累加器 + */ + private void handlePoolPage(List rows, Set customerTaxNos, SyncStats stats) { + stats.pageCount++; + stats.pulledCount += rows.size(); + // 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像 + if (customerTaxNos != null) { int rawPageSize = rows.size(); - if (customerTaxNos != null) { - rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList(); - stats.customerFiltered += rawPageSize - rows.size(); - } - BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows); - stats.created += upsertResult.created(); - stats.updated += upsertResult.updated(); - stats.mirrorUnchanged += upsertResult.unchanged(); - // 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库 - if (!upsertResult.mirrors().isEmpty()) { - RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors()); - stats.bound += refreshResult.bound(); - stats.unchanged += refreshResult.unchanged(); - stats.failed += refreshResult.failed(); - } - // 远程短页:该片到底,正常终止(按原始拉取条数判断,客商过滤不改变翻页节奏) - if (rawPageSize < POOL_PAGE_SIZE) { - stats.sliceCount++; - return; - } - truncated = true; + rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList(); + stats.customerFiltered += rawPageSize - rows.size(); } - // 触顶:该切片数据超过单查询窗口上限,从中点二分续扫(updateTime 落在中点的行归左片 [start, mid), - // 右片 [mid, end) 不含 mid,同 updateTime 行整体归左片,不重不漏) - if (truncated) { - LocalDateTime mid = sliceStart.plus(java.time.Duration.between(sliceStart, sliceEnd).dividedBy(2)); - if (!mid.isAfter(sliceStart)) { - // 切片已细到不足 1 秒仍满页:理论不可能,防御死递归 - log.error("金蝶全票池同步切片 {} ~ {} 已达最小粒度仍满页(>{} 条),该时间片数据异常,跳过", - sliceStart, sliceEnd, MAX_POOL_PAGES * POOL_PAGE_SIZE); - return; - } - log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})", - sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd); - this.syncWindowSlice(sliceStart, mid, invoiceType, customerTaxNos, stats); - this.syncWindowSlice(mid, sliceEnd, invoiceType, customerTaxNos, stats); - return; + BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows); + stats.created += upsertResult.created(); + stats.updated += upsertResult.updated(); + stats.mirrorUnchanged += upsertResult.unchanged(); + // 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库 + if (!upsertResult.mirrors().isEmpty()) { + RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors()); + stats.bound += refreshResult.bound(); + stats.unchanged += refreshResult.unchanged(); + stats.failed += refreshResult.failed(); } - stats.sliceCount++; } /** @@ -1523,7 +1503,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd, + private BfiPageVO pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd, String invoiceType, int pageNo) { BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO(); data.setPageNo(pageNo); @@ -1536,7 +1516,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl poolPage = bfiService.queryInvoicePool(param); log.info("金蝶全票池同步 第{}页拉取 {} 条", pageNo, poolPage.getRows() == null ? 0 : poolPage.getRows().size()); - return poolPage.getRows() == null ? List.of() : poolPage.getRows(); + return poolPage; } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index d2f8098d..b2f92153 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -35,6 +35,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.support.ContractPartySupport; import org.springblade.transport.support.LiveProcessConfigSupport; import org.springblade.transport.support.LoadableAdmissionSupport; import org.springblade.transport.support.TransportBusinessSupport; @@ -118,23 +119,27 @@ public class LoadingManageServiceImpl extends BaseServiceImpl contracts = availableCarrierContracts(projectIds); + Set allowedCarrierNames = null; if (Func.isNotEmpty(waybillIds)) { List waybills = loadWaybills(waybillIds); - Set allowedPartyBs = intersectCarrierPartyBs(waybills, contracts); - if (allowedPartyBs.isEmpty()) { + allowedCarrierNames = intersectCarrierPartyBs(waybills, contracts); + if (allowedCarrierNames.isEmpty()) { return List.of(); } + Set allowedNames = allowedCarrierNames; contracts = contracts.stream() - .filter(contract -> allowedPartyBs.contains(normalizePartyName(contract.getPartyB()))) + .filter(contract -> ContractPartySupport.carrierPartyNames(contract).stream() + .anyMatch(allowedNames::contains)) .toList(); } - return contracts.stream().map(contract -> { - LoadingCarrierContractVO option = new LoadingCarrierContractVO(); - option.setId(contract.getId()); - option.setContractName(contract.getContractName()); - option.setCarrierName(contract.getPartyB()); - return option; - }).toList(); + List options = ContractPartySupport.expandCarrierOptions(contracts); + if (allowedCarrierNames == null) { + return options; + } + Set allowedNames = allowedCarrierNames; + return options.stream() + .filter(option -> allowedNames.contains(normalizePartyName(option.getCarrierName()))) + .toList(); } @Override @@ -674,7 +679,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpl queryWrapper = Wrappers.lambdaQuery().eq(LoadingManage::getIsDeleted, 0); if (!Objects.equals(loadingManage.getAllDept(), 1)) { - queryWrapper.eq(LoadingManage::getDeptId, TransportBusinessSupport.currentDeptId("配载管理")); + Long currentDeptId = TransportBusinessSupport.currentDeptId("配载管理"); + queryWrapper.and(wrapper -> { + wrapper.eq(LoadingManage::getDeptId, currentDeptId); + TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId)); + }); } else if (Func.isNotEmpty(loadingManage.getDeptId())) { queryWrapper.eq(LoadingManage::getDeptId, loadingManage.getDeptId()); } @@ -969,6 +978,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills) { if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) { loadingManage.setCarrierContractId(null); + loadingManage.setCarrierPartyId(null); + loadingManage.setCarrierPartyRole(null); if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) { loadingManage.setCarrierName(null); } @@ -979,6 +990,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl selectedParty = ContractPartySupport.findCarrierParty( + carrierContract, loadingManage.getCarrierPartyId(), loadingManage.getCarrierName()); + if (selectedParty == null && Func.isNotEmpty(loadingManage.getCarrierName()) + && !ContractPartySupport.matchesCarrier(carrierContract, loadingManage.getCarrierName(), loadingManage.getCarrierPartyId())) { + throw new ServiceException("所选承运商不属于该承运商合同的乙方或丙方客商"); + } + if (selectedParty != null) { + Object customerName = selectedParty.get("customerName"); + Object partyId = selectedParty.get("id"); + Object partyRole = selectedParty.get("partyRole"); + loadingManage.setCarrierName(customerName == null ? null : normalizePartyName(String.valueOf(customerName))); + loadingManage.setCarrierPartyId(partyId == null ? null : normalizePartyName(String.valueOf(partyId))); + loadingManage.setCarrierPartyRole(partyRole == null ? null : ContractPartySupport.normalizeRole(String.valueOf(partyRole))); + } else if (Func.isEmpty(loadingManage.getCarrierName())) { + loadingManage.setCarrierName(ContractPartySupport.firstPartyName(carrierContract.getPartyBJson(), carrierContract.getPartyB())); + loadingManage.setCarrierPartyRole(ContractPartySupport.ROLE_B); + } if (Func.isNotEmpty(childWaybills)) { List projectContracts = availableCarrierContracts(projectIdsOf(childWaybills)); Set allowedPartyBs = intersectCarrierPartyBs(childWaybills, projectContracts); - String partyB = normalizePartyName(carrierContract.getPartyB()); - if (Func.isNotEmpty(allowedPartyBs) && !allowedPartyBs.contains(partyB)) { - throw new ServiceException("所选承运商合同乙方与子运单可共同承运的乙方不一致,可选:" + String carrierName = normalizePartyName(loadingManage.getCarrierName()); + if (Func.isNotEmpty(allowedPartyBs) && !allowedPartyBs.contains(carrierName)) { + throw new ServiceException("所选承运客商与子运单可共同承运的客商不一致,可选:" + String.join("、", allowedPartyBs)); } if (allowedPartyBs.isEmpty()) { - throw new ServiceException("所选运单在同一项目下没有共同的承运商合同乙方,不能配载到同一配载单"); + throw new ServiceException("所选运单在同一项目下没有共同的承运商合同客商,不能配载到同一配载单"); } } - loadingManage.setCarrierName(carrierContract.getPartyB()); } private void validateCompletedCarrierContract(LoadingManage loadingManage) { @@ -1050,12 +1079,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpl intersectCarrierPartyBs(List waybills, List projectContracts) { Set intersection = null; - Map customerPartyBMap = loadCustomerContractPartyBMap(waybills); + Map> customerPartyBMap = loadCustomerContractPartyBMap(waybills); for (Waybill waybill : waybills) { Set available = availableCarrierPartyBs(waybill, projectContracts, customerPartyBMap); if (intersection == null) { @@ -1071,23 +1099,23 @@ public class LoadingManageServiceImpl extends BaseServiceImpl availableCarrierPartyBs(Waybill waybill, List projectContracts, - Map customerPartyBMap) { + Map> customerPartyBMap) { if (waybill == null || Func.isEmpty(projectContracts)) { return Set.of(); } Long contractId = waybill.getContractId(); - // 运单没有客户合同时不按乙方过滤;客户合同乙方即我方,应等于承运商合同甲方 - String partyB = contractId == null ? "" : normalizePartyName(customerPartyBMap.get(contractId)); + Set customerPartyBs = contractId == null + ? Set.of() + : customerPartyBMap.getOrDefault(contractId, Set.of()); return projectContracts.stream() .filter(contract -> Objects.equals(contract.getProjectId(), waybill.getProjectId())) - .filter(contract -> Func.isEmpty(partyB) || Objects.equals(normalizePartyName(contract.getPartyA()), partyB)) - .map(ContractManage::getPartyB) - .map(this::normalizePartyName) + .filter(contract -> customerPartyBs.isEmpty() || partyNamesOverlap(partyANames(contract), customerPartyBs)) + .flatMap(contract -> ContractPartySupport.carrierPartyNames(contract).stream()) .filter(Func::isNotEmpty) .collect(Collectors.toCollection(LinkedHashSet::new)); } - private Map loadCustomerContractPartyBMap(List waybills) { + private Map> loadCustomerContractPartyBMap(List waybills) { List contractIds = waybills.stream() .map(Waybill::getContractId) .filter(Objects::nonNull) @@ -1101,11 +1129,55 @@ public class LoadingManageServiceImpl extends BaseServiceImpl normalizePartyName(contract.getPartyB()), + this::partyBNames, (left, right) -> left, LinkedHashMap::new)); } + private Set partyANames(ContractManage contract) { + Set names = ContractPartySupport.parsePartyRows(contract.getPartyAJson()).stream() + .map(row -> normalizePartyName(String.valueOf(row.get("customerName")))) + .filter(Func::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (names.isEmpty() && Func.isNotEmpty(contract.getPartyA())) { + for (String part : contract.getPartyA().split("[、,]")) { + String name = normalizePartyName(part); + if (Func.isNotEmpty(name)) { + names.add(name); + } + } + } + return names; + } + + private Set partyBNames(ContractManage contract) { + Set names = ContractPartySupport.parsePartyRows(contract.getPartyBJson()).stream() + .map(row -> normalizePartyName(String.valueOf(row.get("customerName")))) + .filter(Func::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (names.isEmpty() && Func.isNotEmpty(contract.getPartyB())) { + for (String part : contract.getPartyB().split("[、,]")) { + String name = normalizePartyName(part); + if (Func.isNotEmpty(name)) { + names.add(name); + } + } + } + return names; + } + + private boolean partyNamesOverlap(Set left, Set right) { + if (Func.isEmpty(left) || Func.isEmpty(right)) { + return false; + } + for (String name : left) { + if (right.contains(name)) { + return true; + } + } + return false; + } + private String normalizePartyName(String value) { return value == null ? "" : value.trim(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java index d459c234..d42b3d8d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java @@ -30,6 +30,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.ITransportPlanService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.ContractPartySupport; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.WaybillProcessSupport; import org.springblade.transport.wrapper.WaybillWrapper; @@ -87,11 +88,15 @@ public class MasterOrderServiceImpl extends BaseServiceImpl carriers(Long id) { - return availableCarrierContracts(getRequired(id)).stream().map(contract -> { + return ContractPartySupport.expandCarrierOptions(availableCarrierContracts(getRequired(id))).stream().map(option -> { MasterOrderCarrierVO carrier = new MasterOrderCarrierVO(); - carrier.setContractId(contract.getId()); - carrier.setContractName(contract.getContractName()); - carrier.setCarrierName(contract.getPartyB()); + carrier.setContractId(option.getId()); + carrier.setContractName(option.getContractName()); + carrier.setCarrierName(option.getCarrierName()); + carrier.setCarrierPartyId(option.getCarrierPartyId()); + carrier.setCarrierPartyRole(option.getCarrierPartyRole()); + carrier.setCreditCode(option.getCreditCode()); + carrier.setCustomerId(option.getCustomerId()); return carrier; }).toList(); } @@ -267,7 +272,11 @@ public class MasterOrderServiceImpl extends BaseServiceImpl wrapper.isNull(ContractManage::getContractStage) .or().ne(ContractManage::getContractStage, "terminated")) .orderByDesc(ContractManage::getCreateTime)) - .stream().filter(contract -> Func.isNotEmpty(contract.getPartyB())).toList(); + .stream().filter(contract -> Func.isNotEmpty(ContractPartySupport.carrierPartyNames(contract))).toList(); } private void validateCarrier(Map dispatch, Map availableCarrierContracts) { @@ -615,11 +624,29 @@ public class MasterOrderServiceImpl extends BaseServiceImpl selectedParty = ContractPartySupport.findCarrierParty(carrierContract, carrierPartyId, carrierName); + if (selectedParty != null) { + Object selectedName = selectedParty.get("customerName"); + Object selectedPartyId = selectedParty.get("id"); + Object selectedPartyRole = selectedParty.get("partyRole"); + if (selectedName != null) { + dispatch.put("carrierName", String.valueOf(selectedName).trim()); + } + if (selectedPartyId != null) { + dispatch.put("carrierPartyId", String.valueOf(selectedPartyId).trim()); + } + if (selectedPartyRole != null) { + dispatch.put("carrierPartyRole", ContractPartySupport.normalizeRole(String.valueOf(selectedPartyRole))); + } + } } String transportType = string(dispatch, "transportType", ""); boolean road = transportType.toLowerCase().contains("road") || transportType.contains("公路"); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 98777d55..1fba462b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -116,6 +116,8 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -261,6 +263,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(PaymentApplicationInvoice::getPaymentApplicationId, id).orderByAsc(PaymentApplicationInvoice::getLineNo))); @@ -295,6 +299,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl rows = Func.isBlank(vo.getAttachmentsJson()) + ? new ArrayList<>() : new ArrayList<>(JsonUtil.parse(vo.getAttachmentsJson(), List.class)); + // 剔除历史展示行(含改版前 spec_result 旧值),避免前端随单保存后重复追加 + rows.removeIf(row -> row instanceof Map map + && (HcsSyncConstant.PAYMENT_ATTACH_TYPE_SPEC_RESULT.equals(map.get("attachmentType")) + || "spec_result".equals(map.get("attachmentType")))); + Map specRow = new LinkedHashMap<>(); + specRow.put("name", HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME + ".pdf"); + specRow.put("attachmentType", HcsSyncConstant.PAYMENT_ATTACH_TYPE_SPEC_RESULT); + // 前端代理前缀 /api(网关服务名路由);url 路径以 .pdf 结尾供前端附件组件识别类型,文件流 attachment 强制下载 + specRow.put("url", "/api/blade-transport/hcs/attachment/download/" + + URLEncoder.encode(HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME + ".pdf", StandardCharsets.UTF_8) + + "?fdId=" + vo.getHcsSpecAttachFdId()); + rows.add(specRow); + vo.setAttachmentsJson(JsonUtil.toJson(rows)); + } catch (Exception exception) { + log.warn("付款申请[{}]特批结果附件展示行追加失败", vo.getId(), exception); + } + } + /** * 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用, * 以本地 kingdeeBillId(星瀚单据内码)定位金蝶单据;本地缺失(存量数据)跳过待重推补传; @@ -599,8 +636,15 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl items = new ArrayList<>( + kingdeeAttachmentSyncSupport.parseUserAttachments(entity.getAttachmentsJson())); + // 合规付款特批结果附件(paymentLegal/result 回传):C103-5 按合规附件ID 下载后联带上传金蝶 + if (Func.isNotBlank(entity.getHcsSpecAttachFdId())) { + items.add(KingdeeAttachmentSyncSupport.KingdeeAttachmentItem.hcsAttachment( + HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME, entity.getHcsSpecAttachFdId())); + } + kingdeeAttachmentSyncSupport.syncAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, + id, entity.getKingdeeBillId(), items); } catch (Exception exception) { log.error("付款申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception); } @@ -692,13 +736,17 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaUpdate() .eq(PaymentApplication::getId, id) .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) - .set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + .set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .set(PaymentApplication::getKingdeeFailReason, + failReason.length() > 500 ? failReason.substring(0, 500) : failReason)); } @Override @@ -750,6 +798,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0 + && (previousPaid == null || previousPaid.signum() <= 0 || previousPaid.compareTo(currentPaid) != 0); + if (paidAdvanced) { entity.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); } updateById(entity); @@ -1131,15 +1185,17 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl + * 窗口天数取 Nacos thirdParty.bfi.payResult.queryWindowDays(默认 60) * * @param orgNumber 付款人(公司)编码 * @return 付款处理单列表 */ private List queryPayBillPages(String orgNumber) { LocalDate endDate = LocalDate.now(); - // 业务日期窗口控制在不超过1个月 - LocalDate beginDate = endDate.minusMonths(1).plusDays(1); + int windowDays = Math.max(bfiProperties.getPayResult().getQueryWindowDays(), 1); + LocalDate beginDate = endDate.minusDays(windowDays); BfiPayBillQueryDTO param = new BfiPayBillQueryDTO(); param.setOrgNumber(List.of(orgNumber)); param.setBeginBizdate(beginDate.format(DateTimeFormatter.ISO_LOCAL_DATE)); @@ -1254,8 +1310,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl !Objects.equals(contractId, item.getContractId()))) { throw new ServiceException("多选正式结算单必须属于同一合同"); @@ -2153,6 +2211,19 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 1; @@ -2293,10 +2364,18 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl lines) { return lines != null && !lines.isEmpty(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index 7b4380ca..333a16b8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -44,6 +44,7 @@ import org.springblade.transport.mapper.PreSettlementDetailMapper; import org.springblade.transport.mapper.PreSettlementMapper; import org.springblade.transport.mapper.PreSettlementSummaryFeeMapper; import org.springblade.transport.mapper.PaymentApplicationMapper; +import org.springblade.transport.mapper.PaymentApplicationSettlementMapper; import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper; import org.springblade.transport.mapper.ReceivablePayableDetailMapper; import org.springblade.transport.pojo.dto.PreSettlementAdvanceRequest; @@ -59,6 +60,7 @@ import org.springblade.transport.pojo.entity.PreSettlementDetail; import org.springblade.transport.pojo.entity.PreSettlementDetailFee; import org.springblade.transport.pojo.entity.PreSettlementSummaryFee; import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springblade.transport.pojo.entity.PaymentApplicationSettlement; import org.springblade.transport.pojo.entity.ProjectApply; import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee; import org.springblade.transport.pojo.entity.ReceivablePayableDetail; @@ -95,6 +97,7 @@ import java.util.Objects; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * 预结算单服务实现类 @@ -118,6 +121,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl preSettlementIds = records.stream().map(PreSettlementVO::getId) .filter(Objects::nonNull).collect(Collectors.toSet()); if (preSettlementIds.isEmpty()) return; - List applications = paymentApplicationMapper.selectList( - Wrappers.lambdaQuery() - .select(PaymentApplication::getPreSettlementId, PaymentApplication::getAppliedAmount, - PaymentApplication::getPaidAmount) - .eq(PaymentApplication::getPaymentType, "progress_advance") - .in(PaymentApplication::getPreSettlementId, preSettlementIds) - .eq(PaymentApplication::getApprovalStatus, STATUS_APPROVED) - .eq(PaymentApplication::getIsDeleted, 0)); Map appliedAmounts = new HashMap<>(); Map paidAmounts = new HashMap<>(); - applications.forEach(application -> { - Long preSettlementId = application.getPreSettlementId(); - appliedAmounts.merge(preSettlementId, money(application.getAppliedAmount()), BigDecimal::add); - paidAmounts.merge(preSettlementId, money(application.getPaidAmount()), BigDecimal::add); - }); + // 多选预结算单时付款申请主表只存第一张,按关联行逐单归集;已付金额按关联行顺序分摊实付金额, + // 与同步金蝶预付建档(syncPreSettlementAdvance)同一口径,避免第二张及之后的单据金额恒为0 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .in(PaymentApplicationSettlement::getPreSettlementId, preSettlementIds) + .eq(PaymentApplicationSettlement::getIsDeleted, 0) + .orderByAsc(PaymentApplicationSettlement::getId)); + Set relationPaymentIds = relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map relationPayments = relationPaymentIds.isEmpty() ? new HashMap<>() + : paymentApplicationMapper.selectBatchIds(relationPaymentIds).stream() + .filter(application -> !Objects.equals(application.getIsDeleted(), 1)) + .filter(application -> TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) + .filter(application -> "progress_advance".equals(application.getPaymentType())) + .collect(Collectors.toMap(PaymentApplication::getId, Function.identity(), (left, right) -> left)); + relations.stream() + .filter(relation -> relationPayments.containsKey(relation.getPaymentApplicationId())) + .collect(Collectors.groupingBy(PaymentApplicationSettlement::getPaymentApplicationId, + LinkedHashMap::new, Collectors.toList())) + .forEach((paymentId, paymentRelations) -> { + BigDecimal remainingPaid = money(relationPayments.get(paymentId).getPaidAmount()); + for (int index = 0; index < paymentRelations.size(); index++) { + PaymentApplicationSettlement relation = paymentRelations.get(index); + BigDecimal applied = money(relation.getAppliedAmount()); + BigDecimal paid = index == paymentRelations.size() - 1 ? remainingPaid + : applied.min(remainingPaid); + remainingPaid = remainingPaid.subtract(paid); + appliedAmounts.merge(relation.getPreSettlementId(), applied, BigDecimal::add); + paidAmounts.merge(relation.getPreSettlementId(), paid, BigDecimal::add); + } + }); + // 历史仅写主表、无关联行的付款申请按主表兜底 + paymentApplicationMapper.selectList( + Wrappers.lambdaQuery() + .select(PaymentApplication::getId, PaymentApplication::getPreSettlementId, + PaymentApplication::getAppliedAmount, PaymentApplication::getPaidAmount) + .in(PaymentApplication::getPreSettlementId, preSettlementIds) + .eq(PaymentApplication::getPaymentType, "progress_advance") + .eq(PaymentApplication::getApprovalStatus, STATUS_APPROVED) + .eq(PaymentApplication::getIsDeleted, 0) + .notIn(!relationPaymentIds.isEmpty(), PaymentApplication::getId, relationPaymentIds)) + .forEach(application -> { + appliedAmounts.merge(application.getPreSettlementId(), money(application.getAppliedAmount()), BigDecimal::add); + paidAmounts.merge(application.getPreSettlementId(), money(application.getPaidAmount()), BigDecimal::add); + }); records.forEach(record -> { record.setAdvanceAppliedAmount(money(appliedAmounts.get(record.getId()))); record.setAdvancePaidAmount(money(paidAmounts.get(record.getId()))); @@ -233,13 +270,34 @@ public class PreSettlementServiceImpl extends BaseServiceImpl contracts = contractManageService.list(wrapper).stream() - .filter(this::isAvailableContract).toList(); + .filter(contract -> contract.getProjectId() != null + && List.of("客户合同", "承运商合同").contains(contract.getContractCategory()) + && contractManageService.isUsableForSettlement(contract)) + .toList(); + if (contracts.isEmpty()) return List.of(); + // 项目与客户均批量查询,避免逐条 N+1 查询导致接口超时 Set projectIds = contracts.stream().map(ContractManage::getProjectId) - .filter(Objects::nonNull).collect(Collectors.toSet()); - Map projectById = Func.isEmpty(projectIds) ? Map.of() - : projectApplyService.listByIds(projectIds).stream() + .collect(Collectors.toSet()); + Map projectById = projectApplyService.listByIds(projectIds).stream() .collect(Collectors.toMap(ProjectApply::getId, Function.identity())); - return contracts.stream().map(contract -> { + Set partyNames = contracts.stream() + .flatMap(contract -> Stream.of(contract.getPartyA(), contract.getPartyB())) + .filter(Func::isNotEmpty).collect(Collectors.toSet()); + Set approvedCustomers = customerArchiveService.list(Wrappers.lambdaQuery() + .eq(CustomerArchive::getIsDeleted, 0) + .eq(CustomerArchive::getStatus, 1) + .eq(CustomerArchive::getApprovalStatus, "approved") + .in(CustomerArchive::getFullName, partyNames)) + .stream().map(CustomerArchive::getFullName).collect(Collectors.toSet()); + return contracts.stream() + .filter(contract -> { + ProjectApply project = projectById.get(contract.getProjectId()); + return project != null && !Objects.equals(project.getIsDeleted(), 1) + && List.of("approved", "change_approved").contains(project.getApprovalStatus()) + && approvedCustomers.contains(contract.getPartyA()) + && approvedCustomers.contains(contract.getPartyB()); + }) + .map(contract -> { ProjectApply project = projectById.get(contract.getProjectId()); Map result = new LinkedHashMap<>(); result.put("id", contract.getId()); @@ -1347,19 +1405,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl contractFeeTypeMap(Long contractId) { ContractManage contract = contractId == null ? null : contractManageService.getById(contractId); Map result = new LinkedHashMap<>(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index d1ff04d0..21616552 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -195,7 +195,17 @@ public class ProjectApplyServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(ProjectChange::getProjectId, projectId) + .eq(ProjectChange::getApprovalStatus, STATUS_APPROVED) + .in(ProjectChange::getSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED)) + .set(ProjectChange::getSyncStatus, syncStatus) + .last("order by approved_time desc limit 1")); + } + private LambdaQueryWrapper buildQuery(ProjectChangeVO projectChange) { TransportBusinessSupport.validateAllDept(projectChange.getAllDept(), "项目变更"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(ProjectChange::getIsDeleted, 0); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java index dc68308c..b6ebd497 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java @@ -33,12 +33,6 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.thirdparty.bfi.constant.BfiConstant; -import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; -import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; -import org.springblade.thirdparty.bfi.service.IBfiService; -import org.springblade.thirdparty.bfi.util.BfiUtil; -import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.KingdeeReceiptFlowMapper; import org.springblade.transport.mapper.ReceiptClaimMapper; @@ -53,20 +47,21 @@ import org.springblade.transport.pojo.entity.ReceiptFlowRecord; import org.springblade.transport.pojo.vo.ReceiptClaimRecordVO; import org.springblade.transport.service.IReceiptClaimRecordService; import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.support.hcs.HcsClaimSyncSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -84,21 +79,13 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl selectPage(IPage page, @@ -203,10 +190,6 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl relations = claimSettlementMapper.selectList( Wrappers.lambdaQuery() @@ -256,9 +239,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl - * 回写口径:A301-1 契约本身不支持按来源单号过滤,故按近 30 天业务日期窗口全量翻页后, - * 以金蝶收款单 sourcebillnumber == 流水 awrp_src_code(A301-3 推送时按流水生成、重试复用)在本地匹配; - * 仅推进金蝶回写状态与补登收款单号,不改本地认领金额/分摊;名下未作废认领主单冗余展示字段级联刷新。 - * - * @return 回写条数 - */ - @Override - public int refreshKingdeeResult() { - List pending = receiptFlowMapper.selectList( - Wrappers.lambdaQuery() - .eq(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED) - .in(KingdeeReceiptFlow::getKingdeeBillStatus, - List.of(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED, APPROVED)) - .eq(KingdeeReceiptFlow::getStatus, 1) - .orderByAsc(KingdeeReceiptFlow::getId)); - if (pending.isEmpty()) { - log.info("金蝶认领结果回写:无待回写流水(已推送/已审核且未终态)"); - return 0; - } - Set srcCodes = pending.stream() - .map(KingdeeReceiptFlow::getAwrpSrcCode) - .filter(Func::isNotEmpty) - .collect(Collectors.toSet()); - if (srcCodes.isEmpty()) { - return 0; - } - log.info("金蝶认领结果回写:待回写流水 {} 条", pending.size()); - - LocalDate end = LocalDate.now(); - LocalDate begin = end.minusDays(RESULT_WINDOW_DAYS); - long start = System.currentTimeMillis(); - log.info("调用BFI接口 收款单查询开始,业务日期窗口 {} ~ {}", begin, end); - List rows = BfiUtil.queryAllPages(pageNo -> { - BfiRecBillQueryDTO dto = new BfiRecBillQueryDTO(); - dto.setPageNo(pageNo); - dto.setPageSize(RESULT_PAGE_SIZE); - dto.setBeginBizdate(begin.format(DateTimeFormatter.ISO_LOCAL_DATE)); - dto.setEndBizdate(end.format(DateTimeFormatter.ISO_LOCAL_DATE)); - return bfiService.queryRecBill(dto); + // 作废事务提交后同步合规 C107-3(fdStatus=1 已作废,失败仅记日志不回滚作废) + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + hcsClaimSyncSupport.voidClaimToHcs(claim.getId()); + } }); - log.info("调用BFI接口 收款单查询结束 耗时:{}ms,拉取 {} 条", System.currentTimeMillis() - start, rows.size()); - - // 窗口内收款单按 sourcebillnumber 匹配待回写来源编码,同键多单取首条 - Map matched = rows.stream() - .filter(row -> Func.isNotEmpty(row.getSourcebillnumber())) - .filter(row -> srcCodes.contains(row.getSourcebillnumber())) - .collect(Collectors.toMap(BfiRecBillVO::getSourcebillnumber, Function.identity(), - (first, second) -> first, HashMap::new)); - int updated = 0; - for (KingdeeReceiptFlow flow : pending) { - BfiRecBillVO bill = matched.get(flow.getAwrpSrcCode()); - if (bill == null) { - // 窗口内未查到:金蝶回单延迟/状态未同步,不改状态,由下次回写或人工处置兜底 - continue; - } - // 推送成功时金蝶收款单号先以来源编码占位(A301-3 响应 number 为空,取回执 keys.awrp_src_code), - // 回写命中后以金蝶真实收款单号覆盖 - if (Func.isNotEmpty(bill.getBillno())) { - flow.setKingdeeBillNo(bill.getBillno()); - } - flow.setKingdeeBillStatus(billStatusOf(bill.getBillstatus())); - receiptFlowMapper.updateById(flow); - // 级联刷新名下未作废认领主单的冗余展示字段 - baseMapper.update(null, Wrappers.lambdaUpdate() - .eq(ReceiptClaim::getReceiptFlowId, flow.getId()) - .eq(ReceiptClaim::getClaimStatus, CLAIMED) - .set(ReceiptClaim::getKingdeeBillNo, flow.getKingdeeBillNo()) - .set(ReceiptClaim::getKingdeeBillStatus, flow.getKingdeeBillStatus())); - updated++; - log.info("金蝶认领结果回写 flowId:{},awrpSrcCode:{},金蝶收款单号:{},状态:{} → {}", - flow.getId(), flow.getAwrpSrcCode(), bill.getBillno(), bill.getBillstatus(), - flow.getKingdeeBillStatus()); - } - log.info("金蝶认领结果回写完成:匹配 {} 条,更新 {} 条", matched.size(), updated); - return updated; - } - - /** - * 金蝶收款单单据状态 → 本地认领金蝶状态 - * - * @param billStatus 金蝶 billstatus(A暂存/B已提交/C已审核/D已收款/G已退单) - * @return 本地状态 - */ - private String billStatusOf(String billStatus) { - if (BfiConstant.BILL_STATUS_RECEIVED.equals(billStatus)) { - return KingdeeBfiConstant.KINGDEE_STATUS_RECEIVED; - } - if (BfiConstant.BILL_STATUS_AUDITED.equals(billStatus)) { - return APPROVED; - } - if (BfiConstant.BILL_STATUS_DRAFT.equals(billStatus) - || BfiConstant.BILL_STATUS_SUBMITTED.equals(billStatus)) { - return KingdeeBfiConstant.KINGDEE_STATUS_PUSHED; - } - // 其余状态(含 G 已退单)按处理失败回收,提示人工在金蝶核查 - log.warn("金蝶收款单状态[{}]未匹配映射,认领回写按 failed 处理", billStatus); - return KingdeeBfiConstant.KINGDEE_STATUS_FAILED; } private void fillStatusNames(ReceiptClaimRecordVO record) { @@ -399,17 +287,6 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl allocationMap = allocationMap(request.getSettlements()); // 结算单id列表(升序) @@ -347,10 +344,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl orgNumbers = baseMapper.selectPlatformOrgNumbers(); + if (Func.isEmpty(orgNumbers)) { + throw new ServiceException("组织管理中未维护平台公司金蝶编码,无法同步金蝶收款流水,请先检查组织数据"); } - List announces = queryClaimAnnounces(claimusers); - - if (announces.isEmpty()) { - record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, - "金蝶认领公告未查询到数据"); - return 0; + log.info("收款流水同步按组织过滤,组织编码:{}", orgNumbers); + RecBillSyncState state = new RecBillSyncState(); + state.sliceCount += BfiUtil.forEachWindowSlice("收款流水同步", windowStart, windowEnd, + SYNC_PAGE_SIZE, SYNC_MAX_PAGES, + (sliceStart, sliceEnd, pageNo) -> this.pullRecBillPage(sliceStart, sliceEnd, orgNumbers, pageNo), + rows -> this.handleRecBillPage(rows, state)); + // 尾批不足 SYNC_BATCH_SIZE 的剩余入库 + if (!state.pending.isEmpty()) { + state.batchNo++; + state.totalCount += this.syncBatch(state.batchNo, state.pending); + state.pending.clear(); + state.batchSerials.clear(); } + if (state.pullCount == 0) { + this.record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, + "金蝶收款单未查询到数据"); + } else if (state.totalCount == 0) { + this.record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, + "金蝶收款单无有效数据"); + } + log.info("金蝶收款流水同步完成,翻页{}页切片{}片,拉取收款单 {} 张,新增/更新 {} 条,跳过 {} 条," + + "分 {} 批入库,耗时 {}ms", + state.pageCount, state.sliceCount, state.pullCount, state.totalCount, state.skippedCount, + state.batchNo, System.currentTimeMillis() - start); + return state.totalCount; + } - // 同步口径:A301-2 按参与认领用户(当前登录用户真实姓名)拉取全量认领公告, - // 以金蝶明细流水号为幂等键 upsert 收款流水表;本地已认领金额/状态(claimedAmount/claimStatus) - // 由认领、作废驱动,不被公告顺带覆盖,公告侧状态单独存 kingdee_claim_status/unclaimed_amount。 + /** + * A301-1 按业务日期时间片分页拉取(组织编码过滤整轮共享,切片起止由 BfiUtil 二分传入) + * + * @param sliceStart 切片起点(含) + * @param sliceEnd 切片终点(含) + * @param orgNumbers 平台公司组织编码集 + * @param pageNo 页码,从 1 开始 + * @return 分页结果 + */ + private BfiPageVO pullRecBillPage(LocalDateTime sliceStart, LocalDateTime sliceEnd, + List orgNumbers, int pageNo) { + BfiRecBillQueryDTO queryDTO = new BfiRecBillQueryDTO(); + queryDTO.setPageNo(pageNo); + queryDTO.setPageSize(SYNC_PAGE_SIZE); + queryDTO.setBeginBizdate(sliceStart.format(BFI_DATE_TIME_FORMATTER)); + queryDTO.setEndBizdate(sliceEnd.format(BFI_DATE_TIME_FORMATTER)); + queryDTO.setOrgNumber(orgNumbers); + return bfiService.queryRecBill(queryDTO); + } - // 第一遍:数据校验与过滤,收集有效的 detailSerialNo - log.info("收款流水同步开始数据校验,共 {} 条认领公告", announces.size()); - Map validAnnounces = new LinkedHashMap<>(); - int skippedCount = 0; - for (BfiClaimAnnounceVO announce : announces) { - // 明细流水号优先级:detailid > tradedetailno > bankdetailno - String detailSerialNo = firstNonBlank(announce.getDetailid(), - announce.getTradedetailno(), announce.getBankdetailno()); + /** + * A301-1 单页数据处理:收款单主单粒度逐行校验入缓冲,累计满一批即独立事务入库。 + * 切片边界同秒的收款单可能在相邻两片各拉一次,按批内收款单号去重幂等跳过,不重不漏 + * + * @param rows 本页拉取的收款单 + * @param state 整轮同步状态(待入库缓冲与统计累加) + */ + private void handleRecBillPage(List rows, RecBillSyncState state) { + state.pageCount++; + state.pullCount += rows.size(); + for (BfiRecBillVO recBill : rows) { + // 主单粒度:一张收款单一条流水,不展开流水分录;明细流水号取收款单号(幂等键,C104-2 按此定位合规流水) + String detailSerialNo = trimToEmpty(recBill.getBillno()); if (Func.isEmpty(detailSerialNo)) { - log.warn("收款流水同步跳过:认领公告[{}]缺少明细流水号", announce.getBillno()); - skippedCount++; + log.warn("收款流水同步跳过:收款单缺少单号"); + state.skippedCount++; continue; } - detailSerialNo = detailSerialNo.trim(); - if (validAnnounces.containsKey(detailSerialNo)) { - throw new ServiceException("明细流水号" + detailSerialNo + "重复"); + if (!state.batchSerials.add(detailSerialNo)) { + // 切片边界重复拉取或同批脏数据重复单号,幂等跳过(upsert 本就按单号幂等) + log.warn("收款流水同步跳过:收款单[{}]本批已拉取,跳过重复处理", detailSerialNo); + state.skippedCount++; + continue; } - BigDecimal receiptAmount = money(announce.getReamount()); + // 收款金额取主单 actrecamt(金蝶收款金额口径) + BigDecimal receiptAmount = money(recBill.getActrecamt()); if (receiptAmount.compareTo(BigDecimal.ZERO) <= 0) { - log.warn("收款流水同步跳过:认领公告[{}]收款金额为空或小于0", announce.getBillno()); - skippedCount++; + // log.warn("收款流水同步跳过:收款单[{}]收款金额(actrecamt)为空或小于0", recBill.getBillno()); + state.skippedCount++; continue; } - validAnnounces.put(detailSerialNo, announce); - } - log.info("收款流水数据校验完成:有效 {} 条,跳过 {} 条", validAnnounces.size(), skippedCount); - - if (validAnnounces.isEmpty()) { - log.info("收款流水同步无有效数据,记录操作日志后返回"); - record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, - "金蝶认领公告无有效数据"); - return 0; - } - - // 批量查询已存在的记录(FOR UPDATE 防并发) - log.info("收款流水同步开始批量查询已存在记录,查询条件:detailSerialNo IN {} 条", validAnnounces.size()); - List existingEntities = baseMapper.selectList( - Wrappers.lambdaQuery() - .in(KingdeeReceiptFlow::getDetailSerialNo, validAnnounces.keySet()) - .last("FOR UPDATE") - ); - Map existingMap = existingEntities.stream() - .collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e)); - log.info("收款流水批量查询完成:已存在 {} 条,待新增 {} 条", - existingEntities.size(), validAnnounces.size() - existingEntities.size()); - - // 第二遍:构建新增和更新列表 - log.info("收款流水同步开始构建新增/更新列表"); - List toInsert = new ArrayList<>(); - List toUpdate = new ArrayList<>(); - int createdCount = 0; - int updatedCount = 0; - - for (Map.Entry entry : validAnnounces.entrySet()) { - String detailSerialNo = entry.getKey(); - BfiClaimAnnounceVO announce = entry.getValue(); - BigDecimal receiptAmount = money(announce.getReamount()); - - // 判断是新增还是更新 - KingdeeReceiptFlow existing = existingMap.get(detailSerialNo); - boolean created = (existing == null); - - KingdeeReceiptFlow entity; - if (created) { - // 新增:初始化本地认领金额和状态 - entity = new KingdeeReceiptFlow(); - entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); - entity.setClaimStatus(UNCLAIMED); - } else { - // 更新:保留本地已认领金额和状态 - entity = existing; - } - - // 复制公告数据到实体(不覆盖本地认领字段) - copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount); - // 根据本地累计认领金额与可认领上限重新计算认领状态 - entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()), - claimCeiling(entity))); - - if (created) { - toInsert.add(entity); - createdCount++; - } else { - toUpdate.add(entity); - updatedCount++; + state.pending.add(new BfiRecBillRow(recBill, detailSerialNo, receiptAmount)); + if (state.pending.size() >= SYNC_BATCH_SIZE) { + state.batchNo++; + state.totalCount += this.syncBatch(state.batchNo, state.pending); + state.pending.clear(); + state.batchSerials.clear(); } } - log.info("收款流水列表构建完成:待新增 {} 条,待更新 {} 条", createdCount, updatedCount); + } - // 批量插入和更新 - if (!toInsert.isEmpty()) { - log.info("收款流水同步开始批量插入 {} 条", toInsert.size()); - saveBatch(toInsert); - log.info("收款流水批量插入完成"); - } - if (!toUpdate.isEmpty()) { - log.info("收款流水同步开始批量更新 {} 条", toUpdate.size()); - updateBatchById(toUpdate); - log.info("收款流水批量更新完成"); - } + /** + * 单批入库(独立事务):批内 FOR UPDATE 防并发,按明细流水号 upsert,操作日志批量落库 + * + * @param batchNo 批次号(日志用) + * @param rows 本批流水行 + * @return 本批新增+更新条数 + */ + private int syncBatch(int batchNo, List rows) { + Integer count = transactionTemplate.execute(status -> { + // 批量查询已存在的记录(FOR UPDATE 防并发) + Map existingMap = baseMapper.selectList( + Wrappers.lambdaQuery() + .in(KingdeeReceiptFlow::getDetailSerialNo, + rows.stream().map(BfiRecBillRow::detailSerialNo).toList()) + .last("FOR UPDATE")) + .stream().collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e)); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + for (BfiRecBillRow row : rows) { + KingdeeReceiptFlow existing = existingMap.get(row.detailSerialNo()); + boolean created = (existing == null); + KingdeeReceiptFlow entity; + if (created) { + // 新增:初始化本地认领金额和状态 + entity = new KingdeeReceiptFlow(); + entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); + entity.setClaimStatus(UNCLAIMED); + } else { + // 更新:保留本地已认领金额和状态 + entity = existing; + } + // 复制收款单数据到实体(不覆盖本地认领字段) + copyRecBillRow(entity, row); + // 根据本地累计认领金额与可认领上限重新计算认领状态 + entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()), + claimCeiling(entity))); + if (created) { + toInsert.add(entity); + } else { + toUpdate.add(entity); + } + } + if (!toInsert.isEmpty()) { + saveBatch(toInsert); + } + if (!toUpdate.isEmpty()) { + updateBatchById(toUpdate); + } + // 记录操作日志(批量插入,避免逐行 N+1) + List records = new ArrayList<>(toInsert.size() + toUpdate.size()); + for (KingdeeReceiptFlow entity : toInsert) { + records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水", + entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); + } + for (KingdeeReceiptFlow entity : toUpdate) { + records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水", + entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); + } + if (!records.isEmpty()) { + Db.saveBatch(records); + } + return toInsert.size() + toUpdate.size(); + }); + log.info("收款流水同步第 {} 批入库完成,本批 {} 条", batchNo, count == null ? 0 : count); + return count == null ? 0 : count; + } - // 记录操作日志(批量插入,避免逐行 N+1) - log.info("收款流水同步开始记录操作日志,共 {} 条", toInsert.size() + toUpdate.size()); - List records = new ArrayList<>(toInsert.size() + toUpdate.size()); - for (KingdeeReceiptFlow entity : toInsert) { - records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水", - entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); - } - for (KingdeeReceiptFlow entity : toUpdate) { - records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水", - entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); - } - if (!records.isEmpty()) { - Db.saveBatch(records); - } - log.info("收款流水操作日志记录完成"); + /** + * A301-1 收款单查询返回的收款单 + 流水分录展开行(同步过程传递用) + */ + private record BfiRecBillRow(BfiRecBillVO recBill, + String detailSerialNo, BigDecimal receiptAmount) { + } - log.info("金蝶收款流水同步完成 claimusers:{},拉取 {} 条,新增 {} 条,更新 {} 条", - claimusers, announces.size(), createdCount, updatedCount); - return createdCount + updatedCount; + /** + * 收款流水整轮同步状态:待入库缓冲与统计累加(各切片共享,结束进日志摘要) + */ + private static final class RecBillSyncState { + /** + * 待入库缓冲:累计满 SYNC_BATCH_SIZE 即入库一批并清空 + */ + final List pending = new ArrayList<>(); + /** + * 批内收款单号去重(含切片边界重复拉取) + */ + final Set batchSerials = new HashSet<>(); + /** + * A301-1 原始拉取收款单张数 + */ + int pullCount; + /** + * 跳过条数(缺单号/金额异常/重复拉取) + */ + int skippedCount; + /** + * 新增+更新条数 + */ + int totalCount; + /** + * 已入库批次数 + */ + int batchNo; + /** + * 实际处理的翻页总页数 + */ + int pageCount; + /** + * 实际处理的切片数 + */ + int sliceCount; } private Map candidateRow(FormalSettlement settlement, BigDecimal claimedAmount) { @@ -724,19 +811,12 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl - * 仅认领完成且未推送过的流水可推送;推送前以当前用户姓名重查 A301-2 最新公告对账。 - * 推送失败不抛异常跨事务边界(保证失败状态落库),原因随结果对象返回。 + * A301-1 收款单 → 收款流水表字段拷贝(仅覆盖金蝶侧来源字段,不覆盖本地认领金额与状态)。 + * 金蝶匹配/结算金额原样落库仅作参考,不参与认领额度控制。 */ - @Override - public ReceiptClaimPushResult pushClaimToKingdee(Long flowId) { - if (flowId == null) { - throw new ServiceException("收款流水ID不能为空"); - } - KingdeeReceiptFlow flow = existing(flowId); - if (flow.getBillId() == null) { - return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水"); - } - String claimusers = UserCache.getUserRealName(AuthUtil.getUserId()); - if (Func.isEmpty(claimusers)) { - throw new ServiceException("当前用户无姓名,无法推送金蝶收款认领"); - } - // 推送前对账:以当前用户身份重查 A301-2 拿最新公告(手动推送者须在公告认领范围内) - BfiClaimAnnounceVO announce = queryClaimAnnounces(claimusers).stream() - .filter(item -> flow.getBillId().equals(item.getId())) - .findFirst().orElse(null); - if (announce == null) { - return ReceiptClaimPushResult.fail("金蝶侧未查询到该认领公告,当前用户可能不在公告认领范围内,请重新同步后重试"); - } - return pushClaimLocked(flow.getId(), announce); - } - - /** - * 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。 - * 多副本部署下由 single-flight 分布式锁保证同一轮全局最多一份在跑,已有轮次进行中则本轮跳过。 - * - * @return 执行摘要 - */ - @Override - public String pushClaimByTask() { - return kingdeeSyncLockSupport.runSingleFlight(RECEIPT_CLAIM_PUSH_LOCK_PREFIX, bfiProperties.getReceiptClaimPush().getLockLeaseSeconds(), - "定时推送收款认领", this::executeReceiptClaimPush); - } - - /** - * 定时推送收款认领主体:按认领人姓名串行处理—— - * 先重查该认领人的 A301-2 最新公告并推完其名下全部流水,再到下一个认领人。 - * A301-2 的 claimusers 必填且为单个用户姓名,定时任务无登录人上下文, - * 故按认领人逐人查询公告,按公告id(bill_id)匹配流水。 - * 单条流水推送失败独立消化,不影响同批其他流水。 - * - * @return 执行摘要 - */ - private String executeReceiptClaimPush() { - List candidates = list(Wrappers.lambdaQuery() - .isNotNull(KingdeeReceiptFlow::getBillId) - .in(KingdeeReceiptFlow::getPushStatus, List.of( - KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_NONE, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED)) - .eq(KingdeeReceiptFlow::getClaimStatus, CLAIMED) - .eq(KingdeeReceiptFlow::getStatus, 1) - .orderByAsc(KingdeeReceiptFlow::getId)); - if (candidates.isEmpty()) { - return "无待推送流水"; - } - Map candidateById = candidates.stream() - .collect(Collectors.toMap(KingdeeReceiptFlow::getId, Function.identity(), - (first, second) -> first, LinkedHashMap::new)); - // 认领人 → 其有效认领的流水id - Map> claimedFlowIdsByClaimer = claimMapper.selectList( - Wrappers.lambdaQuery() - .in(ReceiptClaim::getReceiptFlowId, candidateById.keySet()) - .eq(ReceiptClaim::getClaimStatus, CLAIMED)).stream() - .filter(claim -> Func.isNotEmpty(claim.getClaimerName()) && claim.getReceiptFlowId() != null) - .collect(Collectors.groupingBy(ReceiptClaim::getClaimerName, - Collectors.mapping(ReceiptClaim::getReceiptFlowId, Collectors.toSet()))); - if (claimedFlowIdsByClaimer.isEmpty()) { - return "待推送流水 " + candidates.size() + " 条,但无有效认领人,跳过"; - } - int successCount = 0; - int failedCount = 0; - int skippedCount = 0; - // 本轮已推送成功的流水:同一流水被多个认领人认领时,只在首个覆盖它的认领人处推送一次 - Set pushedFlowIds = new HashSet<>(); - // 按认领人逐人串行:查该认领人公告 → 推完其名下流水 → 下一个认领人 - for (String claimerName : claimedFlowIdsByClaimer.keySet().stream().sorted().toList()) { - Set claimerFlowIds = claimedFlowIdsByClaimer.get(claimerName); - List claimerFlows = candidateById.values().stream() - .filter(flow -> claimerFlowIds.contains(flow.getId())) - .filter(flow -> !pushedFlowIds.contains(flow.getId())) - .toList(); - if (claimerFlows.isEmpty()) { - continue; - } - // 该认领人的公告一次查询,按公告id归集 - Map announceByBillId = new HashMap<>(); - try { - for (BfiClaimAnnounceVO announce : queryClaimAnnounces(claimerName)) { - if (announce.getId() != null) { - announceByBillId.putIfAbsent(announce.getId(), announce); - } - } - } catch (Exception exception) { - log.error("定时推送收款认领:认领人[{}]公告查询失败,其名下流水全部跳过", claimerName, exception); - skippedCount += claimerFlows.size(); - continue; - } - for (KingdeeReceiptFlow flow : claimerFlows) { - BfiClaimAnnounceVO announce = announceByBillId.get(flow.getBillId()); - if (announce == null) { - skippedCount++; - log.info("定时推送收款认领:认领人[{}]流水[{}]未在公告查询结果中命中,跳过", - claimerName, flow.getId()); - continue; - } - try { - ReceiptClaimPushResult result = pushClaimLocked(flow.getId(), announce); - if (result.isSuccess()) { - successCount++; - pushedFlowIds.add(flow.getId()); - } else { - failedCount++; - } - } catch (Exception exception) { - failedCount++; - log.error("定时推送收款认领:认领人[{}]流水[{}]推送异常", claimerName, flow.getId(), exception); - } - } - } - String summary = "扫描 " + candidates.size() + " 条,推送成功 " + successCount - + " 条,推送失败 " + failedCount + " 条,公告未命中跳过 " + skippedCount + " 条"; - log.info("定时推送收款认领完成:{}", summary); - return summary; - } - - /** - * 事务内推送:流水行锁覆盖闸门校验、对账、推送与状态落库全过程, - * 防定时任务与手动按钮并发对同一流水双推; - * 推送成功后触发合同合规认领链推送(C103-9/C107-1/C108-1,事务提交后执行,失败不影响金蝶推送结果) - */ - private ReceiptClaimPushResult pushClaimLocked(Long flowId, BfiClaimAnnounceVO announce) { - ReceiptClaimPushResult result = transactionTemplate.execute(status -> doPushClaimToKingdee(flowId, announce)); - if (result != null && result.isSuccess()) { - hcsClaimSyncSupport.pushClaimToHcs(flowId); - } - return result; - } - - /** - * 推送单条流水:静态闸门 → 最新公告对账 → 按流水归集分摊行推送 → 成败状态落库 - */ - private ReceiptClaimPushResult doPushClaimToKingdee(Long flowId, BfiClaimAnnounceVO announce) { - KingdeeReceiptFlow flow = lockedFlow(flowId); - if (flow.getBillId() == null) { - return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水"); - } - // 终态闸门:已推送流水不允许二次推送(作废重认领使等式再满足也不推,金蝶侧人工处置) - if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) { - return ReceiptClaimPushResult.fail("该流水已推送金蝶,不允许重复推送"); - } - // 推送前对账:以最新公告刷新公告侧未认领金额并重算认领状态(快照落库,页面口径同步修正) - flow.setUnclaimedAmount(money(announce.getUnclaimamount())); - flow.setKingdeeClaimStatus(trimToNull(announce.getClaimstatus())); - flow.setClaimMe(announce.getClaimme()); - BigDecimal claimedTotal = flowClaimedAmount(flow.getId()); - flow.setClaimedAmount(claimedTotal); - BigDecimal claimCeiling = claimCeiling(flow); - String claimStatus = amountClaimStatus(claimedTotal, claimCeiling); - flow.setClaimStatus(claimStatus); - updateById(flow); - if (!CLAIMED.equals(claimStatus)) { - return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限(" - + claimCeiling + ")不一致,不满足推送条件"); - } - // 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集) - List allocations = claimSettlementMapper.selectList( - Wrappers.lambdaQuery() - .eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId()) - .eq(ReceiptClaimSettlement::getStatus, 1) - .eq(ReceiptClaimSettlement::getIsDeleted, 0) - .orderByAsc(ReceiptClaimSettlement::getFormalSettlementId)); - if (allocations.isEmpty()) { - return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送"); - } - // 核心单据编号取第一张结算单同步金蝶回写的星瀚内码(应收/应付单金蝶id),TMS本地id金蝶无法识别 - FormalSettlement firstSettlement = formalSettlementMapper.selectById(allocations.get(0).getFormalSettlementId()); - if (firstSettlement == null || Func.isBlank(firstSettlement.getKingdeeBillId())) { - return ReceiptClaimPushResult.fail("结算单" + allocations.get(0).getFormalSettlementNo() - + "未同步金蝶(缺少金蝶单据id),不满足推送条件"); - } - // 推送来源编码:分摊结算单号下划线拼接(A301-3 契约口径;分摊不变则重试编码不变, - // 金蝶按 awrp_src_sys + awrp_src_code 幂等防重试双单) - String awrpSrcCode = allocations.stream() - .map(ReceiptClaimSettlement::getFormalSettlementNo) - .filter(Func::isNotEmpty) - .distinct() - .collect(Collectors.joining("_")); - flow.setAwrpSrcCode(awrpSrcCode); - - BfiClaimPushDTO param = buildClaimPushDTO(flow, claimedTotal, allocations, firstSettlement.getKingdeeBillId()); - log.info("调用BFI接口 收款认领结果推送开始 flowId:{},billId:{},awrpSrcCode:{},分摊明细 {} 行,认领金额 {}", - flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), allocations.size(), claimedTotal); - try { - BfiBillSaveResultVO result = bfiService.pushClaimResult(param); - return applyClaimPushSuccess(flow, extractClaimPushBillNo(result)); - } catch (Exception exception) { - return applyClaimPushFailure(flow, exception); - } - } - - /** - * A301-3 推送报文组装:billid=公告id,单条分录、金额=全部未推送金额(一笔流水一张金蝶收款单), - * 核心单据编号=第一张结算单的金蝶单据id(星瀚内码) - */ - private BfiClaimPushDTO buildClaimPushDTO(KingdeeReceiptFlow flow, BigDecimal claimedTotal, - List allocations, String coreBillId) { - BfiClaimPushDTO param = new BfiClaimPushDTO(); - // 【必填】认领公告id - param.setBillid(flow.getBillId()); - // 【必填】来源系统编码 - param.setAwrpSrcSys(bfiProperties.getSrcSys()); - // 【必填】来源系统单据编码(分摊结算单号下划线拼接) - param.setAwrpSrcCode(flow.getAwrpSrcCode()); - // 【必填】收款类型.编码 - param.setRecpaytypeNumber(KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE); - // 【必填】付款人类型 - param.setPaymenttype(KingdeeBfiConstant.BFI_CLAIM_PAYMENT_TYPE); - // 【必填】付款人名称 - param.setRecviewpayer(flow.getPayerName()); - // 【必填】付款人id - param.setRecpayer(flow.getPayerId()); - // 【必填】单据体分录:仅一条,应收金额=全部未推送金额(累计有效认领) - BfiClaimPushEntryDTO entry = new BfiClaimPushEntryDTO(); - // 【必填】应收金额(全部未推送金额) - entry.setEReceivableamt(claimedTotal); - // 分录行序号(第一张结算单id,分摊已按结算单id升序,多张取最小) - entry.setSeq(allocations.get(0).getFormalSettlementId()); - // 核心单据类型 - // entry.setECorebilltype(KingdeeBfiConstant.BFI_CLAIM_CORE_BILL_TYPE); - // 核心单据编号(第一张结算单的金蝶单据id,星瀚内码) - entry.setECorebillno(coreBillId); - param.setEntryentity(List.of(entry)); - return param; - } - - /** - * 解析 A301-3 推送结果:全项成功返回单据号,否则抛出汇总错误文本(由失败路径落库)。 - * 成功响应的 number 为空,金蝶收款单号取回执 keys 中的 awrp_src_code(来源编码)。 - */ - private String extractClaimPushBillNo(BfiBillSaveResultVO result) { - if (result == null || result.getResult() == null || result.getResult().isEmpty()) { - throw new ServiceException("金蝶认领结果推送失败,返回结果为空"); - } - List items = result.getResult(); - boolean allSuccess = items.stream().allMatch(item -> Boolean.TRUE.equals(item.getBillStatus())); - if (!allSuccess) { - String errorText = items.stream() - .map(BfiBillSaveResultItemVO::getErrorText) - .filter(text -> !text.isEmpty()) - .collect(Collectors.joining(";")); - throw new ServiceException("金蝶认领结果推送失败" - + (errorText.isEmpty() ? "" : ":" + errorText)); - } - return items.stream() - .map(this::claimPushBillNoOf) - .filter(Func::isNotEmpty) - .findFirst().orElse(null); - } - - /** - * 单项推送结果 → 单据号:A301-3 成功响应 number 为空,取回执键值中的 awrp_src_code - */ - private String claimPushBillNoOf(BfiBillSaveResultItemVO item) { - if (Func.isNotEmpty(item.getNumber())) { - return item.getNumber(); - } - return item.getKeys() == null ? null : item.getKeys().get("awrp_src_code"); - } - - /** - * 推送成功:流水置已推送并回填金蝶收款单号,级联刷新名下未作废认领主单的冗余展示字段 - */ - private ReceiptClaimPushResult applyClaimPushSuccess(KingdeeReceiptFlow flow, String kingdeeBillNo) { - log.info("调用BFI接口 收款认领结果推送成功 flowId:{},金蝶收款单号:{}", - flow.getId(), Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo); - flow.setPushStatus(KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED); - flow.setPushedTime(LocalDateTime.now()); - flow.setKingdeeBillNo(kingdeeBillNo); - flow.setKingdeeBillStatus(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED); - // 失败原因需显式置空(实体 update 忽略 null 字段,走 lambdaUpdate) - this.update(Wrappers.lambdaUpdate() - .eq(KingdeeReceiptFlow::getId, flow.getId()) - .set(KingdeeReceiptFlow::getPushStatus, flow.getPushStatus()) - .set(KingdeeReceiptFlow::getPushFailReason, null) - .set(KingdeeReceiptFlow::getPushedTime, flow.getPushedTime()) - .set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode()) - .set(KingdeeReceiptFlow::getKingdeeBillNo, kingdeeBillNo) - .set(KingdeeReceiptFlow::getKingdeeBillStatus, flow.getKingdeeBillStatus())); - // 级联刷新名下未作废认领主单的冗余展示字段 - claimMapper.update(null, Wrappers.lambdaUpdate() - .eq(ReceiptClaim::getReceiptFlowId, flow.getId()) - .eq(ReceiptClaim::getClaimStatus, CLAIMED) - .set(ReceiptClaim::getKingdeeBillNo, kingdeeBillNo) - .set(ReceiptClaim::getKingdeeBillStatus, KingdeeBfiConstant.KINGDEE_STATUS_PUSHED)); - record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(), - "金蝶收款单号:" + (Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo)); - return ReceiptClaimPushResult.success(kingdeeBillNo); - } - - /** - * 推送失败:流水置推送失败并记录原因,本地认领数据保留,可重新推送(重试复用同一来源编码) - */ - private ReceiptClaimPushResult applyClaimPushFailure(KingdeeReceiptFlow flow, Exception exception) { - String reason = Func.isEmpty(exception.getMessage()) - ? exception.getClass().getSimpleName() : exception.getMessage(); - reason = truncateText(reason, 1000); - log.error("调用BFI接口 收款认领结果推送失败 flowId:{},billId:{},awrpSrcCode:{}", - flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), exception); - this.update(Wrappers.lambdaUpdate() - .eq(KingdeeReceiptFlow::getId, flow.getId()) - .set(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED) - .set(KingdeeReceiptFlow::getPushFailReason, reason) - .set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode())); - record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(), - "推送失败:" + truncateText(reason, 200)); - return ReceiptClaimPushResult.fail("金蝶认领结果推送失败:" + reason); - } - - /** - * A301-2 收款认领公告查询:按认领人姓名全量翻页,时间窗口取 Nacos 配置(默认近半年)。 - * 认领公告同步、手动推送对账与定时任务对账共用。 - */ - private List queryClaimAnnounces(String claimusers) { - int months = bfiProperties.getClaimAnnounce().getMonths(); - // 认领人直接取 Nacos 配置值 - String effectiveClaimusers = bfiProperties.getClaimAnnounce().getClaimusers(); - LocalDateTime endDate = LocalDateTime.now(); - LocalDateTime startDate = endDate.minusMonths(months); - long start = System.currentTimeMillis(); - log.info("调用BFI接口 收款认领公告查询开始 claimusers:{},时间窗口:{} ~ {}(近{}个月)", - effectiveClaimusers, startDate.format(BFI_DATE_TIME_FORMATTER), endDate.format(BFI_DATE_TIME_FORMATTER), months); - BfiClaimAnnounceQueryDTO queryDTO = new BfiClaimAnnounceQueryDTO(); - queryDTO.setClaimusers(effectiveClaimusers); - queryDTO.setModifytimeS(startDate.format(BFI_DATE_TIME_FORMATTER)); - queryDTO.setModifytimeE(endDate.format(BFI_DATE_TIME_FORMATTER)); - queryDTO.setPageSize(SYNC_PAGE_SIZE); - List announces = BfiUtil.queryAllPages(pageNo -> { - queryDTO.setPageNo(pageNo); - return bfiService.queryClaimAnnounce(queryDTO); - }); - log.info("调用BFI接口 收款认领公告查询结束 claimusers:{},耗时:{}ms,拉取 {} 条", - effectiveClaimusers, System.currentTimeMillis() - start, announces.size()); - return announces; - } - - /** - * A301-2 认领公告 → 收款流水表字段拷贝(仅覆盖金蝶侧来源字段,不覆盖本地认领金额与状态) - */ - private void copyAnnounceRow(KingdeeReceiptFlow target, BfiClaimAnnounceVO source, - String detailSerialNo, BigDecimal receiptAmount) { - // 金蝶认领公告id - target.setBillId(source.getId()); - // 收款通知单号 + private void copyRecBillRow(KingdeeReceiptFlow target, BfiRecBillRow row) { + BfiRecBillVO source = row.recBill(); + // 金蝶收款单号(既是收款通知单号也是金蝶单据号) target.setReceiptNoticeNo(trimToEmpty(source.getBillno())); - // 付款人名称(取自对方户名) - target.setPayerName(trimToEmpty(source.getOppunit())); - // 付款人id(取自对方账号) - target.setPayerId(trimToEmpty(source.getOppbanknumber())); - // 收款人 - target.setPayeeName(trimToNull(source.getRecpayee())); - // 收款金额 - target.setReceiptAmount(receiptAmount); - // 对方单位名称 - target.setCounterpartyName(trimToEmpty(source.getOppunit())); - // 对方银行账号 - target.setCounterpartyAccount(trimToEmpty(source.getOppbanknumber())); - // 对方开户行 - target.setCounterpartyBank(trimToEmpty(source.getOppbank())); + target.setKingdeeBillNo(trimToNull(source.getBillno())); + // 金蝶回写状态随单据状态同步刷新 + target.setKingdeeBillStatus(billStatusOf(source.getBillstatus())); + // 付款人名称 + target.setPayerName(trimToEmpty(source.getPayername())); + // 收款人(收款公司名称) + target.setPayeeName(trimToNull(source.getOrgName())); + // 收款人(公司).编码 + target.setOrgNumber(trimToNull(source.getOrgNumber())); + // 核算组织.编码 + target.setOpenOrgNumber(trimToNull(source.getOpenorgNumber())); + // 收款金额(主单 actrecamt) + target.setReceiptAmount(row.receiptAmount()); + // 对方单位名称(取付款人名称) + target.setCounterpartyName(trimToEmpty(source.getPayername())); + // 对方银行账号(付款账户) + target.setCounterpartyAccount(trimToEmpty(source.getPayeracctbanknum())); + // 对方开户行(付款银行) + target.setCounterpartyBank(trimToEmpty(source.getPayerbankname())); // 摘要 - target.setSummary(trimToNull(source.getDescription())); - // 交易日期 - target.setTransactionTime(parseDateTime(source.getTradetime())); + target.setSummary(trimToNull(source.getTxtDescription())); + // 交易日期(业务日期) + target.setTransactionTime(parseDateTime(source.getBizdate())); // 明细流水号 - target.setDetailSerialNo(detailSerialNo); - // 未认领金额 - target.setUnclaimedAmount(money(source.getUnclaimamount())); + target.setDetailSerialNo(row.detailSerialNo()); + // 未匹配收款金额(金蝶参考展示,不参与认领额度) + target.setUnmatchAmountRec(money(source.getUnmatchamountrec())); + // 已匹配收款金额 / 已匹配付款金额 / 未匹配付款金额 + target.setMatchAmountRec(money(source.getMatchamountrec())); + target.setMatchAmountPay(money(source.getMatchamountpay())); + target.setUnmatchAmountPay(money(source.getUnmatchamountpay())); + // 已结算金额 / 未结算金额 + target.setSettleAmount(money(source.getSettleamount())); + target.setUnsettleAmount(money(source.getUnsettleamount())); // 手续费 target.setFee(source.getFee() == null ? null : source.getFee().setScale(2, RoundingMode.HALF_UP)); - // 公告认领状态 - target.setKingdeeClaimStatus(trimToNull(source.getClaimstatus())); - // 我已认领 - target.setClaimMe(source.getClaimme()); // 来源更新时间 - target.setSourceUpdatedTime(target.getTransactionTime() == null - ? LocalDateTime.now() : target.getTransactionTime()); + target.setSourceUpdatedTime(LocalDateTime.now()); } - private String firstNonBlank(String... values) { - for (String value : values) { - if (Func.isNotEmpty(value)) { - return value.trim(); - } + /** + * A301-1 单据状态 → 本地金蝶回写状态(同步时随单据刷新) + * + * @param billStatus 金蝶 billstatus(A暂存/B已提交/C已审核/D已收款/G已退单) + * @return 本地状态 + */ + private String billStatusOf(String billStatus) { + if ("D".equals(billStatus)) { + return KingdeeBfiConstant.KINGDEE_STATUS_RECEIVED; } - return null; + if ("C".equals(billStatus)) { + return APPROVED; + } + if ("A".equals(billStatus) || "B".equals(billStatus)) { + return KingdeeBfiConstant.KINGDEE_STATUS_PUSHED; + } + // 其余状态(含 G 已退单/E 变更中)按处理失败记录,提示人工在金蝶核查 + log.warn("金蝶收款单单据状态[{}]未匹配映射,同步按 failed 处理", billStatus); + return KingdeeBfiConstant.KINGDEE_STATUS_FAILED; } private LocalDateTime parseDateTime(String value) { @@ -1237,13 +984,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() + List contracts = contractManageService.list(Wrappers.lambdaQuery() .eq(ContractManage::getIsDeleted, 0) .eq(ContractManage::getProjectId, waybill.getProjectId()) - .eq(ContractManage::getPartyA, customerContract.getPartyB()) - .eq(ContractManage::getPartyB, carrierName) .eq(ContractManage::getContractCategory, "承运商合同") .and(wrapper -> wrapper.isNull(ContractManage::getContractStage) .or().ne(ContractManage::getContractStage, "terminated")) - .orderByDesc(ContractManage::getCreateTime), false); + .orderByDesc(ContractManage::getCreateTime)); + Set customerPartyBs = splitPartyNames(customerContract.getPartyBJson(), customerContract.getPartyB()); + for (ContractManage contract : contracts) { + Set partyANames = splitPartyNames(contract.getPartyAJson(), contract.getPartyA()); + boolean partyAMatched = partyANames.isEmpty() || partyANames.stream().anyMatch(customerPartyBs::contains) + || customerPartyBs.contains(trimToNull(contract.getPartyA())); + if (!partyAMatched) { + continue; + } + if (ContractPartySupport.matchesCarrier(contract, carrierName, waybill.getCarrierPartyId())) { + return contract; + } + } + return null; + } + + private Set splitPartyNames(String partyJson, String fallback) { + Set names = new LinkedHashSet<>(); + for (Map row : ContractPartySupport.parsePartyRows(partyJson)) { + Object customerName = row.get("customerName"); + if (customerName != null && Func.isNotEmpty(String.valueOf(customerName).trim())) { + names.add(String.valueOf(customerName).trim()); + } + } + if (names.isEmpty() && Func.isNotEmpty(fallback)) { + for (String part : fallback.split("[、,]")) { + String name = trimToNull(part); + if (name != null) { + names.add(name); + } + } + } + return names; } private String trimToNull(String value) { @@ -2058,13 +2093,31 @@ public class ReceivablePayableDetailServiceImpl */ private List calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly, boolean strictPlan) { - List> plans = parseList(contract == null ? null : contract.getBillingPlanJson()); + List> plans = billingPlansForWaybill(contract, waybill); Map plan = "__matched__".equals(planId) ? resolveDefaultBillingPlan(plans, waybill.getTransportType()) : resolveBillingPlan(plans, planId); return calculatedFees(waybill, plan, matchOnly, strictPlan); } + /** + * 按运单客商过滤合同计费方案:承运商合同用承运客商,客户合同用客户名称。 + */ + private List> billingPlansForWaybill(ContractManage contract, Waybill waybill) { + if (contract == null) { + return List.of(); + } + if (waybill == null) { + return parseList(contract.getBillingPlanJson()); + } + if ("承运商合同".equals(contract.getContractCategory())) { + return ContractPartySupport.filterBillingPlans( + contract.getBillingPlanJson(), null, waybill.getCarrierName(), waybill.getCarrierPartyId()); + } + return ContractPartySupport.filterBillingPlans( + contract.getBillingPlanJson(), null, waybill.getCustomerName(), null); + } + private List calculatedFees(Waybill waybill, Map plan, boolean matchOnly) { return calculatedFees(waybill, plan, matchOnly, false); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java index f7dd22e3..cfd6d90b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java @@ -242,10 +242,15 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl queryWrapper = Wrappers.lambdaQuery().eq(ShippingTemplate::getIsDeleted, 0); if (!Objects.equals(shippingTemplate.getAllDept(), 1)) { - queryWrapper.eq(ShippingTemplate::getDeptId, TransportBusinessSupport.currentDeptId("发货模板")); + Long currentDeptId = TransportBusinessSupport.currentDeptId("发货模板"); + queryWrapper.and(wrapper -> { + wrapper.eq(ShippingTemplate::getDeptId, currentDeptId); + TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId)); + }); } else if (Func.isNotEmpty(shippingTemplate.getDeptId())) { queryWrapper.eq(ShippingTemplate::getDeptId, shippingTemplate.getDeptId()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java index fdcc043d..5668c4ef 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java @@ -190,7 +190,11 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl { + wrapper.eq(TemporaryCreditLimit::getApplyDeptId, currentDeptId); + TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId)); + }); } else if (Func.isNotEmpty(temporaryCreditLimit.getApplyDeptId())) { queryWrapper.eq(TemporaryCreditLimit::getApplyDeptId, temporaryCreditLimit.getApplyDeptId()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java index 12479023..fb47d985 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java @@ -701,9 +701,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl queryWrapper = Wrappers.lambdaQuery().eq(TransportPlan::getIsDeleted, 0); if (!Objects.equals(transportPlan.getAllDept(), 1)) { Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划"); - // dept_id 为空的历史数据按创建组织归入当前组织 - queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId) - .or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId))); + // dept_id 为空的历史数据按创建组织归入当前组织;业务部门可见其项目下的计划 + queryWrapper.and(wrapper -> { + wrapper.eq(TransportPlan::getDeptId, currentDeptId) + .or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId)); + TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId)); + }); } else if (Func.isNotEmpty(transportPlan.getDeptId())) { queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId()); } 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 611b4e5f..c692583e 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 @@ -69,6 +69,7 @@ import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.ContractPartySupport; import org.springblade.transport.support.LiveProcessConfigSupport; import org.springblade.transport.support.LoadableAdmissionSupport; import org.springblade.transport.support.TransportBusinessSupport; @@ -152,7 +153,8 @@ public class WaybillServiceImpl extends BaseServiceImpl @Override public WaybillVO detail(Long id) { - WaybillVO result = WaybillWrapper.build().entityVO(syncDriverAcceptState(loadEditable(id, false))); + Waybill loaded = syncDriverAcceptState(loadEditable(id, false)); + WaybillVO result = WaybillWrapper.build().entityVO(loaded); fillCustomerNameFromContract(result); fillCarrierContractName(List.of(result)); fillMileageMaintainable(List.of(result)); @@ -1328,18 +1330,7 @@ public class WaybillServiceImpl extends BaseServiceImpl LambdaQueryWrapper queryWrapper; if (Func.isNotEmpty(idList)) { queryWrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0); - List deptIdList = TransportBusinessSupport.resolveDeptFilterIds( - waybill.getAllDept(), waybill.getDeptId(), "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE); - if (Func.isNotEmpty(deptIdList)) { - if (deptIdList.size() == 1) { - queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0)); - } else { - queryWrapper.in(Waybill::getDeptId, deptIdList); - } - } - if (TransportBusinessSupport.isOwnCreateScope(TransportBusinessSupport.MENU_WAYBILL_MANAGE)) { - queryWrapper.eq(Waybill::getCreateUser, AuthUtil.getUserId()); - } + applyAccountScope(queryWrapper, waybill.getAllDept(), waybill.getDeptId()); queryWrapper.in(Waybill::getId, idList).orderByDesc(Waybill::getCreateTime); } else { queryWrapper = buildQuery(waybill); @@ -1397,7 +1388,7 @@ public class WaybillServiceImpl extends BaseServiceImpl @Override @Transactional(rollbackFor = Exception.class) public WaybillVO copy(Long id) { - Waybill source = loadEditable(id, true); + Waybill source = loadEditable(id, false); Waybill target = new Waybill(); target.setProjectId(source.getProjectId()); target.setProjectName(source.getProjectName()); @@ -1817,21 +1808,57 @@ public class WaybillServiceImpl extends BaseServiceImpl return loadingManageService.detail(loadingManage.getId()); } - private LambdaQueryWrapper buildQuery(WaybillVO waybill) { - LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0); - // 与常用线路等模块一致:超管默认不按组织过滤;普通用户 allDept!=1 时强制当前组织 - List deptIdList = TransportBusinessSupport.resolveDeptFilterIds( - waybill.getAllDept(), waybill.getDeptId(), "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE); - if (Func.isNotEmpty(deptIdList)) { - if (deptIdList.size() == 1) { - queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0)); - } else { - queryWrapper.in(Waybill::getDeptId, deptIdList); - } - } + /** + * 运单可见范围:超管不限制;普通账号仅本组织运单,以及归属本账号项目的运单。 + * 本人创建权限仍只看自己创建的运单。 + */ + private void applyAccountScope(LambdaQueryWrapper queryWrapper, Integer allDept, Long deptId) { if (TransportBusinessSupport.isOwnCreateScope(TransportBusinessSupport.MENU_WAYBILL_MANAGE)) { queryWrapper.eq(Waybill::getCreateUser, AuthUtil.getUserId()); + return; } + List deptIdList = TransportBusinessSupport.resolveScopedDeptFilterIds( + allDept, deptId, "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE); + if (Func.isEmpty(deptIdList)) { + return; + } + Long currentUserId = AuthUtil.getUserId(); + queryWrapper.and(wrapper -> { + if (deptIdList.size() == 1) { + wrapper.eq(Waybill::getDeptId, deptIdList.get(0)); + } else { + wrapper.in(Waybill::getDeptId, deptIdList); + } + TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, deptIdList); + TransportBusinessSupport.orProjectVisibleByAccount(wrapper, deptIdList, currentUserId); + if (Func.isNotEmpty(currentUserId) && currentUserId > 0) { + wrapper.or().eq(Waybill::getCreateUser, currentUserId); + } + }); + } + + /** + * 详情、复制等按主键访问时复用列表可见范围,避免越权读取其他账号运单。 + */ + private void assertWaybillVisible(Waybill waybill) { + if (waybill == null || Func.isEmpty(waybill.getId())) { + throw new ServiceException("运单管理不存在"); + } + if (TransportBusinessSupport.isAdministrator()) { + return; + } + WaybillVO probe = new WaybillVO(); + probe.setAllDept(0); + LambdaQueryWrapper wrapper = buildQuery(probe); + wrapper.eq(Waybill::getId, waybill.getId()); + if (count(wrapper) <= 0) { + throw new ServiceException("无权查看该运单"); + } + } + + private LambdaQueryWrapper buildQuery(WaybillVO waybill) { + LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0); + applyAccountScope(queryWrapper, waybill.getAllDept(), waybill.getDeptId()); if (Func.isNotEmpty(waybill.getWaybillNo())) { queryWrapper.eq(Waybill::getWaybillNo, waybill.getWaybillNo()); } @@ -2227,6 +2254,8 @@ public class WaybillServiceImpl extends BaseServiceImpl private void validateCarrierContract(Waybill waybill) { if ("自运".equals(waybill.getCarrierType())) { waybill.setCarrierContractId(null); + waybill.setCarrierPartyId(null); + waybill.setCarrierPartyRole(null); return; } if (Func.isEmpty(waybill.getCarrierContractId())) { @@ -2236,8 +2265,24 @@ public class WaybillServiceImpl extends BaseServiceImpl if (carrierContract == null || Objects.equals(carrierContract.getIsDeleted(), 1) || !"承运商合同".equals(carrierContract.getContractCategory()) || !Objects.equals(carrierContract.getProjectId(), waybill.getProjectId()) - || !Objects.equals(carrierContract.getPartyB(), waybill.getCarrierName())) { - throw new ServiceException("承运商合同必须属于所选项目,且合同乙方须与承运商一致"); + || !ContractPartySupport.matchesCarrier(carrierContract, waybill.getCarrierName(), waybill.getCarrierPartyId())) { + throw new ServiceException("承运商合同必须属于所选项目,且承运商须为合同乙方或丙方客商"); + } + Map selectedParty = ContractPartySupport.findCarrierParty( + carrierContract, waybill.getCarrierPartyId(), waybill.getCarrierName()); + if (selectedParty != null) { + Object customerName = selectedParty.get("customerName"); + Object partyId = selectedParty.get("id"); + Object partyRole = selectedParty.get("partyRole"); + if (customerName != null) { + waybill.setCarrierName(String.valueOf(customerName).trim()); + } + if (partyId != null) { + waybill.setCarrierPartyId(String.valueOf(partyId).trim()); + } + if (partyRole != null) { + waybill.setCarrierPartyRole(ContractPartySupport.normalizeRole(String.valueOf(partyRole))); + } } } @@ -2252,6 +2297,7 @@ public class WaybillServiceImpl extends BaseServiceImpl if (checkDept) { TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE); } + assertWaybillVisible(waybill); return waybill; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ContractPartySupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ContractPartySupport.java new file mode 100644 index 00000000..074fd3be --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ContractPartySupport.java @@ -0,0 +1,539 @@ +/** + * 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.support; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.vo.LoadingCarrierContractVO; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 合同甲/乙/丙多方客商明细支持 + * + * @author Chill + */ +public final class ContractPartySupport { + + public static final String ROLE_A = "A"; + public static final String ROLE_B = "B"; + public static final String ROLE_C = "C"; + + private ContractPartySupport() { + } + + /** + * 规范化三方客商明细并回写名称汇总 + */ + public static void normalizeAndFillSummary(ContractManage contractManage) { + List> partyARows = normalizePartyRows(contractManage.getPartyAJson(), true); + List> partyBRows = normalizePartyRows(contractManage.getPartyBJson(), true); + List> partyCRows = normalizePartyRows(contractManage.getPartyCJson(), false); + contractManage.setPartyAJson(toJsonOrNull(partyARows)); + contractManage.setPartyBJson(toJsonOrNull(partyBRows)); + contractManage.setPartyCJson(toJsonOrNull(partyCRows)); + contractManage.setPartyA(joinCustomerNames(partyARows)); + contractManage.setPartyB(joinCustomerNames(partyBRows)); + contractManage.setPartyC(joinCustomerNames(partyCRows)); + } + + /** + * 提交级校验:甲/乙至少一行,计费方案必须绑定明细客商 + */ + public static void validateRequiredParties(ContractManage contractManage, boolean requireCustomerId) { + List> partyARows = parsePartyRows(contractManage.getPartyAJson()); + List> partyBRows = parsePartyRows(contractManage.getPartyBJson()); + List> partyCRows = parsePartyRows(contractManage.getPartyCJson()); + if (partyARows.isEmpty()) { + throw new ServiceException("请至少添加一个甲方客商"); + } + if (partyBRows.isEmpty()) { + throw new ServiceException("请至少添加一个乙方客商"); + } + validatePartyRows("甲方", partyARows, requireCustomerId); + validatePartyRows("乙方", partyBRows, requireCustomerId); + if (!partyCRows.isEmpty()) { + validatePartyRows("丙方", partyCRows, requireCustomerId); + } + } + + /** + * 校验计费方案均已绑定合同明细中的客商。 + * 客户合同仅允许绑定甲方;承运商合同仅允许绑定乙方/丙方。 + */ + public static void validateBillingPlanBindings(ContractManage contractManage) { + String billingPlanJson = contractManage.getBillingPlanJson(); + if (Func.isEmpty(billingPlanJson)) { + return; + } + Object parsed = JsonUtil.parse(billingPlanJson, List.class); + if (!(parsed instanceof List plans) || plans.isEmpty()) { + return; + } + Set allowedRoles = allowedBillingPartyRoles(contractManage.getContractCategory()); + Map> partyByRowId = indexPartyRows(contractManage).entrySet().stream() + .filter(entry -> allowedRoles.contains(normalizeRole(String.valueOf(entry.getValue().get("partyRole"))))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (left, right) -> left, LinkedHashMap::new)); + Set customerKeys = partyByRowId.values().stream() + .map(ContractPartySupport::customerMatchKey) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + for (int index = 0; index < plans.size(); index++) { + Object value = plans.get(index); + if (!(value instanceof Map plan)) { + throw new ServiceException("计费方案设置格式不正确"); + } + String partyRowId = trimToNull(plan.get("partyRowId")); + String customerId = trimToNull(plan.get("customerId")); + String customerName = trimToNull(plan.get("customerName")); + String partyRole = normalizeRole(trimToNull(plan.get("partyRole"))); + if (Func.isEmpty(partyRowId) && Func.isEmpty(customerId) && Func.isEmpty(customerName)) { + throw new ServiceException("计费方案第" + (index + 1) + "个必须选择适用客商"); + } + if (Func.isNotEmpty(partyRole) && !allowedRoles.contains(partyRole)) { + throw new ServiceException(billingPartyRoleMessage(contractManage.getContractCategory(), index + 1)); + } + boolean matched = false; + if (Func.isNotEmpty(partyRowId) && partyByRowId.containsKey(partyRowId)) { + matched = true; + } else { + String key = customerMatchKey(customerId, customerName, partyRole); + matched = Func.isNotEmpty(key) && customerKeys.contains(key); + if (!matched && Func.isNotEmpty(customerName)) { + matched = partyByRowId.values().stream() + .anyMatch(row -> Objects.equals(trimToNull(row.get("customerName")), customerName) + && (Func.isEmpty(partyRole) || Objects.equals(normalizeRole(String.valueOf(row.get("partyRole"))), partyRole))); + } + } + if (!matched) { + throw new ServiceException(billingPartyRoleMessage(contractManage.getContractCategory(), index + 1)); + } + } + } + + /** + * 计费方案可选客商角色:客户合同=甲方;承运商合同=乙方/丙方 + */ + public static Set allowedBillingPartyRoles(String contractCategory) { + String category = Func.toStr(contractCategory).trim(); + if ("承运商合同".equals(category)) { + return Set.of(ROLE_B, ROLE_C); + } + if ("客户合同".equals(category)) { + return Set.of(ROLE_A); + } + return Set.of(ROLE_A, ROLE_B, ROLE_C); + } + + private static String billingPartyRoleMessage(String contractCategory, int planIndex) { + String category = Func.toStr(contractCategory).trim(); + if ("承运商合同".equals(category)) { + return "计费方案第" + planIndex + "个适用客商只能选择乙方或丙方"; + } + if ("客户合同".equals(category)) { + return "计费方案第" + planIndex + "个适用客商只能选择甲方"; + } + return "计费方案第" + planIndex + "个适用客商不在合同甲/乙/丙明细中"; + } + + public static List> parsePartyRows(String partyJson) { + if (Func.isEmpty(partyJson)) { + return List.of(); + } + try { + Object parsed = JsonUtil.parse(partyJson, List.class); + if (!(parsed instanceof List rows) || rows.isEmpty()) { + return List.of(); + } + List> result = new ArrayList<>(); + for (Object row : rows) { + if (row instanceof Map map) { + Map values = new LinkedHashMap<>(); + map.forEach((key, value) -> values.put(String.valueOf(key), value)); + result.add(values); + } + } + return result; + } catch (Exception exception) { + return List.of(); + } + } + + public static List> carrierPartyRows(ContractManage contractManage) { + List> rows = new ArrayList<>(); + parsePartyRows(contractManage == null ? null : contractManage.getPartyBJson()).forEach(row -> { + row.put("partyRole", ROLE_B); + rows.add(row); + }); + parsePartyRows(contractManage == null ? null : contractManage.getPartyCJson()).forEach(row -> { + row.put("partyRole", ROLE_C); + rows.add(row); + }); + return rows; + } + + public static Set carrierPartyNames(ContractManage contractManage) { + Set names = new LinkedHashSet<>(); + for (Map row : carrierPartyRows(contractManage)) { + String name = normalizePartyName(trimToNull(row.get("customerName"))); + if (Func.isNotEmpty(name)) { + names.add(name); + } + } + // 兼容历史仅有汇总字段的合同 + if (names.isEmpty() && contractManage != null) { + String partyB = normalizePartyName(contractManage.getPartyB()); + if (Func.isNotEmpty(partyB)) { + names.add(partyB); + } + String partyC = normalizePartyName(contractManage.getPartyC()); + if (Func.isNotEmpty(partyC)) { + names.add(partyC); + } + } + return names; + } + + public static boolean matchesCarrier(ContractManage contractManage, String carrierName, String carrierPartyId) { + if (contractManage == null) { + return false; + } + List> rows = carrierPartyRows(contractManage); + if (Func.isNotEmpty(carrierPartyId)) { + for (Map row : rows) { + if (Objects.equals(trimToNull(row.get("id")), trimToNull(carrierPartyId))) { + return true; + } + } + } + String normalizedName = normalizePartyName(carrierName); + if (Func.isEmpty(normalizedName)) { + return false; + } + if (rows.isEmpty()) { + return Objects.equals(normalizePartyName(contractManage.getPartyB()), normalizedName) + || Objects.equals(normalizePartyName(contractManage.getPartyC()), normalizedName); + } + return rows.stream() + .anyMatch(row -> Objects.equals(normalizePartyName(trimToNull(row.get("customerName"))), normalizedName)); + } + + public static Map findCarrierParty(ContractManage contractManage, String carrierPartyId, String carrierName) { + List> rows = carrierPartyRows(contractManage); + if (Func.isNotEmpty(carrierPartyId)) { + for (Map row : rows) { + if (Objects.equals(trimToNull(row.get("id")), trimToNull(carrierPartyId))) { + return row; + } + } + } + String normalizedName = normalizePartyName(carrierName); + if (Func.isEmpty(normalizedName)) { + return null; + } + for (Map row : rows) { + if (Objects.equals(normalizePartyName(trimToNull(row.get("customerName"))), normalizedName)) { + return row; + } + } + return null; + } + + public static List> filterBillingPlans(String billingPlanJson, Long customerId, String customerName, String partyRowId) { + List> plans = parseObjectList(billingPlanJson); + if (plans.isEmpty()) { + return plans; + } + String normalizedName = normalizePartyName(customerName); + String normalizedPartyRowId = trimToNull(partyRowId); + String customerIdText = customerId == null ? null : String.valueOf(customerId); + List> matched = plans.stream() + .filter(plan -> { + if (Func.isNotEmpty(normalizedPartyRowId) + && Objects.equals(trimToNull(plan.get("partyRowId")), normalizedPartyRowId)) { + return true; + } + if (Func.isNotEmpty(customerIdText) + && Objects.equals(trimToNull(plan.get("customerId")), customerIdText)) { + return true; + } + return Func.isNotEmpty(normalizedName) + && Objects.equals(normalizePartyName(trimToNull(plan.get("customerName"))), normalizedName); + }) + .collect(Collectors.toCollection(ArrayList::new)); + // 历史方案未绑定客商时,不强制过滤,避免老数据无法计费 + if (matched.isEmpty() && plans.stream().noneMatch(ContractPartySupport::hasCustomerBinding)) { + return plans; + } + return matched; + } + + public static List expandCarrierOptions(Collection contracts) { + if (Func.isEmpty(contracts)) { + return List.of(); + } + List options = new ArrayList<>(); + for (ContractManage contract : contracts) { + if (contract == null) { + continue; + } + List> rows = carrierPartyRows(contract); + if (rows.isEmpty()) { + LoadingCarrierContractVO option = new LoadingCarrierContractVO(); + option.setId(contract.getId()); + option.setContractName(contract.getContractName()); + option.setCarrierName(contract.getPartyB()); + option.setCarrierPartyRole(ROLE_B); + options.add(option); + continue; + } + for (Map row : rows) { + LoadingCarrierContractVO option = new LoadingCarrierContractVO(); + option.setId(contract.getId()); + option.setContractName(contract.getContractName()); + option.setCarrierName(trimToNull(row.get("customerName"))); + option.setCarrierPartyId(trimToNull(row.get("id"))); + option.setCarrierPartyRole(normalizeRole(String.valueOf(row.get("partyRole")))); + option.setCreditCode(trimToNull(row.get("creditCode"))); + option.setCustomerId(toLong(row.get("customerId"))); + options.add(option); + } + } + return options; + } + + public static String normalizePartyName(String value) { + return Func.isEmpty(value) ? "" : value.trim(); + } + + public static String normalizeRole(String role) { + if (Func.isEmpty(role)) { + return null; + } + String text = role.trim().toUpperCase(); + if ("A".equals(text) || "甲方".equals(role) || "甲".equals(role)) { + return ROLE_A; + } + if ("B".equals(text) || "乙方".equals(role) || "乙".equals(role)) { + return ROLE_B; + } + if ("C".equals(text) || "丙方".equals(role) || "丙".equals(role)) { + return ROLE_C; + } + return text; + } + + public static String firstPartyName(String partyJson, String fallback) { + List> rows = parsePartyRows(partyJson); + if (!rows.isEmpty()) { + String name = trimToNull(rows.get(0).get("customerName")); + if (Func.isNotEmpty(name)) { + return name; + } + } + return TransportBusinessSupport.trimToNull(fallback); + } + + private static List> normalizePartyRows(String partyJson, boolean keepEmptyPlaceholder) { + List> source = parsePartyRows(partyJson); + List> result = new ArrayList<>(); + for (Map sourceRow : source) { + Map row = new LinkedHashMap<>(); + String id = trimToNull(sourceRow.get("id")); + if (Func.isEmpty(id)) { + id = String.valueOf(IdWorker.getId()); + } + row.put("id", id); + Long customerId = toLong(sourceRow.get("customerId")); + if (customerId != null) { + row.put("customerId", customerId); + } + String customerName = trimToNull(sourceRow.get("customerName")); + if (Func.isNotEmpty(customerName)) { + row.put("customerName", customerName); + } + String creditCode = trimToNull(sourceRow.get("creditCode")); + if (Func.isNotEmpty(creditCode)) { + row.put("creditCode", creditCode); + } + Long contactId = toLong(sourceRow.get("contactId")); + if (contactId != null) { + row.put("contactId", contactId); + } + String contactName = trimToNull(sourceRow.get("contactName")); + if (Func.isNotEmpty(contactName)) { + row.put("contactName", contactName); + } + String contactPhone = trimToNull(sourceRow.get("contactPhone")); + if (Func.isNotEmpty(contactPhone)) { + row.put("contactPhone", contactPhone); + } + if (Func.isEmpty(customerName) && customerId == null && !keepEmptyPlaceholder) { + continue; + } + result.add(row); + } + return result; + } + + private static void validatePartyRows(String roleLabel, List> rows, boolean requireCustomerId) { + Set customerKeys = new LinkedHashSet<>(); + for (int index = 0; index < rows.size(); index++) { + Map row = rows.get(index); + String customerName = trimToNull(row.get("customerName")); + Long customerId = toLong(row.get("customerId")); + if (Func.isEmpty(customerName)) { + throw new ServiceException(roleLabel + "第" + (index + 1) + "行请选择客商"); + } + if (requireCustomerId && customerId == null) { + throw new ServiceException(roleLabel + "第" + (index + 1) + "行请选择有效客商"); + } + String contactName = trimToNull(row.get("contactName")); + String contactPhone = trimToNull(row.get("contactPhone")); + if (Func.isNotEmpty(contactName) && Func.isEmpty(contactPhone)) { + throw new ServiceException(roleLabel + "第" + (index + 1) + "行联系人需带出联系电话"); + } + String key = customerId == null ? customerName : String.valueOf(customerId); + if (!customerKeys.add(key)) { + throw new ServiceException(roleLabel + "存在重复客商:" + customerName); + } + } + } + + private static Map> indexPartyRows(ContractManage contractManage) { + Map> indexed = new LinkedHashMap<>(); + putIndexedRows(indexed, parsePartyRows(contractManage.getPartyAJson()), ROLE_A); + putIndexedRows(indexed, parsePartyRows(contractManage.getPartyBJson()), ROLE_B); + putIndexedRows(indexed, parsePartyRows(contractManage.getPartyCJson()), ROLE_C); + return indexed; + } + + private static void putIndexedRows(Map> indexed, List> rows, String role) { + for (Map row : rows) { + Map copy = new LinkedHashMap<>(row); + copy.put("partyRole", role); + String id = trimToNull(copy.get("id")); + if (Func.isNotEmpty(id)) { + indexed.put(id, copy); + } + } + } + + private static String customerMatchKey(Map row) { + return customerMatchKey(trimToNull(row.get("customerId")), trimToNull(row.get("customerName")), + normalizeRole(String.valueOf(row.get("partyRole")))); + } + + private static String customerMatchKey(String customerId, String customerName, String partyRole) { + if (Func.isNotEmpty(customerId)) { + return normalizeRole(partyRole) + ":" + customerId; + } + if (Func.isNotEmpty(customerName)) { + return normalizeRole(partyRole) + ":" + customerName; + } + return null; + } + + private static boolean hasCustomerBinding(Map plan) { + return Func.isNotEmpty(trimToNull(plan.get("partyRowId"))) + || Func.isNotEmpty(trimToNull(plan.get("customerId"))) + || Func.isNotEmpty(trimToNull(plan.get("customerName"))); + } + + private static List> parseObjectList(String json) { + if (Func.isEmpty(json)) { + return List.of(); + } + try { + Object parsed = JsonUtil.parse(json, List.class); + if (!(parsed instanceof List list) || list.isEmpty()) { + return List.of(); + } + List> result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + Map values = new LinkedHashMap<>(); + map.forEach((key, value) -> values.put(String.valueOf(key), value)); + result.add(values); + } + } + return result; + } catch (Exception exception) { + return Collections.emptyList(); + } + } + + private static String joinCustomerNames(List> rows) { + List names = rows.stream() + .map(row -> trimToNull(row.get("customerName"))) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + return names.isEmpty() ? null : String.join("、", names); + } + + private static String toJsonOrNull(List> rows) { + return rows == null || rows.isEmpty() ? null : JsonUtil.toJson(rows); + } + + private static String trimToNull(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() || "null".equalsIgnoreCase(text) ? null : text; + } + + private static Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.longValue(); + } + String text = trimToNull(value); + if (Func.isEmpty(text)) { + return null; + } + try { + return Long.valueOf(text); + } catch (NumberFormatException exception) { + return null; + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java index 84ee99ce..1ba44b36 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java @@ -212,19 +212,34 @@ public class KingdeeApBillSyncSupport { if (Func.isBlank(awrpSrcCode)) { return false; } - BfiApBillQueryDTO param = new BfiApBillQueryDTO(); - param.setAwrpSrcCode(List.of(awrpSrcCode)); - param.setAwrpSrcSys(bfiProperties.getSrcSys()); - for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> { - param.setPageNo(pageNo); - param.setPageSize(PAY_RESULT_PAGE_SIZE); - return bfiService.queryApBill(param); - })) { - if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && Boolean.TRUE.equals(apBill.getHadwrittenoff())) { - return true; + return queryApBillWriteOffFlags(List.of(awrpSrcCode)).getOrDefault(awrpSrcCode, Boolean.FALSE); + } + + /** + * A102-4 冲销状态批量查询:防重键批量分批查询财务应付单,返回 防重键 -> 是否已冲销 + * (hadwrittenoff=true,部分冲销同样视为已冲销;同键多单取或,查不到的键不在结果中即视为未冲销)。 + * 定时回写结算单金蝶冲销状态用,整批一次查询消除逐单远程查询 N+1 + * + * @param awrpSrcCodes 财务应付单防重键列表 + * @return 防重键 -> 是否已冲销 + */ + public Map queryApBillWriteOffFlags(List awrpSrcCodes) { + Map writeOffFlags = new LinkedHashMap<>(); + batchForeach(awrpSrcCodes, bfiProperties.getWriteOffResult().getQueryBatchSize(), batch -> { + BfiApBillQueryDTO param = new BfiApBillQueryDTO(); + param.setAwrpSrcCode(batch); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(PAY_RESULT_PAGE_SIZE); + return bfiService.queryApBill(param); + })) { + if (Func.isNotEmpty(apBill.getAwrpSrcCode())) { + writeOffFlags.merge(apBill.getAwrpSrcCode(), Boolean.TRUE.equals(apBill.getHadwrittenoff()), Boolean::logicalOr); + } } - } - return false; + }); + return writeOffFlags; } // ==================== A102-2 财务应付单保存 ==================== @@ -282,8 +297,8 @@ public class KingdeeApBillSyncSupport { param.setCurrencyNumber(currency); // 【必填】付款组织.编码 param.setPayorgNumber(orgNumber); - // 部门.编码:项目业务部门自身的金蝶编码(结算/付款组织仍传上溯平台公司编码,口径与付款申请 A103-3 一致) - param.setDepartmentNumber(this.kingdeeDeptNumber(contract.getProjectId(), context)); + // 部门.编码:合同平台公司下业务部门配置的金蝶部门代码 + param.setDepartmentNumber(this.kingdeeDeptNumber(contract, contract.getProjectId(), context)); // 采购组织.编码(非必传) // param.setPurorgNumber(orgNumber); // 【必填】款项性质.编码 @@ -962,6 +977,20 @@ public class KingdeeApBillSyncSupport { * @return 组织编码 */ public String bfiOrgNumber(ContractManage contract, KingdeeSyncContext context) { + if (contract == null) { + throw new ServiceException("推送金蝶失败,合同不存在,无法确定金蝶组织编码"); + } + // 优先取合同平台公司金蝶代码;未维护时回退项目业务部门上溯 + if (contract.getPlatformCompanyId() != null) { + Dept platformCompany = SysCache.getDept(contract.getPlatformCompanyId()); + if (platformCompany == null) { + throw new ServiceException("推送金蝶失败,合同平台公司不存在,无法确定金蝶组织编码"); + } + if (Func.isBlank(platformCompany.getKingdeeDeptCode())) { + throw new ServiceException("推送金蝶失败,平台公司[" + platformCompany.getDeptName() + "]未维护金蝶组织编码,请检查部门数据"); + } + return platformCompany.getKingdeeDeptCode(); + } if (contract.getProjectId() == null) { throw new ServiceException("推送金蝶失败,合同[" + Func.toStr(contract.getContractNo()) + "]未关联项目,无法确定金蝶组织编码"); } @@ -993,20 +1022,41 @@ public class KingdeeApBillSyncSupport { } /** - * 金蝶部门编码:项目业务部门自身的金蝶编码(blade_dept.kingdee_dept_code,不上溯平台公司), - * 用于付款申请单 awrp_department_number(部门.编码);项目未关联/未维护业务部门/业务部门未维护金蝶编码时拦截 + * 金蝶部门编码:必须取「业务部门配置」中合同平台公司 + 业务部门的金蝶部门代码; + * 未配置时拦截推送 * * @param projectId 项目id * @param context 金蝶同步上下文(缓存载体) * @return 部门编码 */ public String kingdeeDeptNumber(Long projectId, KingdeeSyncContext context) { - ProjectApply project = this.requireOrgProject(projectId, context); - Dept dept = SysCache.getDept(project.getBusinessDeptId()); - if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) { - throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未维护金蝶编码,请检查部门数据"); + return kingdeeDeptNumber(null, projectId, context); + } + + /** + * 金蝶部门编码:按合同平台公司下业务部门配置的金蝶部门代码推送 + * + * @param contract 合同(优先取其平台公司、合同负责部门) + * @param projectId 项目id(合同未带业务部门时回退项目业务部门) + * @param context 金蝶同步上下文(缓存载体) + * @return 部门编码 + */ + public String kingdeeDeptNumber(ContractManage contract, Long projectId, KingdeeSyncContext context) { + Long platformCompanyId = contract == null ? null : contract.getPlatformCompanyId(); + Long businessDeptId = contract == null ? null : contract.getOrganizationId(); + if (businessDeptId == null && projectId != null) { + ProjectApply project = this.requireOrgProject(projectId, context); + businessDeptId = project.getBusinessDeptId(); } - return dept.getKingdeeDeptCode(); + if (platformCompanyId == null || businessDeptId == null) { + throw new ServiceException("业务部门对应的金蝶组织代码未配置,单据无法推送,请联系管理员处理。"); + } + org.springblade.system.pojo.entity.BusinessDeptConfig config = + SysCache.getBusinessDeptConfig(platformCompanyId, businessDeptId); + if (config == null || Func.isBlank(config.getKingdeeDeptCode())) { + throw new ServiceException("业务部门对应的金蝶组织代码未配置,单据无法推送,请联系管理员处理。"); + } + return config.getKingdeeDeptCode().trim(); } /** 金蝶组织/部门编码共用校验:项目存在且已维护业务部门 */ @@ -1043,7 +1093,7 @@ public class KingdeeApBillSyncSupport { ? KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE : KingdeeBfiConstant.BFI_SUPPLIER_ASSTACT_TYPE;*/ } - /** 财务应付单往来户.编码:合同乙方关联客商档案取客商编码,未维护时拦截 */ + /** 财务应付单往来户.编码:合同乙方关联客商档案取供应商编码/客商编码,均未维护时拦截 */ public String bfiAsstactNumber(ContractManage contract, KingdeeSyncContext context) { if (Func.isBlank(contract.getPartyB())) { throw new ServiceException("推送金蝶财务应付单失败,合同乙方不能为空,请检查合同[" + Func.toStr(contract.getContractNo()) + "]乙方信息"); @@ -1051,17 +1101,23 @@ public class KingdeeApBillSyncSupport { // 批量同步时按乙方名称缓存客商编码(同批同一乙方只查一次),单笔同步退化为直接查询 String customerCode = context.customerCodeCache().computeIfAbsent(contract.getPartyB(), this::queryCustomerCode); if (Func.isBlank(customerCode)) { - throw new ServiceException("推送金蝶财务应付单失败,合同乙方[" + contract.getPartyB() + "]未在客商档案中维护客商编码,请补充后重试"); + throw new ServiceException("推送金蝶财务应付单失败,合同乙方[" + contract.getPartyB() + "]未在客商档案中维护供应商编码或客商编码,请补充后重试"); } return customerCode; } - /** 按乙方名称(全称或简称)查询客商编码,未维护返回 null(应收单同步组件复用:按结算单付款方查询) */ + /** + * 按乙方名称(全称或简称)查询金蝶往来户编码:优先供应商编码(A901-1 金蝶编码建档/同步时落库), + * 未维护时兜底客商编号(A902-2 推送创建的金蝶档案编码即客商编号),均未维护返回 null(应收单同步组件复用:按结算单付款方查询) + */ public String queryCustomerCode(String partyB) { CustomerArchive customer = customerArchiveMapper.selectOne(Wrappers.lambdaQuery() .and(wrapper -> wrapper.eq(CustomerArchive::getFullName, partyB).or().eq(CustomerArchive::getShortName, partyB)) .eq(CustomerArchive::getIsDeleted, 0).last("limit 1")); - return customer == null ? null : customer.getCustomerCode(); + if (customer == null) { + return null; + } + return Func.isBlank(customer.getSupplierCode()) ? customer.getCustomerCode() : customer.getSupplierCode(); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java index f9e214c1..75253e6f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java @@ -150,19 +150,34 @@ public class KingdeeArBillSyncSupport { if (Func.isBlank(arBillSrcCode)) { return false; } - BfiArBillQueryDTO param = new BfiArBillQueryDTO(); - param.setAwrpSrcCode(List.of(arBillSrcCode)); - param.setAwrpSrcSys(bfiProperties.getSrcSys()); - for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> { - param.setPageNo(pageNo); - param.setPageSize(AR_RESULT_PAGE_SIZE); - return bfiService.queryArBill(param); - })) { - if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && Boolean.TRUE.equals(arBill.getHadwrittenoff())) { - return true; + return queryArBillWriteOffFlags(List.of(arBillSrcCode)).getOrDefault(arBillSrcCode, Boolean.FALSE); + } + + /** + * A302-4 冲销状态批量查询:防重键批量分批查询财务应收单,返回 防重键 -> 是否已冲销 + * (hadwrittenoff=true,部分冲销同样视为已冲销;同键多单取或,查不到的键不在结果中即视为未冲销)。 + * 定时回写结算单金蝶冲销状态用,整批一次查询消除逐单远程查询 N+1 + * + * @param arBillSrcCodes 财务应收单防重键列表 + * @return 防重键 -> 是否已冲销 + */ + public Map queryArBillWriteOffFlags(List arBillSrcCodes) { + Map writeOffFlags = new LinkedHashMap<>(); + kingdeeApBillSyncSupport.batchForeach(arBillSrcCodes, bfiProperties.getWriteOffResult().getQueryBatchSize(), batch -> { + BfiArBillQueryDTO param = new BfiArBillQueryDTO(); + param.setAwrpSrcCode(batch); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(AR_RESULT_PAGE_SIZE); + return bfiService.queryArBill(param); + })) { + if (Func.isNotEmpty(arBill.getAwrpSrcCode())) { + writeOffFlags.merge(arBill.getAwrpSrcCode(), Boolean.TRUE.equals(arBill.getHadwrittenoff()), Boolean::logicalOr); + } } - } - return false; + }); + return writeOffFlags; } // ==================== A302-2 财务应收单保存 ==================== @@ -218,8 +233,8 @@ public class KingdeeArBillSyncSupport { param.setOrgNumber(orgNumber); // 收款组织.编码:同结算组织 param.setRecorgNumber(orgNumber); - // 部门.编码:项目业务部门自身的金蝶编码(结算/收款组织仍传上溯平台公司编码,口径与付款申请/应付单一致) - param.setDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract.getProjectId(), context)); + // 部门.编码:合同平台公司下业务部门配置的金蝶部门代码 + param.setDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, contract.getProjectId(), context)); // 【必填】单据日期 param.setBizdate(billDateText); // 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java index dd20ddde..bda86c68 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java @@ -35,17 +35,21 @@ import org.springblade.core.tool.utils.Func; import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.transport.constant.KingdeeAttachmentConstant; +import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper; import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; +import org.springblade.transport.support.hcs.HcsAttachmentSupport; import org.springframework.stereotype.Component; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.net.URLDecoder; 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.LocalDateTime; import java.util.ArrayList; @@ -64,9 +68,10 @@ import java.util.stream.Collectors; * 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch); * 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过); * 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。

- *

附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与 + *

附件来源三类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url,仅接受 http(s) 绝对地址)、 * 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path, - * path 变化(红冲重开)视为新文件自动触发重传)。

+ * path 变化(红冲重开)视为新文件自动触发重传)与 + * HCS 合规附件(付款特批结果附件等,按合规附件ID 经 C103-5 下载,幂等键=fdId)。

* * @author Chill */ @@ -80,6 +85,7 @@ public class KingdeeAttachmentSyncSupport { private final IBfiService bfiService; private final KingdeeAttachmentSyncMapper attachmentSyncMapper; + private final HcsAttachmentSupport hcsAttachmentSupport; /** 附件下载客户端(无状态,进程内复用) */ private final HttpClient downloadClient = HttpClient.newBuilder() @@ -91,11 +97,30 @@ public class KingdeeAttachmentSyncSupport { * 待同步附件项 * * @param name 附件名称(含扩展名,展示/金蝶侧文件名) - * @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path + * @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path;合规附件=合规附件ID fdId * @param size 附件大小(字节,可空:空时下载后按实际字节校验) * @param kingdeePathSource true 表示 key 为星瀚侧文件 path(经 A801-2 下载);false 表示 http(s) 附件 url + * @param hcsFdIdSource true 表示 key 为合规附件ID fdId(经 C103-5 下载) */ - public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) { + public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource, boolean hcsFdIdSource) { + + /** + * 兼容既有四参构造(默认非合规附件来源) + */ + public KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) { + this(name, key, size, kingdeePathSource, false); + } + + /** + * HCS 合规附件项(C103-5 按合规附件ID 下载) + * + * @param name 附件名称 + * @param fdId 合规附件ID + * @return 附件项 + */ + public static KingdeeAttachmentItem hcsAttachment(String name, String fdId) { + return new KingdeeAttachmentItem(name, fdId, null, false, true); + } } /** @@ -155,12 +180,14 @@ public class KingdeeAttachmentSyncSupport { throw new IllegalArgumentException("附件超过20MB大小限制(" + formatSize(item.size()) + "),请压缩后重试"); } - byte[] content = fetchContent(item); + FetchedFile fetched = fetchContent(item); + byte[] content = fetched.content(); if (content.length > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) { throw new IllegalArgumentException("附件超过20MB大小限制(" + formatSize((long) content.length) + "),请压缩后重试"); } - String fileName = Func.isBlank(item.name()) ? "attachment" : item.name(); + String fileName = Func.isNotBlank(fetched.fileName()) ? fetched.fileName() + : (Func.isBlank(item.name()) ? "attachment" : item.name()); BfiAttachmentUploadVO uploadVO = bfiService.uploadAttachment(entityNumber, billPkId, KingdeeAttachmentConstant.CONTROL_KEY, fileName, content); log.info("金蝶附件上传成功:业务单据[{}:{}],附件[{}],kingdeePath={}", bizType, bizId, item.name(), uploadVO.getPath()); @@ -225,7 +252,8 @@ public class KingdeeAttachmentSyncSupport { continue; } String url = firstText(map, "url", "link", "fileUrl", "downloadUrl", "domain", "src"); - if (Func.isBlank(url)) { + // 仅接受 http(s) 绝对地址:相对/代理路径(如合规特批附件展示行)无法直下,跳过防下载必败 + if (Func.isBlank(url) || !(url.startsWith("http://") || url.startsWith("https://"))) { continue; } String name = firstText(map, "originalName", "name", "fileName"); @@ -362,14 +390,31 @@ public class KingdeeAttachmentSyncSupport { } /** - * 拉取附件内容:星瀚侧文件经 A801-2 下载(流式读取,防大文件全量进内存失控); - * 用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验 + * 附件内容拉取结果(合规附件按 C103-5 响应头解析真实文件名,其余来源 fileName 为 null 沿用原名称) + * + * @param content 附件内容 + * @param fileName 解析出的文件名(可空) */ - private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception { + private record FetchedFile(byte[] content, String fileName) { + } + + /** + * 拉取附件内容:合规附件经 C103-5 按合规附件ID 下载(流式读取,防大文件全量进内存失控); + * 星瀚侧文件经 A801-2 下载(流式读取);用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验 + */ + private FetchedFile fetchContent(KingdeeAttachmentItem item) throws Exception { + if (item.hcsFdIdSource()) { + Response response = hcsAttachmentSupport.downloadRaw(item.key()); + try (InputStream inputStream = response.body().asInputStream()) { + return new FetchedFile(readCapped(inputStream), resolveHcsFileName(response, item.name())); + } finally { + response.close(); + } + } if (item.kingdeePathSource()) { Response response = bfiService.downloadInvoice(item.key()); try (InputStream inputStream = response.body().asInputStream()) { - return readCapped(inputStream); + return new FetchedFile(readCapped(inputStream), null); } finally { response.close(); } @@ -383,10 +428,96 @@ public class KingdeeAttachmentSyncSupport { throw new IllegalStateException("附件下载失败,HTTP状态码 " + response.statusCode()); } try (InputStream inputStream = response.body()) { - return readCapped(inputStream); + return new FetchedFile(readCapped(inputStream), null); } } + /** + * 解析合规附件文件名:统一业务名(HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME)+ 扩展名, + * 扩展名优先取 C103-5 响应头 Content-Disposition 文件名后缀,其次按 Content-Type 映射,兜底 .pdf; + * 不上送合规侧原始名(如"文档内容.pdf"),避免金蝶侧附件不可辨识 + * + * @param response C103-5 文件流响应 + * @param fallbackName 统一业务名 + * @return 业务名 + 后缀名 + */ + private String resolveHcsFileName(Response response, String fallbackName) { + String name = Func.isBlank(fallbackName) ? HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME : fallbackName.trim(); + if (name.lastIndexOf('.') > 0) { + return name; + } + String extension = extensionOf(fileNameFromDisposition(firstHeader(response, "Content-Disposition"))); + if (Func.isBlank(extension)) { + extension = extensionOfContentType(firstHeader(response, "Content-Type")); + } + return name + extension; + } + + /** 取文件名扩展名(含点,无扩展名返回空串) */ + private String extensionOf(String fileName) { + String name = Func.isBlank(fileName) ? "" : fileName.trim(); + int index = name.lastIndexOf('.'); + return index > 0 && index < name.length() - 1 ? name.substring(index) : ""; + } + + /** + * 响应头 Content-Disposition 文件名提取(容忍 filename*=UTF-8''xxx 与 filename="xxx" 两种形式,异常返回 null) + */ + private String fileNameFromDisposition(String disposition) { + if (Func.isBlank(disposition)) { + return null; + } + try { + String lower = disposition.toLowerCase(); + int index = lower.indexOf("filename*="); + if (index >= 0) { + String value = disposition.substring(index + "filename*=".length()); + int quote = value.indexOf("''"); + value = quote >= 0 ? value.substring(quote + 2) : value; + return URLDecoder.decode(value.replaceAll("\"", "").trim(), StandardCharsets.UTF_8); + } + index = lower.indexOf("filename="); + if (index >= 0) { + return URLDecoder.decode(disposition.substring(index + "filename=".length()) + .replaceAll("\"", "").trim(), StandardCharsets.UTF_8); + } + } catch (Exception ignored) { + // 响应头格式异常按无文件名处理,走 Content-Type 扩展名兜底 + } + return null; + } + + /** 响应头取值(key 忽略大小写,取首个非空值) */ + private String firstHeader(Response response, String name) { + if (response.headers() == null) { + return null; + } + for (Map.Entry> header : response.headers().entrySet()) { + if (header.getKey() != null && header.getKey().equalsIgnoreCase(name) && Func.isNotEmpty(header.getValue())) { + for (String value : header.getValue()) { + if (Func.isNotBlank(value)) { + return value; + } + } + } + } + return null; + } + + /** Content-Type → 扩展名(特批结果附件常规为 pdf,未识别类型兜底 .pdf) */ + private String extensionOfContentType(String contentType) { + String type = contentType == null ? "" : contentType.split(";")[0].trim().toLowerCase(); + return switch (type) { + case "application/pdf" -> ".pdf"; + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "image/bmp" -> ".bmp"; + case "image/gif" -> ".gif"; + case "application/ofd" -> ".ofd"; + default -> ".pdf"; + }; + } + /** 流式读取并封顶(超出软限制即中断读取,避免大文件占满内存) */ private byte[] readCapped(InputStream inputStream) throws IOException { int bufferSize = 64 * 1024; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java index 48a74c14..cc2e590c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java @@ -185,7 +185,9 @@ public class KingdeeReceivedInvoiceSyncSupport { param.setPaymenttype(BfiConstant.PAY_MODE_CASH); // 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径) param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); - // 【必填】结算组织.编码:合同关联项目业务部门反查平台公司(与应付单同源) + // 部门金蝶代码校验:收票单主表无部门字段,推送前仍按合同平台公司下业务部门配置校验 + kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, contract.getProjectId(), context); + // 【必填】结算组织.编码:合同平台公司金蝶组织编码(与应付单同源) param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context)); // 【必填】结算币别:合同结算币种 param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract)); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java index 0a5dd9e1..842f6ec0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java @@ -23,23 +23,32 @@ package org.springblade.transport.support; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.constant.RoleConstant; import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.SpringUtil; import org.springblade.system.cache.RoleDataScopeCache; import org.springblade.system.cache.SysCache; +import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.vo.RoleDataScopeVO; +import org.springblade.transport.mapper.ProjectApplyMapper; +import org.springblade.transport.pojo.entity.ProjectApply; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * 运输业务公共校验支持 @@ -125,14 +134,14 @@ public final class TransportBusinessSupport { } /** - * 解析指定菜单下的角色数据权限(超管视为全部组织)。 + * 解析指定菜单下的角色数据权限(超管视为全部组织),并与用户「数据管理权限」组织取并集。 */ public static RoleDataScopeVO resolveRoleDataScope(String menuCode) { if (isAdministrator()) { return RoleDataScopeVO.all(); } RoleDataScopeVO scope = RoleDataScopeCache.resolveByMenuCode(menuCode); - return scope == null ? RoleDataScopeVO.all() : scope; + return mergeUserManageDepts(scope == null ? RoleDataScopeVO.all() : scope); } /** @@ -152,10 +161,43 @@ public final class TransportBusinessSupport { if (isAdministrator()) { return true; } - RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest(); + RoleDataScopeVO scope = resolveWidestDataScope(); return scope == null || scope.isAllDept(); } + /** + * 解析最宽角色数据权限,并与用户「数据管理权限」组织取并集。 + */ + public static RoleDataScopeVO resolveWidestDataScope() { + if (isAdministrator()) { + return RoleDataScopeVO.all(); + } + RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest(); + return mergeUserManageDepts(scope == null ? RoleDataScopeVO.all() : scope); + } + + /** + * 角色组织权限与用户数据管理组织取并集。 + *

+ * 全部组织 / 本人创建 不因用户管理组织改变;其余类型将用户管理组织(含下级)并入 deptIds。 + */ + private static RoleDataScopeVO mergeUserManageDepts(RoleDataScopeVO scope) { + if (scope == null || scope.isAllDept() || scope.isOwnCreate()) { + return scope; + } + Long userId = AuthUtil.getUserId(); + List manageDeptIds = UserCache.getManageDeptIds(userId); + if (Func.isEmpty(manageDeptIds)) { + return scope; + } + LinkedHashSet mergedDeptIds = new LinkedHashSet<>(); + if (Func.isNotEmpty(scope.getDeptIds())) { + mergedDeptIds.addAll(scope.getDeptIds()); + } + mergedDeptIds.addAll(manageDeptIds); + return RoleDataScopeVO.ofDepts(scope.getScopeType(), List.copyOf(mergedDeptIds)); + } + /** * 是否仅本人创建数据权限 */ @@ -180,6 +222,29 @@ public final class TransportBusinessSupport { return dept; } + /** + * 列表行是否只读:与 {@link #assertCurrentDept(Long, String, String)} 判定保持一致。 + *

+ * 超管 / 全部组织 / 本人创建数据权限下可编辑;指定组织权限下,仅所属组织在授权范围内时可编辑; + * 否则仅当前登录用户所属组织的数据可编辑。 + * + * @param deptId 业务数据所属组织 + * @param menuCode 菜单编码 + * @return true-前端仅展示查看 + */ + public static boolean isDeptReadonly(Long deptId, String menuCode) { + RoleDataScopeVO scope = resolveRoleDataScope(menuCode); + if (scope.isAllDept() || scope.isOwnCreate()) { + return false; + } + List allowedDeptIds = scope.getDeptIds(); + if (Func.isNotEmpty(allowedDeptIds)) { + return deptId == null || deptId <= 0 || !allowedDeptIds.contains(deptId); + } + Long currentDeptId = Func.firstLong(AuthUtil.getDeptId()); + return currentDeptId != null && !Objects.equals(deptId, currentDeptId); + } + /** * 校验目标业务数据属于当前用户可操作的组织。 */ @@ -207,7 +272,7 @@ public final class TransportBusinessSupport { if (hasAllDeptScope()) { return; } - RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest(); + RoleDataScopeVO scope = resolveWidestDataScope(); if (scope != null && scope.isOwnCreate()) { return; } @@ -234,6 +299,99 @@ public final class TransportBusinessSupport { } } + /** + * 列表可见范围 OR:所属项目的业务部门落在指定组织内。 + *

+ * 与项目立项「本部门可见 business_dept_id」对齐,使合同/运单等业务数据 + * 在负责部门(organizationId/deptId)不是当前组织时,业务部门用户仍可看到。 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public static void orProjectVisibleByBusinessDept(AbstractWrapper wrapper, Collection deptIds) { + if (wrapper == null || Func.isEmpty(deptIds)) { + return; + } + List ids = deptIds.stream().filter(Objects::nonNull).filter(id -> id > 0).distinct().toList(); + if (ids.isEmpty()) { + return; + } + if (ids.size() == 1) { + wrapper.or().apply( + "project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND business_dept_id = {0})", + ids.get(0)); + return; + } + String inList = ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + wrapper.or().apply( + "project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND business_dept_id IN (" + + inList + "))"); + } + + /** + * 判断项目是否归属指定业务部门(用于详情只读/可操作判定)。 + */ + public static boolean isProjectOwnedByBusinessDept(Long projectId, Long deptId) { + if (Func.isEmpty(projectId) || Func.isEmpty(deptId) || deptId <= 0) { + return false; + } + ProjectApplyMapper projectApplyMapper = SpringUtil.getBean(ProjectApplyMapper.class); + if (projectApplyMapper == null) { + return false; + } + Long count = projectApplyMapper.selectCount(Wrappers.lambdaQuery() + .eq(ProjectApply::getId, projectId) + .eq(ProjectApply::getBusinessDeptId, deptId) + .eq(ProjectApply::getIsDeleted, 0)); + return count != null && count > 0; + } + + /** + * 列表实际使用的组织范围。 + *

+ * 角色菜单未单独收窄、解析结果为「全部组织」时,超管仍不限制; + * 普通用户在未显式请求全部组织(allDept != 1)时收口到当前组织, + * 避免账号看到其他组织的业务数据。本人创建权限不按组织收口。 + */ + public static List resolveScopedDeptFilterIds(Integer allDept, Long deptId, String moduleName, String menuCode) { + List deptIdList = resolveDeptFilterIds(allDept, deptId, moduleName, menuCode); + if (Func.isNotEmpty(deptIdList) || isAdministrator() || isOwnCreateScope(menuCode) || Objects.equals(allDept, 1)) { + return deptIdList; + } + return Collections.singletonList(currentDeptId(moduleName)); + } + + /** + * 账号项目可见范围:承办部门命中指定组织,或当前用户是项目创建人、经办人、负责人。 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public static void orProjectVisibleByAccount(AbstractWrapper wrapper, Collection deptIds, Long userId) { + if (wrapper == null) { + return; + } + List ids = deptIds == null ? List.of() : deptIds.stream() + .filter(Objects::nonNull) + .filter(id -> id > 0) + .distinct() + .toList(); + if (!ids.isEmpty()) { + if (ids.size() == 1) { + wrapper.or().apply( + "project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND FIND_IN_SET({0}, IFNULL(undertake_dept_id,'')))", + String.valueOf(ids.get(0))); + } else { + String undertakeSql = ids.stream() + .map(id -> "FIND_IN_SET(" + id + ", IFNULL(undertake_dept_id,''))") + .collect(Collectors.joining(" OR ")); + wrapper.or().apply( + "project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND (" + undertakeSql + "))"); + } + } + if (Func.isNotEmpty(userId) && userId > 0) { + wrapper.or().apply( + "project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND (create_user = {0} OR handler_user_id = {0} OR principal_user_id = {0}))", + userId); + } + } + /** * 解析列表组织筛选 ID。 * @@ -296,7 +454,7 @@ public final class TransportBusinessSupport { } return Collections.emptyList(); } - RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest(); + RoleDataScopeVO scope = resolveWidestDataScope(); if (scope != null && scope.isOwnCreate()) { if (Objects.equals(allDept, 1) && Func.isNotEmpty(deptId) && deptId > 0) { return Collections.singletonList(deptId); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java index 07bcfeb0..34f944d0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java @@ -66,6 +66,7 @@ import java.util.Objects; * 任一附件转传失败 → 抛出异常由整单推送失败进补偿重试(失败原因落单据 hcs_sync_msg)。 *

* 合同链路挂载字段 fdEntityKey(v4 枚举):主文件 approved=true → eopCdraftSign(用印)、 + * 双章归档文件(fileType=双章归档文件,归档材料非用印正文)→ eopCdraftOther(其他附件,2026-10-30)、 * 特批附件(fileType=特批附件)→ eopCdraftSpecial(随 C103-1 fdSepcailContract=true 特批流程)、其它附件 → eopCdraftOther。 * * @author Chill @@ -106,7 +107,7 @@ public class HcsAttachmentSupport { } /** - * 解析合同主文件 JSON(仅 approved=true 的文件,挂用印字段)与其它附件 JSON(挂其他附件字段)并转传 + * 解析合同主文件 JSON(仅 approved=true 的文件:双章归档文件挂其他附件字段,其余挂用印字段)与其它附件 JSON 并转传 * * @param bizType 业务单据类型(HcsSyncConstant.BIZ_TYPE_*) * @param bizId 业务单据id @@ -119,8 +120,11 @@ public class HcsAttachmentSupport { List attachmentRefs = new ArrayList<>(); for (Map file : parseJsonArray(contractFileJson)) { if (isApproved(file)) { + // 双章归档文件挂其他附件面板(2026-10-30:归档材料非用印正文),其余用印材料挂用印字段 + Object fileType = file.get(HcsSyncConstant.ATTACHMENT_TYPE_FIELD); + boolean sealArchive = fileType != null && HcsSyncConstant.ATTACHMENT_TYPE_SEAL.equals(String.valueOf(fileType).trim()); attachmentRefs.add(this.transferOne(bizType, bizId, file, operatorLoginName, - HcsSyncConstant.CONTRACT_ATTACH_KEY_SEAL)); + sealArchive ? HcsSyncConstant.CONTRACT_ATTACH_KEY_OTHER : HcsSyncConstant.CONTRACT_ATTACH_KEY_SEAL)); } } for (Map attachment : parseJsonArray(attachmentsJson)) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsClaimSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsClaimSyncSupport.java index a74391fd..973fdbea 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsClaimSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsClaimSyncSupport.java @@ -11,8 +11,8 @@ * 2. Redistribution of this software's source code to any third party * is strictly prohibited. *

- * 3. Licensees may copyright their own code but cannot use segments - * from this software for their own commercial purposes. + * 3. Licensees may copyright their own code but cannot use segments of this software + * for their own commercial purposes. *

* Using this software signifies agreement to this license agreement and terms of use, * and you expressly agree to be bound by the terms of use of this software. @@ -50,6 +50,7 @@ import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.mapper.ContractManageMapper; import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.KingdeeReceiptFlowMapper; +import org.springblade.transport.mapper.ReceiptClaimHcsRecordMapper; import org.springblade.transport.mapper.ReceiptClaimMapper; import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; import org.springblade.transport.pojo.entity.ContractManage; @@ -57,12 +58,14 @@ import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.HcsContractExeStage; import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; import org.springblade.transport.pojo.entity.ReceiptClaim; +import org.springblade.transport.pojo.entity.ReceiptClaimHcsRecord; import org.springblade.transport.pojo.entity.ReceiptClaimSettlement; import org.springblade.transport.service.IContractExeStageService; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import java.util.LinkedHashMap; import java.util.List; @@ -73,13 +76,14 @@ import java.util.stream.Collectors; /** * HCS 收款认领链推送编排支撑组件(C103-9 更新履约计划 + C107-1 认领记录 + C108-1 履约流水) *

- * 触发点:金蝶认领推送(A301-3)成功之后(手动按钮与定时任务共用钩子)。 + * 触发点:收款流水认领事务提交后按认领主单推送(作废认领事务提交后推 C107-3 作废同步)。 * 开关复用既有配置(不单独新增):exe-stage.sync-enabled 门禁 C103-9/C107-1(与合同链履约计划同步同开关), * perf-record.sync-enabled 门禁 C108-1(与付款链履约记录同步同开关)。 * 编排顺序:先 C103-9(认领金额累加到收款履约计划行并重推,C107-1/C108-1 引用其返回的阶段ID), - * 再按「认领主单 × 合同」推 C107-1(合规流水ID 经 C104-2 按明细流水号定位),最后按合同推 C108-1 履约流水。 + * 再按「认领主单 × 合同」推 C107-1(合规流水ID 经 C104-2 按明细流水号定位,返回 fdId 落库 + * blade_receipt_claim_hcs_record 供作废 C107-3 引用),最后按合同推 C108-1 履约流水。 * 合同未推送合规(hcs_fd_id 空)时仅累加计划行金额,随合同链路首推。 - * 推送失败仅记日志不回滚金蝶推送结果(单合同失败不影响其余合同)。 + * 推送失败仅落失败标记与日志,不回滚认领主流程(单合同失败不影响其余合同,不做定时重试)。 * * @author Chill */ @@ -92,59 +96,80 @@ public class HcsClaimSyncSupport { * 认领完成状态(与 ReceiptFlowServiceImpl.CLAIMED 同值) */ private static final String CLAIMED = "claimed"; + /** + * 推送状态:推送成功 + */ + private static final String PUSH_SUCCESS = "success"; + /** + * 推送状态:推送失败(仅记标记,不做定时重试) + */ + private static final String PUSH_FAILED = "failed"; + /** + * 推送状态:已作废(C107-3 同步成功) + */ + private static final String PUSH_VOIDED = "voided"; + /** + * 认领记录状态:已认领 + */ + private static final int CLAIM_STATUS_CLAIMED = 0; + /** + * 认领记录状态:已作废 + */ + private static final int CLAIM_STATUS_VOIDED = 1; private final IHcsClient hcsClient; private final HcsProperties hcsProperties; private final KingdeeReceiptFlowMapper receiptFlowMapper; private final ReceiptClaimMapper claimMapper; private final ReceiptClaimSettlementMapper claimSettlementMapper; + private final ReceiptClaimHcsRecordMapper claimHcsRecordMapper; private final FormalSettlementMapper formalSettlementMapper; private final ContractManageMapper contractManageMapper; private final IContractExeStageService contractExeStageService; private final HcsExeStageSyncSupport hcsExeStageSyncSupport; /** - * 金蝶认领推送成功后推送合同合规(C103-9 → C107-1 → C108-1) + * 认领事务提交后推送合同合规(C103-9 → C107-1 → C108-1,按认领主单维度) *

* 复用既有开关:exe-stage.sync-enabled(履约计划同步)门禁 C103-9/C107-1, * perf-record.sync-enabled(履约记录同步)门禁 C108-1;开关关闭或推送异常均静默返回, - * 不影响金蝶认领推送主流程与事务。 + * 不影响认领主流程与事务。 * - * @param flowId 收款流水ID + * @param claimId 认领主单ID */ - public void pushClaimToHcs(Long flowId) { + public void pushClaimToHcs(Long claimId) { if (!hcsProperties.getExeStage().isSyncEnabled()) { return; } try { - doPushClaimToHcs(flowId); + doPushClaimToHcs(claimId); } catch (Exception exception) { - log.error("HCS收款认领链推送异常 flowId:{}", flowId, exception); + log.error("HCS收款认领链推送异常 claimId:{}", claimId, exception); } } /** - * 推送主体:归集有效认领分摊(按合同分组)→ 逐合同推送(单合同失败不影响其余合同) + * 推送主体:归集本认领主单有效分摊(按合同分组)→ 逐合同推送(单合同失败不影响其余合同) */ - private void doPushClaimToHcs(Long flowId) { - KingdeeReceiptFlow flow = receiptFlowMapper.selectById(flowId); + private void doPushClaimToHcs(Long claimId) { + ReceiptClaim claim = claimMapper.selectById(claimId); + if (claim == null || !CLAIMED.equals(claim.getClaimStatus())) { + log.info("HCS收款认领链推送跳过:认领主单不存在或已作废 claimId={}", claimId); + return; + } + KingdeeReceiptFlow flow = receiptFlowMapper.selectById(claim.getReceiptFlowId()); if (flow == null) { + log.warn("HCS收款认领链推送跳过:收款流水不存在 claimId={},flowId={}", claimId, claim.getReceiptFlowId()); return; } - // 未作废认领主单(作废认领不同步合规) - List claims = claimMapper.selectList(Wrappers.lambdaQuery() - .eq(ReceiptClaim::getReceiptFlowId, flowId) - .eq(ReceiptClaim::getClaimStatus, CLAIMED)); - if (claims.isEmpty()) { - return; - } - // 有效分摊行(作废时已置 status=0) + // 本认领主单有效分摊行(作废时已置 status=0) List allocations = claimSettlementMapper.selectList( Wrappers.lambdaQuery() - .eq(ReceiptClaimSettlement::getReceiptFlowId, flowId) + .eq(ReceiptClaimSettlement::getReceiptClaimId, claimId) .eq(ReceiptClaimSettlement::getStatus, 1) .eq(ReceiptClaimSettlement::getIsDeleted, 0)); if (allocations.isEmpty()) { + log.info("HCS收款认领链推送跳过:认领主单无有效分摊 claimId={}", claimId); return; } // 结算单批量定位合同(一次 IN 查询,避免逐行 N+1) @@ -152,20 +177,18 @@ public class HcsClaimSyncSupport { allocations.stream().map(ReceiptClaimSettlement::getFormalSettlementId) .filter(Objects::nonNull).distinct().toList()).stream() .collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first)); - // 合同id → 认领主单id → 分摊金额 - Map> amountByContractByClaim = new LinkedHashMap<>(); + // 合同id → 本认领主单分摊金额 + Map amountByContract = new LinkedHashMap<>(); for (ReceiptClaimSettlement allocation : allocations) { FormalSettlement settlement = settlements.get(allocation.getFormalSettlementId()); if (settlement == null || settlement.getContractId() == null - || allocation.getReceiptClaimId() == null || allocation.getAllocatedReceiptAmount() == null) { + || allocation.getAllocatedReceiptAmount() == null) { continue; } - amountByContractByClaim - .computeIfAbsent(settlement.getContractId(), key -> new LinkedHashMap<>()) - .merge(allocation.getReceiptClaimId(), allocation.getAllocatedReceiptAmount(), BigDecimal::add); + amountByContract.merge(settlement.getContractId(), allocation.getAllocatedReceiptAmount(), BigDecimal::add); } - if (amountByContractByClaim.isEmpty()) { - log.info("HCS收款认领链推送跳过:分摊行未关联合同 flowId={}", flowId); + if (amountByContract.isEmpty()) { + log.info("HCS收款认领链推送跳过:分摊行未关联合同 claimId={},flowId={}", claimId, flow.getId()); return; } // 合规侧收款流水ID(C107-1 必填引用,一次定位全部合同共用;定位失败仅跳过 C107-1,不阻塞履约计划更新) @@ -173,44 +196,44 @@ public class HcsClaimSyncSupport { try { hcsCollectionFlowId = locateHcsCollectionFlowId(flow); } catch (Exception exception) { - log.error("HCS收款流水定位失败 flowId:{},跳过C107-1认领记录推送", flowId, exception); + log.error("HCS收款流水定位失败 flowId:{},跳过C107-1认领记录推送", flow.getId(), exception); } - for (Map.Entry> contractEntry : amountByContractByClaim.entrySet()) { + for (Map.Entry contractEntry : amountByContract.entrySet()) { try { - pushOneContract(flow, claims, contractEntry.getKey(), contractEntry.getValue(), hcsCollectionFlowId); + pushOneContract(flow, claim, contractEntry.getKey(), contractEntry.getValue(), hcsCollectionFlowId); } catch (Exception exception) { - log.error("HCS收款认领链推送失败 flowId:{},contractId:{}", flowId, contractEntry.getKey(), exception); + log.error("HCS收款认领链推送失败 claimId:{},flowId:{},contractId:{}", + claimId, flow.getId(), contractEntry.getKey(), exception); } } } /** - * 推送单个合同:① C103-9 更新履约计划(认领金额累加重推)→ ② C107-1 认领记录(按认领主单分组)→ ③ C108-1 履约流水 + * 推送单个合同:① C103-9 更新履约计划(认领金额累加重推)→ ② C107-1 认领记录(结果落库)→ ③ C108-1 履约流水 */ - private void pushOneContract(KingdeeReceiptFlow flow, List claims, Long contractId, - Map amountByClaim, String hcsCollectionFlowId) { + private void pushOneContract(KingdeeReceiptFlow flow, ReceiptClaim claim, Long contractId, + BigDecimal claimAmount, String hcsCollectionFlowId) { ContractManage contractManage = contractManageMapper.selectById(contractId); if (contractManage == null) { log.warn("HCS收款认领链推送跳过:合同不存在 contractId={}", contractId); return; } - BigDecimal claimedTotal = amountByClaim.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); // ① C103-9 更新合同履约计划:认领金额累加到收款计划行;已同步行置 update 凭阶段ID 重推 - contractExeStageService.accumulateClaimedAmountOnPush(contractId, claimedTotal); + contractExeStageService.accumulateClaimedAmountOnPush(contractId, claimAmount); HcsContractExeStage stageRow = contractExeStageService.findCollectionStageRow(contractId, HcsSyncConstant.FUND_NATURE_PROGRESS); if (StringUtil.isNotBlank(contractManage.getHcsFdId())) { // 行级重推(无待同步行返回提示,不视为失败);合同未推送合规时随合同链路首推 hcsExeStageSyncSupport.syncExeStages(contractManage); } - // ② C107-1 认领记录:按「认领主单 × 合同」推送,金额为该认领主单分摊到本合同的金额 + // ② C107-1 认领记录:本认领主单分摊到本合同的金额,返回 fdId 落库供 C107-3 作废引用 if (StringUtil.isBlank(hcsCollectionFlowId)) { - log.warn("HCS认领记录推送跳过:未定位到合规收款流水 flowId={},detailSerialNo={}", flow.getId(), flow.getDetailSerialNo()); + log.warn("HCS认领记录推送跳过:未定位到合规收款流水 claimId={},flowId={},detailSerialNo={}", + claim.getId(), flow.getId(), flow.getDetailSerialNo()); + saveClaimHcsRecord(claim, flow, contractManage, claimAmount, null, PUSH_FAILED, "C104-2未定位到合规收款流水"); } else { - Map claimsById = claims.stream() - .collect(Collectors.toMap(ReceiptClaim::getId, item -> item, (first, second) -> first)); - for (Map.Entry claimEntry : amountByClaim.entrySet()) { - pushClaimRecord(flow, claimsById.get(claimEntry.getKey()), contractManage, stageRow, claimEntry.getValue(), hcsCollectionFlowId); - } + String claimRecordFdId = pushClaimRecord(flow, claim, contractManage, stageRow, claimAmount, hcsCollectionFlowId); + saveClaimHcsRecord(claim, flow, contractManage, claimAmount, claimRecordFdId, + StringUtil.isBlank(claimRecordFdId) ? PUSH_FAILED : PUSH_SUCCESS, null); } // ③ C108-1 履约流水:按合同一条(开关 perf-record.sync-enabled,与付款链履约记录同步同开关), // 须已推送合规且存在已同步收款计划行(fd_plan_id 必填) @@ -226,16 +249,18 @@ public class HcsClaimSyncSupport { log.info("HCS履约流水推送跳过:无已同步收款履约计划行 contractNo={}", contractManage.getContractNo()); return; } - pushPerfRecord(flow, contractManage, stageRow, claimedTotal, latestClaimDate(claims)); + pushPerfRecord(flow, contractManage, stageRow, claimAmount, claim.getClaimDate()); } /** * C107-1 认领记录推送(单认领主单 × 单合同) + * + * @return 合规侧认领记录 fdId(响应未返回返回 null) */ - private void pushClaimRecord(KingdeeReceiptFlow flow, ReceiptClaim claim, ContractManage contractManage, - HcsContractExeStage stageRow, BigDecimal claimAmount, String hcsCollectionFlowId) { - if (claim == null || claimAmount == null || claimAmount.signum() <= 0) { - return; + private String pushClaimRecord(KingdeeReceiptFlow flow, ReceiptClaim claim, ContractManage contractManage, + HcsContractExeStage stageRow, BigDecimal claimAmount, String hcsCollectionFlowId) { + if (claimAmount == null || claimAmount.signum() <= 0) { + return null; } HcsClaimRecordAddDTO param = new HcsClaimRecordAddDTO(); // 【必填】收款流水引用(合规侧流水ID,C104-2 按明细流水号定位) @@ -264,7 +289,7 @@ public class HcsClaimSyncSupport { param.setFdClaimDate(toMillis(claim.getClaimDate())); param.setFdRemark(claim.getRemark()); // 状态 0=已认领(仅推送未作废认领) - param.setFdStatus(0); + param.setFdStatus(CLAIM_STATUS_CLAIMED); log.info("HCS认领记录推送开始 flowId:{},claimId:{},contractNo:{},认领金额 {}", flow.getId(), claim.getId(), contractManage.getContractNo(), claimAmount); HcsResult result = hcsClient.claimRecordAdd(param); @@ -272,26 +297,102 @@ public class HcsClaimSyncSupport { log.error("HCS认领记录推送失败响应:{},claimId={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), claim.getId()); throw new HcsException("C107-1 认领记录推送失败:" + (result == null ? "响应为空" : result.getMsg())); } - // shortcut: C107-1 返回 fdId 未持久化(认领作废同步 C107-3 未实现,重推会重复);需作废联动时先落库 + String claimRecordFdId = result.getData() == null ? null : result.getData().getFdId(); log.info("HCS认领记录推送成功 flowId:{},claimId:{},contractNo:{},claimRecordFdId:{}", flow.getId(), claim.getId(), contractManage.getContractNo(), - result.getData() == null ? "响应未返回" : result.getData().getFdId()); + StringUtil.isBlank(claimRecordFdId) ? "响应未返回" : claimRecordFdId); + return claimRecordFdId; } /** - * C108-1 履约流水推送(单合同一条:申请金额=实际金额=该合同累计认领金额) + * C107-1 推送结果落库(认领主单 × 合同一条):成功持久化合规 fdId,失败落标记与原因 + */ + private void saveClaimHcsRecord(ReceiptClaim claim, KingdeeReceiptFlow flow, ContractManage contractManage, + BigDecimal claimAmount, String claimRecordFdId, String pushStatus, String failReason) { + ReceiptClaimHcsRecord record = new ReceiptClaimHcsRecord(); + record.setReceiptClaimId(claim.getId()); + record.setReceiptFlowId(flow.getId()); + record.setContractId(contractManage.getId()); + record.setContractNo(contractManage.getContractNo()); + record.setClaimAmount(claimAmount); + record.setHcsClaimRecordFdId(claimRecordFdId); + record.setPushStatus(pushStatus); + record.setFailReason(failReason); + record.setPushedTime(PUSH_SUCCESS.equals(pushStatus) ? LocalDateTime.now() : null); + claimHcsRecordMapper.insert(record); + } + + /** + * 认领作废事务提交后同步合规 C107-3(修改认领记录 fdStatus=1 已作废) + *

+ * 仅同步本认领主单推送成功的合规记录;单条失败独立记日志,不影响其余合同。 + * 作废失败时本地记录保持 success(合规侧仍为已认领),需人工在合规侧处置。 + * + * @param claimId 认领主单ID + */ + public void voidClaimToHcs(Long claimId) { + if (!hcsProperties.getExeStage().isSyncEnabled()) { + return; + } + try { + List records = claimHcsRecordMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceiptClaimHcsRecord::getReceiptClaimId, claimId) + .eq(ReceiptClaimHcsRecord::getPushStatus, PUSH_SUCCESS)); + if (records.isEmpty()) { + log.info("HCS认领作废同步跳过:无推送成功的合规认领记录 claimId={}", claimId); + return; + } + for (ReceiptClaimHcsRecord record : records) { + try { + voidOneClaimRecord(record); + } catch (Exception exception) { + log.error("HCS认领作废同步失败 claimId:{},recordId:{},hcsFdId:{}", + claimId, record.getId(), record.getHcsClaimRecordFdId(), exception); + } + } + } catch (Exception exception) { + log.error("HCS认领作废同步异常 claimId:{}", claimId, exception); + } + } + + /** + * C107-3 单条作废:fdId 必填,fdStatus=1 已作废;成功后本地记录置 voided + */ + private void voidOneClaimRecord(ReceiptClaimHcsRecord record) { + HcsClaimRecordAddDTO param = new HcsClaimRecordAddDTO(); + // 【必填】合规侧认领记录ID(C107-1 推送成功时落库) + param.setFdId(record.getHcsClaimRecordFdId()); + // 状态 1=已作废 + param.setFdStatus(CLAIM_STATUS_VOIDED); + log.info("HCS认领作废同步开始 claimId:{},contractNo:{},hcsFdId:{}", + record.getReceiptClaimId(), record.getContractNo(), record.getHcsClaimRecordFdId()); + HcsResult result = hcsClient.claimRecordUpdate(param); + if (result == null || !result.isSuccess()) { + throw new HcsException("C107-3 认领作废同步失败:" + (result == null ? "响应为空" : result.getMsg())); + } + // 本地推送记录置已作废 + claimHcsRecordMapper.update(null, Wrappers.lambdaUpdate() + .eq(ReceiptClaimHcsRecord::getId, record.getId()) + .set(ReceiptClaimHcsRecord::getPushStatus, PUSH_VOIDED)); + log.info("HCS认领作废同步成功 claimId:{},contractNo:{},hcsFdId:{}", + record.getReceiptClaimId(), record.getContractNo(), record.getHcsClaimRecordFdId()); + } + + /** + * C108-1 履约流水推送(单合同一条:申请金额=实际金额=本次认领分摊金额) */ private void pushPerfRecord(KingdeeReceiptFlow flow, ContractManage contractManage, - HcsContractExeStage stageRow, BigDecimal claimedTotal, LocalDate planDate) { + HcsContractExeStage stageRow, BigDecimal claimAmount, LocalDate planDate) { // 表单模板ID(配置 perf-record.xform-id,环境值 §9-U9,未配置不传) String xformId = StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId(); HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO(); // 【必填】申请编号:收款通知单号,兜底明细流水号 props.setFdApplyNo(firstNonBlank(flow.getReceiptNoticeNo(), flow.getDetailSerialNo())); - // 申请金额 / 实际金额:该合同累计认领金额 - props.setFdApplyAmount(claimedTotal.toPlainString()); - props.setFdFactAmount(claimedTotal.toPlainString()); - // 【必填】履约日期:最近认领日期(毫秒时间戳) + // 申请金额 / 实际金额:本次认领分摊金额 + props.setFdApplyAmount(claimAmount.toPlainString()); + props.setFdFactAmount(claimAmount.toPlainString()); + // 【必填】履约日期:认领日期(毫秒时间戳) props.setFdPlanDate(toMillis(planDate)); // 履约类型:收款 props.setFdPalnType(HcsSyncConstant.PERFORM_TYPE_COLLECTION); @@ -300,7 +401,7 @@ public class HcsClaimSyncSupport { // 履约计划id / 来源履约计划项ID:收款计划行合规阶段ID(HCS 只认自己的阶段ID) props.setFdId(stageRow.getHcsStageFdId()); props.setFdPlanId(stageRow.getHcsStageFdId()); - // 金蝶单据号:A301-3 推送返回的金蝶收款单号 + // 金蝶单据号:金蝶收款单号(A301-1 同步落库) props.setFdJdDocNo(flow.getKingdeeBillNo()); // 【必填】合同ID:HCS 合同ID props.setFdContractId(contractManage.getHcsFdId()); @@ -318,7 +419,7 @@ public class HcsClaimSyncSupport { param.setCurUser(HcsUserRefVO.ofLoginName(hcsProperties.getOperatorLoginName())); param.setFormData(formData); param.setMechanisms(mechanisms); - log.info("HCS履约流水新建开始 flowId:{},contractNo:{},认领金额 {}", flow.getId(), contractManage.getContractNo(), claimedTotal); + log.info("HCS履约流水新建开始 flowId:{},contractNo:{},认领金额 {}", flow.getId(), contractManage.getContractNo(), claimAmount); HcsResult result = hcsClient.perfRecordAdd(param); if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) { log.error("HCS履约流水新建失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo()); @@ -353,17 +454,6 @@ public class HcsClaimSyncSupport { .findFirst().orElse(null); } - /** - * 最近认领日期(C108-1 履约日期) - */ - private LocalDate latestClaimDate(List claims) { - return claims.stream() - .map(ReceiptClaim::getClaimDate) - .filter(Objects::nonNull) - .max(LocalDate::compareTo) - .orElse(LocalDate.now()); - } - private String firstNonBlank(String... values) { for (String value : values) { if (Func.isNotEmpty(value)) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java index 30fe801d..8c4f6c16 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java @@ -32,6 +32,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.hcs.config.HcsProperties; import org.springblade.hcs.constant.HcsConstant; @@ -61,6 +62,7 @@ import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.HcsContractCheckLog; import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.support.ContractPartySupport; import org.springframework.stereotype.Component; import java.math.BigDecimal; @@ -112,6 +114,7 @@ public class HcsContractSyncSupport { private final HcsAttachmentSupport hcsAttachmentSupport; private final HcsExeStageSyncSupport hcsExeStageSyncSupport; private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport; + private final HcsCreditCheckSupport hcsCreditCheckSupport; /** * 合同金蝶同步结果接收处理(合规 contractKingdee/result 推送,D903,投送人/钢厂/结算中心合同共用): @@ -250,7 +253,7 @@ public class HcsContractSyncSupport { markSynced(contractManage, fdContractId, resultMessage); } } else { - updateContract(contractManage, risky); + updateContract(contractManage, null, risky); resultMessage = "合同更新成功:" + contractManage.getContractNo(); markSynced(contractManage, contractManage.getHcsFdId(), resultMessage); } @@ -279,16 +282,13 @@ public class HcsContractSyncSupport { RiskResult riskPartyB = queryRisk(contractManage, "乙方", contractManage.getPartyB()); // 风险等级:甲乙双方取先查到的非空值(无法量化比较,留痕双方原始值,联调后细化) String riskLevel = StringUtil.isNotBlank(riskPartyA.riskLevel()) ? riskPartyA.riskLevel() : riskPartyB.riskLevel(); - // 黑名单状态:任一方黑名单即为是 - boolean blacklist = riskPartyA.blacklist() || riskPartyB.blacklist(); - contractManage.setHcsRiskLevel(riskLevel); - contractManage.setHcsBlackStatus(blacklist ? BLACK_STATUS_YES : "no"); - contractManage.setHcsRiskTime(LocalDateTime.now()); - contractManageMapper.updateById(contractManage); + // 黑名单状态:任一方金蝶黑名单(A801-5)命中即为是(复用 HcsCreditCheckSupport.isKingdeeBlacklistHit 公共口径) + boolean blacklist = riskPartyA.kingdeeHit() || riskPartyB.kingdeeHit(); + // 判定结果仅在内存参与推送/特批流程(risky),风险展示数据由列表/详情实时关联客商档案组装,合同表不再落库风险留痕 boolean risky = HcsSyncConstant.RISK_LEVEL_HIGH.equals(riskLevel) || blacklist; - log.info("HCS合同风险校验完成:contractNo={},甲方[{}={}/{}],乙方[{}={}/{}],高风险/黑名单={}", - contractManage.getContractNo(), contractManage.getPartyA(), riskPartyA.blackStatus(), riskPartyA.riskLevel(), - contractManage.getPartyB(), riskPartyB.blackStatus(), riskPartyB.riskLevel(), risky); + log.info("HCS合同风险校验完成:contractNo={},甲方[金蝶黑名单={},等级={}],乙方[金蝶黑名单={},等级={}],高风险/黑名单={}", + contractManage.getContractNo(), riskPartyA.kingdeeHit(), riskPartyA.riskLevel(), + riskPartyB.kingdeeHit(), riskPartyB.riskLevel(), risky); return risky; } @@ -311,9 +311,8 @@ public class HcsContractSyncSupport { // TMS 流程挂起为"特批审核",特批通过恢复展示、驳回联动合同审批驳回 contractManage.setCurrentNode(HcsSyncConstant.SPEC_REVIEW_NODE); contractManageMapper.updateById(contractManage); - log.info("HCS合同高风险/黑名单挂起特批审核:contractNo={},riskLevel={},blackStatus={},挂起节点={}", - contractManage.getContractNo(), contractManage.getHcsRiskLevel(), contractManage.getHcsBlackStatus(), - contractManage.getCurrentNode()); + log.info("HCS合同高风险/黑名单挂起特批审核:contractNo={},挂起节点={}", + contractManage.getContractNo(), contractManage.getCurrentNode()); } /** @@ -346,13 +345,13 @@ public class HcsContractSyncSupport { if (customerArchive == null) { writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP, standing + "方[" + partyName + "]反查不到客商编号,跳过风险校验(§9-U6)", null); - return new RiskResult(null, null, false); + return new RiskResult(false, null); } // 内部组织类型客商默认无风险,不调 C101-1(与展示/提交/付款链路口径一致) if (HcsCreditCheckSupport.isInternalCustomer(customerArchive)) { writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP, standing + "方[" + partyName + "]为内部组织客商,默认无风险跳过校验", null); - return new RiskResult(null, null, false); + return new RiskResult(false, null); } HcsRiskQueryDTO param = new HcsRiskQueryDTO(); // 【必填】客商编号:按甲乙方名称反查 CustomerArchive 客商编码(§9-U6) @@ -364,12 +363,12 @@ public class HcsContractSyncSupport { throw new HcsException("C101-1 客商风险查询失败:" + (result == null ? "响应为空" : result.getMsg())); } HcsRiskVO risk = result.getData(); - String blackStatus = risk == null ? null : risk.getFdBlackStatus(); String riskLevel = risk == null ? null : risk.getFdRiskLevel(); - boolean blacklist = BLACK_STATUS_YES.equalsIgnoreCase(blackStatus); - writeCheckLog(contractManage, blacklist ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS, - standing + "方[" + partyName + "]黑名单=" + blackStatus + ",风险等级=" + riskLevel, result.getTraceId()); - return new RiskResult(blackStatus, riskLevel, blacklist); + // 黑名单判定复用公共方法:仅以金蝶黑名单(A801-5)命中为准,C101-1 黑名单状态不参与 + boolean kingdeeHit = hcsCreditCheckSupport.isKingdeeBlacklistHit(customerArchive.getUnifiedCreditCode()); + writeCheckLog(contractManage, kingdeeHit ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS, + standing + "方[" + partyName + "]金蝶黑名单=" + kingdeeHit + ",风险等级=" + riskLevel, result.getTraceId()); + return new RiskResult(kingdeeHit, riskLevel); } /** @@ -405,11 +404,12 @@ public class HcsContractSyncSupport { } /** - * C103-11 合同更新(入参同发起,v4 新增顶层 fdId 定位已有合同);risky=true 时报文带 fdSepcailContract=true + * C103-11 合同更新(入参同发起,v4 新增顶层 fdId 定位已有合同);risky=true 时报文带 fdSepcailContract=true; + * attachmentRefs 归档推送传新增双章归档文件引用(挂其他附件面板),常规变更更新传 null 不挂载 */ - private void updateContract(ContractManage contractManage, boolean risky) { - HcsContractDraftDTO param = buildDraftParam(contractManage, null, risky); - log.info("HCS合同推送开始:{}(C103-11 更新,附件不重复挂载待联调确认,特批流程={})", contractManage.getContractNo(), risky); + private void updateContract(ContractManage contractManage, List attachmentRefs, boolean risky) { + HcsContractDraftDTO param = buildDraftParam(contractManage, attachmentRefs, risky); + log.info("HCS合同推送开始:{}(C103-11 更新,挂载附件 {} 个,特批流程={})", contractManage.getContractNo(), attachmentRefs == null ? 0 : attachmentRefs.size(), risky); HcsResult result = hcsClient.contractUpdate(param); if (result == null || !result.isSuccess()) { log.error("HCS合同更新失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo()); @@ -417,6 +417,47 @@ public class HcsContractSyncSupport { } } + /** + * 双章归档文件上传后即时归档推送(C103-11 更新):报文随 fdArchiveStatus=toarchive(buildXformFields 按 + * TMS 归档状态=已归档 传值),并把本次新增的双章归档文件转传挂载其他附件面板(eopCdraftOther,2026-10-30)。 + * 合同尚未发起合规(无 hcs_fd_id)时跳过——首次推送 C103-1 报文自带归档状态与归档附件; + * 特批标志不重触发(risky 固定 false,特批随首次发起一次性完成),特批审核中跳过避免干扰在途流程; + * 失败由调用方留痕,可重新上传双章归档文件或变更重推补偿 + * + * @param contractId 合同单ID + * @param newSealFilesJson 本次新增的双章归档文件元数据 JSON 数组(updateAttachments 按附件键排除原有行提取) + * @return 推送结果描述 + */ + public String pushArchiveUpdate(Long contractId, String newSealFilesJson) { + ContractManage contractManage = contractManageMapper.selectById(contractId); + if (contractManage == null) { + log.warn("HCS合同归档推送跳过(合同不存在):id={}", contractId); + return "合同不存在,跳过归档推送"; + } + if (StringUtil.isBlank(contractManage.getHcsFdId())) { + log.info("HCS合同归档推送跳过(尚未发起合规,归档状态随首次推送报文携带):contractNo={}", contractManage.getContractNo()); + return "合同尚未发起合规,归档状态随首次推送携带"; + } + if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus()) + || HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(contractManage.getHcsSpecStatus()) + || !HcsSyncConstant.TMS_ARCHIVE_STATUS_ARCHIVED.equals(contractManage.getArchiveStatus())) { + log.info("HCS合同归档推送跳过(状态不满足):contractNo={},approvalStatus={},hcsSpecStatus={},archiveStatus={}", + contractManage.getContractNo(), contractManage.getApprovalStatus(), contractManage.getHcsSpecStatus(), contractManage.getArchiveStatus()); + return "合同状态不满足归档推送条件,跳过"; + } + // 本次新增双章归档文件转传(映射表命中复用合规附件ID防重复上传)并统一挂载其他附件面板 + List attachmentRefs = hcsProperties.getAttachment().isTransferEnabled() + ? hcsAttachmentSupport.transferAttachments(HcsSyncConstant.BIZ_TYPE_CONTRACT, contractManage.getId(), + newSealFilesJson, hcsProperties.getOperatorLoginName()) : new ArrayList<>(); + for (HcsAttachmentRefDTO attachmentRef : attachmentRefs) { + attachmentRef.setFdEntityKey(HcsSyncConstant.CONTRACT_ATTACH_KEY_OTHER); + } + updateContract(contractManage, attachmentRefs, false); + String resultMessage = "合同归档推送成功(fdArchiveStatus=toarchive,挂载归档附件 " + attachmentRefs.size() + " 个):" + contractManage.getContractNo(); + markSynced(contractManage, contractManage.getHcsFdId(), resultMessage); + return resultMessage; + } + /** * 组装 C103-1/C103-11 入参(字段标注照 §3.4 规范,映射表见设计文档 §5.2); * attachmentRefs 新建传合规附件引用列表(顶层 attachment,fdId+fdEntityKey,必填,无附件传空列表),更新传 null 不挂载 @@ -460,6 +501,12 @@ public class HcsContractSyncSupport { // 【必填】合同编号:ContractManage.contractNo fields.add(field(HcsContractFieldConstant.FIELD_FD_NUMBER, "合同编号", contractManage.getContractNo())); fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_NO, "合同编号", contractManage.getContractNo())); + // 【必填】业务合同号:合规侧 2026-10-30 新增,值同合同编号 contractNo + fields.add(field(HcsContractFieldConstant.FIELD_FD_BUS_CONTRACT_NUMBER, "业务合同号", contractManage.getContractNo())); + // 归档状态:TMS 已归档(双章归档文件已上传)→ toarchive 待归档(archiving/archived/archiveerror 由合规侧自行流转,未归档不传) + if (HcsSyncConstant.TMS_ARCHIVE_STATUS_ARCHIVED.equals(contractManage.getArchiveStatus())) { + fields.add(field(HcsContractFieldConstant.FIELD_FD_ARCHIVE_STATUS, "归档状态", HcsContractFieldConstant.ARCHIVE_STATUS_TO_ARCHIVE)); + } // 【必填】合同标题:ContractManage.contractName fields.add(field(HcsContractFieldConstant.FIELD_FD_SUBJECT, "合同标题", contractManage.getContractName())); fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_NAME, "合同名称", contractManage.getContractName())); @@ -532,44 +579,74 @@ public class HcsContractSyncSupport { } /** - * signer 相对方(签署方):甲/乙双方。fdType 档案优先:客商档案为内部组织(customerKind=internal/内部组织) + * signer 相对方(签署方):甲/乙/丙多方客商。fdType 档案优先:客商档案为内部组织(customerKind=internal/内部组织) * 的一方为我方签约主体传 inner,另一方传 customer(外部相对方,2026-10-14 联调口径); * 双方均非内部组织或查不到档案时按合同类别推定(承运商合同甲=inner,客户合同乙=inner,其余 customer); - * 丙方(三方合同)TMS 无签约主体字段来源,暂不支持 + * 多方时按明细展开,同地位多客商分别签署 */ private List buildSigners(ContractManage contractManage) { List signers = new ArrayList<>(); - // 甲乙方各反查一次客商档案,编号与内部组织判定共用查询结果 - CustomerArchive partyAArchive = queryCustomerArchive(contractManage.getPartyA()); - CustomerArchive partyBArchive = queryCustomerArchive(contractManage.getPartyB()); + List partyANames = partyNames(contractManage.getPartyAJson(), contractManage.getPartyA()); + List partyBNames = partyNames(contractManage.getPartyBJson(), contractManage.getPartyB()); + List partyCNames = partyNames(contractManage.getPartyCJson(), contractManage.getPartyC()); + String firstPartyA = partyANames.isEmpty() ? null : partyANames.get(0); + String firstPartyB = partyBNames.isEmpty() ? null : partyBNames.get(0); + CustomerArchive partyAArchive = queryCustomerArchive(firstPartyA); + CustomerArchive partyBArchive = queryCustomerArchive(firstPartyB); boolean partyAInternal = HcsCreditCheckSupport.isInternalCustomer(partyAArchive); boolean partyBInternal = HcsCreditCheckSupport.isInternalCustomer(partyBArchive); String partyAType; String partyBType; + String partyCType; if (partyAInternal || partyBInternal) { - // 档案命中:内部组织一方=我方签约主体(inner),外部一方统一 customer partyAType = partyAInternal ? HcsConstant.SIGNER_FD_TYPE_INNER : HcsConstant.SIGNER_FD_TYPE_CUSTOMER; partyBType = partyBInternal ? HcsConstant.SIGNER_FD_TYPE_INNER : HcsConstant.SIGNER_FD_TYPE_CUSTOMER; + partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; } else if ("承运商合同".equals(contractManage.getContractCategory())) { - // 兜底:档案未命中内部组织,按类别推定——我方为付款/发包方,签约主体在甲方 partyAType = HcsConstant.SIGNER_FD_TYPE_INNER; partyBType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; + partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; } else if ("客户合同".equals(contractManage.getContractCategory())) { - // 兜底:我方为收款/承运方,签约主体在乙方 partyAType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; partyBType = HcsConstant.SIGNER_FD_TYPE_INNER; + partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; } else { - // 兜底:其他类别双方统一 customer(2026-10-14 联调口径) partyAType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; partyBType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; + partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER; + } + for (String partyName : partyANames) { + CustomerArchive archive = Objects.equals(partyName, firstPartyA) ? partyAArchive : queryCustomerArchive(partyName); + signers.add(signer("甲", partyAType, partyName, archive)); + } + for (String partyName : partyBNames) { + CustomerArchive archive = Objects.equals(partyName, firstPartyB) ? partyBArchive : queryCustomerArchive(partyName); + signers.add(signer("乙", partyBType, partyName, archive)); + } + for (String partyName : partyCNames) { + signers.add(signer("丙", partyCType, partyName, queryCustomerArchive(partyName))); } - // 甲方相对方:fdStanding=甲(编号按名称反查,反查不到留空,§9-U6) - signers.add(signer("甲", partyAType, contractManage.getPartyA(), partyAArchive)); - // 乙方相对方:fdStanding=乙 - signers.add(signer("乙", partyBType, contractManage.getPartyB(), partyBArchive)); return signers; } + private List partyNames(String partyJson, String fallback) { + List names = new ArrayList<>(); + for (Map row : ContractPartySupport.parsePartyRows(partyJson)) { + Object customerName = row.get("customerName"); + if (customerName != null && Func.isNotEmpty(String.valueOf(customerName).trim())) { + names.add(String.valueOf(customerName).trim()); + } + } + if (names.isEmpty() && Func.isNotEmpty(fallback)) { + for (String part : fallback.split("[、,]")) { + if (Func.isNotEmpty(part) && Func.isNotEmpty(part.trim())) { + names.add(part.trim()); + } + } + } + return names; + } + /** * 单个相对方:fdType 由签约主体落位规则决定(我方主体 inner,外部相对方 customer) */ @@ -780,8 +857,8 @@ public class HcsContractSyncSupport { } /** - * 单方风险查询结果 + * 单方风险查询结果(kingdeeHit=金蝶黑名单 A801-5 是否命中,公共口径见 {@link HcsCreditCheckSupport#isKingdeeBlacklistHit}) */ - private record RiskResult(String blackStatus, String riskLevel, boolean blacklist) { + private record RiskResult(boolean kingdeeHit, String riskLevel) { } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsCreditCheckSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsCreditCheckSupport.java index 2b158e4e..235443a4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsCreditCheckSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsCreditCheckSupport.java @@ -26,6 +26,7 @@ package org.springblade.transport.support.hcs; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.toolkit.Db; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; @@ -40,6 +41,10 @@ import org.springblade.hcs.dto.HcsRiskQueryDTO; import org.springblade.hcs.dto.HcsRiskVO; import org.springblade.hcs.exception.HcsException; import org.springblade.hcs.feign.IHcsClient; +import org.springblade.system.cache.DictBizCache; +import org.springblade.system.cache.DictCache; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; +import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.mapper.ContractManageMapper; import org.springblade.transport.mapper.CustomerArchiveMapper; @@ -52,11 +57,15 @@ import org.springblade.transport.pojo.vo.CustomerRiskVO; import org.springframework.stereotype.Component; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Stream; /** * HCS 付款提交校验支撑组件(C101-1 客商风险 + C106-1 额度校验,设计文档 §5.4,D15/R2-Q3) @@ -87,6 +96,7 @@ public class HcsCreditCheckSupport { private final ContractManageMapper contractManageMapper; private final CustomerArchiveMapper customerArchiveMapper; private final HcsContractCheckLogMapper hcsContractCheckLogMapper; + private final IBfiService bfiService; /** * 付款提交同步校验入口(submit 事务内调用,校验结果字段随提交一并落库) @@ -195,12 +205,28 @@ public class HcsCreditCheckSupport { /** * 客商风险查询(C101-1,展示与提交校验共用,前端接入设计 §3.3): * 内部组织类型客商默认无风险(不调 C101-1);外部客商按编号查询风险等级与黑名单状态并留痕; - * 高风险/黑名单时组装风险提示文案(风险项明细 C101-1 未返回,固定文案展示) + * 金蝶黑名单(A801-5)命中同样判定为风险客商(risky=true,C101-1 空数据不漏提示); + * riskTip 经 {@link #buildRiskDetail} 统一组装(等级 + 金蝶黑名单失信类别/列入事由),与列表悬浮明细同口径 * * @param customerCode 客商编号 * @return 风险展示信息(customerKind/fdBlackStatus/fdRiskLevel/risky/riskTip) */ public CustomerRiskVO queryCustomerRisk(String customerCode) { + return this.queryCustomerRisk(customerCode, null, null); + } + + /** + * 客商风险查询(带信用代码/名称,新增客商档案表单实时校验用): + * 金蝶带出的客商尚未落库(本地档案反查不到)时,信用代码无法从档案取得, + * 由前端将金蝶档案带出的统一社会信用代码随编号一并传入,保证 A801-5 黑名单段不缺失; + * 档案缺失时客商名称同样兜底用传入名称(否则提示文案里只能回显编号) + * + * @param customerCode 客商编号(金蝶档案带出的编码或手输编号) + * @param creditCode 统一社会信用代码(档案存在时以档案为准,档案缺失时兜底入参) + * @param customerNameParam 客商名称(档案缺失时兜底展示用) + * @return 风险展示信息 + */ + public CustomerRiskVO queryCustomerRisk(String customerCode, String creditCode, String customerNameParam) { if (StringUtil.isBlank(customerCode)) { throw new ServiceException("客商编号不能为空"); } @@ -208,16 +234,54 @@ public class HcsCreditCheckSupport { .eq(CustomerArchive::getCustomerCode, customerCode) .eq(CustomerArchive::getIsDeleted, 0) .last("limit 1")); - CustomerRiskVO riskVO = new CustomerRiskVO(); - riskVO.setCustomerCode(customerCode); - riskVO.setCustomerName(customerArchive == null ? customerCode : customerArchive.getFullName()); + // 档案存在以档案名称为准;档案缺失(新增客商实时校验)兜底用前端传入名称,最后才回显编号 + String customerName = customerArchive == null + ? (StringUtil.isNotBlank(customerNameParam) ? customerNameParam : customerCode) + : customerArchive.getFullName(); // 内部组织类型客商默认无风险;档案不存在按外部客商查询(不静默放行) - boolean internal = isInternalCustomer(customerArchive); - riskVO.setCustomerKind(internal ? HcsSyncConstant.CUSTOMER_KIND_INTERNAL : "external"); - if (internal) { + if (isInternalCustomer(customerArchive)) { + CustomerRiskVO riskVO = new CustomerRiskVO(); + riskVO.setCustomerCode(customerCode); + riskVO.setCustomerName(customerName); + riskVO.setCustomerKind(HcsSyncConstant.CUSTOMER_KIND_INTERNAL); riskVO.setRisky(Boolean.FALSE); return riskVO; } + RelativeRiskResult fetch = this.fetchRelativeRisk(customerCode, customerName); + CustomerRiskVO riskVO = fetch.risk(); + // 表单实时提示与列表明细统一组装口径:追加金蝶黑名单段(A801-5 按信用代码查询,失败静默缺失; + // 档案缺失(新增客商实时校验)时兜底用入参信用代码) + String archiveCreditCode = customerArchive == null ? null : customerArchive.getUnifiedCreditCode(); + String effectiveCreditCode = StringUtil.isNotBlank(archiveCreditCode) ? archiveCreditCode : creditCode; + BfiBlacklistVO hit = StringUtil.isBlank(effectiveCreditCode) ? null + : this.queryBlacklistByCodes(List.of(effectiveCreditCode.trim().toUpperCase())).get(effectiveCreditCode.trim().toUpperCase()); + String blackCategory = hit == null ? null + : (Func.isEmpty(hit.getAwrpCombofieldTitle()) ? hit.getAwrpCombofield() : hit.getAwrpCombofieldTitle()); + // 黑名单判定仅以金蝶黑名单(A801-5)命中为准:C101-1 可能对已列入黑名单的客商返回空数据,命中时必须置 risky=true 才能触发前端提示; + // 命中结果落 kingdeeBlackHit 原始标记,红字文案细分(黑名单/高风险)由前端翻译 + if (hit != null) { + riskVO.setRisky(Boolean.TRUE); + riskVO.setKingdeeBlackHit(Boolean.TRUE); + } + riskVO.setRiskTip(buildRiskDetail(customerName, riskVO.getFdRiskLevel(), + blackCategory, hit == null ? null : hit.getAwrpTextareafield())); + try { + hcsContractCheckLogMapper.insert(this.buildRiskCheckLog(customerCode, riskVO, fetch.traceId())); + } catch (Exception exception) { + log.error("HCS客商风险查询留痕写入失败:fdNumber={}", customerCode, exception); + } + return riskVO; + } + + /** + * C101-1 单客商远程查询并组装展示信息(不查档案、不写留痕,供单查与定时批量刷新复用); + * riskTip 不在本方法组装,由调用方经 {@link #buildRiskDetail} 统一组装 + * + * @param customerCode 客商编号 + * @param customerName 客商名称(展示信息用) + * @return 风险展示信息 + 合规 traceId + */ + private RelativeRiskResult fetchRelativeRisk(String customerCode, String customerName) { HcsRiskQueryDTO param = new HcsRiskQueryDTO(); // 【必填】客商编号(用哪个编码待联调,§9-U6) param.setFdNumber(customerCode); @@ -231,28 +295,36 @@ public class HcsCreditCheckSupport { String blackStatus = risk == null ? null : risk.getFdBlackStatus(); String riskLevel = risk == null ? null : risk.getFdRiskLevel(); boolean risky = HcsSyncConstant.RISK_LEVEL_HIGH.equals(riskLevel) || BLACK_STATUS_YES.equalsIgnoreCase(blackStatus); + CustomerRiskVO riskVO = new CustomerRiskVO(); + riskVO.setCustomerCode(customerCode); + riskVO.setCustomerName(customerName); + riskVO.setCustomerKind("external"); riskVO.setFdBlackStatus(blackStatus); riskVO.setFdRiskLevel(riskLevel); riskVO.setRiskLevelName(riskLevelName(blackStatus, riskLevel)); riskVO.setRisky(risky); - if (risky) { - // 风险提示固定短文案(C101-1 未返回风险项明细,不拼接枚举清单):黑名单与高风险区分表述 - riskVO.setRiskTip("【" + riskVO.getCustomerName() + "】:" - + (BLACK_STATUS_YES.equalsIgnoreCase(blackStatus) ? "被列入外部黑名单" : "为高风险客商")); - } - try { - HcsContractCheckLog checkLog = new HcsContractCheckLog(); - checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT); - checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK); - checkLog.setResult(risky ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS); - checkLog.setMsg("客商风险查询[" + customerCode + "]黑名单=" + blackStatus + ",风险等级=" + riskLevel + ",高风险/黑名单=" + risky); - checkLog.setTraceId(result.getTraceId()); - checkLog.setCreateTime(LocalDateTime.now()); - hcsContractCheckLogMapper.insert(checkLog); - } catch (Exception exception) { - log.error("HCS客商风险查询留痕写入失败:fdNumber={}", customerCode, exception); - } - return riskVO; + return new RelativeRiskResult(riskVO, result.getTraceId()); + } + + /** + * C101-1 单次远程查询结果(风险展示信息 + 合规 traceId,留痕与批量刷新共用) + */ + private record RelativeRiskResult(CustomerRiskVO risk, String traceId) { + } + + /** + * 组装客商风险查询留痕(单查与定时批量刷新共用,msg 口径一致) + */ + private HcsContractCheckLog buildRiskCheckLog(String customerCode, CustomerRiskVO risk, String traceId) { + HcsContractCheckLog checkLog = new HcsContractCheckLog(); + checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT); + checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK); + checkLog.setResult(Boolean.TRUE.equals(risk.getRisky()) ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS); + checkLog.setMsg("客商风险查询[" + customerCode + "]黑名单=" + risk.getFdBlackStatus() + + ",风险等级=" + risk.getFdRiskLevel() + ",高风险/黑名单=" + risk.getRisky()); + checkLog.setTraceId(traceId); + checkLog.setCreateTime(LocalDateTime.now()); + return checkLog; } /** @@ -287,18 +359,83 @@ public class HcsCreditCheckSupport { } /** - * 风险等级转义(C101-1 枚举 0=低风险/1=中风险/2=高风险):黑名单状态优先展示,无等级返回空 + * 风险等级转义:黑名单状态优先展示;等级走业务字典 risk_level 翻译({@link #resolveRiskLevelName}),无等级返回空 */ private String riskLevelName(String blackStatus, String riskLevel) { if (BLACK_STATUS_YES.equalsIgnoreCase(blackStatus)) { return "黑名单"; } - return switch (Objects.toString(riskLevel, "")) { - case "2" -> "高风险"; - case "1" -> "中风险"; - case "0" -> "低风险"; - default -> null; - }; + return resolveRiskLevelName(riskLevel); + } + + /** + * 客商风险等级名称翻译(业务字典 risk_level,dictKey=C101-1 风险等级 0=低风险/1=中风险/2=高风险); + * 业务字典未命中兜底系统字典,仍未命中返回 null(明细不拼等级段);列表组装与展示链路统一复用本方法 + * + * @param riskLevel C101-1 风险等级 + * @return 等级名称,未命中返回 null + */ + public static String resolveRiskLevelName(String riskLevel) { + if (Func.isEmpty(riskLevel)) { + return null; + } + String levelName = DictBizCache.getValue(HcsSyncConstant.RISK_LEVEL_DICT_CODE, riskLevel); + if (StringUtil.isBlank(levelName)) { + levelName = DictCache.getValue(HcsSyncConstant.RISK_LEVEL_DICT_CODE, riskLevel); + } + return StringUtil.isBlank(levelName) ? null : levelName; + } + + /** + * 金蝶黑名单命中判定(A801-5,公共方法):按统一社会信用代码/税号查询, + * 黑名单记录存在即命中;查询失败静默返回 false(queryBlacklistByCodes 内记 error 日志备查)。 + * 展示组装(buildRiskDetail)、列表筛选、合同推送链路(checkRelativeRisk)统一复用本方法口径 + * + * @param creditCode 统一社会信用代码/税号 + * @return 是否命中金蝶黑名单 + */ + public boolean isKingdeeBlacklistHit(String creditCode) { + if (StringUtil.isBlank(creditCode)) { + return false; + } + String code = creditCode.trim().toUpperCase(); + return this.queryBlacklistByCodes(List.of(code)).get(code) != null; + } + + /** + * 组装客商风险明细文案(后台统一组装、前端只展示;列表悬浮明细与表单实时提示共用本方法,口径一致), + * 格式:【客商名称】:被列入外部黑名单(失信类别:xx);{列入事由};风险等级:xx,分段说明: + * ① 黑名单段:**仅以金蝶黑名单(A801-5)命中为准**(失信类别/列入事由任一非空), + * 失信类别(awrp_combofield_title,缺失兜底 awrp_combofield)有值时括号展示区分禁止交易/风险警示类; + * 合同合规系统 C101-1 的黑名单状态(fdBlackStatus)不作为展示黑名单依据; + * ② 列入事由:金蝶 A801-5 awrp_textareafield 真实明细,非空时追加; + * ③ 等级段:C101-1 风险等级(risk_level 字典翻译),非空时追加 + * + * @param customerName 客商名称 + * @param riskLevel C101-1 风险等级(0/1/2) + * @param blackCategory 金蝶黑名单失信类别 + * @param blackReason 金蝶黑名单列入事由 + * @return 明细文案,无风险信息返回空字符串 + */ + public static String buildRiskDetail(String customerName, String riskLevel, String blackCategory, String blackReason) { + List parts = new ArrayList<>(); + boolean blacklist = Func.isNotEmpty(blackCategory) || Func.isNotEmpty(blackReason); + if (blacklist) { + parts.add(Func.isNotEmpty(blackCategory) + ? "被列入外部黑名单(失信类别:" + blackCategory + ")" + : "被列入外部黑名单"); + } + if (Func.isNotEmpty(blackReason)) { + parts.add(blackReason); + } + String levelName = resolveRiskLevelName(riskLevel); + if (StringUtil.isNotBlank(levelName)) { + parts.add("风险等级:" + levelName); + } + if (parts.isEmpty()) { + return ""; + } + return "【" + customerName + "】:" + String.join(";", parts); } /** @@ -324,6 +461,197 @@ public class HcsCreditCheckSupport { return riskList; } + /** + * 客商风险每日定时刷新(C101-1,客商档案列表展示与筛选数据源): + * ① 取合同期限内(正式/临时期限任一覆盖当天)审核通过且未终止合同的甲乙方名称; + * ② 按名称反查客商档案,内部组织客商默认无风险跳过; + * ③ 逐个查询 C101-1(接口仅支持单编号,属远程调用非数据库 N+1;复用 {@link #fetchRelativeRisk},不重复查档案), + * 单个失败记 error 日志不中断(失败原因备查); + * ④ 外部客商按 200/批切分,每批黑名单(A801-5)一次批量查询 + C101-1 逐个远程查询,失信类别/列入事由落原始字段 + * (明细文案由列表查询统一组装); + * ⑤ 每批成功结果立即批量落库(Db.updateBatchById,仅提交 id + 风险列,不整行回写)+ 留痕批量写入(Db.saveBatch), + * 风险/黑名单批次互不影响,单批失败不中断其余批次,避免逐条操作数据库 + * + * @return 结果汇总描述 + */ + public String refreshCustomerRiskForActiveContracts() { + LocalDate today = LocalDate.now(); + List contractList = contractManageMapper.selectList(Wrappers.lambdaQuery() + .select(ContractManage::getPartyA, ContractManage::getPartyB) + .in(ContractManage::getApprovalStatus, HcsSyncConstant.CONTRACT_APPROVED_STATUSES) + .ne(ContractManage::getCurrentNode, HcsSyncConstant.CONTRACT_TERMINATED_NODE) + .and(wrapper -> wrapper + .and(item -> item.le(ContractManage::getStartDate, today).ge(ContractManage::getEndDate, today)) + .or(item -> item.le(ContractManage::getTemporaryStartDate, today).ge(ContractManage::getTemporaryEndDate, today)))); + List partyNames = contractList.stream() + .flatMap(contract -> Stream.of(contract.getPartyA(), contract.getPartyB())) + .filter(Func::isNotEmpty) + .map(String::trim) + .distinct() + .toList(); + if (partyNames.isEmpty()) { + return "合同期限内无生效合同,未刷新客商风险"; + } + List archiveList = customerArchiveMapper.selectList(Wrappers.lambdaQuery() + .in(CustomerArchive::getFullName, partyNames) + .eq(CustomerArchive::getIsDeleted, 0)); + List externalArchives = archiveList.stream() + .filter(customerArchive -> !isInternalCustomer(customerArchive)) + .toList(); + int internalCount = archiveList.size() - externalArchives.size(); + int refreshCount = 0; + int failCount = 0; + // 外部客商按 200/批处理:每批黑名单一次批量查询 + C101-1 逐个远程查询(接口仅支持单编号,非数据库 N+1), + // 批内成功结果立即批量落库与留痕,风险/黑名单批次互不影响,单批失败不中断其余批次 + for (int start = 0; start < externalArchives.size(); start += HcsSyncConstant.CUSTOMER_RISK_REFRESH_BATCH) { + List batch = externalArchives.subList(start, Math.min(start + HcsSyncConstant.CUSTOMER_RISK_REFRESH_BATCH, externalArchives.size())); + Map blacklistMap = this.queryBlacklistByCodes(batch.stream() + .map(CustomerArchive::getUnifiedCreditCode) + .filter(Func::isNotEmpty) + .map(code -> code.trim().toUpperCase()) + .distinct() + .toList()); + List batchRefreshList = new ArrayList<>(); + List batchCheckLogs = new ArrayList<>(); + for (CustomerArchive customerArchive : batch) { + try { + RelativeRiskResult fetch = this.fetchRelativeRisk(customerArchive.getCustomerCode(), customerArchive.getFullName()); + CustomerRiskVO risk = fetch.risk(); + // 仅提交风险列,避免整行回写覆盖刷新期间其它并发修改(审批状态/额度/金蝶推送等) + CustomerArchive riskPatch = new CustomerArchive(); + riskPatch.setId(customerArchive.getId()); + riskPatch.setHcsRiskLevel(risk.getFdRiskLevel()); + riskPatch.setKingdeeBlackStatus(risk.getFdBlackStatus()); + riskPatch.setHcsRiskTime(LocalDateTime.now()); + // 金蝶黑名单命中落原始字段(失信类别/列入事由),明细文案由列表查询统一组装 + BfiBlacklistVO hit = Func.isEmpty(customerArchive.getUnifiedCreditCode()) + ? null : blacklistMap.get(customerArchive.getUnifiedCreditCode().trim().toUpperCase()); + if (hit != null) { + String category = Func.isEmpty(hit.getAwrpCombofieldTitle()) ? hit.getAwrpCombofield() : hit.getAwrpCombofieldTitle(); + riskPatch.setKingdeeBlackCategory(category); + riskPatch.setKingdeeBlackReason(hit.getAwrpTextareafield()); + } + batchCheckLogs.add(this.buildRiskCheckLog(customerArchive.getCustomerCode(), risk, fetch.traceId())); + batchRefreshList.add(riskPatch); + } catch (Exception exception) { + failCount++; + log.error("定时刷新客商风险失败:customerCode={},fullName={}", + customerArchive.getCustomerCode(), customerArchive.getFullName(), exception); + } + } + // 批内成功结果立即提交数据库,批间互不影响 + if (!batchRefreshList.isEmpty()) { + Db.updateBatchById(batchRefreshList); + refreshCount += batchRefreshList.size(); + } + this.saveRiskCheckLogs(batchCheckLogs); + } + String summary = String.format("生效合同 %d 份,覆盖客商 %d 个,刷新 %d 个,内部客商跳过 %d 个,失败 %d 个", + contractList.size(), archiveList.size(), refreshCount, internalCount, failCount); + if (failCount > 0) { + log.warn("定时刷新客商风险存在失败:{}", summary); + } + return summary; + } + + /** + * 按甲乙方名称批量查客商档案风险数据(合同/变更列表与详情组装用,合同表不再落库风险留痕): + * 一次 IN 查询避免 N+1,键为客商名称;仅保留已有风险数据的档案(等级或金蝶黑名单字段非空), + * 名称重名时取第一条;内部组织客商无风险数据天然不入 map + * + * @param partyNames 甲/乙方名称集合 + * @return 客商名称 -> 客商档案(含 hcsRiskLevel/kingdeeBlackCategory/kingdeeBlackReason) + */ + public Map queryArchiveRiskMap(Collection partyNames) { + if (Func.isEmpty(partyNames)) { + return Map.of(); + } + Map archiveMap = new HashMap<>(); + for (CustomerArchive archive : customerArchiveMapper.selectList(Wrappers.lambdaQuery() + .in(CustomerArchive::getFullName, partyNames) + .eq(CustomerArchive::getIsDeleted, 0))) { + if (Func.isEmpty(archive.getFullName())) { + continue; + } + boolean hasRiskData = Func.isNotEmpty(archive.getHcsRiskLevel()) + || Func.isNotEmpty(archive.getKingdeeBlackCategory()) || Func.isNotEmpty(archive.getKingdeeBlackReason()); + if (hasRiskData) { + archiveMap.putIfAbsent(archive.getFullName(), archive); + } + } + return archiveMap; + } + + /** + * 合同客商风险等级合并(甲乙双方取较高值,2>1>0,无有效等级返回 null) + */ + public static String higherRiskLevel(String riskLevelA, String riskLevelB) { + int levelA = parseRiskLevel(riskLevelA); + int levelB = parseRiskLevel(riskLevelB); + if (levelA < 0 && levelB < 0) { + return null; + } + return String.valueOf(Math.max(levelA, levelB)); + } + + private static int parseRiskLevel(String riskLevel) { + if (Func.isEmpty(riskLevel)) { + return -1; + } + try { + return Integer.parseInt(riskLevel.trim()); + } catch (NumberFormatException exception) { + return -1; + } + } + + /** + * 客商档案金蝶黑名单命中判定(失信类别/列入事由任一非空) + */ + public static boolean isKingdeeBlackHit(CustomerArchive archive) { + return archive != null && (Func.isNotEmpty(archive.getKingdeeBlackCategory()) || Func.isNotEmpty(archive.getKingdeeBlackReason())); + } + + /** + * 金蝶黑名单批量查询(A801-5,定时刷新专用,调用方按 200 客商/批切分保证入参 ≤200): + * 键为大写税号;失信类别取 awrp_combofield_title、缺失兜底 awrp_combofield(口径同提审校验 validateBlacklist); + * 查询异常不阻断刷新,记 error 日志备查后返回空 map(该批黑名单段缺失) + * + * @param creditCodes 统一社会信用代码集合(大写,≤200) + * @return 大写税号 -> 黑名单记录 + */ + private Map queryBlacklistByCodes(List creditCodes) { + if (creditCodes.isEmpty()) { + return Map.of(); + } + try { + Map blacklistMap = new HashMap<>(); + for (BfiBlacklistVO item : this.bfiService.queryBlacklist("1", creditCodes)) { + if (Func.isNotEmpty(item.getNumber())) { + blacklistMap.put(item.getNumber().trim().toUpperCase(), item); + } + } + return blacklistMap; + } catch (Exception exception) { + log.error("定时刷新客商风险批量查询金蝶黑名单失败:size={}", creditCodes.size(), exception); + return Map.of(); + } + } + + /** + * 客商风险留痕批量写入(Db.saveBatch,避免逐条 insert);失败记 error 日志备查,不阻断刷新结果 + */ + private void saveRiskCheckLogs(List checkLogs) { + if (checkLogs.isEmpty()) { + return; + } + try { + Db.saveBatch(checkLogs); + } catch (Exception exception) { + log.error("定时刷新客商风险留痕批量写入失败:size={}", checkLogs.size(), exception); + } + } + /** * 按名称反查客商档案(内部/外部类型判断与客商编码获取共用;查不到返回 null) */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java index 89bed20c..088b44d8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java @@ -46,19 +46,19 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Stream; /** * HCS 履约规则同步支撑组件(C103-9 新版行级同步,设计文档 §5.3,ADR-0002) *

- * 随合同推送链路执行(合同须已同步、hcs_fd_id 存在):聚合 row_op 非 none 的规则行 - * (add 行不带 fdId,update/delete 行必须带合规阶段ID),整体 fdEopExeplanOperateType - * 首次为 add、后续为 update;返回的 fdExeplanId 回填 blade_contract_manage.hcs_exeplan_id, - * 变更阶段 fdId 按行回填 hcs_stage_fd_id(合规返回的 fdId 必须持久化,后续行更新/删除全靠它)。 + * 随合同推送链路执行(合同须已同步、hcs_fd_id 存在):聚合 row_op 非 none 的规则行, + * 整体 fdEopExeplanOperateType 首次为 add、后续为 update;请求行必送 fdUniqueId(取 TMS 行ID,重试稳定),响应行回 fdId; + * 履约计划ID 取响应顶层 fdExeplanId 回填 blade_contract_manage.hcs_exeplan_id, + * 各行 fdId 按行回填 hcs_stage_fd_id(必须持久化,供履约记录等接口引用)。 * * @author Chill */ @@ -123,9 +123,11 @@ public class HcsExeStageSyncSupport { List stages = new ArrayList<>(); for (HcsContractExeStage row : pendingRows) { HcsExeplanStageDTO stage = new HcsExeplanStageDTO(); - // 阶段项ID:行级 update/delete 必传(取 hcs_stage_fd_id),add 行不带 - if (!"add".equals(row.getRowOp())) { - stage.setFdId(row.getHcsStageFdId()); + // 阶段行ID:update/delete 行必传(HCS 凭首次 add 返回的 fdId 定位行,缺失报"履约计划已不存在"),add 行无值自动省略 + stage.setFdId(row.getHcsStageFdId()); + // 阶段唯一标识:仅 add 行必传(取 TMS 行ID,HCS 回显存储);update/delete 行实测无需携带,以 fdId 定位 + if ("add".equals(row.getRowOp())) { + stage.setFdUniqueId(String.valueOf(row.getId())); } // 【必填】行级操作:add / update / delete stage.setFdEopExeplanOperateType(row.getRowOp()); @@ -154,19 +156,26 @@ public class HcsExeStageSyncSupport { } /** - * 回填:fdExeplanId → 合同 hcs_exeplan_id;变更阶段 fdId 按行回填 hcs_stage_fd_id 并清除行级操作标记 + * 回填:响应行 fdId 即履约计划ID → 合同 hcs_exeplan_id(fdAddStages/fdChangeStages 取首个非空);各行 fdId 按行回填 hcs_stage_fd_id 并清除行级操作标记 *

- * TODO 待联调确认:fdChangeStages 与请求行的对应关系 v4 未明确,优先按请求行序对应(首次全 add 场景), - * 行数不一致时按业务属性组合键匹配,仍匹配不到的 add 行保持待同步(下轮重试)。 + * 新增行阶段在 fdAddStages 返回(实测不含业务属性,按 add 行出现顺序对应),变更行在 fdChangeStages 返回; + * fdChangeStages 按请求行序对应,行数不一致时按业务属性组合键匹配,仍匹配不到的 add 行保持待同步(下轮重试)。 */ private void backfill(ContractManage contractManage, List pendingRows, List requestStages, HcsExeplanVO exeplan) { - // 履约计划ID 回填(后续行更新/删除依赖,必须持久化) - if (StringUtil.isNotBlank(exeplan.getFdExeplanId()) - && !exeplan.getFdExeplanId().equals(contractManage.getHcsExeplanId())) { - contractManage.setHcsExeplanId(exeplan.getFdExeplanId()); + // 履约计划ID 回填:优先取响应顶层 fdExeplanId(实测 add/update 均有返回),兜底取响应行 fdId + String fdExeplanId = StringUtil.isNotBlank(exeplan.getFdExeplanId()) ? exeplan.getFdExeplanId() + : Stream.of(exeplan.getFdChangeStages(), exeplan.getFdAddStages()) + .filter(Objects::nonNull) + .flatMap(List::stream) + .map(HcsExeplanStageVO::getFdId) + .filter(StringUtil::isNotBlank) + .findFirst().orElse(null); + if (StringUtil.isNotBlank(fdExeplanId)) { + contractManage.setHcsExeplanId(fdExeplanId); } backfillExeplanId(contractManage); + List addStages = exeplan.getFdAddStages() == null ? List.of() : exeplan.getFdAddStages(); List changeStages = exeplan.getFdChangeStages() == null ? List.of() : exeplan.getFdChangeStages(); // 行数一致时按请求行序一一对应;不一致时按业务属性组合键匹配 boolean positional = changeStages.size() == requestStages.size(); @@ -177,9 +186,12 @@ public class HcsExeStageSyncSupport { HcsContractExeStage row = pendingRows.get(index); String stageFdId = row.getHcsStageFdId(); if ("add".equals(row.getRowOp())) { - addRowCount++; - HcsExeplanStageVO matched = positional ? changeStages.get(index) : byAttribute.get(attributeKey(requestStages.get(index))); + HcsExeplanStageVO matched = addRowCount < addStages.size() ? addStages.get(addRowCount) : null; + if (matched == null) { + matched = positional ? changeStages.get(index) : byAttribute.get(attributeKey(requestStages.get(index))); + } stageFdId = matched == null ? null : matched.getFdId(); + addRowCount++; if (StringUtil.isBlank(stageFdId)) { unmatchedCount++; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsImmediatePushSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsImmediatePushSupport.java index cc0e6106..eee77238 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsImmediatePushSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsImmediatePushSupport.java @@ -131,6 +131,17 @@ public class HcsImmediatePushSupport { this.submitAfterCommit(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractNow(id), true); } + /** + * 合同双章归档文件上传后即时归档推送(C103-11 更新带 fdArchiveStatus=toarchive + 归档附件挂其他附件面板): + * 须在附件保存事务内调用(afterCommit 触发),失败仅留痕(可重新上传双章归档文件或变更重推补偿) + * + * @param id 合同单ID + * @param newSealFilesJson 本次新增的双章归档文件元数据 JSON 数组 + */ + public void pushContractArchiveAfterCommit(Long id, String newSealFilesJson) { + this.submitAfterCommit(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractArchiveNow(id, newSealFilesJson), true); + } + /** * 付款申请审批通过后即时发起付款特批(C106-3,无需特批则直接推履约数据): * 须在审批事务内调用(afterCommit 触发);按"审批即发起特批"要求不受 immediate-enabled 开关控制 @@ -153,6 +164,13 @@ public class HcsImmediatePushSupport { this.pushWithLock(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractNow(id)); } + /** + * 即时推送开关(thirdParty.hcs.push.immediate-enabled)状态,供 Controller 判断审批响应是否实际等待过推送结果 + */ + public boolean isImmediateEnabled() { + return hcsProperties.getPush().isImmediateEnabled(); + } + /** * 项目立项"通过"按钮路径:在审批事务提交返回后由 Controller 同步调用,失败原因留在当前线程供其取走 * @@ -273,6 +291,17 @@ public class HcsImmediatePushSupport { } } + /** + * 合同归档推送执行体:合同未发起合规/状态不满足由 Support 内部跳过,失败仅留痕 + */ + private void pushContractArchiveNow(Long id, String newSealFilesJson) { + try { + hcsContractSyncSupport.pushArchiveUpdate(id, newSealFilesJson); + } catch (Exception exception) { + log.error("HCS合同归档推送失败(可重新上传双章归档文件或变更重推补偿):id={},{}", id, exception.getMessage(), exception); + } + } + /** * 付款推送执行体:非审批通过/已同步短路跳过;失败已由 Support markFailed,仅留痕交扫描/手动重推补偿 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsInvoicePerfSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsInvoicePerfSyncSupport.java new file mode 100644 index 00000000..00ed9b2f --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsInvoicePerfSyncSupport.java @@ -0,0 +1,222 @@ +/** + * 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.support.hcs; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.hcs.config.HcsProperties; +import org.springblade.hcs.exception.HcsException; +import org.springblade.transport.constant.HcsSyncConstant; +import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.InvoiceApplicationInvoiceMapper; +import org.springblade.transport.mapper.InvoiceApplicationMapper; +import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper; +import org.springblade.transport.mapper.FormalSettlementMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.HcsContractExeStage; +import org.springblade.transport.pojo.entity.InvoiceApplication; +import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; +import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; +import org.springblade.transport.service.IContractExeStageService; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * HCS 履约开票记录同步支撑组件(C108-1,需求:金蝶开票完成后推送合同系统) + *

+ * 粒度:金蝶回写的每张发票(blade_invoice_application_invoice)对应一条履约开票记录; + * 触发:A303-5 开票结果回写落库发票后,随开票登记扫描任务(HcsPushTask invoice-application)推送; + * 履约日期为发票完成开票日期(invoice_date),履约类型「开票」(关联合同收款履约计划行); + * 合同经应收结算链路回溯(开票申请 → 应收结算关联 → 正式结算单合同,同 C111 §9-U10 口径); + * 幂等凭 hcs_perf_fd_id(非空跳过),失败置重试计数与原因由下轮扫描自动补偿。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsInvoicePerfSyncSupport { + + private final HcsProperties hcsProperties; + private final InvoiceApplicationInvoiceMapper invoiceApplicationInvoiceMapper; + private final InvoiceApplicationMapper invoiceApplicationMapper; + private final InvoiceApplicationSettlementMapper invoiceApplicationSettlementMapper; + private final FormalSettlementMapper formalSettlementMapper; + private final ContractManageMapper contractManageMapper; + private final IContractExeStageService contractExeStageService; + private final HcsPerfRecordAddSupport hcsPerfRecordAddSupport; + + /** + * 扫描并推送待同步的履约开票记录(每张发票一条 C108-1),返回结果汇总 + * + * @return 汇总描述 + */ + public String pushUnsyncedInvoicePerfRecords() { + if (!hcsProperties.getPerfRecord().isSyncEnabled()) { + return "履约记录同步开关未开启"; + } + int maxRetry = hcsProperties.getPush().getMaxRetry(); + // 待推送发票:未建过履约记录且重试未超限 + List invoiceList = invoiceApplicationInvoiceMapper.selectList( + Wrappers.lambdaQuery() + .isNull(InvoiceApplicationInvoice::getHcsPerfFdId) + .and(wrapper -> wrapper.isNull(InvoiceApplicationInvoice::getHcsPerfRetryCount) + .or().lt(InvoiceApplicationInvoice::getHcsPerfRetryCount, maxRetry)) + .last("limit " + hcsProperties.getPush().getBatchSize())); + if (invoiceList.isEmpty()) { + return "履约开票记录:无待推送发票"; + } + // 批量装配上下文(申请单/合同/计划行,一次 IN 查询避免逐行 N+1) + Map applicationById = loadApplications(invoiceList); + Map contractByApplicationId = loadContracts(List.copyOf(applicationById.values())); + int successCount = 0; + int failCount = 0; + for (InvoiceApplicationInvoice invoice : invoiceList) { + try { + pushOneInvoice(invoice, applicationById.get(invoice.getInvoiceApplicationId()), + contractByApplicationId.get(invoice.getInvoiceApplicationId())); + successCount++; + } catch (Exception exception) { + markFailed(invoice, StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage()); + failCount++; + } + } + String summary = String.format("履约开票记录:扫描 %d 张,成功 %d 张,失败 %d 张(重试上限 %d)", + invoiceList.size(), successCount, failCount, maxRetry); + if (failCount > 0) { + log.warn("HCS履约开票记录推送存在失败:{}", summary); + } + return summary; + } + + /** + * 推送单张发票的履约开票记录 + */ + private void pushOneInvoice(InvoiceApplicationInvoice invoice, InvoiceApplication application, ContractManage contractManage) { + if (application == null || !"approved".equals(application.getApprovalStatus())) { + throw new HcsException("开票申请不存在或未审批通过:invoiceApplicationId=" + invoice.getInvoiceApplicationId()); + } + if (contractManage == null) { + throw new HcsException("应收结算链路回溯不到合同(applicationNo=" + application.getApplicationNo() + ",同 C111 §9-U10 口径)"); + } + // 关联合同的收款履约计划行(fd_plan_id 必填,无已同步计划行时通用组件抛出重试,合同链路首推后自愈) + HcsContractExeStage stageRow = contractExeStageService.findCollectionStageRow( + contractManage.getId(), HcsSyncConstant.FUND_NATURE_PROGRESS); + BigDecimal amount = invoice.getInvoiceAmount() == null ? BigDecimal.ZERO : invoice.getInvoiceAmount(); + String perfFdId = hcsPerfRecordAddSupport.pushPerfRecordAdd(contractManage, stageRow, + invoice.getInvoiceNo(), amount, toMillis(invoice.getInvoiceDate()), + HcsSyncConstant.PERF_TYPE_INVOICE, invoice.getSerialNo(), invoice.getId(), + "开票履约-" + invoice.getInvoiceNo()); + markSynced(invoice, perfFdId); + } + + /** + * 批量装配发票所属开票申请(过滤已作废) + */ + private Map loadApplications(List invoiceList) { + List applicationIds = invoiceList.stream() + .map(InvoiceApplicationInvoice::getInvoiceApplicationId).filter(Objects::nonNull).distinct().toList(); + if (applicationIds.isEmpty()) { + return Map.of(); + } + return invoiceApplicationMapper.selectBatchIds(applicationIds).stream() + .collect(Collectors.toMap(InvoiceApplication::getId, item -> item, (first, second) -> first)); + } + + /** + * 批量回溯合同:申请单 → 应收结算关联 → 正式结算单合同(一次 IN 查询,同 C111 §9-U10 口径) + */ + private Map loadContracts(List applicationList) { List applicationIds = applicationList.stream().map(InvoiceApplication::getId).toList(); + if (applicationIds.isEmpty()) { + return Map.of(); + } + Map relationByApplicationId = invoiceApplicationSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceApplicationSettlement::getInvoiceApplicationId, applicationIds)).stream() + .filter(relation -> relation.getFormalSettlementId() != null) + .collect(Collectors.toMap(InvoiceApplicationSettlement::getInvoiceApplicationId, relation -> relation, + (first, second) -> first, LinkedHashMap::new)); + if (relationByApplicationId.isEmpty()) { + return Map.of(); + } + Map settlementById = formalSettlementMapper.selectBatchIds( + relationByApplicationId.values().stream() + .map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList()).stream() + .filter(settlement -> settlement.getContractId() != null) + .collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first)); + if (settlementById.isEmpty()) { + return Map.of(); + } + Map contractById = contractManageMapper.selectBatchIds( + settlementById.values().stream().map(FormalSettlement::getContractId).distinct().toList()).stream() + .collect(Collectors.toMap(ContractManage::getId, item -> item, (first, second) -> first)); + Map contractByApplicationId = new LinkedHashMap<>(); + relationByApplicationId.forEach((applicationId, relation) -> { + FormalSettlement settlement = settlementById.get(relation.getFormalSettlementId()); + if (settlement != null) { + contractByApplicationId.put(applicationId, contractById.get(settlement.getContractId())); + } + }); + return contractByApplicationId; + } + + /** + * 推送成功回填:履约记录ID 立即持久化(幂等键),清失败原因 + */ + private void markSynced(InvoiceApplicationInvoice invoice, String perfFdId) { + invoice.setHcsPerfFdId(perfFdId); + invoice.setHcsPerfRetryCount(0); + invoice.setHcsPerfSyncMsg(null); + invoiceApplicationInvoiceMapper.updateById(invoice); + log.info("HCS履约开票记录推送成功:invoiceNo={},perfFdId={}", invoice.getInvoiceNo(), perfFdId); + } + + /** + * 推送失败回写:重试次数+1 + 失败原因(下轮扫描自动补偿) + */ + private void markFailed(InvoiceApplicationInvoice invoice, String message) { + invoice.setHcsPerfRetryCount(invoice.getHcsPerfRetryCount() == null ? 1 : invoice.getHcsPerfRetryCount() + 1); + invoice.setHcsPerfSyncMsg(HcsSyncConstant.truncateMsg(message)); + invoiceApplicationInvoiceMapper.updateById(invoice); + log.error("HCS履约开票记录推送失败:invoiceNo={},retryCount={},{}", invoice.getInvoiceNo(), invoice.getHcsPerfRetryCount(), message); + } + + private Long toMillis(java.time.LocalDateTime localDateTime) { + return localDateTime == null ? null + : localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java index 46059777..3fe88486 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java @@ -26,6 +26,7 @@ package org.springblade.transport.support.hcs; import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; @@ -49,7 +50,8 @@ import java.util.List; /** * HCS 付款链推送编排支撑组件(结算付款特批 C106-3/4 + 履约计划重推 C103-9 + 履约记录 C108-1/2 + 特批结果推送接收,设计文档 §5.0/§5.3/§5.5/§5.6) *

- * 编排顺序:特批(提交时高风险/黑名单须特批附件或额度超额标记 need 时发起)→ 特批通过后再推履约计划(付款审批款项金额累加行)→ 履约记录(审批通过新建 / 实际付款推进更新)。 + * 编排顺序:特批(提交时高风险/黑名单须特批附件或额度超额标记 need 时发起)→ 金蝶实际付款门禁(未付款时履约计划与履约记录均不推送)→ 履约计划重推(付款审批累加款项金额行,金蝶付款后随链重推)→ 履约记录(金蝶实际付款后新建/分期推进更新)。 + * 特批通过后不推履约数据:履约计划与履约记录统一在各业务环节推送(付款=金蝶实际付款后,收款/开票/收票各自环节)。 * 特批审核期间(need/pushing/processing)仅发起/等待 C106,不推送履约计划与履约记录;驳回不产生履约数据。 * 特批流程ID 与履约记录ID 在各自 Support 内即时持久化;整体同步状态(hcs_sync_status)由本组件统一回写。 * 特批发起后 TMS 挂起 currentNode="特批审核",合规 paymentLegal/result 回调联动付款审批状态 @@ -76,7 +78,7 @@ public class HcsPaymentSyncSupport { private final ApplicationEventPublisher eventPublisher; /** - * 推送付款申请到 HCS:特批(need 时发起 C106-3 后挂起)→ 特批通过后履约计划重推(C103-9 款项金额累加行)→ 履约记录(新建/更新)→ 回填整体同步状态 + * 推送付款申请到 HCS:特批(need 时发起 C106-3 后挂起)→ 金蝶实际付款门禁(未付款时整体跳过,付款回写后重新置待同步触发推送)→ 履约计划重推(C103-9 款项金额累加行)→ 履约记录(C108-1 新建/C108-2 更新)→ 回填整体同步状态 * * @param payment 付款申请单(approvalStatus=approved) * @return 推送结果描述 @@ -84,7 +86,7 @@ public class HcsPaymentSyncSupport { public String pushPayment(PaymentApplication payment) { StringBuilder result = new StringBuilder(); // ① 结算付款特批:提交时高风险/黑名单(须特批附件)或额度超额(hcs_spec_status=need)且未发起时发起 C106-3; - // 发起后挂起等待特批结果,履约计划与履约记录由特批通过后的 pushAfterSpecApproved 补推(驳回不产生履约数据) + // 发起后挂起等待特批结果,特批通过后不推履约数据(各业务环节推送),仅补推金蝶(驳回不产生履约数据) if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus())) { String specResult = hcsSpecPaySyncSupport.pushSpecPay(payment); // C106-4 更新路径不改状态,统一归一为特批审核中,避免下轮扫描重复发起 @@ -98,27 +100,34 @@ public class HcsPaymentSyncSupport { // 特批审核中:仅等待合规回调/人工确认,不推送履约计划与履约记录 if (HcsSyncConstant.SPEC_STATUS_PUSHING.equals(payment.getHcsSpecStatus()) || HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(payment.getHcsSpecStatus())) { - String message = "付款特批审核中,待特批通过后推送履约数据"; + String message = "付款特批审核中,履约数据待各业务环节推送"; markSynced(payment, message); return message; } - // ② 履约计划重推(C103-9 行级更新):付款审批通过累加款项金额置 row_op=update 的计划行, - // 凭已存合规阶段ID 重推;独立于履约记录开关,推送失败抛出由付款链置 failed 重试兜底 - String stageResult = this.pushExeStageUpdate(payment); - if (StringUtil.isNotBlank(stageResult)) { - if (result.length() > 0) { - result.append(";"); - } - result.append(stageResult); + // ② 金蝶实际付款门禁:履约计划与履约记录均待金蝶实际付款后推送(需求口径:金蝶付款成功后再推给合同系统)—— + // 审批通过仅本地累加计划金额置待同步,此处不重推;未付款时整体照常回写, + // 付款回写推进(applyPayApplyResult)重新置待同步后由扫描任务触发本推送 + if (payment.getPaidAmount() == null || payment.getPaidAmount().signum() <= 0) { + String message = "金蝶未实际付款,待付款后推送履约计划与履约记录"; + markSynced(payment, message); + return message; } - // ③ 履约记录:审批通过新建 C108-1 / 实际付款推进更新 C108-2(一期暂不对接默认关) + // ③ 履约计划重推(C103-9 行级更新):付款审批通过累加款项金额置 row_op=update 的计划行, + // 凭已存合规阶段ID 重推;推送失败抛出由付款链置 failed 重试兜底 + if (hcsProperties.getExeStage().isSyncEnabled()) { + String stageResult = this.pushExeStageUpdate(payment); + if (StringUtil.isNotBlank(stageResult)) { + if (result.length() > 0) { + result.append(";"); + } + result.append(stageResult); + } + } + // ④ 履约记录:首次回写实际付款走 C108-1 新建,分期付款推进(paidAmount 变化置待同步)走 C108-2 更新 if (!hcsProperties.getPerfRecord().isSyncEnabled()) { markSynced(payment, result.toString()); return result.toString(); } - if (result.length() > 0) { - result.append(";"); - } result.append(hcsPerfRecordSyncSupport.pushPerfRecord(payment)); markSynced(payment, result.toString()); return result.toString(); @@ -143,22 +152,6 @@ public class HcsPaymentSyncSupport { return "履约规则无待同步行".equals(stageResult) ? "" : stageResult; } - /** - * 特批通过后补推履约数据(C103-9 计划重推 + C108 履约记录):补推失败置 failed 留待人工重推兜底, - * 不阻断特批结果落库与金蝶补推 - * - * @param payment 付款申请单(hcs_spec_status=approved) - */ - private void pushAfterSpecApproved(PaymentApplication payment) { - try { - this.pushPayment(payment); - } catch (Exception exception) { - String message = StringUtil.isBlank(exception.getMessage()) ? "履约数据推送异常" : exception.getMessage(); - markFailed(payment, message); - log.error("付款特批通过后履约数据推送失败:paymentNo={},{}", payment.getPaymentNo(), message, exception); - } - } - /** * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口) * @@ -207,10 +200,7 @@ public class HcsPaymentSyncSupport { syncPaymentOnSpecResult(payment, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result)); payment.setHcsSpecStatus(result); paymentApplicationMapper.updateById(payment); - // 特批通过后补推履约计划与履约记录(驳回不产生履约数据),失败不阻断后续金蝶补推 - if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result)) { - this.pushAfterSpecApproved(payment); - } + // 特批通过后不推履约数据:履约计划与履约记录统一在各业务环节(付款=金蝶实际付款后)推送 publishKingdeeAutoSyncOnSpecApproved(payment, result); log.info("HCS特批结果人工确认:paymentNo={},specFdId={},结果={}", payment.getPaymentNo(), payment.getHcsSpecFdId(), result); return "特批结果已确认:" + result; @@ -219,7 +209,7 @@ public class HcsPaymentSyncSupport { /** * 付款特批结果接收处理(合规 paymentLegal/result 回调通知特批审批结果): * sourceSystem 须与 third-party.hcs.src-system 配置一致(不符抛出"来源系统标识错误"异常), - * requestId=TMS 付款申请ID、status=APPROVED/REJECTED; + * requestId=合规特批流程ID(hcs_spec_fd_id,C106-3 回存值)、status=APPROVED/REJECTED; * 特批结果联动付款审批状态:approved 恢复"审批通过"放行(后续金蝶推送放行), * rejected 联动付款审批驳回(returned),走"重新编辑→提交→审批→重新特批"闭环 * @@ -250,11 +240,13 @@ public class HcsPaymentSyncSupport { } syncPaymentOnSpecResult(payment, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status)); payment.setHcsSpecStatus(status); - paymentApplicationMapper.updateById(payment); - // 特批通过后补推履约计划与履约记录(驳回不产生履约数据),失败不阻断后续金蝶补推 - if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status)) { - this.pushAfterSpecApproved(payment); + // 特批通过回传特批结果附件(fileUrl 值=合规附件ID):落库供 C103-5 下载与金蝶附件联带; + // 回调无附件时保留旧值(shortcut: 驳回重批期间旧附件仍展示,重批回调会覆盖,金蝶侧特批中门禁不会误传) + if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status) && StringUtil.isNotBlank(param.getFileUrl())) { + payment.setHcsSpecAttachFdId(param.getFileUrl().trim()); } + paymentApplicationMapper.updateById(payment); + // 特批通过后不推履约数据:履约计划与履约记录统一在各业务环节(付款=金蝶实际付款后)推送 publishKingdeeAutoSyncOnSpecApproved(payment, status); log.info("付款特批结果推送处理完成:paymentNo={},结果={},reason={}", payment.getPaymentNo(), status, param.getReason()); return "特批结果已处理:" + status; @@ -288,17 +280,16 @@ public class HcsPaymentSyncSupport { } /** - * 按 TMS 付款申请ID 定位付款申请(合规推送 requestId) + * 按合规特批流程ID定位付款申请(合规推送 requestId=hcs_spec_fd_id,即 C106-3 返回并回存的流程 fdId; + * 与合同链路 locateContractByFdId 同口径,C106-3 发起报文未传 TMS 付款申请ID,合规无法回传数字ID) */ private PaymentApplication locatePayment(String requestId) { if (StringUtil.isBlank(requestId)) { return null; } - try { - return paymentApplicationMapper.selectById(Long.valueOf(requestId.trim())); - } catch (NumberFormatException exception) { - return null; - } + return paymentApplicationMapper.selectOne(Wrappers.lambdaQuery() + .eq(PaymentApplication::getHcsSpecFdId, requestId.trim()) + .last("limit 1")); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordAddSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordAddSupport.java new file mode 100644 index 00000000..ebd1101b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordAddSupport.java @@ -0,0 +1,134 @@ +/** + * 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.support.hcs; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.hcs.config.HcsProperties; +import org.springblade.hcs.dto.HcsFormSaveVO; +import org.springblade.hcs.dto.HcsMechanismsDTO; +import org.springblade.hcs.dto.HcsPerfRecordFormDataDTO; +import org.springblade.hcs.dto.HcsPerfRecordPropsDTO; +import org.springblade.hcs.dto.HcsPerfRecordSaveDTO; +import org.springblade.hcs.dto.HcsResult; +import org.springblade.hcs.dto.HcsUserRefVO; +import org.springblade.hcs.exception.HcsException; +import org.springblade.hcs.feign.IHcsClient; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.HcsContractExeStage; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.List; + +/** + * HCS 履约记录通用新建支撑组件(C108-1,收款/开票/收票场景共用) + *

+ * 付款申请场景因含 C108-2 实际金额推进与附件转传,仍走 {@link HcsPerfRecordSyncSupport}; + * 本组件收敛其余场景的 C108-1 组装与推送:申请金额=实际金额、履约计划id 取已同步履约计划行合规阶段ID(必填)、 + * 机制数据传空列表(无附件)、履约记录ID 由调用方按各自粒度持久化。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsPerfRecordAddSupport { + + private final IHcsClient hcsClient; + private final HcsProperties hcsProperties; + + /** + * C108-1 新建履约记录(通用) + * + * @param contractManage 合同(须已推送合规,hcsFdId 必有) + * @param stageRow 已同步履约计划行(fd_plan_id 必填,无已同步计划行时抛出由调用方重试) + * @param applyNo 申请编号(收款=通知单号 / 开票=发票号 / 收票=发票号) + * @param amount 申请金额(=实际金额) + * @param perfDateMillis 履约日期(毫秒时间戳:收款=交易日期 / 开票=发票完成开票日期 / 收票=发票日期) + * @param planType 履约类型:payment 付款 / collection 收款 + * @param jdDocNo 金蝶单据号(溯源) + * @param businessId TMS 业务ID(溯源) + * @param title 履约记录标题 + * @return 推送结果描述 + */ + public String pushPerfRecordAdd(ContractManage contractManage, HcsContractExeStage stageRow, String applyNo, + BigDecimal amount, Long perfDateMillis, String planType, + String jdDocNo, Long businessId, String title) { + // 【必填】fd_plan_id 履约计划id:无已同步计划行时无值可传,抛出由调用方置重试(合同链路首推后自愈) + if (stageRow == null || StringUtil.isBlank(stageRow.getHcsStageFdId())) { + throw new HcsException("C108 履约记录未匹配到已同步履约计划行,fd_plan_id 必传:applyNo=" + applyNo); + } + if (StringUtil.isBlank(contractManage.getHcsFdId())) { + throw new HcsException("C108 履约记录推送失败,合同尚未推送合规:contractNo=" + contractManage.getContractNo()); + } + // 表单模板ID(配置 perf-record.xform-id,环境值 §9-U9,未配置不传) + String xformId = StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId(); + HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO(); + // 申请金额 / 实际金额:接口按字符串传值 + props.setFdApplyAmount(amount == null ? null : amount.toPlainString()); + props.setFdFactAmount(amount == null ? null : amount.toPlainString()); + // 【必填】申请编号 + props.setFdApplyNo(applyNo); + // 【必填】履约日期(毫秒时间戳) + props.setFdPlanDate(perfDateMillis); + // 履约类型(原文拼写 fd_paln_type 必须沿用) + props.setFdPalnType(planType); + // 性质:取关联履约计划行的款项性质 + props.setFdProperty(stageRow.getFundNature()); + // 履约计划id / 来源履约计划项ID:履约计划行合规阶段ID(HCS 只认自己的阶段ID) + props.setFdId(stageRow.getHcsStageFdId()); + props.setFdPlanId(stageRow.getHcsStageFdId()); + // 金蝶单据号(溯源用) + props.setFdJdDocNo(jdDocNo); + // 【必填】合同ID:HCS 合同ID + props.setFdContractId(contractManage.getHcsFdId()); + // 业务ID / 创建时间(溯源) + props.setFdBusinessId(businessId == null ? null : String.valueOf(businessId)); + props.setFdCreateTime(System.currentTimeMillis()); + props.setFdXformId(xformId); + HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO(); + formData.setFdXformId(xformId); + formData.setFdDocSubject(title); + formData.setDynamicProps(props); + // 机制数据(C108-1 必填):无附件传空列表 + HcsMechanismsDTO mechanisms = new HcsMechanismsDTO(); + mechanisms.setAttachment(List.of()); + HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO(); + param.setCurUser(HcsUserRefVO.ofLoginName(hcsProperties.getOperatorLoginName())); + param.setFormData(formData); + param.setMechanisms(mechanisms); + log.info("HCS履约记录新建开始:title={},applyNo={},金额={}", title, applyNo, amount); + HcsResult result = hcsClient.perfRecordAdd(param); + if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) { + log.error("HCS履约记录新建失败响应:{},title={},applyNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), title, applyNo); + throw new HcsException("C108-1 履约记录新建失败:" + (result == null ? "响应为空" : result.getMsg())); + } + log.info("HCS履约记录新建成功:title={},applyNo={},perfFdId={}", title, applyNo, result.getData().getFdId()); + return result.getData().getFdId(); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java index 7e7aa5a0..9d2c3a32 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java @@ -58,7 +58,7 @@ import java.util.List; * 付款申请为主线:审批通过 → C108-1 新增(返回 fdId 回填 hcs_perf_fd_id); * 实际付款推进(paidAmount 变化置待同步)→ C108-2 更新(formData.fdId=hcs_perf_fd_id 必填,响应无 data)。 * 关联履约阶段:按合同+付款类型匹配已同步的履约规则行(付款/收款执行与计划阶段对应), - * 无关联则不传 fd_id(§5.6)。 + * fd_plan_id(履约计划id)接口必填,无已同步计划行时抛出重试(合同链路首推后自愈)。 * * @author Chill */ @@ -149,6 +149,10 @@ public class HcsPerfRecordSyncSupport { private HcsPerfRecordFormDataDTO buildFormData(PaymentApplication payment) { ContractManage contractManage = payment.getContractId() == null ? null : contractManageMapper.selectById(payment.getContractId()); HcsContractExeStage stageRow = findPaymentStageRow(payment); + // 【必填】fd_plan_id 履约计划id:无已同步计划行时无值可传,抛出由付款链置 failed 重试(合同链路首推后下轮自愈) + if (stageRow == null || StringUtil.isBlank(stageRow.getHcsStageFdId())) { + throw new HcsException("C108 履约记录未匹配到已同步履约计划行,fd_plan_id 必传:paymentNo=" + payment.getPaymentNo()); + } HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO(); HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO(); // 申请金额:appliedAmount(接口按字符串传值) @@ -159,12 +163,15 @@ public class HcsPerfRecordSyncSupport { props.setFdApplyNo(payment.getPaymentNo()); // 【必填】计划日期:applyDate(毫秒时间戳) props.setFdPlanDate(toMillis(payment.getApplyDate())); - // 计划类型:paymentType(原文拼写 fd_paln_type 必须沿用) - props.setFdPalnType(payment.getPaymentType()); - // 性质:款项性质取关联履约规则行的款项性质(无关联不传) - props.setFdProperty(stageRow == null ? null : stageRow.getFundNature()); - // 来源履约计划项ID:关联履约阶段 hcs_stage_fd_id(无关联则不传,§5.6) - props.setFdId(stageRow == null ? null : stageRow.getHcsStageFdId()); + // 计划类型:履约记录类型按场景传中文「付款」(原文拼写 fd_paln_type 必须沿用) + props.setFdPalnType(HcsSyncConstant.PERF_TYPE_PAYMENT); + // 性质:款项性质取关联履约计划行的款项性质 + props.setFdProperty(stageRow.getFundNature()); + // 履约计划id / 来源履约计划项ID:履约计划行合规阶段ID(HCS 只认自己的阶段ID) + props.setFdId(stageRow.getHcsStageFdId()); + props.setFdPlanId(stageRow.getHcsStageFdId()); + // 金蝶单据号:付款推送金蝶返回的单号(溯源用) + props.setFdJdDocNo(payment.getKingdeeBillNo()); // 【必填】合同ID:HCS 合同ID(合同未推送时为空,联调确认兜底口径) props.setFdContractId(contractManage == null ? null : contractManage.getHcsFdId()); // 业务ID:TMS 付款申请单主键(方便溯源,v4 新增) @@ -176,6 +183,8 @@ public class HcsPerfRecordSyncSupport { props.setFdXformId(xformId); // 【必填】表单模板ID(formData 顶层 fd_xform_id,v4 必填) formData.setFdXformId(xformId); + // 标题:付款履约-付款申请单号(参照结算付款特批标题风格) + formData.setFdDocSubject("付款履约-" + payment.getPaymentNo()); formData.setDynamicProps(props); return formData; } @@ -194,8 +203,9 @@ public class HcsPerfRecordSyncSupport { } HcsMechanismsDTO mechanisms = new HcsMechanismsDTO(); mechanisms.setAttachment(attachmentRefs.stream().map(ref -> { - // 附件ID(C103-2 转传返回) + // 附件ID(C103-2 转传返回);C108-1 为发起类接口,绑定类型固定 ADD ref.setFdEntityKey(PERF_ATTACH_ENTITY_KEY); + ref.setFdBindType("ADD"); return ref; }).toList()); return mechanisms; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReceiptPerfSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReceiptPerfSyncSupport.java new file mode 100644 index 00000000..13d2bddc --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReceiptPerfSyncSupport.java @@ -0,0 +1,211 @@ +/** + * 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.support.hcs; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.hcs.config.HcsProperties; +import org.springblade.hcs.exception.HcsException; +import org.springblade.transport.constant.HcsSyncConstant; +import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.FormalSettlementMapper; +import org.springblade.transport.mapper.InvoiceReceiptMapper; +import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.HcsContractExeStage; +import org.springblade.transport.pojo.entity.InvoiceReceipt; +import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement; +import org.springblade.transport.service.IContractExeStageService; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * HCS 履约收票记录同步支撑组件(C108-1,需求:收票单每条结算信息对应一条履约收票记录) + *

+ * 粒度:收票登记结算单分摊行(blade_invoice_receipt_settlement)对应一条履约收票记录; + * 触发:收票单推送合规(C109)成功后,随收票单扫描任务(HcsPushTask invoice-receipt)推送, + * 扫描范围限定所属收票单 hcs_sync_status=synced(C109 已推送),失败下轮自动补偿; + * 履约日期为发票日期(收票单 invoice_date),履约类型「收票」(关联合同付款履约计划行); + * 合同经应付结算链路回溯(结算分摊行 → 正式结算单合同); + * 幂等凭 hcs_perf_fd_id(非空跳过),失败置重试计数与原因由下轮扫描自动补偿。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsReceiptPerfSyncSupport { + + private final HcsProperties hcsProperties; + private final InvoiceReceiptSettlementMapper invoiceReceiptSettlementMapper; + private final InvoiceReceiptMapper invoiceReceiptMapper; + private final FormalSettlementMapper formalSettlementMapper; + private final ContractManageMapper contractManageMapper; + private final IContractExeStageService contractExeStageService; + private final HcsPerfRecordAddSupport hcsPerfRecordAddSupport; + + /** + * 扫描并推送待同步的履约收票记录(每条结算信息一条 C108-1),返回结果汇总 + * + * @return 汇总描述 + */ + public String pushUnsyncedReceiptPerfRecords() { + if (!hcsProperties.getPerfRecord().isSyncEnabled()) { + return "履约记录同步开关未开启"; + } + int maxRetry = hcsProperties.getPush().getMaxRetry(); + // 待推送结算信息:未建过履约记录且重试未超限 + List settlementList = invoiceReceiptSettlementMapper.selectList( + Wrappers.lambdaQuery() + .isNull(InvoiceReceiptSettlement::getHcsPerfFdId) + .and(wrapper -> wrapper.isNull(InvoiceReceiptSettlement::getHcsPerfRetryCount) + .or().lt(InvoiceReceiptSettlement::getHcsPerfRetryCount, maxRetry)) + .last("limit " + hcsProperties.getPush().getBatchSize())); + if (settlementList.isEmpty()) { + return "履约收票记录:无待推送结算信息"; + } + // 批量装配上下文(收票单/结算单/合同/计划行,一次 IN 查询避免逐行 N+1) + Map receiptById = loadReceipts(settlementList); + Map settlementById = loadSettlements(settlementList); + int successCount = 0; + int failCount = 0; + for (InvoiceReceiptSettlement settlementLine : settlementList) { + try { + pushOneSettlementLine(settlementLine, receiptById.get(settlementLine.getInvoiceReceiptId()), + settlementById.get(settlementLine.getFormalSettlementId())); + successCount++; + } catch (Exception exception) { + markFailed(settlementLine, StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage()); + failCount++; + } + } + String summary = String.format("履约收票记录:扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", + settlementList.size(), successCount, failCount, maxRetry); + if (failCount > 0) { + log.warn("HCS履约收票记录推送存在失败:{}", summary); + } + return summary; + } + + /** + * 推送单条结算信息的履约收票记录 + */ + private void pushOneSettlementLine(InvoiceReceiptSettlement settlementLine, InvoiceReceipt receipt, FormalSettlement settlement) { + if (receipt == null || !"approved".equals(receipt.getApprovalStatus())) { + throw new HcsException("收票单不存在或未审批通过:invoiceReceiptId=" + settlementLine.getInvoiceReceiptId()); + } + // C109 收票单须已推送合规(履约记录引用合规合同与计划行) + if (!HcsSyncConstant.HCS_STATUS_SYNCED.equals(receipt.getHcsSyncStatus())) { + throw new HcsException("收票单尚未推送合规(invoiceNo=" + receipt.getInvoiceNo() + "),待 C109 推送成功后重试"); + } + if (settlement == null || settlement.getContractId() == null) { + throw new HcsException("结算分摊行回溯不到合同(settlementId=" + settlementLine.getFormalSettlementId() + ")"); + } + ContractManage contractManage = contractManageMapper.selectById(settlement.getContractId()); + if (contractManage == null) { + throw new HcsException("合同不存在:contractId=" + settlement.getContractId()); + } + // 关联合同的付款履约计划行(fd_plan_id 必填,无已同步计划行时通用组件抛出重试,合同链路首推后自愈) + HcsContractExeStage stageRow = contractExeStageService.findPaymentStageRow( + contractManage.getId(), HcsSyncConstant.FUND_NATURE_PROGRESS); + // 金额:分摊到该结算单的收票金额 + BigDecimal amount = settlementLine.getAllocatedInvoiceAmount() == null + ? settlementLine.getSettlementAmount() : settlementLine.getAllocatedInvoiceAmount(); + if (amount == null) { + throw new HcsException("结算分摊行金额为空(id=" + settlementLine.getId() + ")"); + } + // 申请编号:发票号-结算单号(同一发票分摊多结算单时逐条可区分) + String settlementNo = StringUtil.isBlank(settlementLine.getFormalSettlementNo()) ? settlement.getFormalSettlementNo() : settlementLine.getFormalSettlementNo(); + String applyNo = receipt.getInvoiceNo() + "-" + (settlementNo == null ? "" : settlementNo); + String perfFdId = hcsPerfRecordAddSupport.pushPerfRecordAdd(contractManage, stageRow, + applyNo, amount, toMillis(receipt.getInvoiceDate()), + HcsSyncConstant.PERF_TYPE_INVOICE_RECEIPT, receipt.getKingdeeReceivedInvoiceNo(), settlementLine.getId(), + "收票履约-" + applyNo); + markSynced(settlementLine, perfFdId); + } + + /** + * 批量装配结算信息所属收票单(须审批通过且 C109 已推送) + */ + private Map loadReceipts(List settlementList) { + List receiptIds = settlementList.stream() + .map(InvoiceReceiptSettlement::getInvoiceReceiptId).filter(Objects::nonNull).distinct().toList(); + if (receiptIds.isEmpty()) { + return Map.of(); + } + return invoiceReceiptMapper.selectBatchIds(receiptIds).stream() + .collect(Collectors.toMap(InvoiceReceipt::getId, item -> item, (first, second) -> first)); + } + + /** + * 批量装配结算信息关联的正式结算单 + */ + private Map loadSettlements(List settlementList) { + List settlementIds = settlementList.stream() + .map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + if (settlementIds.isEmpty()) { + return Map.of(); + } + return formalSettlementMapper.selectBatchIds(settlementIds).stream() + .collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first, LinkedHashMap::new)); + } + + /** + * 推送成功回填:履约记录ID 立即持久化(幂等键),清失败原因 + */ + private void markSynced(InvoiceReceiptSettlement settlementLine, String perfFdId) { + settlementLine.setHcsPerfFdId(perfFdId); + settlementLine.setHcsPerfRetryCount(0); + settlementLine.setHcsPerfSyncMsg(null); + invoiceReceiptSettlementMapper.updateById(settlementLine); + log.info("HCS履约收票记录推送成功:settlementId={},perfFdId={}", settlementLine.getId(), perfFdId); + } + + /** + * 推送失败回写:重试次数+1 + 失败原因(下轮扫描自动补偿) + */ + private void markFailed(InvoiceReceiptSettlement settlementLine, String message) { + settlementLine.setHcsPerfRetryCount(settlementLine.getHcsPerfRetryCount() == null ? 1 : settlementLine.getHcsPerfRetryCount() + 1); + settlementLine.setHcsPerfSyncMsg(HcsSyncConstant.truncateMsg(message)); + invoiceReceiptSettlementMapper.updateById(settlementLine); + log.error("HCS履约收票记录推送失败:settlementId={},retryCount={},{}", settlementLine.getId(), settlementLine.getHcsPerfRetryCount(), message); + } + + private Long toMillis(LocalDate localDate) { + return localDate == null ? null : localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java index 6f4de144..d9543a14 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java @@ -42,7 +42,9 @@ import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.mapper.ContractManageMapper; import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.HcsContractExeStage; import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springblade.transport.service.IContractExeStageService; import org.springframework.stereotype.Component; import java.math.BigDecimal; @@ -74,6 +76,7 @@ public class HcsSpecPaySyncSupport { private final HcsProperties hcsProperties; private final ContractManageMapper contractManageMapper; private final PaymentApplicationMapper paymentApplicationMapper; + private final IContractExeStageService contractExeStageService; private final HcsAttachmentSupport hcsAttachmentSupport; /** @@ -132,6 +135,8 @@ public class HcsSpecPaySyncSupport { props.setFdDocSubject("结算付款特批-" + payment.getPaymentNo()); // 来源付款单ID:付款申请单号(§5.5 映射表) props.setFdId(payment.getPaymentNo()); + props.setFdSourceSys(hcsProperties.getSrcSystem()); + // 【必填】付款金额:appliedAmount props.setFdPayAmount(payment.getAppliedAmount()); // 【必填】合计金额:settlementAmount @@ -156,6 +161,8 @@ public class HcsSpecPaySyncSupport { props.setFdDocStatus(hcsProperties.getSpecPay().getDocStatus()); // 特批附件字段标识:固定 fd_spc_approval_attach props.setFdSpcApprovalAttach(SPEC_ATTACH_ENTITY_KEY); + // 【必填】款项日期:传履约计划id(匹配付款计划行合规阶段ID,无关联不传,口径同 C108 履约记录) + props.setFdContractPlan(this.resolveStageFdId(payment)); formData.setDynamicProps(props); // 【必填】表单层单据主题 formData.setFdDocSubject("结算付款特批-" + payment.getPaymentNo()); @@ -168,6 +175,18 @@ public class HcsSpecPaySyncSupport { return param; } + /** + * 匹配合同的付款履约计划行合规阶段ID(无匹配返回 null,DTO NON_NULL 序列化时不传,与 C108 履约记录口径一致) + */ + private String resolveStageFdId(PaymentApplication payment) { + if (payment.getContractId() == null) { + return null; + } + HcsContractExeStage stageRow = this.contractExeStageService.findPaymentStageRow(payment.getContractId(), + HcsSyncConstant.fundNatureOfPayment(payment.getPaymentType())); + return stageRow == null ? null : stageRow.getHcsStageFdId(); + } + /** * 特批附件挂载(附件转传开关关闭或无附件时返回 null) */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerRiskRefreshTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerRiskRefreshTask.java new file mode 100644 index 00000000..79522200 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerRiskRefreshTask.java @@ -0,0 +1,72 @@ +/** + * 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.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.hcs.config.HcsProperties; +import org.springblade.transport.support.hcs.HcsCreditCheckSupport; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Component; + +/** + * 客商风险每日定时刷新任务(C101-1) + *

+ * 合同期限范围内的生效合同,每日定时查询其甲乙方关联客商的风险等级/黑名单状态并落库 + * (blade_customer_archive.hcs_risk_level/kingdee_black_status/hcs_risk_time), + * 作为客商档案列表「客商风险」列展示与筛选的数据源;查询逐次留痕 blade_hcs_check_log, + * 单个失败记录失败原因日志备查。 + * 业务逻辑收敛在 {@link HcsCreditCheckSupport#refreshCustomerRiskForActiveContracts()},便于手动触发联调。 + *

+ * 调度配置(Nacos thirdParty.hcs.risk.*):refreshEnabled 开关、refreshCron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RefreshScope +@RequiredArgsConstructor +public class CustomerRiskRefreshTask { + + private final HcsCreditCheckSupport hcsCreditCheckSupport; + private final HcsProperties hcsProperties; + + /** 客商风险每日刷新调度,默认每天 02:30 执行(错开金蝶客商同步 02:00) */ + @Scheduled(cron = "${thirdParty.hcs.risk.refreshCron:0 30 2 * * ?}") + public void refreshCustomerRisk() { + if (!hcsProperties.getRisk().isRefreshEnabled()) { + return; + } + try { + String summary = hcsCreditCheckSupport.refreshCustomerRiskForActiveContracts(); + log.info("定时刷新客商风险:{}", summary); + } catch (Exception exception) { + log.error("定时刷新客商风险异常", exception); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java index 874fc41f..70bdaadf 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java @@ -42,12 +42,16 @@ import org.springblade.transport.pojo.entity.InvoiceApplication; import org.springblade.transport.pojo.entity.InvoiceReceipt; import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.service.IContractChangeService; +import org.springblade.transport.service.IProjectChangeService; import org.springblade.transport.support.hcs.HcsContractSpecSyncSupport; import org.springblade.transport.support.hcs.HcsContractSyncSupport; +import org.springblade.transport.support.hcs.HcsInvoicePerfSyncSupport; import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport; import org.springblade.transport.support.hcs.HcsPaymentSyncSupport; import org.springblade.transport.support.hcs.HcsProjectSyncSupport; import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport; +import org.springblade.transport.support.hcs.HcsReceiptPerfSyncSupport; import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -108,6 +112,10 @@ public class HcsPushTask { private final HcsPaymentSyncSupport hcsPaymentSyncSupport; private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport; private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport; + private final HcsInvoicePerfSyncSupport hcsInvoicePerfSyncSupport; + private final HcsReceiptPerfSyncSupport hcsReceiptPerfSyncSupport; + private final IContractChangeService contractChangeService; + private final IProjectChangeService projectChangeService; private final BladeRedis bladeRedis; /** @@ -144,19 +152,37 @@ public class HcsPushTask { } /** - * 开票登记(C111)定时推送:扫描审批通过且待同步/待补偿的开票申请(含金蝶开票完成后回填),独立开关与 cron 调度 + * 开票登记(C111)定时推送:扫描审批通过且待同步/待补偿的开票申请(含金蝶开票完成后回填), + * 并推送履约开票记录(C108-1,金蝶回写发票逐张推送),独立开关与 cron 调度 */ @Scheduled(cron = "${thirdParty.hcs.push.invoice-application.cron:0 3/5 * * * ?}") public void pushHcsInvoiceApplication() { - this.runOnce("开票登记", HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION, hcsProperties.getPush().getInvoiceApplication().isSyncEnabled(), this::pushUnsyncedInvoiceApplications); + this.runOnce("开票登记", HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION, hcsProperties.getPush().getInvoiceApplication().isSyncEnabled(), this::syncHcsInvoiceApplications); } /** - * 收票单(C109)定时推送:扫描审批通过且待同步/待补偿的收票单,独立开关与 cron 调度 + * 开票链路组合动作:开票登记(C111)推送 + 履约开票记录(C108-1)推送(定时任务与手动批量入口共用) + */ + public String syncHcsInvoiceApplications() { + String pushSummary = this.pushUnsyncedInvoiceApplications(); + return pushSummary + ";" + hcsInvoicePerfSyncSupport.pushUnsyncedInvoicePerfRecords(); + } + + /** + * 收票单(C109)定时推送:扫描审批通过且待同步/待补偿的收票单,并推送履约收票记录 + * (C108-1,收票单每条结算信息一条,限 C109 已推送),独立开关与 cron 调度 */ @Scheduled(cron = "${thirdParty.hcs.push.invoice-receipt.cron:0 4/5 * * * ?}") public void pushHcsInvoiceReceipt() { - this.runOnce("收票单", HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT, hcsProperties.getPush().getInvoiceReceipt().isSyncEnabled(), this::pushUnsyncedInvoiceReceipts); + this.runOnce("收票单", HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT, hcsProperties.getPush().getInvoiceReceipt().isSyncEnabled(), this::syncHcsInvoiceReceipts); + } + + /** + * 收票链路组合动作:收票单(C109)推送 + 履约收票记录(C108-1)推送(定时任务与手动批量入口共用) + */ + public String syncHcsInvoiceReceipts() { + String pushSummary = this.pushUnsyncedInvoiceReceipts(); + return pushSummary + ";" + hcsReceiptPerfSyncSupport.pushUnsyncedReceiptPerfRecords(); } /** @@ -211,10 +237,13 @@ public class HcsPushTask { for (ProjectApply projectApply : projectList) { try { hcsProjectSyncSupport.pushProject(projectApply); + // 推送成功回写原项目最近一条待同步变更单,闭合变更单同步状态(C102-1 首推或 C102-2 更新均覆盖) + projectChangeService.writeBackSyncStatus(projectApply.getId(), HcsSyncConstant.HCS_STATUS_SYNCED); successCount++; } catch (Exception exception) { String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); hcsProjectSyncSupport.markFailed(projectApply, message); + projectChangeService.writeBackSyncStatus(projectApply.getId(), HcsSyncConstant.HCS_STATUS_FAILED); failCount++; } } @@ -242,10 +271,13 @@ public class HcsPushTask { for (ContractManage contractManage : contractList) { try { hcsContractSyncSupport.pushContract(contractManage); + // 推送成功回写原合同最近一条待同步变更单,闭合变更单同步状态(C103-1 首推或 C103-11 更新均覆盖) + contractChangeService.writeBackSyncStatus(contractManage.getId(), HcsSyncConstant.HCS_STATUS_SYNCED); successCount++; } catch (Exception exception) { String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); hcsContractSyncSupport.markFailed(contractManage, message); + contractChangeService.writeBackSyncStatus(contractManage.getId(), HcsSyncConstant.HCS_STATUS_FAILED); failCount++; } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeWriteOffResultTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeWriteOffResultTask.java new file mode 100644 index 00000000..dd4a0e22 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeWriteOffResultTask.java @@ -0,0 +1,63 @@ +/** + * 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.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.service.IFormalSettlementService; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 金蝶冲销状态定时回写任务 + *

+ * A102-4/A302-4 批量查询已同步结算单对应金蝶旧应付/应收单的 hadwrittenoff(部分冲销视同已冲销), + * 回写结算单冲销状态,用于结算单列表展示金蝶旧单是否已冲销。 + * 业务逻辑收敛在服务方法(分布式锁 + 分批查询 + 批量落库),便于手动触发联调。 + *

+ * 调度配置(Nacos thirdParty.bfi.writeOffResult.*):syncEnabled 开关、cron 表达式、queryBatchSize 分批大小。 + * + * @author Chill + */ +@Slf4j +@Component +@RefreshScope +@RequiredArgsConstructor +public class KingdeeWriteOffResultTask { + + private final IFormalSettlementService formalSettlementService; + private final BfiProperties bfiProperties; + + /** 金蝶冲销状态回写调度,默认每10分钟执行一次(分钟位与付款结果回写错峰) */ + @Scheduled(cron = "${thirdParty.bfi.writeOffResult.cron:0 4/10 * * * ?}") + public void syncKingdeeWriteOffResult() { + if (!bfiProperties.getWriteOffResult().isSyncEnabled()) { + return; + } + try { + String summary = formalSettlementService.syncKingdeeWriteOffResult(); + log.info("定时回写金蝶冲销状态:{}", summary); + } catch (Exception exception) { + log.error("定时回写金蝶冲销状态异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java deleted file mode 100644 index ec681cf7..00000000 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java +++ /dev/null @@ -1,67 +0,0 @@ -/** - * 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 - * is strictly prohibited. - *

- * 3. Licensees may copyright their own code but cannot use segments - * from this software for their own commercial purposes. - *

- * Using this software signifies agreement to this license agreement and terms of use, - * and you expressly agree to be bound by the terms of use of this software. - *

- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED - * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS - * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. - *

- * Author: Chill Zhuang (bladejava@qq.com) - */ -package org.springblade.transport.task; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springblade.transport.config.BfiProperties; -import org.springblade.transport.service.IReceiptFlowService; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) - *

- * 认领动作只在本地登记,本任务扫描「认领完成且未推送过」的收款流水自动推送金蝶, - * 与收款流水页「推送金蝶」手动按钮共用同一服务方法(推送前按认领人重查 A301-2 最新公告对账)。 - * 单条流水推送失败独立消化、可重试,不影响同批其他流水。 - *

- * 调度配置(Nacos thirdParty.bfi.receiptClaimPush.*):syncEnabled 开关、cron 表达式。 - * - * @author Chill - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class ReceiptClaimPushTask { - - private final IReceiptFlowService receiptFlowService; - private final BfiProperties bfiProperties; - - /** 收款认领推送调度,默认每10分钟执行一次 */ - @Scheduled(cron = "${thirdParty.bfi.receiptClaimPush.cron:0 4/10 * * * ?}") - public void pushClaimToKingdee() { - if (!bfiProperties.getReceiptClaimPush().isSyncEnabled()) { - return; - } - try { - String summary = receiptFlowService.pushClaimByTask(); - log.info("定时推送收款认领:{}", summary); - } catch (Exception exception) { - log.error("定时推送收款认领异常", exception); - } - } -} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/CommonCargoWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/CommonCargoWrapper.java index c0841d37..d026d57b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/CommonCargoWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/CommonCargoWrapper.java @@ -23,12 +23,11 @@ package org.springblade.transport.wrapper; import org.springblade.core.mp.support.BaseEntityWrapper; -import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.BeanUtil; -import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; import org.springblade.transport.pojo.entity.CommonCargo; import org.springblade.transport.pojo.vo.CommonCargoVO; +import org.springblade.transport.support.TransportBusinessSupport; import java.math.BigDecimal; import java.util.Objects; @@ -52,8 +51,7 @@ public class CommonCargoWrapper extends BaseEntityWrapper "处理失败"; default -> "未推送"; }); - // 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值) - BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount()); + // 剩余可认领金额 = 收款金额 − 累计认领金额(认领只登记本地,额度由本地累计认领控制) BigDecimal receiptAmount = money(entity.getReceiptAmount()); - BigDecimal claimCeiling = unclaimedAmount; - if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) { - claimCeiling = unclaimedAmount.min(receiptAmount); - } BigDecimal claimedAmount = money(entity.getClaimedAmount()); - vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO)); + vo.setRemainingAmount(receiptAmount.subtract(claimedAmount).max(BigDecimal.ZERO)); vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); return vo; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ShippingTemplateWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ShippingTemplateWrapper.java index 0301b7d4..a25de296 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ShippingTemplateWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ShippingTemplateWrapper.java @@ -23,12 +23,11 @@ package org.springblade.transport.wrapper; import org.springblade.core.mp.support.BaseEntityWrapper; -import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.BeanUtil; -import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; import org.springblade.transport.pojo.entity.ShippingTemplate; import org.springblade.transport.pojo.vo.ShippingTemplateVO; +import org.springblade.transport.support.TransportBusinessSupport; import java.util.Objects; @@ -48,8 +47,7 @@ public class ShippingTemplateWrapper extends BaseEntityWrapper { waybillVO.setCreateUserName(UserCache.getUserRealName(waybill.getCreateUser())); waybillVO.setUpdateUserName(UserCache.getUserRealName(waybill.getUpdateUser())); waybillVO.setDataSource(TransportBusinessSupport.normalizeWaybillDataSource(waybill.getDataSource())); - Long currentDeptId = Func.firstLong(AuthUtil.getDeptId()); - waybillVO.setReadonly(currentDeptId != null && !Objects.equals(waybill.getDeptId(), currentDeptId)); + waybillVO.setReadonly(TransportBusinessSupport.isDeptReadonly(waybill.getDeptId(), TransportBusinessSupport.MENU_WAYBILL_MANAGE)); String displayStatus = WaybillProcessSupport.normalizeBusinessStatus( waybill.getBusinessStatus(), waybill.getProcessJson(), waybill.getDriverAcceptStatus()); waybillVO.setBusinessStatus(displayStatus); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java index 7f425f84..2834ff42 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java @@ -19,6 +19,7 @@ import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IMaintenanceRecordService; import org.springblade.transport.service.IWaybillService; import java.lang.reflect.Field; @@ -86,7 +87,8 @@ class ExceptionDisposalDetailPayloadTest { loadingManageMapper = mock(LoadingManageMapper.class); service = new ExceptionDisposalServiceImpl( followRecordMapper, waybillService, mock(IDriverWaybillService.class), - mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper); + mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper, + mock(IMaintenanceRecordService.class)); inject("baseMapper", disposalMapper); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java index a8c9d84b..80688ef5 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -20,6 +20,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IMaintenanceRecordService; import org.springblade.transport.service.IWaybillService; import java.lang.reflect.Field; @@ -73,7 +74,8 @@ class ExceptionDisposalListQueryTest { waybillService, mock(IDriverWaybillService.class), scopeSupport, - loadingManageMapper); + loadingManageMapper, + mock(IMaintenanceRecordService.class)); inject("baseMapper", disposalMapper); when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java index 706b5d37..667a1168 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java @@ -21,6 +21,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IMaintenanceRecordService; import org.springblade.transport.service.IWaybillService; import java.lang.reflect.Field; @@ -69,7 +70,7 @@ class ExceptionDisposalRejectRevokeTest { service = new ExceptionDisposalServiceImpl( followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class), mock(org.springblade.transport.support.MpUserDataScopeSupport.class), - mock(LoadingManageMapper.class)); + mock(LoadingManageMapper.class), mock(IMaintenanceRecordService.class)); inject("baseMapper", disposalMapper); when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1); when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java index 04ba435c..4328e1a9 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -19,6 +19,7 @@ import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IMaintenanceRecordService; import org.springblade.transport.service.IWaybillService; import java.lang.reflect.Field; @@ -74,7 +75,7 @@ class ExceptionDisposalReportGateTest { service = new ExceptionDisposalServiceImpl( mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService, mock(org.springblade.transport.support.MpUserDataScopeSupport.class), - mock(LoadingManageMapper.class)); + mock(LoadingManageMapper.class), mock(IMaintenanceRecordService.class)); inject("baseMapper", disposalMapper); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java index 7c9c0b6b..26067a72 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java @@ -23,6 +23,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IMaintenanceRecordService; import org.springblade.transport.service.IWaybillService; import java.lang.reflect.Field; @@ -88,7 +89,8 @@ class ExceptionDisposalRouteAdjustTest { loadingManageMapper = mock(LoadingManageMapper.class); service = new ExceptionDisposalServiceImpl( followRecordMapper, waybillService, mock(IDriverWaybillService.class), - mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper); + mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper, + mock(IMaintenanceRecordService.class)); inject("baseMapper", disposalMapper); when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1); when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1); diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java index 535935c7..72396d90 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java @@ -27,14 +27,20 @@ package org.springblade.thirdparty.bfi.config; import feign.Request; import feign.RequestInterceptor; +import feign.codec.Decoder; import feign.codec.Encoder; import feign.form.spring.SpringFormEncoder; import org.springblade.thirdparty.bfi.interceptor.BfiRequestInterceptor; import org.springframework.beans.factory.ObjectFactory; import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.support.SpringDecoder; import org.springframework.cloud.openfeign.support.SpringEncoder; import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import java.util.Collections; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -71,4 +77,26 @@ public class BfiFeignClientConfig { public Encoder feignFormEncoder(ObjectFactory messageConverters) { return new SpringFormEncoder(new SpringEncoder(messageConverters)); } + + /** + * BFI 专用解码器:金蝶部分接口成功响应缺失 Content-Type 头(kong 网关 chunked 转发丢失, + * 如 cas_paybill/query),默认解码器按 application/octet-stream 匹配不到 JSON 转换器导致 + * "no suitable HttpMessageConverter" 解码失败。缺失 Content-Type 时强制按 JSON 解码, + * 其余响应保持默认解码逻辑(含二进制下载接口) + */ + @Bean + public Decoder bfiDecoder(ObjectFactory messageConverters) { + SpringDecoder defaultDecoder = new SpringDecoder(messageConverters); + MappingJackson2HttpMessageConverter jacksonConverter = new MappingJackson2HttpMessageConverter(); + jacksonConverter.setSupportedMediaTypes(List.of(MediaType.APPLICATION_JSON, MediaType.ALL)); + SpringDecoder forceJsonDecoder = new SpringDecoder(() -> new HttpMessageConverters(jacksonConverter)); + return (response, type) -> { + boolean missingContentType = response.headers().keySet().stream() + .noneMatch("content-type"::equalsIgnoreCase); + if (missingContentType) { + return forceJsonDecoder.decode(response, type); + } + return defaultDecoder.decode(response, type); + }; + } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java index 608c39b2..fdecaa0a 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java @@ -250,6 +250,16 @@ public class BfiConstant { */ public static final String BILL_STATUS_RETURNED = "G"; + /** + * 使用状态:可用 + */ + public static final String ENABLE_ENABLED = "1"; + + /** + * 使用状态:禁用 + */ + public static final String ENABLE_DISABLED = "0"; + /** * 结算状态:未结算 */ diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java index 8e321535..dd983387 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java @@ -30,6 +30,7 @@ import org.springblade.thirdparty.bfi.config.BfiFeignClientConfig; import org.springblade.thirdparty.bfi.constant.BfiConstant; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBankQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiBlacklistQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushBodyDTO; @@ -50,6 +51,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; @@ -65,6 +67,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiSupplierVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; import org.springframework.cloud.openfeign.FeignClient; @@ -99,6 +102,8 @@ public interface IBfiClient { String PAY_BILL_QUERY_URL = "${thirdParty.bfi.payBillQueryUrl:/ierp/kapi/v2/awrp/cas/cas_paybill/query}"; String CUSTOMER_QUERY_URL = "${thirdParty.bfi.customerQueryUrl:/ierp/kapi/v2/awrp/basedata/bd_customer/query}"; String CUSTOMER_SAVE_URL = "${thirdParty.bfi.customerSaveUrl:/ierp/kapi/v2/awrp/basedata/bd_customer/save}"; + String SUPPLIER_QUERY_URL = "${thirdParty.bfi.supplierQueryUrl:/ierp/kapi/v2/awrp/basedata/bd_supplier/query}"; + String BEBANK_QUERY_URL = "${thirdParty.bfi.bebankQueryUrl:/ierp/kapi/v2/awrp/basedata/bd_bebank/query}"; String AP_BILL_SAVE_URL = "${thirdParty.bfi.apBillSaveUrl:/ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save}"; String AP_BILL_QUERY_URL = "${thirdParty.bfi.apBillQueryUrl:/ierp/kapi/v2/awrp/ap/ap_finapbill/query}"; String AR_BILL_SAVE_URL = "${thirdParty.bfi.arBillSaveUrl:/ierp/kapi/v2/awrp/ar/ar/ar_finarbill/save}"; @@ -208,6 +213,9 @@ public interface IBfiClient { /** * A103-4 付款处理查询 + *

+ * 注意:开始业务日期的请求参数名以金蝶契约为准,为 {@code beigin_bizdate}(并非 begin_bizdate), + * 传错会被忽略并返回「参数【beigin_bizdate】必填」。 * * @param accessToken 访问令牌,通过 getToken 获取 * @param pageNo 页码,从1开始 @@ -222,7 +230,7 @@ public interface IBfiClient { BfiResultVO> payBillQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestParam("pageNo") Integer pageNo, @RequestParam("pageSize") Integer pageSize, - @RequestParam("begin_bizdate") String beginBizdate, + @RequestParam("beigin_bizdate") String beginBizdate, @RequestParam("end_bizdate") String endBizdate, @RequestParam(value = "billno", required = false) List billno, @RequestParam("org_number") List orgNumber); @@ -266,6 +274,43 @@ public interface IBfiClient { @PostMapping(CUSTOMER_SAVE_URL) BfiResultVO customerSave(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiCustomerSaveBodyDTO param); + /** + * A901-1 供应商查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param enable 使用状态 + * @param status 数据状态 + * @param createorgNumber 创建组织.编码 + * @param societycreditcode 统一社会信用代码(个人填写身份证号或手机号) + * @param idno 身份证号 + * @param modifytimeS 修改时间-开始 + * @param modifytimeE 修改时间-结束 + * @return 供应商分页数据 + */ + @GetMapping(SUPPLIER_QUERY_URL) + BfiResultVO> supplierQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam(value = "enable", required = false) String enable, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "createorg_number", required = false) String createorgNumber, + @RequestParam(value = "societycreditcode", required = false) List societycreditcode, + @RequestParam(value = "idno", required = false) List idno, + @RequestParam(value = "modifytimeS", required = false) String modifytimeS, + @RequestParam(value = "modifytimeE", required = false) String modifytimeE); + + /** + * A905-3 行名行号查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 查询参数 + * @return 行名行号分页数据 + */ + @GetMapping(BEBANK_QUERY_URL) + BfiResultVO> bebankQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @SpringQueryMap BfiBankQueryDTO param); + /** * A102-2 财务应付单保存 * diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBankQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBankQueryDTO.java new file mode 100644 index 00000000..b4c51c6b --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBankQueryDTO.java @@ -0,0 +1,77 @@ +/** + * 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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A905-3 行名行号查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBankQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 名称(行名) + */ + private List name; + /** + * 编码(行号) + */ + private List number; + /** + * 修改时间-开始,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过6个月 + */ + private String modifytimeS; + /** + * 修改时间-结束,格式:yyyy-MM-dd HH:mm:ss + */ + private String modifytimeE; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayBillQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayBillQueryDTO.java index 07a88861..385a2dfa 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayBillQueryDTO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayBillQueryDTO.java @@ -51,7 +51,8 @@ public class BfiPayBillQueryDTO implements Serializable { */ private Integer pageSize; /** - * 开始业务日期,格式:yyyy-MM-dd,限定时间范围不可超过1个月 + * 开始业务日期,格式:yyyy-MM-dd,限定时间范围不可超过1个月; + * 请求参数名为 beigin_bizdate(金蝶契约拼写如此,非 begin_bizdate) */ private String beginBizdate; /** diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiRecBillQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiRecBillQueryDTO.java index 90ed5056..a648c977 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiRecBillQueryDTO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiRecBillQueryDTO.java @@ -38,7 +38,7 @@ import java.util.List; *

* 请求示例: *

{@code
- * GET /ierp/kapi/v2/awrp/cas/cas_recbill/queryRecBill?begin_bizdate=2021-09-08&end_bizdate=2026-09-08&org_number=01.01.20.02.04.01&pageSize=10&pageNo=1
+ * GET /ierp/kapi/v2/awrp/cas/cas_recbill/queryRecBill?begin_bizdate=2021-09-08 00:00:00&end_bizdate=2026-09-08 23:59:59&org_number=01.01.20.02.04.01&pageSize=10&pageNo=1
  * }
* * @author Chill @@ -57,12 +57,13 @@ public class BfiRecBillQueryDTO implements Serializable { */ private Integer pageSize; /** - * 【必填】开始业务日期,格式:yyyy-MM-dd,限定时间范围不可超过1个月 + * 【必填】开始业务日期,格式:yyyy-MM-dd HH:mm:ss,窗口天数由调用方控制; + * 传纯日期 yyyy-MM-dd 会被金蝶拒绝(bos.filterSqlBuilder 时间格式错误) */ @JsonProperty("begin_bizdate") private String beginBizdate; /** - * 【必填】结束业务日期,格式:yyyy-MM-dd + * 【必填】结束业务日期,格式:yyyy-MM-dd HH:mm:ss */ @JsonProperty("end_bizdate") private String endBizdate; diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiSupplierQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiSupplierQueryDTO.java new file mode 100644 index 00000000..99d835db --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiSupplierQueryDTO.java @@ -0,0 +1,83 @@ +/** + * 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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A901-1 供应商查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiSupplierQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 创建组织.编码 + */ + @JsonProperty("createorg_number") + private String createorgNumber; + /** + * 修改时间-开始,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过6个月 + */ + private String modifytimeS; + /** + * 修改时间-结束,格式:yyyy-MM-dd HH:mm:ss + */ + private String modifytimeE; + /** + * 统一社会信用代码(个人填写身份证号或手机号) + */ + private List societycreditcode; + /** + * 身份证号 + */ + private List idno; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBankVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBankVO.java new file mode 100644 index 00000000..1f602dd0 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBankVO.java @@ -0,0 +1,109 @@ +/** + * 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.thirdparty.bfi.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A905-3 行名行号查询返回结果 + * + * @author Chill + */ +@Data +public class BfiBankVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 编码(行号) + */ + private String number; + /** + * 名称(行名) + */ + private String name; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; + /** + * 主数据内码 + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long masterid; + /** + * 联行号 + */ + @JsonAlias("union_number") + private String unionNumber; + /** + * Swift Code + */ + @JsonAlias("swift_code") + private String swiftCode; + /** + * 银行类别名称 + */ + private String bankcatename; + /** + * 省份(银企) + */ + private String provincetxt; + /** + * 城市(银企) + */ + private String citytxt; + /** + * 分行代码 + */ + private String branchcode; + /** + * 疑似过期[Y:是, N:否] + */ + private String isoverdue; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiSupplierBankVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiSupplierBankVO.java new file mode 100644 index 00000000..852bab9a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiSupplierBankVO.java @@ -0,0 +1,114 @@ +/** + * 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.thirdparty.bfi.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A901-1 供应商查询 银行信息分录 + * + * @author Chill + */ +@Data +public class BfiSupplierBankVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 银行账号 + */ + private String bankaccount; + /** + * 账户名称 + */ + private String accountname; + /** + * 默认 + */ + @JsonProperty("isdefault_bank") + private Boolean isdefaultBank; + /** + * 银行账户类型 + */ + private String bankaccounttype; + /** + * 账户性质[0:公司, 1:个人] + */ + @JsonProperty("account_property") + private String accountProperty; + /** + * 默认手续费承担方[1:付款方, 2:收款方] + */ + private String commissionbearer; + /** + * 币种(货币代码) + */ + private Currency currency; + /** + * 开户银行(编码) + */ + private Bank bank; + + /** + * 币种引用 + */ + @Data + public static class Currency implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 货币代码 + */ + private String number; + } + + /** + * 开户银行引用 + */ + @Data + public static class Bank implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 编码(行号) + */ + private String number; + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiSupplierVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiSupplierVO.java new file mode 100644 index 00000000..523b2bbd --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiSupplierVO.java @@ -0,0 +1,181 @@ +/** + * 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.thirdparty.bfi.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A901-1 供应商查询返回结果 + * + * @author Chill + */ +@Data +public class BfiSupplierVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 编码 + */ + private String number; + /** + * 名称 + */ + private String name; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; + /** + * 控制策略[1:逐级分配, 2:自由分配, 5:全局共享, 6:管控范围内共享, 7:私有] + */ + private String ctrlstrategy; + /** + * 简称 + */ + private String simplename; + /** + * 伙伴类型[1:法人企业, 2:非法人企业, 3:非企业单位, 4:个人, 5:个体户, 6:大陆以外的企业] + */ + private String type; + /** + * 统一社会信用代码(个人填写身份证号或手机号) + */ + private String societycreditcode; + /** + * 电子邮箱 + */ + @JsonProperty("postal_code") + private String postalCode; + /** + * 身份证号 + */ + private String idno; + /** + * 供应商分组.编码 + */ + @JsonProperty("group_number") + private String groupNumber; + /** + * 详细地址 + */ + @JsonProperty("bizpartner_address") + private String bizpartnerAddress; + /** + * 联系人 + */ + private String linkman; + /** + * 联系电话 + */ + @JsonProperty("bizpartner_phone") + private String bizpartnerPhone; + /** + * 传真 + */ + @JsonProperty("bizpartner_fax") + private String bizpartnerFax; + /** + * 纳税人识别号 + */ + @JsonProperty("tx_register_no") + private String txRegisterNo; + /** + * 法人代表 + */ + private String artificialperson; + /** + * 注册资本 + */ + private String regcapital; + /** + * 营业期限 + */ + private String businessterm; + /** + * 成立日期 + */ + private String establishdate; + /** + * 经营范围 + */ + private String businessscope; + /** + * 业务职能[1:采购, 2:结算, 3:收款, 4:供货] + */ + private String bizfunction; + /** + * 简拼 + */ + private String simplepinyin; + /** + * 税号 + */ + private String taxno; + /** + * 是否为贸易客商[A:是, B:否] + */ + @JsonProperty("awrp_supfortrade") + private String awrpSupfortrade; + /** + * 数据来源系统[XH:星瀚, WCY:物产云, JCZX:检测中心, NBERP:南北ERP, WCCK:城矿ERP, WY:维云(服务), CMS:合同系统, DTP:数据中台] + */ + @JsonProperty("awrp_datasourcesys") + private String awrpDatasourcesys; + /** + * 备注 + */ + @JsonProperty("awrp_sppfbeizhu") + private String awrpSppfbeizhu; + /** + * 银行信息分录 + */ + @JsonProperty("entry_bank") + private List entryBank; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java index 50a26ba2..e0c79af3 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java @@ -30,6 +30,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBankQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; @@ -48,9 +49,11 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiPayBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiSupplierQueryDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; @@ -65,6 +68,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiSupplierVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; @@ -183,6 +187,22 @@ public interface IBfiService { */ BfiBillSaveResultVO saveCustomer(BfiCustomerSaveDTO param); + /** + * A901-1 供应商查询 + * + * @param param 查询参数,页码从1开始,修改时间范围不可超过6个月 + * @return 供应商分页数据 + */ + BfiPageVO querySupplier(BfiSupplierQueryDTO param); + + /** + * A905-3 行名行号查询 + * + * @param param 查询参数,页码从1开始,修改时间范围不可超过6个月 + * @return 行名行号分页数据 + */ + BfiPageVO queryBank(BfiBankQueryDTO param); + /** * A102-2 财务应付单保存 * @@ -267,7 +287,7 @@ public interface IBfiService { /** * A301-1 收款单查询(收款处理查询) * - * @param param 查询参数,业务日期范围不可超过1个月 + * @param param 查询参数,业务日期格式 yyyy-MM-dd HH:mm:ss,窗口天数由调用方控制 * @return 收款处理分页数据 */ BfiPageVO queryRecBill(BfiRecBillQueryDTO param); diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java index de5f8b77..e010b0f5 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java @@ -43,6 +43,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBankQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiBlacklistQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; @@ -72,12 +73,14 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiSupplierQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; @@ -95,6 +98,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiSupplierVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; import org.springblade.thirdparty.bfi.service.IBfiService; @@ -332,6 +336,28 @@ public class BfiServiceImpl implements IBfiService { return result.getData(); } + @Override + public BfiPageVO querySupplier(BfiSupplierQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI供应商失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("供应商查询", () -> client.supplierQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getEnable(), param.getStatus(), param.getCreateorgNumber(), + param.getSocietycreditcode(), param.getIdno(), param.getModifytimeS(), param.getModifytimeE())); + return result.getData(); + } + + @Override + public BfiPageVO queryBank(BfiBankQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI行名行号失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("行名行号查询", () -> client.bebankQuery(accessToken, param)); + return result.getData(); + } + @Override public BfiBillSaveResultVO saveApBill(BfiApBillSaveDTO param) { if (param == null || StringUtil.isBlank(param.getAwrpSrcSys()) || StringUtil.isBlank(param.getAwrpSrcCode())) { @@ -460,10 +486,7 @@ public class BfiServiceImpl implements IBfiService { if (param == null || StringUtil.isBlank(param.getSerialNo()) || StringUtil.isBlank(param.getSellerTaxpayerId())) { throw new BfiException("查询BFI开票申请发票失败,单据编号与销方税号不能为空"); } - if (properties.isMockEnabled()) { - log.info("【MOCK】查询BFI开票申请发票,serialNo={}", param.getSerialNo()); - return mockQueryInvoiceApplyInvoice(); - } + // mock 模式由 BfiClientMock 读取桩文件 invoiceApplyOpenApi.json(有桩用桩、无桩透传真实接口),此处不再内置固定响应 // data 原文需序列化为 JSON 后 Base64 编码 String data = Base64.getEncoder().encodeToString(JSONUtil.toJsonStr(param).getBytes(StandardCharsets.UTF_8)); BfiInvoiceApplyOpenApiBodyDTO body = new BfiInvoiceApplyOpenApiBodyDTO(); @@ -753,22 +776,4 @@ public class BfiServiceImpl implements IBfiService { long expireMillis = tokenVO.getExpiresIn() - BfiConstant.TOKEN_EXPIRE_BUFFER_MILLIS; bladeRedis.getValueOps().setIfAbsent(BfiConstant.BFI_TOKEN_CACHE_KEY, tokenVO.getAccessToken(), Duration.ofMillis(expireMillis)); } - - /** - * Mock 开票申请发票查询(开发调试用) - * - * @return mock 发票查询结果 - */ - private BfiOpenApiResultVO> mockQueryInvoiceApplyInvoice() { - String mockData = "W3siaW52b2ljZUxpc3QiOlt7ImFib2xpc2hSZWFzb24iOiIiLCJiaWxsTm8iOiJLUFNRLUpDWlgtMjAyNjAzLTAwMDU4MCIsImJ1eWVyQWRkcmVzc0FuZFRlbCI6IumSpuW3nuW4gumSpuW3nua4r+WvjOa4r+aWsOWfjjMw5qCLMDjoh7MxM+WPt+WSjDMx5qCLMDHoh7MwOeWPt+WVhumTujA3NzctMzY2NTExMSIsImJ1eWVyQmFua0FuZEFjY291bnQiOiLkuK3lm73lhpzkuJrpk7booYzogqHku73mnInpmZDlhazlj7jpkqblt57muK/mlK/ooYwgMjA3NDA0MDEwNDAwMTA3NjYiLCJidXllck5hbWUiOiLlub/opb/moYLlu7rlt6XnqIvotKjph4/mo4DmtYvmnInpmZDlhazlj7giLCJidXllclByb3BlcnR5IjowLCJidXllclJlY2lwaWVudE1haWwiOiIiLCJidXllclJlY2lwaWVudFBob25lIjoiIiwiYnV5ZXJUYXhwYXllcklkIjoiOTE0NTA3MDQwNTEwMDE2ODdRIiwiY2FuUmVkQW1vdW50IjozNjIyLjY0LCJjYW5SZWRUYXhBbW91bnQiOjIxNy4zNiwiY2FuY2VsZXIiOiIiLCJjaGVja0NvZGUiOiIiLCJkZXZpY2VObyI6IiIsImRvd25sb2FkRmxhZyI6MiwiZHJhd2VyIjoi6ZmI5bCP5bCPIiwiZ29vZHMiOlt7ImFtb3VudCI6MzYyMi42NCwiYmlsbFNvdXJjZUlkIjoiIiwiZGV0YWlsSWQiOiIyNDM3OTM3MTA2NTMwNjE5MzkzIiwiZGV0YWlsUm93Tm8iOjEsImdvb2RzTmFtZSI6IirpibTor4Hlkqjor6LmnI3liqEq5qOA5rWL6LS5IiwiaW5jbHVkZVRheEFtb3VudCI6Mzg0MC4wMCwiaW5jbHVkZVRheFByaWNlIjoiMzg0MCIsImxpbmVQcm9wZXJ0eSI6MCwicHJpY2UiOiIzNjIyLjY0MTUwOTQzMzk2IiwicHJpdklsZWdlQ29udGVudCI6IiIsInByaXZpbGVnZUZsYWciOjAsInF1YW50aXR5IjoiMSIsInJlbWFpblJlZEFtb3VudCI6MzYyMi42NCwicmVtYWluUmVkUXVhbnRpdHkiOiIxIiwicmVtYWluUmVkVGF4IjoyMTcuMzYsInJldmVudWVDb2RlIjoiMzA0MDYwMjk5MDAwMDAwMDAwMCIsInJldmVudWVOYW1lIjoiKumJtOivgeWSqOivouacjeWKoSoiLCJzZXEiOjAsInNwZWNpZmljYXRpb24iOiIiLCJ0YXhBbW91bnQiOjIxNy4zNiwidGF4RmxhZyI6IjEiLCJ0YXhSYXRlIjoiMC4wNiIsInVuaXRzIjoi6aG5IiwiemVyb1RheFJhdGVGbGFnIjoiIn1dLCJpZCI6MjQzNzkzNzEwNjUzMDYxOTM5MiwiaW5jbHVkZVRheEFtb3VudCI6Mzg0MC4wMCwiaW52ZW50b3J5RmxhZyI6MCwiaW52b2ljZUNvZGUiOiIiLCJpbnZvaWNlRGF0ZSI6MTc3MzgyNDc2NDAwMCwiaW52b2ljZUZpbGVVcmwiOiJodHRwczovL2FwaS5waWFvem9uZS5jb20vcnBhL2ZyZWUvcHJldmlldy8zNjYxOTQwMDM3ODk2OTEzOTIvOTE0NTAwMDBNQTVRMUcxTTU5LzVORlkyWEZHNlNWV0lXNzJPUDA1P3R5cGU9MSIsImludm9pY2VJbWFnZVVybCI6Imh0dHBzOi8vYXBpLnBpYW96b25lLmNvbS9ycGEvZnJlZS9wcmV2aWV3LzM2NjE5NDAwMzc4OTY5MTM5Mi85MTQ1MDAwME1BNVExRzFNNTkvNU5GWTJYRkc2U1ZXSVc3Mk9QMDU/dHlwZT0yIiwiaW52b2ljZU51bWJlciI6IjI2NDUyMDAwMDAwNDIzMjQwOTE2IiwiaW52b2ljZVBkZkZpbGVVcmwiOiJodHRwczovL2FwaS5waWFvem9uZS5jb20vcnBhL2ZyZWUvcHJldmlldy8zNjYxOTQwMDM3ODk2OTEzOTIvOTE0NTAwMDBNQTVRMUcxTTU5LzVORlkyWEZHNlNWV0lXNzJPUDA1P3R5cGU9MCIsImludm9pY2VQcm9wZXJ0eSI6MCwiaW52b2ljZVNvdXJjZSI6IjQiLCJpbnZvaWNlU3RhdHVzIjowLCJpbnZvaWNlVHlwZSI6IjA4eGRwIiwiaW52b2ljZVhtbEZpbGVVcmwiOiJodHRwczovL2FwaS5waWFvem9uZS5jb20vcnBhL2ZyZWUvcHJldmlldy8zNjYxOTQwMDM3ODk2OTEzOTIvOTE0NTAwMDBNQTVRMUcxTTU5LzVORlkyWEZHNlNWV0lXNzJPUDA1P3R5cGU9MyIsImlzc3VlU3RhdHVzIjoiMCIsIm1lcmdlRGlmZmVyVGF4QW1vdW50IjowLjAwLCJvcmdOYW1lIjoi5bm/6KW/5aOu5peP6Ieq5rK75Yy65bu6562R5bel56iL6LSo6YeP5qOA5rWL5Lit5b+D5pyJ6ZmQ5YWs5Y+4Iiwib3JpZ2luYWxJbnZvaWNlQ29kZSI6IiIsIm9yaWdpbmFsSW52b2ljZU51bWJlciI6IiIsInBheWVlIjoiIiwicHJpbnRGbGFnIjowLCJyZWRJbmZvQmlsbE5vIjoiIiwicmVkUmVhc29uIjoiIiwicmVtYXJrIjoiIiwicmVzdWx0IjoiIiwicmV2aWV3ZXIiOiIiLCJzZWxsZXJBZGRyZXNzQW5kVGVsIjoi5Y2X5a6B5biC6KW/5Lmh5aGY5Yy65YyX6ZmF6LevMeWPtzA3NzEtMzE3OTYwMSIsInNlbGxlckJhbmtBbmRBY2NvdW50Ijoi5bel5ZWG6ZO26KGM5Y2X5a6B5biC5rC45paw5pSv6KGMMjEwMjEwNDAwOTMwMDI5NjA0MSIsInNlbGxlck5hbWUiOiLlub/opb/lo67ml4/oh6rmsrvljLrlu7rnrZHlt6XnqIvotKjph4/mo4DmtYvkuK3lv4PmnInpmZDlhazlj7giLCJzZWxsZXJUYXhwYXllcklkIjoiOTE0NTAwMDBNQTVRMUcxTTU5Iiwic2VyaWFsTm8iOiIyMDBfMjAyNjAzMTgxNzA1NTAwMDFfMDAwMSIsInNrbSI6IiIsInNwZWNpYWxSZWRGbGFnIjowLCJzcGxpdE9yTWVyZ2VGbGFnIjoiMCIsInRheEZsYWciOiIxIiwidGVybWluYWxObyI6IiIsInRvdGFsQW1vdW50IjozNjIyLjY0LCJ0b3RhbFRheEFtb3VudCI6MjE3LjM2fV0sImJpbGxObyI6IktQU1EtSkNaWC0yMDI2MDMtMDAwNTgwIn1d"; - BfiOpenApiResultVO> result = new BfiOpenApiResultVO<>(); - result.setErrorCode("0"); - result.setSuccess(true); - String decodedJson = new String(Base64.getDecoder().decode(mockData), StandardCharsets.UTF_8); - JSONObject decodedObj = JSONUtil.parseObj(decodedJson); - result.setErrorCode(decodedObj.getStr("errorCode")); - result.setSuccess(decodedObj.getBool("success")); - result.setData(decodedObj.getBeanList("data", BfiInvoiceApplyOpenApiBillVO.class)); - return result; - } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java index ed603bb2..685da253 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java @@ -25,15 +25,19 @@ */ package org.springblade.thirdparty.bfi.util; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.thirdparty.bfi.pojo.dto.BfiBaseDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.UUID; +import java.util.function.Consumer; import java.util.function.IntFunction; /** @@ -44,6 +48,7 @@ import java.util.function.IntFunction; * * @author Chill */ +@Slf4j public class BfiUtil { /** @@ -56,6 +61,11 @@ public class BfiUtil { */ public static final int DEFAULT_MAX_PAGES = 50; + /** + * 按 totalCount 推导总页数时的兜底最大页数,仅防金蝶侧返回异常导致死循环 + */ + public static final int DEFAULT_TOTAL_MAX_PAGES = 500; + private BfiUtil() { } @@ -139,4 +149,163 @@ public class BfiUtil { } return rows; } + + /** + * BFI 分页查询通用翻页:按首页返回的 totalCount 推导总页数,自动翻页汇总全量数据行 + *

+ * 与 {@link #queryAllPages(IntFunction)} 的区别:总页数由 totalCount 决定,固定页数仅作兜底, + * 适合业务日期窗口较长、数据量可能超过固定页数上限的查询场景;达到兜底上限或未取满 totalCount 时打印告警,避免静默截断 + * + * @param pageFetcher 页码 -> 分页结果,lambda 内负责设置页码(必要时含页大小)并调用接口 + * @param 数据行类型 + * @return 全量数据行 + */ + public static List queryAllPagesByTotal(IntFunction> pageFetcher) { + return queryAllPagesByTotal(pageFetcher, DEFAULT_TOTAL_MAX_PAGES); + } + + /** + * BFI 分页查询通用翻页:按首页返回的 totalCount 推导总页数,自动翻页汇总全量数据行 + *

+ * 终止条件:返回页为空、数据行为空、lastPage=true、数据行不满一页(按返回 pageSize 判断)、取满推导出的总页数 + *

+ * totalCount 缺失或非正数时退化为 {@link #queryAllPages(IntFunction, int)} 的固定页数上限逻辑 + * + * @param pageFetcher 页码 -> 分页结果,lambda 内负责设置页码(必要时含页大小)并调用接口 + * @param maxPages 兜底最大页数,防止金蝶侧 totalCount/lastPage 返回异常时死循环 + * @param 数据行类型 + * @return 全量数据行 + */ + public static List queryAllPagesByTotal(IntFunction> pageFetcher, int maxPages) { + List rows = new ArrayList<>(); + int totalPages = maxPages; + Integer totalCount = null; + for (int pageNo = 1; pageNo <= totalPages; pageNo++) { + BfiPageVO page = pageFetcher.apply(pageNo); + if (page == null || Func.isEmpty(page.getRows())) { + return rows; + } + rows.addAll(page.getRows()); + if (pageNo == 1) { + totalCount = page.getTotalCount(); + totalPages = resolveTotalPages(page, maxPages); + } + if (Boolean.TRUE.equals(page.getLastPage())) { + return rows; + } + if (page.getPageSize() != null && page.getRows().size() < page.getPageSize()) { + return rows; + } + } + if (totalCount != null && rows.size() < totalCount) { + log.warn("BFI分页查询未取满,totalCount={},已汇总 {} 条,可能存在数据截断", totalCount, rows.size()); + } + return rows; + } + + /** + * 按首页 totalCount 与 pageSize 推导总页数,超过兜底上限时按上限截断并告警 + * + * @param page 首页分页结果 + * @param maxPages 兜底最大页数 + * @return 总页数 + */ + private static int resolveTotalPages(BfiPageVO page, int maxPages) { + Integer totalCount = page.getTotalCount(); + Integer pageSize = page.getPageSize(); + if (totalCount == null || totalCount <= 0 || pageSize == null || pageSize <= 0) { + return maxPages; + } + int totalPages = (totalCount + pageSize - 1) / pageSize; + if (totalPages > maxPages) { + log.warn("BFI分页查询需要 {} 页(totalCount={}),超过兜底上限 {} 页,超出部分将被截断", totalPages, totalCount, maxPages); + return maxPages; + } + return totalPages; + } + + /** + * BFI 时间窗口二分切片续扫翻页,逐页回调处理,窗口数据超过单查询翻页上限时尾部不漏同步 + *

+ * 适用按时间窗口增量同步且数据量可能超过单查询翻页上限的场景 + * (A801-1 票池同步按 updateTime、A301-1 收款单同步按业务日期): + *

{@code
+	 * int sliceCount = BfiUtil.forEachWindowSlice("金蝶全票池同步", windowStart, windowEnd,
+	 * 	PAGE_SIZE, MAX_PAGES,
+	 * 	(sliceStart, sliceEnd, pageNo) -> {
+	 * 		data.setUpdateTime(sliceStart.format(FORMATTER));
+	 * 		data.setUpdateTimeEnd(sliceEnd.format(FORMATTER));
+	 * 		data.setPageNo(pageNo);
+	 * 		data.setPageSize(PAGE_SIZE);
+	 * 		return bfiService.queryInvoicePool(param);
+	 * 	},
+	 * 	rows -> batchUpsert(rows));
+	 * }
+ *

+ * 单片独立翻页(最多 maxPages 页):空页或末页不满一页即该片到底;触达页数保护说明该时间片 + * 数据超过单查询窗口上限(maxPages × pageSize),从中点二分为 [start, mid] + [mid, end] 两片递归续扫, + * 总覆盖不受单查询上限约束。切片边界同刻度的数据可能在相邻两片各拉一次,pageHandler 必须幂等; + * 切片细到不足 1 秒仍满页视为远端异常,告警跳过防御死递归。 + * 终止只认空页与短页,不依赖 lastPage(末页不满一页天然触发短页终止,lastPage 异常提前终止反而漏拉) + * + * @param bizName 业务名称(日志前缀) + * @param windowStart 窗口起点(含) + * @param windowEnd 窗口终点(含) + * @param pageSize 分页大小(须与 pageFetcher 内实际设置的页大小一致) + * @param maxPages 单片最大页数保护 + * @param pageFetcher 时间片起止 + 页码 → 分页结果,lambda 内负责设置时间窗口与页码并调用接口 + * @param pageHandler 单页数据行处理(过滤、upsert、统计等),必须幂等 + * @param 数据行类型 + * @return 实际处理完的切片数(含空片,异常跳过的不计) + */ + public static int forEachWindowSlice(String bizName, LocalDateTime windowStart, LocalDateTime windowEnd, + int pageSize, int maxPages, WindowPageFetcher pageFetcher, Consumer> pageHandler) { + // 二分收敛保护:切片区间为空说明时间粒度异常,放弃该片 + if (!windowStart.isBefore(windowEnd)) { + log.error("{}切片区间为空({} ~ {}),时间粒度异常,放弃该切片", bizName, windowStart, windowEnd); + return 0; + } + for (int pageNo = 1; pageNo <= maxPages; pageNo++) { + BfiPageVO page = pageFetcher.fetch(windowStart, windowEnd, pageNo); + List rows = page == null || page.getRows() == null ? List.of() : page.getRows(); + if (rows.isEmpty()) { + return 1; + } + pageHandler.accept(rows); + // 短页:该片到底,正常终止(不依赖 lastPage,末页不满一页天然触发) + if (rows.size() < pageSize) { + return 1; + } + } + // 触顶:该时间片数据超过单查询窗口上限,从中点二分续扫(边界同刻度数据由 pageHandler 幂等兜底) + LocalDateTime mid = windowStart.plus(Duration.between(windowStart, windowEnd).dividedBy(2)); + if (!mid.isAfter(windowStart)) { + log.error("{}切片 {} ~ {} 已达最小粒度仍满页(>{} 条),该时间片数据异常,跳过", + bizName, windowStart, windowEnd, (long) maxPages * pageSize); + return 0; + } + log.warn("{}切片 {} ~ {} 触达单片翻页保护 {} 页(>{} 条),二分续扫 [{} ~ {}] + [{} ~ {}]", + bizName, windowStart, windowEnd, maxPages, (long) maxPages * pageSize, windowStart, mid, mid, windowEnd); + return forEachWindowSlice(bizName, windowStart, mid, pageSize, maxPages, pageFetcher, pageHandler) + + forEachWindowSlice(bizName, mid, windowEnd, pageSize, maxPages, pageFetcher, pageHandler); + } + + /** + * 时间窗口切片翻页取数函数:按切片起止时间 + 页码拉取一页数据 + * + * @param 数据行类型 + */ + @FunctionalInterface + public interface WindowPageFetcher { + + /** + * 拉取单个时间片的一页数据 + * + * @param sliceStart 切片起点(含) + * @param sliceEnd 切片终点(含) + * @param pageNo 页码,从 1 开始 + * @return 分页结果 + */ + BfiPageVO fetch(LocalDateTime sliceStart, LocalDateTime sliceEnd, int pageNo); + } } diff --git a/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java b/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java index 5794196b..19c8bb0c 100644 --- a/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java +++ b/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java @@ -65,6 +65,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiSupplierQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; @@ -78,6 +79,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiSupplierVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; import org.springblade.thirdparty.bfi.service.impl.BfiServiceImpl; import org.springframework.data.redis.core.ValueOperations; @@ -212,6 +214,23 @@ class BfiServiceImplTest { assertThrows(BfiException.class, () -> service.queryCustomer(new BfiCustomerQueryDTO())); } + @Test + void querySupplierShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.supplierQuery(eq("token-1"), eq(1), eq(10), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(success(page)); + BfiSupplierQueryDTO param = new BfiSupplierQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + assertEquals(page, service.querySupplier(param)); + } + + @Test + void querySupplierShouldThrowWhenPageEmpty() { + assertThrows(BfiException.class, () -> service.querySupplier(new BfiSupplierQueryDTO())); + } + @Test void saveCustomerShouldValidateRequiredFields() { BfiCustomerSaveDTO noName = new BfiCustomerSaveDTO(); diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java index dc2b4b2a..56580bb0 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java @@ -252,6 +252,11 @@ public class HcsProperties { * 付款提交前风险校验开关 */ private boolean checkOnPayment = true; + /** + * 客商风险每日定时刷新开关(thirdParty.hcs.risk.refresh-enabled,默认关闭): + * 刷新合同期限内生效合同关联客商的风险等级/黑名单状态并落库,支撑列表展示与筛选 + */ + private boolean refreshEnabled = false; } @Data diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsClaimRecordAddDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsClaimRecordAddDTO.java index 8185a17c..627f7b7f 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsClaimRecordAddDTO.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsClaimRecordAddDTO.java @@ -47,6 +47,10 @@ import java.math.BigDecimal; public class HcsClaimRecordAddDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; + /** + * 认领记录ID(C107-1 新增不传,C107-3 修改时必填,作废传 fdStatus=1) + */ + private String fdId; /** * 收款流水(fdId 必填,为合规系统流水ID) */ diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanStageDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanStageDTO.java index 21dbc7ac..7443fc35 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanStageDTO.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanStageDTO.java @@ -36,7 +36,8 @@ import java.math.BigDecimal; /** * 合同合规系统(HCS)C103-9(新) 履约规则同步 行级操作项(数据源 blade_contract_exe_stage,ADR-0002) *

- * 新增行可不带 fdId;更新/删除行必须带 fdId(合规阶段ID,回填自首次同步返回)。 + * add 行必传 fdUniqueId(TMS 行唯一标识,HCS 回显存储);update/delete 行必传 fdId(首次 add 返回的阶段行ID, + * 持久化于 hcs_stage_fd_id),实测无需 fdUniqueId。 * * @author Chill */ @@ -47,9 +48,13 @@ public class HcsExeplanStageDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; /** - * 阶段项ID(行级 update/delete 必传,取 blade_contract_exe_stage.hcs_stage_fd_id) + * 阶段行ID(HCS 返回的 fdId,持久化于 hcs_stage_fd_id;update/delete 行必传,add 行可不传) */ private String fdId; + /** + * 阶段唯一标识(仅 add 行必传,取 TMS 行ID,HCS 回显存储;update/delete 行以 fdId 定位,无需此值) + */ + private String fdUniqueId; /** * 行级操作:add / update / delete */ diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanVO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanVO.java index 973ff964..be3dc269 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanVO.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsExeplanVO.java @@ -49,7 +49,11 @@ public class HcsExeplanVO implements Serializable { */ private String fdExeplanId; /** - * 本次变更的阶段列表(按行回填 hcs_stage_fd_id) + * 本次新增的阶段列表(add 行返回,不含业务属性,按行回填 hcs_stage_fd_id) + */ + private List fdAddStages; + /** + * 本次变更的阶段列表(update/delete 行返回,按行回填 hcs_stage_fd_id) */ private List fdChangeStages; } diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsLegalResultDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsLegalResultDTO.java index b74880d9..1cb8bb4f 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsLegalResultDTO.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsLegalResultDTO.java @@ -51,7 +51,7 @@ public class HcsLegalResultDTO implements Serializable { */ private String sourceSystem; /** - * 定位单据ID(付款特批=TMS 付款申请ID;合同特批=合规合同ID,与 id 同值,按 blade_contract_manage.hcs_fd_id 匹配) + * 定位单据ID(付款特批=合规特批流程ID(hcs_spec_fd_id,C106-3 回存值);合同特批=合规合同ID,与 id 同值,按 blade_contract_manage.hcs_fd_id 匹配) */ private String requestId; /** @@ -66,4 +66,8 @@ public class HcsLegalResultDTO implements Serializable { * 结果说明(驳回原因等,非必传) */ private String reason; + /** + * 特批结果附件(付款特批回传携带,值为合规附件ID,C103-5 下载入参,非必传) + */ + private String fileUrl; } diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsPerfRecordSaveDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsPerfRecordSaveDTO.java index 362d8539..98abd32b 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsPerfRecordSaveDTO.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsPerfRecordSaveDTO.java @@ -54,6 +54,10 @@ public class HcsPerfRecordSaveDTO implements Serializable { * 履约记录表单数据 */ private HcsPerfRecordFormDataDTO formData; + /** + * 是否校验权限(C108-1/2 接口要求固定传 false,跳过合规侧权限校验) + */ + private Boolean checkAuth = Boolean.FALSE; /** * 附件挂载 */ diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsSpecPayPropsDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsSpecPayPropsDTO.java index d2ddbce8..4b292d5e 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsSpecPayPropsDTO.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsSpecPayPropsDTO.java @@ -55,6 +55,11 @@ public class HcsSpecPayPropsDTO implements Serializable { */ @JsonProperty("fd_contract_no") private String fdContractNo; + /** + * 外部系统编号 + */ + @JsonProperty("fd_source_sys") + private String fdSourceSys; /** * 收款账户名 */ @@ -145,4 +150,9 @@ public class HcsSpecPayPropsDTO implements Serializable { */ @JsonProperty("fd_spc_approval_attach") private String fdSpcApprovalAttach; + /** + * 款项日期(传履约计划id,接口必填;无关联计划行不传,与 C108 履约记录口径一致) + */ + @JsonProperty("fd_contract_plan") + private String fdContractPlan; } diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java index 916c36f0..c21952e4 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java @@ -108,6 +108,7 @@ public interface IHcsClient { String INVOICE_REG_ADD_URL = "${thirdParty.hcs.invoiceRegAddUrl:/openapi/eop-data/eopDataInvoiceRegistrationOpen/add}"; String INVOICE_REG_UPDATE_URL = "${thirdParty.hcs.invoiceRegUpdateUrl:/openapi/eop-data/eopDataInvoiceRegistrationOpen/update}"; String CLAIM_RECORD_ADD_URL = "${thirdParty.hcs.claimRecordAddUrl:/openapi/eop-data/eopDataCollectionFlowClaimRecordOpen/add}"; + String CLAIM_RECORD_UPDATE_URL = "${thirdParty.hcs.claimRecordUpdateUrl:/openapi/eop-data/eopDataCollectionFlowClaimRecordOpen/update}"; String COLLECTION_FLOW_LIST_URL = "${thirdParty.hcs.collectionFlowListUrl:/openapi/eop-data/eopDataCollectionFlowOpen/list}"; /** @@ -341,6 +342,15 @@ public interface IHcsClient { @PostMapping(CLAIM_RECORD_ADD_URL) HcsResult claimRecordAdd(@RequestBody HcsClaimRecordAddDTO param); + /** + * C107-3 修改认领记录(fdId 必填;作废认领传 fdStatus=1) + * + * @param param 请求参数(入参同新增,仅 fdId 必填) + * @return 修改认领记录 fdId + */ + @PostMapping(CLAIM_RECORD_UPDATE_URL) + HcsResult claimRecordUpdate(@RequestBody HcsClaimRecordAddDTO param); + /** * C104-2 查询收款流水列表(认领记录推送前按明细流水号定位合规侧收款流水ID) * diff --git a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java index 0098b2b9..85f8cae5 100644 --- a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java +++ b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java @@ -38,9 +38,22 @@ public class IDCardVO implements Serializable { */ @JsonFormat(pattern = DateUtil.PATTERN_DATE, timezone = "GMT+8") private Date birthday; + /** + * 住址 + */ + private String address; + /** + * 有效期起 + */ + @JsonFormat(pattern = DateUtil.PATTERN_DATE, timezone = "GMT+8") + private Date validDateStart; /** * 有效期止 */ @JsonFormat(pattern = DateUtil.PATTERN_DATE, timezone = "GMT+8") private Date validDateEnd; + /** + * 长期有效:1-是,0-否 + */ + private Integer longTerm; } diff --git a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java index e4853d28..5303dabb 100644 --- a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java +++ b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java @@ -1,6 +1,9 @@ package org.springblade.thirdparty.ocr.service.impl; import cn.hutool.core.lang.TypeReference; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,10 +16,10 @@ import org.springblade.thirdparty.ocr.constant.OCRImageType; import org.springblade.thirdparty.ocr.convert.AttachmentIndexConvert; import org.springblade.thirdparty.ocr.exception.OCRException; import org.springblade.thirdparty.ocr.feign.IOCRClient; -import org.springblade.thirdparty.ocr.feign.IOCROtherClient; import org.springblade.thirdparty.ocr.pojo.dto.*; import org.springblade.thirdparty.ocr.pojo.vo.*; import org.springblade.thirdparty.ocr.service.IOCRService; +import org.springblade.thirdparty.ocr.support.ShenFenZhengIdCardSupport; import org.springblade.thirdparty.ocr.util.ConvertUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -43,10 +46,12 @@ public class OCRServiceImpl implements IOCRService { private static final BigDecimal WEIGHT_LIMIT = BigDecimal.valueOf(200); private final IOCRClient client; - private final IOCROtherClient otherClient; - @Value("${thirdParty.ocr.baseOtherUrl}") - private String ocrBaseOtherUrl; + /** + * 身份证 OCR 服务地址,Nacos:thirdParty.ocr.idCardBaseUrl + */ + @Value("${thirdParty.ocr.idCardBaseUrl:${thirdParty.ocr.baseOtherUrl}}") + private String idCardBaseUrl; @Override public TransportCertificateVO recognitionTransportCertificate(CertificateBatchRecognitionDTO param) { @@ -339,45 +344,79 @@ public class OCRServiceImpl implements IOCRService { if (CollectionUtils.isEmpty(urls)) { throw new OCRException("图像链接不能为空"); } + if (StringUtil.isBlank(idCardBaseUrl)) { + throw new OCRException("身份证OCR服务地址未配置(thirdParty.ocr.idCardBaseUrl)"); + } IDCardVO result = new IDCardVO(); try { for (String url : urls) { - OCRCardBankDetRecDTO param = new OCRCardBankDetRecDTO(url); -// param.setDataType("IDCard-Main"); - log.info("调用ocr证件定位+结构化信息提取接口,请求地址:{},请求参数:{}", - getCardBankDetRecUrl(), JSONUtil.toJsonStr(param)); - OCRResultVO resultVO = otherClient.cardBankDetRec(param); - if (resultVO != null && OCRConstant.SUCCESS_CODE.equals(resultVO.getCode())) { - // 成功 - OCRBigCommonCardVO card = resultVO.getResult(); - convertIDCard(result, card); + if (StringUtil.isBlank(url)) { continue; } - log.error("调用ocr接口证件定位+结构化信息提取异常响应参数:{}", JSONUtil.toJsonStr(resultVO)); - String message = getMessage(resultVO); - throw new OCRException("调用ocr接口证件定位+结构化信息提取异常 " + message); + ShenFenZhengIdCardSupport.merge(result, recognizeIdCardByShenFenZheng(url.trim())); } } catch (OCRException e) { throw e; } catch (Exception e) { - log.error("调用ocr接口证件定位+结构化信息提取异常", e); - throw new OCRException("调用ocr接口证件定位+结构化信息提取异常"); + log.error("调用身份证OCR接口异常", e); + throw new OCRException("调用身份证OCR接口异常"); } return result; } - private String getCardBankDetRecUrl() { - return ocrBaseOtherUrl.replaceAll("/+$", "") + "/CardBankDetRec"; + /** + * 下载图片后以 formData.image_file 调用 ShenFenZheng + * + * @param imageUrl 可访问的图片地址 + * @return 单张识别结果 + */ + private IDCardVO recognizeIdCardByShenFenZheng(String imageUrl) { + String requestUrl = idCardBaseUrl.replaceAll("/+$", "") + ShenFenZhengIdCardSupport.PATH; + byte[] imageBytes; + try { + imageBytes = HttpUtil.downloadBytes(imageUrl); + } catch (Exception exception) { + log.error("下载身份证图片失败,url={}", imageUrl, exception); + throw new OCRException("下载身份证图片失败"); + } + if (imageBytes == null || imageBytes.length == 0) { + throw new OCRException("身份证图片内容为空"); + } + String fileName = resolveImageFileName(imageUrl); + log.info("调用身份证OCR接口,请求地址:{},文件名:{},大小:{}", requestUrl, fileName, imageBytes.length); + try (HttpResponse response = HttpRequest.post(requestUrl) + .form(ShenFenZhengIdCardSupport.FILE_FIELD, imageBytes, fileName) + .timeout(60000) + .execute()) { + String body = response.body(); + log.info("调用身份证OCR接口响应,status={},body={}", response.getStatus(), body); + if (!response.isOk()) { + throw new OCRException("调用身份证OCR接口失败,HTTP状态码:" + response.getStatus()); + } + if (!ShenFenZhengIdCardSupport.isSuccess(body)) { + throw new OCRException("身份证OCR识别失败"); + } + return ShenFenZhengIdCardSupport.parse(body); + } catch (OCRException exception) { + throw exception; + } catch (Exception exception) { + log.error("调用身份证OCR接口异常,url={}", requestUrl, exception); + throw new OCRException("调用身份证OCR接口异常"); + } } - /** - * 转换身份证 - * @param result - * @param card - * @return - */ - private void convertIDCard(IDCardVO result, OCRBigCommonCardVO card) { - convert(result, card, ConvertUtil::getIDCardSetter, null); + private String resolveImageFileName(String imageUrl) { + String path = imageUrl; + int queryIndex = path.indexOf('?'); + if (queryIndex >= 0) { + path = path.substring(0, queryIndex); + } + int slashIndex = path.lastIndexOf('/'); + String name = slashIndex >= 0 ? path.substring(slashIndex + 1) : path; + if (StringUtil.isBlank(name) || !name.contains(".")) { + return "id-card.jpg"; + } + return name; } /** diff --git a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/support/ShenFenZhengIdCardSupport.java b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/support/ShenFenZhengIdCardSupport.java new file mode 100644 index 00000000..73e69d45 --- /dev/null +++ b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/support/ShenFenZhengIdCardSupport.java @@ -0,0 +1,301 @@ +/** + * 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.thirdparty.ocr.support; + +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.thirdparty.ocr.pojo.vo.IDCardVO; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 身份证 OCR(ShenFenZheng)响应解析 + *

+ * 第三方返回形如: + * {@code result."0" = [[{"label":"身份证_姓名"}], [{"result":"张三"}], {"r":[...]}]}, + * 这里抽成「标签 → 文本」后再回填 {@link IDCardVO}。 + * + * @author Chill + */ +public final class ShenFenZhengIdCardSupport { + + /** + * 接口路径 + */ + public static final String PATH = "/ShenFenZheng"; + + /** + * formData 文件字段名 + */ + public static final String FILE_FIELD = "image_file"; + + private static final String LONG_TERM_TEXT = "长期"; + + private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})"); + + private static final Map> FIELD_SETTERS = initFieldSetters(); + + private ShenFenZhengIdCardSupport() { + } + + /** + * 解析第三方原始 JSON 为身份证 VO + * + * @param responseBody 响应原文 + * @return 识别结果;结构异常时返回空对象 + */ + public static IDCardVO parse(String responseBody) { + IDCardVO result = new IDCardVO(); + if (StringUtil.isBlank(responseBody)) { + return result; + } + JSONObject root = JSONUtil.parseObj(responseBody); + Map fields = readLabelValues(root.getJSONObject("result")); + for (Map.Entry entry : fields.entrySet()) { + BiConsumer setter = FIELD_SETTERS.get(normalizeLabel(entry.getKey())); + if (setter != null && StringUtil.isNotBlank(entry.getValue())) { + setter.accept(result, entry.getValue().trim()); + } + } + return result; + } + + /** + * 判断第三方是否识别成功 + * + * @param responseBody 响应原文 + * @return true 表示成功 + */ + public static boolean isSuccess(String responseBody) { + if (StringUtil.isBlank(responseBody)) { + return false; + } + JSONObject root = JSONUtil.parseObj(responseBody); + Boolean status = root.getBool("status"); + if (Boolean.TRUE.equals(status)) { + return true; + } + // 个别版本只给 identifyCode,1001 视为成功 + Integer identifyCode = root.getInt("identifyCode"); + return identifyCode != null && identifyCode == 1001; + } + + /** + * 合并多张图的识别结果:后写的非空值覆盖先写的,方便正反面各识别一次后汇总 + * + * @param target 汇总目标 + * @param source 本次识别结果 + */ + public static void merge(IDCardVO target, IDCardVO source) { + if (target == null || source == null) { + return; + } + if (StringUtil.isNotBlank(source.getName())) { + target.setName(source.getName()); + } + if (StringUtil.isNotBlank(source.getIdNo())) { + target.setIdNo(source.getIdNo()); + } + if (StringUtil.isNotBlank(source.getGender())) { + target.setGender(source.getGender()); + } + if (StringUtil.isNotBlank(source.getNation())) { + target.setNation(source.getNation()); + } + if (source.getBirthday() != null) { + target.setBirthday(source.getBirthday()); + } + if (StringUtil.isNotBlank(source.getAddress())) { + target.setAddress(source.getAddress()); + } + if (source.getValidDateStart() != null) { + target.setValidDateStart(source.getValidDateStart()); + } + if (source.getValidDateEnd() != null) { + target.setValidDateEnd(source.getValidDateEnd()); + } + if (source.getLongTerm() != null) { + target.setLongTerm(source.getLongTerm()); + } + } + + private static Map readLabelValues(JSONObject result) { + Map fields = new LinkedHashMap<>(); + if (result == null || result.isEmpty()) { + return fields; + } + for (String key : result.keySet()) { + JSONArray item = result.getJSONArray(key); + if (item == null || item.size() < 2) { + continue; + } + String label = readNestedText(item.get(0), "label"); + String value = readNestedText(item.get(1), "result"); + if (StringUtil.isNotBlank(label) && StringUtil.isNotBlank(value)) { + fields.put(label, value); + } + } + return fields; + } + + private static String readNestedText(Object node, String fieldName) { + if (node instanceof JSONArray array) { + if (array.isEmpty()) { + return null; + } + Object first = array.get(0); + if (first instanceof JSONObject object) { + return object.getStr(fieldName); + } + return null; + } + if (node instanceof JSONObject object) { + return object.getStr(fieldName); + } + return null; + } + + private static String normalizeLabel(String label) { + return StringUtil.isBlank(label) ? "" : label.trim(); + } + + private static Map> initFieldSetters() { + Map> setters = new LinkedHashMap<>(); + setters.put("身份证_姓名", IDCardVO::setName); + setters.put("姓名", IDCardVO::setName); + setters.put("身份证_公民身份号码", IDCardVO::setIdNo); + setters.put("公民身份号码", IDCardVO::setIdNo); + setters.put("公民身份证号码", IDCardVO::setIdNo); + setters.put("身份证_性别", IDCardVO::setGender); + setters.put("性别", IDCardVO::setGender); + setters.put("身份证_民族", (card, value) -> card.setNation(normalizeNation(value))); + setters.put("民族", (card, value) -> card.setNation(normalizeNation(value))); + setters.put("身份证_出生", (card, value) -> card.setBirthday(parseDate(value))); + setters.put("出生", (card, value) -> card.setBirthday(parseDate(value))); + setters.put("身份证_住址", IDCardVO::setAddress); + setters.put("住址", IDCardVO::setAddress); + setters.put("身份证_有效期限", ShenFenZhengIdCardSupport::applyValidity); + setters.put("有效期限", ShenFenZhengIdCardSupport::applyValidity); + setters.put("身份证_签发日期", (card, value) -> card.setValidDateStart(parseDate(value))); + setters.put("签发日期", (card, value) -> card.setValidDateStart(parseDate(value))); + setters.put("身份证_失效日期", ShenFenZhengIdCardSupport::applyEndDate); + setters.put("失效日期", ShenFenZhengIdCardSupport::applyEndDate); + return setters; + } + + private static String normalizeNation(String value) { + String nation = value == null ? "" : value.trim(); + if (nation.isEmpty()) { + return nation; + } + return nation.endsWith("族") ? nation : nation + "族"; + } + + private static void applyValidity(IDCardVO card, String value) { + if (StringUtil.isBlank(value)) { + return; + } + if (value.contains(LONG_TERM_TEXT)) { + card.setLongTerm(1); + card.setValidDateEnd(null); + Date start = parseFirstDate(value); + if (start != null) { + card.setValidDateStart(start); + } + return; + } + card.setLongTerm(0); + Date start = parseFirstDate(value); + Date end = parseLastDate(value); + if (start != null) { + card.setValidDateStart(start); + } + if (end != null) { + card.setValidDateEnd(end); + } + } + + private static void applyEndDate(IDCardVO card, String value) { + if (StringUtil.isBlank(value)) { + return; + } + if (value.contains(LONG_TERM_TEXT)) { + card.setLongTerm(1); + card.setValidDateEnd(null); + return; + } + Date end = parseDate(value); + if (end != null) { + card.setLongTerm(0); + card.setValidDateEnd(end); + } + } + + private static Date parseDate(String text) { + return parseFirstDate(text); + } + + private static Date parseFirstDate(String text) { + Matcher matcher = DATE_PATTERN.matcher(text); + if (!matcher.find()) { + return null; + } + return toDate(matcher.group(1), matcher.group(2), matcher.group(3)); + } + + private static Date parseLastDate(String text) { + Matcher matcher = DATE_PATTERN.matcher(text); + String year = null; + String month = null; + String day = null; + while (matcher.find()) { + year = matcher.group(1); + month = matcher.group(2); + day = matcher.group(3); + } + if (year == null) { + return null; + } + return toDate(year, month, day); + } + + private static Date toDate(String year, String month, String day) { + try { + String formatted = String.format("%s-%02d-%02d", year, Integer.parseInt(month), Integer.parseInt(day)); + return DateUtil.parse(formatted, DateUtil.PATTERN_DATE); + } catch (Exception ignored) { + return null; + } + } +} diff --git a/doc/docs/hcs合同/2026-10-28-C108履约记录四场景推送改造流程梳理(收款-付款-开票-收票).md b/doc/docs/hcs合同/2026-10-28-C108履约记录四场景推送改造流程梳理(收款-付款-开票-收票).md new file mode 100644 index 00000000..f47d8675 --- /dev/null +++ b/doc/docs/hcs合同/2026-10-28-C108履约记录四场景推送改造流程梳理(收款-付款-开票-收票).md @@ -0,0 +1,268 @@ +# C108 履约记录四场景推送改造流程梳理(收款/付款/开票/收票) + +> 日期:2026-10-28 +> 范围:`tms-api` blade-transport HCS 推送链路;合同系统(HCS)接口 C108-1 履约记录新建 / C108-2 履约记录更新 +> 关联文档:《2026-10-27-履约计划与履约记录逻辑与流程梳理》(改造前基线) + +--- + +## 1. 需求口径 + +C108-1(履约记录新建)、C108-2(履约记录更新)两个接口需在收款、付款、开票、收票四个场景使用: + +| # | 场景 | 对应 TMS 数据 | 推送时机 | 履约日期(fd_plan_date) | 履约类型(fd_paln_type) | +|---|------|--------------|----------|------------------------|------------------------| +| 1 | 履约收款记录 | 收款认领记录(收款流水 × 认领分摊) | 金蝶认领推送(A301-3)成功后 | **交易日期**(收款流水 `transaction_time`) | 收款 | +| 2 | 履约付款记录 | 付款申请(含实际付款金额) | **金蝶实际付款后**(付款结果回写推进) | 申请日期(计划日期口径,维持原样) | 付款 | +| 3 | 履约开票记录 | 开票申请的金蝶回写发票(每张发票一条) | **金蝶开票完成后**(A303-5 发票落库后) | **发票完成开票日期**(`invoice_date`) | 开票 | +| 4 | 履约收票记录 | 收票单的每条结算信息(分摊行,每条一条) | 收票单推送合规(C109)成功后 | **发票日期**(收票单 `invoice_date`) | 收票 | + +> `fd_paln_type` 按四场景分别传中文值:**付款 / 收款 / 开票 / 收票**(常量 `HcsSyncConstant.PERF_TYPE_*`);C103-9 履约计划行的履约类型仍传 `payment`/`collection`,两者互不影响。 + +本次确认的业务口径调整:**履约付款记录由「审批通过即新建」改为「金蝶实际付款后才新建」**。 + +--- + +## 2. 总览:改造前后对照 + +| 场景 | 改造前 | 改造后 | +|------|--------|--------| +| 收款 | 已实现(C108-1),履约日期=最近认领日期 | 履约日期改为交易日期;组装逻辑收敛到通用组件 | +| 付款 | 审批通过即 C108-1 新建,特批通过后补推履约计划与履约记录,实际付款后 C108-2 更新 | **特批通过后不推任何履约数据**;未实际付款不推送;首次实际付款 C108-1 新建;分期付款推进 C108-2 更新 | +| 开票 | 无(仅 C111 开票登记) | 新增:金蝶回写发票逐张推 C108-1,发票粒度幂等+重试 | +| 收票 | 无(仅 C109 收票单) | 新增:每条结算分摊行推 C108-1,分摊行粒度幂等+重试,且限 C109 已推送 | + +--- + +## 3. 通用机制 + +### 3.1 接口与字段 + +- C108-1:`IHcsClient.perfRecordAdd`,返回 `fdId`(履约记录ID,**必须持久化**,C108-2 更新必传) +- C108-2:`IHcsClient.perfRecordUpdate`,`formData.fdId` 必填,响应无 data,`success=true` 即成功 +- `fd_plan_id`(履约计划id)接口必填:取合同**已同步的履约计划行合规阶段ID**(`blade_contract_exe_stage.hcs_stage_fd_id`);无已同步计划行时抛出重试,合同链路首推 C103-9 后下轮自愈 +- `fd_paln_type` 为接口文档原文拼写(plan 笔误),必须沿用;取值 `payment` / `collection` +- 机制数据(附件)C108-1 必填:新三场景(收款/开票/收票)无附件传空列表;付款场景保留附件转传逻辑 + +### 3.2 开关 + +统一复用 `thirdParty.hcs.perf-record.sync-enabled`(默认关,灰度打开),无新增配置: +- 收款:门禁 C108-1(C103-9/C107-1 仍由 `exe-stage.sync-enabled` 门禁) +- 付款:门禁 C108-1/C108-2 +- 开票/收票:门禁各自扫描任务,且随宿主任务(invoice-application / invoice-receipt)的 `sync-enabled` 一起生效 + +### 3.3 幂等与重试模式 + +- **付款**:单据粒度,`blade_payment_application.hcs_perf_fd_id` + `hcs_sync_status`(unsynced/synced/failed)+ `hcs_retry_count`,随 HcsPushTask 付款任务扫描重试 +- **开票**:发票粒度,`blade_invoice_application_invoice.hcs_perf_fd_id`(非空即已推,扫描跳过)+ `hcs_perf_retry_count` / `hcs_perf_sync_msg`,失败下轮自动补偿,超上限人工 +- **收票**:分摊行粒度,`blade_invoice_receipt_settlement.hcs_perf_fd_id` + 同上两列;扫描范围限定所属收票单 C109 已推送(`hcs_sync_status=synced`) +- **收款**:认领链事件驱动(金蝶认领推送成功后内联),fdId 暂不落库(shortcut:认领链无 C108-2 场景,重推会重复,需作废联动时先落库) + +### 3.4 通用组件 + +新增 `HcsPerfRecordAddSupport`(transport/support/hcs):收敛收款/开票/收票三场景的 C108-1 组装与推送(申请金额=实际金额、fd_plan_id 校验、标题、溯源字段),入参:合同、履约计划行、申请编号、金额、履约日期、履约类型、金蝶单据号、业务ID、标题。 +付款场景因含 C108-2 推进与附件转传,保留原 `HcsPerfRecordSyncSupport`。 + +--- + +## 4. 各场景流程明细 + +### 4.1 履约收款记录(对应认领记录) + +``` +认领操作 → 收款流水推送金蝶(A301-3)成功 + → HcsClaimSyncSupport.pushClaimToHcs(flowId) + ① C103-9 认领金额累加收款履约计划行并重推 + ② C107-1 认领记录(按「认领主单 × 合同」分组,金额为分摊到该合同的金额) + ③ C108-1 履约流水(按合同一条) ← 本次改造点 +``` + +- 履约日期:**收款流水交易日期** `blade_kingdee_receipt_flow.transaction_time`(原为最近认领日期,空时兜底当天) +- 申请编号:收款通知单号,兜底明细流水号 +- 申请金额 = 实际金额 = 该合同累计认领金额 +- fd_jd_doc_no:A301-3 返回的金蝶收款单号;fd_business_id:收款流水ID +- 标题:`收款履约-{通知单号}` +- 前置门禁:合同已推送合规(hcs_fd_id 非空)、存在已同步收款计划行,不满足仅记日志跳过 + +### 4.2 履约付款记录(对应付款记录,金蝶实际付款了再推) + +``` +付款申请审批通过 + → 如需特批:发起 C106-3 挂起,特批通过后仅联动审批恢复 + 补推金蝶 + ★改:特批通过后不推履约计划与履约记录(履约数据统一在各业务环节推送) + → 审批通过即时推送链不产生履约付款记录(paidAmount 为空/0 跳过, + 提示"金蝶未实际付款,待付款后推送履约记录",整体状态照常置 synced) + +金蝶实际付款 → 定时任务 A103-2 付款结果回写(applyPayApplyResult) + → paidAmount 首次 >0 或金额变化 → hcs_sync_status 置 unsynced ★改(原仅在有 fdId 时置) + → HcsPushTask 付款任务下轮扫描 → pushPayment + C103-9 履约计划重推(付款审批累加金额行,exe-stage 开关门禁) + 无 hcs_perf_fd_id → C108-1 新建(申请金额 + 实际金额)→ fdId 回填 hcs_perf_fd_id + 有 hcs_perf_fd_id → C108-2 更新(分期付款推进实际金额) +``` + +- 触发数据源:A103-2 分录 `e_paidamt` 汇总(分录缺失且已付款时兜底申请全额) +- 履约日期:申请日期(fd_plan_date 计划日期口径不变);C108-2 仅在金额推进时触发,`paying` 状态每轮回写金额不变不再重复推送 +- 标题:`付款履约-{付款申请单号}`;fd_jd_doc_no:金蝶付款申请单号 + +### 4.3 履约开票记录(对应开票申请,金蝶开票完成后推送) + +``` +开票申请审批通过 → 推金蝶(A303-1)→ 推合规 C111 开票登记(票据日期空) +金蝶开票完成 → 定时任务 A303-5 开票结果回写(executeInvoiceResultSync) + → 发票逐张落库 blade_invoice_application_invoice + → 申请单置 kingdeeStatus=invoiced + hcs_sync_status=unsynced(触发 C111-3 回填票据日期) + +HcsPushTask 开票任务(每5分钟): + ① pushUnsyncedInvoiceApplications:C111 开票登记推送/回填 + ② HcsInvoicePerfSyncSupport.pushUnsyncedInvoicePerfRecords ★新增 + 扫描 hcs_perf_fd_id IS NULL 且重试未超限的发票 + → 批量装配:申请单(须审批通过)→ 应收结算关联 → 正式结算单合同 → 收款履约计划行 + → 每张发票一条 C108-1:金额=发票价税合计,履约日期=发票完成开票日期 + → 成功回填 hcs_perf_fd_id;失败 retry+1 记 hcs_perf_sync_msg 下轮补偿 +``` + +- 申请编号:发票号;fd_jd_doc_no:金蝶开票申请单编号(serialNo);fd_business_id:发票记录ID +- 标题:`开票履约-{发票号}` +- 合同回溯不到(无应收结算关联/合同)→ 记失败原因,下轮重试(同 C111 §9-U10 口径) + +### 4.4 履约收票记录(对应收票单每条结算信息) + +``` +收票登记审批通过 + → 推金蝶收票单(A303-2 + A302-6) + → HcsPushTask 收票任务(每5分钟): + ① pushUnsyncedInvoiceReceipts:C109 收票单推送合规 + ② HcsReceiptPerfSyncSupport.pushUnsyncedReceiptPerfRecords ★新增 + 扫描 hcs_perf_fd_id IS NULL 且重试未超限的分摊行, + 且所属收票单 hcs_sync_status=synced(C109 已推送)+ 审批通过 + → 每条结算信息一条 C108-1:金额=分摊收票金额(兜底结算金额), + 履约日期=收票单发票日期,履约类型 payment,关联合同付款履约计划行 + → 成功回填 hcs_perf_fd_id;失败 retry+1 记 hcs_perf_sync_msg 下轮补偿 +``` + +- 申请编号:`{发票号}-{结算单号}`(同一发票分摊多结算单时逐条可区分);标题:`收票履约-{发票号}-{结算单号}` +- fd_jd_doc_no:金蝶收票单号(`kingdee_received_invoice_no`);fd_business_id:分摊行ID +- 合同回溯:分摊行 → 正式结算单 → contractId + +--- + +## 5. 定时任务编排(改造后) + +| 任务 | cron(默认) | 内容 | +|------|-------------|------| +| payment | `0 2/5 * * * ?` | 付款特批 C106 + 履约计划 C103-9 + **履约付款记录 C108-1/2(金蝶实际付款门禁)** | +| invoice-application | `0 3/5 * * * ?` | C111 开票登记 + **履约开票记录 C108-1(发票粒度)** | +| invoice-receipt | `0 4/5 * * * ?` | C109 收票单 + **履约收票记录 C108-1(分摊行粒度)** | +| (收款无独立任务) | 事件驱动 | 金蝶认领推送(A301-3)成功后内联:C103-9 → C107-1 → C108-1 | + +--- + +## 6. 数据库变更 + +`doc/sql/transport/blade_invoice_application_invoice_perf_20261028.sql`、`blade_invoice_receipt_settlement_perf_20261028.sql`: + +- `blade_invoice_application_invoice` 加 `hcs_perf_fd_id varchar(64)` / `hcs_perf_retry_count int` / `hcs_perf_sync_msg varchar(512)` +- `blade_invoice_receipt_settlement` 加同三列 + +--- + +## 7. 关键代码位置 + +| 文件 | 说明 | +|------|------| +| `support/hcs/HcsPerfRecordAddSupport.java` | ★新增 通用 C108-1 组件(收款/开票/收票共用) | +| `support/hcs/HcsInvoicePerfSyncSupport.java` | ★新增 履约开票记录扫描推送 | +| `support/hcs/HcsReceiptPerfSyncSupport.java` | ★新增 履约收票记录扫描推送 | +| `support/hcs/HcsClaimSyncSupport.java` | 改:履约日期→交易日期;组装改调通用组件 | +| `support/hcs/HcsPaymentSyncSupport.java` | 改:C108 环节增加「金蝶实际付款」门禁 | +| `service/impl/PaymentApplicationServiceImpl.java` | 改:实际付款推进置 unsynced(不再要求已有 fdId;金额不变不重复触发) | +| `task/HcsPushTask.java` | 改:开票/收票任务追加履约记录推送;组合方法改 public 供手动批量入口复用 | +| `controller/HcsSyncController.java` | 改:`POST /hcs/sync/invoice-application|invoice-receipt` 批量入口改走组合方法(手动触发也推履约记录) | +| `pojo/entity/InvoiceApplicationInvoice.java`、`InvoiceReceiptSettlement.java` | 改:加 3 个 hcs_perf_* 字段 | + +--- + +## 8. 验证指引 + +### 8.1 前置准备 + +1. **执行 SQL**:`doc/sql/transport/blade_invoice_application_invoice_perf_20261028.sql`、`blade_invoice_receipt_settlement_perf_20261028.sql`(两表各加 3 列)。 +2. **编译**:`mvn clean compile -DskipTests -pl blade-service/blade-transport -am`(已验证通过)。 +3. **Nacos 开关**(`third-party-api.yaml` / 环境配置): + - `thirdParty.hcs.perf-record.sync-enabled: true`(履约记录总开关,四场景共用,默认关) + - 宿主任务开关:`thirdParty.hcs.push.payment.sync-enabled`、`thirdParty.hcs.push.invoice-application.sync-enabled`、`thirdParty.hcs.push.invoice-receipt.sync-enabled`(定时任务与手动批量共用) + - 收款链另需 `thirdParty.hcs.exe-stage.sync-enabled: true`(门禁 C103-9/C107-1) + - 环境项照旧:`thirdParty.hcs.base-url/authorization/key-auth`、`perf-record.xform-id` +4. **基础数据**:合同已推送合规(`blade_contract_manage.hcs_fd_id` 非空)且存在已同步履约计划行(`blade_contract_exe_stage.hcs_stage_fd_id` 非空,收款场景需收款行、付款/收票需付款行、开票需收款行)——`fd_plan_id` 必填来源,无则推送记失败等待合同链路首推后自愈。 + +### 8.2 手动触发入口(联调不等定时任务) + +| 动作 | 接口 | +|------|------| +| 认领推送金蝶(收款链触发点) | `POST /receipt-flow/push-kingdee` | +| 付款结果回写(A103-2/A103-4) | `POST /payment-application/sync-kingdee-result` | +| 开票结果回写(A303-5 发票落库) | `POST /invoice-application/sync-kingdee-invoice-result` | +| 收票单推送金蝶(A303-2+A302-6) | `POST /invoice-receipt/push-kingdee` | +| HCS 批量扫描一轮(含履约记录) | `POST /hcs/sync/payment`、`/hcs/sync/invoice-application`、`/hcs/sync/invoice-receipt` | +| HCS 单条重推(付款) | `POST /hcs/sync/payment/{付款申请ID}` | + +> 注意:开票/收票的手动批量与定时任务走同一组合方法(C111/C109 + 履约记录一起推);单条重推仅覆盖 C111/C109 主单据,发票/分摊行粒度的履约重推走批量扫描(fdId 为空的行自动补偿)。 + +### 8.3 分场景验证步骤 + +**场景 1|履约收款记录** + +1. 页面对收款流水做认领(分摊到关联合同的正式结算单),推送金蝶认领:`POST /receipt-flow/push-kingdee`。 +2. TMS 日志:`HCS认领记录推送成功` → `HCS履约流水新建成功`(含 perfFdId)。 +3. HCS 侧核对履约记录:标题 `收款履约-{通知单号}`、申请/实际金额=该合同累计认领金额、**fd_plan_date=收款流水交易日期**(改造点,不再是最早/最近认领日期)、fd_plan_id=收款计划行阶段ID。 +4. 边界:交易时间为空的流水 → fd_plan_date 兜底当天;认领作废不触发推送。 + +**场景 2|履约付款记录(金蝶实际付款了再推)** + +1. 付款申请审批通过并推金蝶(`POST /payment-application/sync-kingdee`);此时**不应**产生履约付款记录——日志出现 `金蝶未实际付款,待付款后推送履约记录`,HCS 侧无记录。 +2. 特批场景(高风险/黑名单收款方或额度超额):提交后发起 C106-3 挂起;特批通过(回调 `paymentLegal/result` 或人工确认 `POST /hcs/spec-pay/confirm`)后,核对**无任何履约计划重推与履约记录推送动作**(仅审批恢复 + 金蝶补推),HCS 侧仍无履约付款记录。 +3. 在金蝶完成实际付款后,手动回写:`POST /payment-application/sync-kingdee-result`。 +4. 数据库:`SELECT paid_amount, hcs_sync_status FROM blade_payment_application WHERE id=?` → paid_amount>0 且 hcs_sync_status=unsynced。 +5. 手动扫描:`POST /hcs/sync/payment` → 日志 `HCS履约记录新建成功`(C108-1)→ 数据库 `hcs_perf_fd_id` 回填。 +6. 分期付款(金蝶再次付款)→ 再触发回写 → 金额变化置 unsynced → 再扫描 → 日志 `HCS履约记录更新成功`(C108-2);HCS 侧 fd_fact_amount 与最新 paid_amount 一致。 +7. 反向验证:`paying` 状态下连续两轮回写但金额不变 → 不置 unsynced、不重复推送。 + +**场景 3|履约开票记录** + +1. 开票申请审批通过 → 推金蝶 → 金蝶完成开票后手动回写:`POST /invoice-application/sync-kingdee-invoice-result` → `blade_invoice_application_invoice` 落库发票。 +2. 手动扫描:`POST /hcs/sync/invoice-application` → 汇总含 `履约开票记录:扫描 X 张,成功 X 张`。 +3. 数据库:`SELECT invoice_no, invoice_date, hcs_perf_fd_id, hcs_perf_sync_msg FROM blade_invoice_application_invoice WHERE invoice_application_id=?` → 每张发票 fdId 回填、msg 清空。 +4. HCS 侧核对:每张发票一条履约记录、标题 `开票履约-{发票号}`、**fd_plan_date=发票完成开票日期**、金额=发票价税合计、fd_plan_id=收款计划行阶段ID。 +5. 失败重试:构造失败(如临时断开 HCS / 删掉合同 hcs_fd_id)→ fdId 保持空、`hcs_perf_retry_count` 每轮 +1、`hcs_perf_sync_msg` 记录原因;恢复后下轮自动补推成功。 + +**场景 4|履约收票记录** + +1. 收票登记(分摊到结算单)审批通过 → 推金蝶:`POST /invoice-receipt/push-kingdee`。 +2. 手动扫描:`POST /hcs/sync/invoice-receipt` → 先 `C109 收票单推送成功`,再 `履约收票记录:扫描 X 条,成功 X 条`。 +3. 数据库:`SELECT formal_settlement_no, allocated_invoice_amount, hcs_perf_fd_id, hcs_perf_sync_msg FROM blade_invoice_receipt_settlement WHERE invoice_receipt_id=?` → 每条分摊行 fdId 回填。 +4. HCS 侧核对:每条结算信息一条履约记录、申请编号 `-{发票号}-{结算单号}`、**fd_plan_date=收票单发票日期**、金额=分摊收票金额、fd_plan_id=付款计划行阶段ID、履约类型 payment。 +5. 依赖顺序验证:C109 未推送成功时先扫描 → 分摊行 `hcs_perf_sync_msg` 记"收票单尚未推送合规";C109 成功后下轮自动补推,无需人工干预。 + +### 8.4 通用检查点 + +| 检查项 | 方法 | 预期 | +|--------|------|------| +| 幂等 | 同条件连续触发两轮 `/hcs/sync/{bizType}` | 第二轮履约记录扫描 0 条(fdId 非空跳过),HCS 无重复记录 | +| 失败重试上限 | 持续失败场景观察 | `hcs_perf_retry_count` 达 `thirdParty.hcs.push.max-retry`(默认 5)后不再扫描,人工处理后重置计数 | +| 履约日期口径 | HCS 侧逐条核对 fd_plan_date | 收款=交易日期 / 开票=发票开票日期 / 收票=发票日期 / 付款=申请日期 | +| 溯源字段 fd_business_id | HCS 侧核对 | 付款=付款申请ID、收款=流水ID、开票=发票记录ID、收票=分摊行ID | +| 开关关闭回归 | `perf-record.sync-enabled=false` 后触发各链路 | 金蝶/合规主流程(A301-3、C111、C109)不受影响,仅履约记录跳过 | +| 日志 | 检索 `HCS履约` | 推送开始/成功/失败成对出现,失败带响应 msg 与重试计数 | + +> 服务端核对路径:HCS 管理端按 `fd_doc_subject`(收款履约-/付款履约-/开票履约-/收票履约-前缀)检索履约记录列表。 + +--- + +## 9. 联调注意事项与风险 + +1. **fd_paln_type 口径(已确认)**:履约记录类型按四场景传中文——付款 / 收款 / 开票 / 收票;款项性质仍取关联履约计划行(progress),如合规侧字典要求编码(如 4141 样例)联调时调整常量即可。 +2. **C108-1 无服务端判重键**:新三场景极端情况下「推送超时但远端已成功」的重试可能在合同系统产生重复记录(与既有 C107-1 同风险);开票/收票有本地 fdId 幂等只能防"确认成功后"的重复,不能防"未知结果"的重试。 +3. **收票履约依赖 C109 先推**:收票单未推送合规(或推送失败)时分摊行记失败原因等待,C109 成功后下轮自动补偿。 +4. **存量数据**:改造前已按「审批通过即新建」推送过履约付款记录的单据(已有 `hcs_perf_fd_id`),后续金额推进仍走 C108-2,行为兼容;开票/收票新场景上线后只会处理新落库数据(`hcs_perf_fd_id` 为空的历史发票会被补推,如需排除请手工回填标记)。 +5. 测试由业务侧执行;开关 `thirdParty.hcs.perf-record.sync-enabled` 默认关闭,灰度打开后观察 HcsPushTask 日志(前缀 `HCS履约`)。 diff --git a/doc/docs/hcs合同/2026-10-29-履约计划与履约记录四场景业务说明(业务人员版).md b/doc/docs/hcs合同/2026-10-29-履约计划与履约记录四场景业务说明(业务人员版).md new file mode 100644 index 00000000..5fba57d5 --- /dev/null +++ b/doc/docs/hcs合同/2026-10-29-履约计划与履约记录四场景业务说明(业务人员版).md @@ -0,0 +1,289 @@ +# 履约计划与履约记录业务说明(付款 / 开票 / 收票 / 收款) + +> 适用对象:合同、结算、付款、开票、收票相关业务人员 +> 更新日期:2026-10-29 +> 用途:说明 TMS 与合同合规系统之间「履约计划」「履约记录」是怎么产生、什么时候产生、先后顺序和注意事项。 + +--- + +## 一、先看懂三个概念 + +| 概念 | 通俗理解 | 举例 | +| --- | --- | --- | +| **合同** | 业务发生的前提 | 与承运商签的运输合同、与客户签的运输服务合同 | +| **履约计划** | 合同约定的**收付款计划台账**(打算收/付多少钱、什么时候收/付) | 合同约定进度款 100 万,分几次结算 | +| **履约记录** | 计划实际执行的**流水**(真正付了、收了、开了票、收了票) | 本次实际付款 30 万、本次认领收款 20 万 | + +一句话记住:**计划是“打算”,记录是“实际发生”**。先有计划,才有记录;记录必须挂在某一条计划下面。 + +--- + +## 二、总体流程 + +``` +合同审批通过 + ↓ +生成 / 确认履约计划(付款计划、收款计划) + ↓ +合同与计划推送到合规系统(合规侧生成对应的计划编号) + ↓ +业务实际发生:付款 / 收款 / 开票 / 收票 + ↓ +系统自动生成履约记录,回传合规系统 +``` + +关键顺序: + +1. **合同先推合规**; +2. **履约计划再推合规**; +3. **计划推送成功后,履约记录才能生成**。 + +如果合同还没推合规,或者计划还没推成功,记录会暂时生成不了,等前面的步骤完成后自动补上(收款场景除外,见第六节)。 + +--- + +## 三、履约计划是怎么来的 + +### 3.1 计划从哪来 + +| 来源 | 说明 | +| --- | --- | +| 合同里维护「履约规则」 | 业务人员在合同页签里填写付款/收款计划行 | +| 合同审批通过自动生成 | 合同没有维护计划行时,系统自动补一条默认计划:承运商合同生成付款计划,客户合同生成收款计划 | +| 业务发生自动累加 | 付款审批通过、收款认领后,计划金额自动累加 | + +### 3.2 计划金额什么时候变 + +| 业务动作 | 计划金额变化 | +| --- | --- | +| 付款申请**审批通过** | 对应付款计划的金额 += 本次申请金额(本地记账,**金蝶实际付款后**才同步到合规) | +| 收款流水**认领完成** | 对应收款计划的金额 += 本次认领分摊金额 | + +请注意: + +- 计划金额是**“已批准 / 已认领”的口径**,不是实际到账/实付口径; +- 实际付了多少、收了多少,看履约记录; +- 付款计划只在款项性质一致时累加(预付款不会加到进度款计划上); +- 同一合同多次付款、多次认领,计划金额会持续累加。 + +### 3.3 计划的修改 + +- 合同审批通过后,已推送成功的计划行再修改,系统会自动重新推送合规; +- 删除已推送的计划行,会在合规侧同步删除(不能直接物理删除); +- 新增的计划行随下一次推送一起补到合规。 + +--- + +## 四、四个场景的履约记录 + +### 4.1 付款履约记录 + +**什么时候产生:金蝶实际付款之后。** + +业务过程: + +``` +付款申请审批通过 + → 本地累加计划金额(暂不同步合规),不产生履约付款记录 + → 推送金蝶付款申请 +金蝶完成实际付款 + → 系统回写实付金额 + → 计划金额同步到合规 + 生成履约付款记录 +``` + +业务要点: + +| 项目 | 说明 | +| --- | --- | +| 生成条数 | 一张付款申请对应一条;**分次付款不新增,只更新同一条的实际金额** | +| 金额 | 申请金额 + 实际付款金额 | +| 日期 | 付款申请日期 | +| 附件 | 付款场景会把付款附件一并传到合规 | +| 特批 | 需要特批的付款,特批通过后也不会立刻生成记录,仍要等金蝶实际付款 | + +> 常见误区:付款审批通过后就去合规找履约付款记录——此时还没有,属于正常现象,金蝶付款后才会出现(付款计划金额的累加同样是金蝶实际付款后才同步到合规)。 + +--- + +### 4.2 收款履约记录 + +**什么时候产生:收款流水认领完成之后。** + +业务过程: + +``` +收款流水到账并同步到 TMS + → 业务人员做认领(分摊到应收结算单) + → 认领提交后,系统自动: + ① 更新该合同的收款计划金额 + ② 推送认领记录到合规 + ③ 生成收款履约记录 +``` + +业务要点: + +| 项目 | 说明 | +| --- | --- | +| 生成条数 | 每完成一次认领,按涉及的合同各生成一条 | +| 金额 | **本次认领分摊到该合同的金额**(不是合同累计认领金额) | +| 日期 | 认领日期 | +| 作废 | 认领作废只会在合规侧把认领记录置为作废,**不会冲回计划金额,也不会冲回已生成的履约记录** | + +> 注意:收款链路的推送失败**不会自动重试**,如合规侧没有数据,请联系系统管理员排查。 + +--- + +### 4.3 开票履约记录 + +**什么时候产生:金蝶开票完成、发票回到 TMS 之后。** + +业务过程: + +``` +开票申请审批通过 + → 推送金蝶开票 + → 推送合规开票登记(此时还没有发票日期) +金蝶完成开票 + → 系统回写发票信息(每张发票一条) + → 按每张发票生成履约开票记录 +``` + +业务要点: + +| 项目 | 说明 | +| --- | --- | +| 生成条数 | **每张发票一条**;一张开票申请开多张发票,就会生成多条 | +| 金额 | 发票价税合计 | +| 日期 | 发票的开票日期 | +| 标题 | `开票履约-发票号`,可在合规侧按此检索 | +| 失败处理 | 合同或计划条件不满足时暂不生成,系统自动重试,条件满足后补上 | + +--- + +### 4.4 收票履约记录 + +**什么时候产生:收票单成功推送合规之后。** + +业务过程: + +``` +收票登记审批通过 + → 推送金蝶收票单 + → 推送合规收票单(C109) + → 推送成功后,按收票单里的每条结算信息生成履约收票记录 +``` + +业务要点: + +| 项目 | 说明 | +| --- | --- | +| 生成条数 | **每条结算信息(分摊行)一条**;一张收票单分摊到多张结算单就会生成多条 | +| 金额 | 分摊到该结算单的收票金额 | +| 日期 | 收票单的发票日期 | +| 标题 | `收票履约-发票号-结算单号` | +| 失败处理 | 收票单还没成功推合规时暂不生成,等收票单推送成功后自动补上 | + +--- + +### 4.5 四场景速查 + +| 场景 | 什么时候出现 | 生成条数 | 金额口径 | 日期口径 | +| --- | --- | --- | --- | --- | +| 付款 | 金蝶实际付款后 | 一张付款申请一条(分期更新同一条) | 申请金额 / 实付金额 | 申请日期 | +| 收款 | 认领提交后 | 每次认领 × 每个合同一条 | 本次认领分摊金额 | 认领日期 | +| 开票 | 金蝶开票完成后 | 每张发票一条 | 发票价税合计 | 发票开票日期 | +| 收票 | 收票单推合规成功后 | 每条结算分摊行一条 | 分摊收票金额 | 发票日期 | + +--- + +## 五、合规侧怎么核对 + +在合规系统履约记录列表中,按标题前缀检索最快: + +| 场景 | 标题前缀 | +| --- | --- | +| 付款 | `付款履约-` | +| 收款 | `收款履约-` | +| 开票 | `开票履约-` | +| 收票 | `收票履约-` | + +核对时重点看四项:**合同是否正确、金额是否正确、日期口径是否符合上表、条数是否与业务动作匹配**。 + +--- + +## 六、异常与重试 + +| 场景 | 推送失败后是否自动重试 | 业务需要做什么 | +| --- | --- | --- | +| 付款 | 是,系统定时自动重试 | 一般无需处理;长期失败联系管理员 | +| 开票 | 是,系统定时自动重试 | 确认金蝶是否已开票完成;长期失败联系管理员 | +| 收票 | 是,系统定时自动重试 | 先确认收票单是否已成功推送合规 | +| 收款 | **否** | 联系系统管理员手动排查处理 | + +常见“暂时生成不了”的原因: + +1. 合同还没推送合规; +2. 履约计划还没推送成功(没有可关联的计划); +3. 对应单据还没审批通过; +4. 收票单还没成功推送合规; +5. 金蝶还没实际付款 / 还没完成开票。 + +前四类在条件满足后会自动补推,不需要重复操作单据。 + +--- + +## 七、作废、驳回与变更 + +| 业务动作 | 对合规履约数据的影响 | +| --- | --- | +| 付款审批驳回 | 不产生履约付款记录 | +| 付款需要特批 | 特批期间不产生履约数据;特批通过后也要等金蝶实际付款 | +| 付款申请作废 | 当前**不会冲回**已累加的计划金额,也不会冲回履约付款记录 | +| 收款认领作废 | 合规侧认领记录作废;**计划金额与收款履约记录不冲回** | +| 合同变更审批通过 | 计划行随变更重新推送合规,已生成的履约记录不受影响 | + +> 如业务上需要“作废后金额必须回退”,请单独提需求或在合规侧人工调整,不能依赖当前自动链路。 + +--- + +## 八、常见问题(FAQ) + +**1. 合同刚审批通过,合规为什么没有履约记录?** +正常。此时只有履约计划,记录要等业务实际发生(付款、收款、开票、收票)后才生成。 + +**2. 付款已经审批通过了,合规为什么没有付款履约记录?** +正常。付款履约记录以**金蝶实际付款**为准,审批通过只更新计划金额。 + +**3. 同一笔付款分两次支付,合规会出现两条记录吗?** +不会。一张付款申请对应一条记录,第二次付款更新实际金额。 + +**4. 同一笔收款分多次认领,合规会出现几条?** +每次认领都会生成一条(按涉及合同分别生成),金额为本次分摊金额。 + +**5. 一张开票申请开了多张发票,合规有几条?** +每张发票一条。 + +**6. 收票单有多条结算信息,合规有几条?** +每条结算信息一条。 + +**7. 合规侧一条记录都没有,先查什么?** +按顺序确认:合同是否已推合规 → 履约计划是否已推成功 → 单据是否已审批通过 →(开票)金蝶是否已开票完成 →(收票)收票单是否已推合规。收款链路不会自动重试,需联系管理员。 + +**8. 计划金额和履约记录金额为什么对不上?** +两者口径不同:计划金额是“已批准/已认领”的累计口径,履约记录是“本次实际发生”的口径。付款场景下,计划看申请金额,记录看实付金额。 + +**9. 作废后合规金额为什么没变?** +当前作废不回冲计划金额和履约记录,需要在合规侧人工处理或单独提需求。 + +--- + +## 附录:业务名称与合规接口对照(供对接同学参考) + +| 业务名称 | 合规接口 | +| --- | --- | +| 合同推送 | C103-1 / C103-11 | +| 履约计划推送 | C103-9 | +| 付款履约记录 新建 / 更新 | C108-1 / C108-2 | +| 收款认领记录 新增 / 作废 | C107-1 / C107-3 | +| 开票登记 | C111 | +| 收票单推送 | C109 | diff --git a/doc/docs/hcs合同/2026-10-29-履约计划与履约记录四场景实现逻辑(付款-开票-收票-收款).md b/doc/docs/hcs合同/2026-10-29-履约计划与履约记录四场景实现逻辑(付款-开票-收票-收款).md new file mode 100644 index 00000000..a305ae36 --- /dev/null +++ b/doc/docs/hcs合同/2026-10-29-履约计划与履约记录四场景实现逻辑(付款-开票-收票-收款).md @@ -0,0 +1,317 @@ +# 履约计划与履约记录四场景实现逻辑(付款 / 开票 / 收票 / 收款) + +> 日期:2026-10-29 +> 范围:`tms-api` blade-transport 中 TMS ↔ 合同合规系统(HCS)的「履约计划」(C103-9)与「履约记录」(C108-1/2)。 +> 目标:只讲清**逻辑、流程、先后顺序**,不展开代码细节。 + +--- + +## 0. 总览 + +一句话:**履约计划是合同资金的“计划行”,履约记录是这些计划行的“实际发生流水”**。计划先行(先拿到 HCS 阶段ID),记录才能引用它;四个业务场景(付款/收款/开票/收票)各自在“业务真正发生”的时点产生一条或多条履约记录。 + +### 0.1 全景顺序 + +``` +合同审批通过 + └─ 建默认履约计划行 → 推合同 C103-1(拿合同 hcs_fd_id) + └─ 推履约计划 C103-9(拿计划 hcs_exeplan_id + 每行 hcs_stage_fd_id) + ├─ 付款:审批通过 → 计划金额本地累加(不推合规) → 金蝶实际付款 → C103-9 重推 + C108-1/C108-2 + ├─ 收款:认领提交 → 计划金额累加 → C107-1 → C108-1 + ├─ 开票:金蝶开票完成、发票落库 → C111 登记 → 每张发票 C108-1 + └─ 收票:收票单推合规 C109 成功 → 每条结算分摊行 C108-1 +``` + +### 0.2 四条链的关键先后关系 + +| 场景 | 前一条链必须先完成 | 履约记录产生时点 | 记录粒度 | +| --- | --- | --- | --- | +| 付款 | C103-1 拿到 `hcs_fd_id`;C103-9 计划行拿到 `hcs_stage_fd_id` | **金蝶实际付款后**(`paidAmount>0` 且金额推进) | 付款申请单(1 张单 1 条,分期走更新) | +| 收款 | 同上,且收款计划行已同步 | **认领事务提交后** | 认领主单 × 合同(每次认领各 1 条) | +| 开票 | 合同/收款计划行已同步;金蝶 A303-5 发票已落库 | **金蝶开票完成后** | 每张发票 1 条 | +| 收票 | 合同/付款计划行已同步;**C109 收票单已推合规成功** | **C109 推送成功后** | 每条结算分摊行 1 条 | + +--- + +## 1. 两个对象与 ID 链 + +| 对象 | TMS 落点 | HCS 接口 | 作用 | +| --- | --- | --- | --- | +| 履约计划行 | `blade_contract_exe_stage`(`HcsContractExeStage`) | C103-9 行级 add/update/delete | 合同维度的付款/收款资金计划 | +| 履约记录 | 各业务表上的 `hcs_perf_fd_id` | C108-1 新建 / C108-2 更新 | 计划的实际执行流水 | + +必须持久化的 ID 链(后面所有推送都依赖它): + +``` +blade_contract_manage.hcs_fd_id 合规合同ID(C103-1) +blade_contract_manage.hcs_exeplan_id 合规履约计划ID(C103-9) +blade_contract_exe_stage.hcs_stage_fd_id 合规阶段ID(C103-9 按行回填,= C108 的 fd_plan_id) +blade_payment_application.hcs_perf_fd_id 付款履约记录ID(C108-1 返回) +blade_invoice_application_invoice.hcs_perf_fd_id 开票履约记录ID +blade_invoice_receipt_settlement.hcs_perf_fd_id 收票履约记录ID +blade_receipt_claim_hcs_record.hcs_claim_record_fd_id C107-1 认领记录ID(供 C107-3 作废) +``` + +`fd_plan_id` 在 C108 接口中**必填**,只能取 HCS 自己返回的 `hcs_stage_fd_id`。因此没有已同步计划行时,履约记录一律推不出去,只能失败重试,等合同链把计划补推成功后自愈。 + +--- + +## 2. 履约计划(C103-9) + +### 2.1 计划行从哪来 + +| 入口 | 行为 | +| --- | --- | +| 合同表单「履约规则」页签维护(`ContractExeStageServiceImpl#submitRows/removeRow`) | 新行 → `row_op=add`;已同步行编辑 → `update`;已同步行删除 → 软删 + `delete` 标记(不能物理删,delete 要凭阶段ID 上报) | +| 合同审批通过(`createDefaultRowOnApprove`) | 无任何计划行时自动建默认行:承运商合同=付款/进度款,客户合同=收款/进度款;同时把该合同所有未拿到阶段ID 的行统一置 `add` | +| 付款审批通过(`accumulatePaidAmountOnApprove`) | 按付款类型映射款项性质(正式结算=进度款,其余=预付款),对**精确匹配**该款项性质的已同步付款行,原子累加 `fund_amount += 申请金额`,并置 `update + unsynced` | +| 收款认领(`accumulateClaimedAmountOnPush`) | 取收款/进度款计划行(无进度款则兜底首条已同步收款行),累加本次分摊到该合同的认领金额,置 `update + unsynced` | + +要点: + +- 金额累加只发生在“审批通过/认领提交”,**不是**在实际收付款时;因此计划金额反映的是“已批准/已认领的计划口径”,履约记录才反映实际发生额。 +- 付款累加严格要求款项性质一致,匹配不到就跳过,避免预付款计进进度款;收款累加允许兜底行。 +- 已同步行靠 `hcs_stage_fd_id` 重推;未同步行保持 `add`,随合同链首推(首次推送即携带累加后的金额)。 + +### 2.2 同步逻辑(`HcsExeStageSyncSupport#syncExeStages`) + +1. 前置:合同必须有 `hcs_fd_id`。 +2. 取该合同所有 `row_op <> 'none'` 的行;删除行已软删,用原生 SQL 绕过逻辑删除才能取到。 +3. 整体操作类型:`hcs_exeplan_id` 为空 → `add`(首次),否则 `update`。 +4. 行级:`add` 行带 `fdUniqueId=TMS行ID`(重试稳定);`update/delete` 行必须带 `fdId=hcs_stage_fd_id`。 +5. 响应回填:回填 `hcs_exeplan_id`、逐行回填 `hcs_stage_fd_id`,清 `row_op`、置 `synced`;`add` 行没匹配到阶段ID → 置 `failed`,下轮重试。 + +### 2.3 三个触发入口(谁调 C103-9) + +| 入口 | 位置 | 门禁 | 说明 | +| --- | --- | --- | --- | +| 合同链 | `HcsContractSyncSupport#pushContract` 第⑤步 | `exe-stage.sync-enabled` | 合同起草/更新成功后、特批挂起前同步计划 | +| 付款链 | `HcsPaymentSyncSupport#pushPayment` 第③步 | `exe-stage.sync-enabled` | **金蝶实际付款门禁通过后**重推计划(审批通过仅本地累加金额不推送),再推履约记录 | +| 收款链 | `HcsClaimSyncSupport#pushOneContract` 第①步 | `exe-stage.sync-enabled` | 先累加并重推计划,再推 C107-1/C108-1 | + +> 合同还没推合规时,计划行只做本地标记/累加,不报错,等合同链首推时一并带上。 + +--- + +## 3. 履约记录(C108) + +### 3.1 公共前置与公共字段 + +所有场景的 C108-1 都要求: + +- 合同已推合规(`hcs_fd_id` 非空); +- 存在已同步计划行(`hcs_stage_fd_id` 非空):付款/收票取**付款**计划行,收款/开票取**收款**计划行; +- `perf-record.sync-enabled` 开启; +- `fd_plan_id`/`fd_id` = 计划行 `hcs_stage_fd_id`,`fd_property` = 计划行款项性质。 + +四个场景的差异主要在:**触发时点、粒度、金额、履约日期、类型、幂等键**。 + +### 3.2 付款(`HcsPaymentSyncSupport` + `HcsPerfRecordSyncSupport`) + +``` +付款申请审批通过 + → hcs_sync_status=unsynced;计划金额本地累加(row_op=update,不推合规) + → 即时推送/扫描:paidAmount=0 → 履约计划与履约记录均不推送 + (提示“金蝶未实际付款”),整体仍置 synced + +金蝶实际付款 → A103-2 回写(applyPayApplyResult) + → paidAmount 首次>0 或金额变化 → hcs_sync_status=unsynced + → 扫描付款任务:先 C103-9 重推累加金额的计划行 + 无 hcs_perf_fd_id → C108-1 新建,返回 fdId 立即落库 + 有 hcs_perf_fd_id → C108-2 更新(分期付款推进实际金额) +``` + +- 金额:申请金额=`appliedAmount`,实际金额=`paidAmount`。 +- 日期:申请日期(`applyDate`)。 +- 类型:`付款`。 +- 幂等:单据粒度,`hcs_perf_fd_id` 决定新建还是更新;金额不变不会重复触发。 +- 附件:仅付款场景保留附件转传(C103-2 → `fd_plan_att`),其余场景传空列表。 +- 特批:`need` 发起 C106-3 后挂起;特批通过后**不补推任何履约数据**,只恢复审批并补推金蝶;驳回不产生履约数据。 + +### 3.3 收款(`HcsClaimSyncSupport`) + +``` +认领登记(只登记本地,不推金蝶) + → 事务提交后 afterCommit → pushClaimToHcs(claimId) + ① 按“本次分摊到该合同”的金额累加收款计划行 → C103-9 重推 + ② C107-1 认领记录(认领主单 × 合同一条,金额=本次分摊额) + ③ C108-1 履约流水(同一粒度:认领主单 × 合同一条) +``` + +- 触发点:**认领事务提交后**,不是金蝶回调。 +- 前置:`exe-stage.sync-enabled` 为整条链总门禁;C108-1 另受 `perf-record.sync-enabled` 门禁。 +- 定位合规流水:C104-2 按明细流水号查出合规收款流水ID;查不到只跳过 C107-1,不影响计划更新。 +- 金额:本次认领分摊到该合同的金额(申请金额=实际金额)。 +- 日期:认领日期(当前代码 `claim.claimDate`)。 +- 类型:当前代码传的是 `collection`(见第 6 节差异说明)。 +- 幂等/重试:事件驱动、**无定时重试**;C108-1 返回的 fdId 未落库(无 C108-2 场景),失败只记日志。 +- 作废:只推 C107-3 把 C107-1 记录置为已作废;**不回冲计划金额、不回冲履约记录**。 + +### 3.4 开票(`HcsInvoicePerfSyncSupport`) + +``` +开票申请审批通过 → 推金蝶 A303-1 → 推合规 C111 开票登记(票据日期为空) +金蝶开票完成 → A303-5 回写:发票逐张落库;申请单置 invoiced + hcs_sync_status=unsynced + +扫描开票任务(同一轮): + ① C111 推送/回填票据日期 + ② 履约开票记录:扫描 hcs_perf_fd_id 为空且未超重试上限的发票 + 申请单须审批通过 + 合同回溯:开票申请 → 应收结算关联 → 正式结算单 → 合同 + 每张发票一条 C108-1 +``` + +- 金额:发票价税合计 `invoiceAmount`。 +- 日期:发票完成开票日期 `invoiceDate`。 +- 类型:`开票`。 +- 申请编号:发票号;标题:`开票履约-{发票号}`。 +- 幂等:发票粒度,`hcs_perf_fd_id` 非空即跳过;失败 `retry+1` + 原因,下轮补偿,超上限人工处理。 +- 合同回溯不到、无已同步收款计划行 → 记失败原因,等条件满足后自愈。 + +### 3.5 收票(`HcsReceiptPerfSyncSupport`) + +``` +收票单审批通过 → 推金蝶收票单(A303-2 + A302-6) + +扫描收票任务(同一轮): + ① C109 收票单推送合规 → 成功置 hcs_sync_status=synced + ② 履约收票记录:扫描 hcs_perf_fd_id 为空且未超重试上限的分摊行 + 所属收票单须审批通过且 hcs_sync_status=synced(C109 必须先成功) + 合同回溯:分摊行 → 正式结算单 → 合同 + 每条结算分摊行一条 C108-1 +``` + +- 金额:分摊收票金额 `allocatedInvoiceAmount`,为空兜底结算金额。 +- 日期:收票单发票日期 `invoiceDate`。 +- 类型:`收票`;关联**付款**计划行。 +- 申请编号:`{发票号}-{结算单号}`;标题:`收票履约-{申请编号}`。 +- 幂等:分摊行粒度,`hcs_perf_fd_id` 非空跳过;C109 未成功时记“尚未推送合规”,成功后下轮自动补偿。 + +### 3.6 四场景对照 + +| 维度 | 付款 | 收款 | 开票 | 收票 | +| --- | --- | --- | --- | --- | +| 触发时点 | 金蝶实际付款回写后 | 认领事务提交后 | 金蝶开票完成、发票落库后 | C109 推送成功后 | +| 粒度 | 付款申请单 | 认领主单 × 合同 | 每张发票 | 每条结算分摊行 | +| 申请金额 | 申请金额 | 本次分摊金额 | 发票价税合计 | 分摊收票金额 | +| 实际金额 | 实付金额 | 同申请金额 | 同申请金额 | 同申请金额 | +| 履约日期 | 申请日期 | 认领日期 | 发票开票日期 | 发票日期 | +| 类型 | 付款 | collection(现状) | 开票 | 收票 | +| 计划行 | 付款(按款项性质) | 收款(进度款优先) | 收款(进度款优先) | 付款(进度款优先) | +| 幂等键 | `hcs_perf_fd_id`(可更新) | 无(每次认领新建) | `hcs_perf_fd_id` | `hcs_perf_fd_id` | +| 失败重试 | 定时扫描 | 无 | 定时扫描 | 定时扫描 | +| 更新接口 | C108-2 支持 | 不支持 | 不支持 | 不支持 | + +--- + +## 4. 端到端时序(先后顺序) + +### 4.1 计划必须先于记录 + +```mermaid +sequenceDiagram + participant C as 合同 + participant H as HCS + participant B as 业务单据(付款/认领/发票/收票) + C->>H: C103-1 合同起草 + H-->>C: hcs_fd_id + C->>H: C103-9 履约计划(add) + H-->>C: hcs_exeplan_id + 每行 hcs_stage_fd_id + Note over C,H: 阶段ID 落库后,下面四条链才有 fd_plan_id + B->>H: C108-1 履约记录(fd_plan_id=阶段ID) +``` + +### 4.2 付款链 + +```mermaid +sequenceDiagram + participant P as 付款申请 + participant H as HCS + participant K as 金蝶 + P->>P: 审批通过:计划金额+=申请金额 + P->>H: C103-9 重推计划 + P->>P: paidAmount=0,跳过 C108 + K-->>P: 实际付款回写(paidAmount 变化) + P->>H: C108-1(首次)/ C108-2(分期) +``` + +### 4.3 收款链 + +```mermaid +sequenceDiagram + participant U as 认领 + participant H as HCS + U->>U: 认领登记(本地) + U->>H: ① C103-9 累加收款计划并重推 + U->>H: ② C104-2 定位合规流水 → C107-1 认领记录 + U->>H: ③ C108-1 履约流水(本次分摊金额) +``` + +### 4.4 开票 / 收票(任务内顺序) + +``` +开票任务:C111 开票登记 → 发票逐张 C108-1(同一轮) +收票任务:C109 收票单 → 分摊行逐条 C108-1(同一轮,C109 必须先成功) +``` + +--- + +## 5. 定时任务与开关 + +| 任务 | 默认 cron | 内容 | +| --- | --- | --- | +| contract | `0 1/5 * * * ?` | C103-1/C103-11 → C103-9 | +| payment | `0 2/5 * * * ?` | C106 特批 →(金蝶实际付款后)C103-9 → C108-1/2 | +| invoice-application | `0 3/5 * * * ?` | C111 → 履约开票 C108-1 | +| invoice-receipt | `0 4/5 * * * ?` | C109 → 履约收票 C108-1 | +| 收款 | 无独立任务 | 认领事务提交后事件驱动 | + +| 配置(`thirdParty.hcs.*`) | 作用 | 默认 | +| --- | --- | --- | +| `exe-stage.sync-enabled` | C103-9 总开关;同时门禁整条收款链 | true | +| `perf-record.sync-enabled` | 四场景履约记录总开关 | false | +| `perf-record.xform-id` | C108 表单模板ID | 空 | +| `push.payment/invoice-application/invoice-receipt.sync-enabled` | 各任务开关 | false | +| `push.max-retry` / `push.batch-size` | 重试上限 / 单轮批量 | 5 / 配置值 | +| `attachment.transfer-enabled` | 付款履约记录附件转传 | true | + +手动入口:`POST /hcs/sync/{bizType}` 批量扫描一轮(开票/收票为组合动作,含履约记录);`POST /hcs/sync/{bizType}/{id}` 单条重推(仅主单据,不覆盖发票/分摊行粒度)。 + +--- + +## 6. 当前实现与设计文档的差异(重要) + +以下为代码实际情况,与《2026-10-27 履约计划与履约记录逻辑与流程梳理》《2026-10-28 C108 履约记录四场景推送改造流程梳理》存在出入,落地时以本节为准: + +| 点 | 设计文档口径 | 当前代码 | +| --- | --- | --- | +| 收款触发 | 金蝶认领推送 A301-3 成功后 | **认领事务提交后**(认领只登记本地,不推金蝶) | +| 收款履约日期 | 收款流水交易日期 `transaction_time` | **认领日期 `claimDate`**(提交 `4a76e65b` 回退了该调整) | +| 收款履约类型 | 中文「收款」 | **`collection`**(`PERFORM_TYPE_COLLECTION`,未使用 `PERF_TYPE_COLLECTION`) | +| 收款金额 | 该合同累计认领金额 | **本次认领分摊到该合同的金额** | +| 收款组装 | 复用 `HcsPerfRecordAddSupport` | **仍为 `HcsClaimSyncSupport` 内部独立组装** | +| 付款链 C103-9 门禁 | 不受 `exe-stage` 开关限制 | **受 `exe-stage.sync-enabled` 控制** | +| 收款链 fdId | 需落库以支持后续更新 | **未落库**(无 C108-2 场景) | +| 付款作废 / 认领作废 | — | 均不回冲计划金额与履约记录(认领仅 C107-3 作废) | + +建议:若确认收款需按「交易日期 + 中文类型 + 通用组件」口径,应把 `HcsClaimSyncSupport#pushPerfRecord` 切到 `HcsPerfRecordAddSupport`,并同步修正日期与类型常量;否则应同步更新设计文档,避免联调口径不一致。 + +--- + +## 7. 代码索引 + +| 职责 | 类 / 方法 | +| --- | --- | +| 计划行维护、差异标记、金额累加、默认行 | `ContractExeStageServiceImpl`:`submitRows` / `removeRow` / `accumulatePaidAmountOnApprove` / `accumulateClaimedAmountOnPush` / `createDefaultRowOnApprove` / `findPaymentStageRow` / `findCollectionStageRow` | +| C103-9 同步与回填 | `HcsExeStageSyncSupport#syncExeStages` | +| 付款链编排 | `HcsPaymentSyncSupport#pushPayment` | +| 付款履约记录 C108-1/2 | `HcsPerfRecordSyncSupport#pushPerfRecord` | +| 收款链编排 | `HcsClaimSyncSupport#pushClaimToHcs` / `#pushOneContract` / `#pushPerfRecord` / `#voidClaimToHcs` | +| C108-1 通用组件(开票/收票共用) | `HcsPerfRecordAddSupport#pushPerfRecordAdd` | +| 开票履约记录 | `HcsInvoicePerfSyncSupport#pushUnsyncedInvoicePerfRecords` | +| 收票履约记录 | `HcsReceiptPerfSyncSupport#pushUnsyncedReceiptPerfRecords` | +| 定时任务编排 | `HcsPushTask`:`pushHcsPayment` / `syncHcsInvoiceApplications` / `syncHcsInvoiceReceipts` | +| 审批即时推送 | `HcsImmediatePushSupport#pushPaymentAndWait / pushPaymentAfterCommit` | +| 金蝶实付回写触发 | `PaymentApplicationServiceImpl#applyPayApplyResult` | +| 认领事务提交触发 | `ReceiptFlowServiceImpl`(`afterCommit` → `pushClaimToHcs`) | +| 常量与映射 | `HcsSyncConstant`(`PERF_TYPE_*`、`PERFORM_TYPE_*`、`FUND_NATURE_*`、`fundNatureOfPayment`) | diff --git a/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md b/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md index e0bb492c..c5295b29 100644 --- a/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md +++ b/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md @@ -2,6 +2,11 @@ > 日期:2026-10-16 > 范围:`tms-api` blade-transport 收款流水认领、认领记录模块;`tms-erp-web` 认领记录页面 +> +> **口径变更提示(2026-10-29)**:文中"公告未认领金额 `unclaimed_amount` / A301-2 口径"已废止,见 +> [2026-10-29-收款流水菜单改造](./2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步).md)。 +> 现认领上限为**收款金额 `receipt_amount`(A301-1 `actrecamt`)**,`remaining_amount = max(收款金额 − 累计认领金额, 0)`; +> 金蝶未匹配金额 `unmatch_amount_rec` 仅作参考展示,不参与额度控制。认领/作废/多次认领的其余口径不变。 --- @@ -45,10 +50,10 @@ blade_kingdee_receipt_flow(收款流水 / 认领通知单,1) 1. 锁定流水(通知单)与所选结算单。 2. 校验两条累计上限: - 每张结算单:已有累计认领 + 本次分摊 ≤ 结算总应收含税金额; - - 流水:已有累计认领(`flowClaimedAmount`,只算未作废记录)+ 本次总额 ≤ 公告未认领金额(`unclaimed_amount`,A301-2 口径)。 + - 流水:已有累计认领(`flowClaimedAmount`,只算未作废记录)+ 本次总额 ≤ 收款金额(`receipt_amount`,A301-1 口径;2026-10-29 前为公告未认领金额)。 3. **插入一条** `ReceiptClaim` 主单:`claim_amount` = 本次分摊总额,`claim_status = claimed`。 4. 按所选结算单逐张**插入 N 条** `ReceiptClaimSettlement` 分摊明细。 -5. 累加流水 `claimed_amount`,按累计值重算流水状态(< 未认领金额 → partial,= → claimed)。 +5. 累加流水 `claimed_amount`,按累计值重算流水状态(< 收款金额 → partial,≥ 收款金额 → claimed)。 6. 只登记本地,不推送金蝶;推送由定时任务/手动按钮触发。 所以:**同一通知单第二次认领是再插一条新主单**,不存在"找已有主单合并金额"的逻辑;校验全部按"未作废记录累计"实时计算,天然支持多次认领。 @@ -69,17 +74,17 @@ canClaim(row) { 2. **剩余可认领金额 > 0**:`remainingAmount` 由后端 `ReceiptFlowWrapper` 统一计算: ``` - remainingAmount = max(公告未认领金额 unclaimed_amount − 累计认领金额 claimed_amount, 0) + remainingAmount = max(收款金额 receipt_amount − 累计认领金额 claimed_amount, 0) ``` 按认领状态换算: | 流水认领状态 | remainingAmount | 认领按钮 | | --- | --- | --- | -| unclaimed 未认领(公告金额 > 0) | = 公告未认领金额 > 0 | 显示 | +| unclaimed 未认领(收款金额 > 0) | = 收款金额 > 0 | 显示 | | partial 部分认领 | > 0 | 显示 | -| claimed 认领完成 | = 0(累计 ≥ 公告金额) | **不显示** | -| 公告未认领金额为 0 的流水 | = 0 | **不显示** | +| claimed 认领完成 | = 0(累计 ≥ 收款金额) | **不显示** | +| 收款金额为 0 或缺失的流水 | = 0 | **不显示** | | 无 `receipt_flow_claim` 权限 | — | **不显示** | 即:**按钮显隐只看金额余额与权限,不看认领状态字段本身**;`claimed` 状态的行因为剩余额度为 0 自然不显示按钮。按钮不显示且不满足"推送金蝶"条件时,操作列显示"-"。 diff --git a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/01-结算调整单列表-金蝶状态与推送入口.png b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/01-结算调整单列表-金蝶状态与推送入口.png new file mode 100644 index 00000000..8303ad57 Binary files /dev/null and b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/01-结算调整单列表-金蝶状态与推送入口.png differ diff --git a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/02-新增调整单-回带结算单信息与费用明细.png b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/02-新增调整单-回带结算单信息与费用明细.png new file mode 100644 index 00000000..8410d6d1 Binary files /dev/null and b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/02-新增调整单-回带结算单信息与费用明细.png differ diff --git a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/03-新增调整单-选单后金蝶冲销预检提示.png b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/03-新增调整单-选单后金蝶冲销预检提示.png new file mode 100644 index 00000000..4d3bf538 Binary files /dev/null and b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/03-新增调整单-选单后金蝶冲销预检提示.png differ diff --git a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/04-重新推送-高危二次确认.png b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/04-重新推送-高危二次确认.png new file mode 100644 index 00000000..6f20c8d9 Binary files /dev/null and b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/04-重新推送-高危二次确认.png differ diff --git a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md index a33bdcc2..d31ac25f 100644 --- a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md +++ b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md @@ -20,6 +20,8 @@ > 全程交互使用中文。 +![结算调整单列表:审批通过的调整单统一一个入口,未同步显示「推送金蝶」、已同步显示「重新推送(高危)」;列表带「金蝶单据状态 / 金蝶单据号」列](./2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/01-结算调整单列表-金蝶状态与推送入口.png) + ## 2. 名词与接口速查 | 代号 | 含义 | @@ -119,6 +121,8 @@ - 排除收付完成/尾款等不可调整(`adjustableFormalSettlement`,约 295); - 保存时自动回带客商、调整类型、合同、原结算金额等。 +![新增结算调整单:选定关联结算单后自动回带客商、结算单类型、合同、原结算金额,并带出原单费用明细供录入调整金额](./2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/02-新增调整单-回带结算单信息与费用明细.png) + ### 6.2 选单后金蝶冲销预检(新增,软提示) 选定关联结算单号、回带信息后,前端调后端**预检接口**: @@ -133,6 +137,9 @@ - 预检为**软提示**,不阻断建单(默认建议);是否升级为硬拦截由业务另行确认。 - **降级**:金蝶查询超时/不可用/接口异常时,预检返回"未校验",**不阻断**建单与回带,由后续审批分流与重推闸门兜底。 - 预检结果**只用于提示**,不作为后续环节的结论缓存:审批(操作 2)、重推(操作 3)各自按**当下**状态重新判定。 +- 预检查询结果**顺带回写**正式结算单的冲销状态字段(`kingdee_writeoff_status`,见 §15)供列表展示;异常降级时不回写、保留上次值。 + +![选单后金蝶冲销预检:该结算单已推送金蝶且旧单尚未冲销时的醒目提示,仅为软提示,不阻断建单](./2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/03-新增调整单-选单后金蝶冲销预检提示.png) > 为什么不把硬拦截放在选单:冲销是金蝶侧**不可逆/需人工撤销**的对外操作,而审批只是 TMS 内部决策。若选单即要求已冲销,会出现"旧单已冲销、调整单却被驳回/删除"的**应付单空档**(详见第 13 节)。 @@ -165,7 +172,7 @@ `repush(调整单id)` 重写为: 1. **前置校验**:调整单 `approvalStatus = approved`;原单存在。 -2. **冲销校验(硬闸门)**:取调整单号 + 原单号,按旧键(`原结算单号_明细单据号`)查 A102-4(应付)/ A302-4(应收);要求存在且 `hadwrittenoff = true`,否则抛出「原应付/应收单尚未在金蝶冲销,禁止重推」。 +2. **冲销校验(硬闸门)**:取调整单号 + 原单号,按旧键(`原结算单号_明细单据号`)查 A102-4(应付)/ A302-4(应收);要求存在且 `hadwrittenoff = true`,否则抛出「原应付/应收单尚未在金蝶冲销,禁止重推」。**闸门查询结果无论放行还是拦截均回写**正式结算单冲销状态(§15.3③),拦截后列表即同步为"未冲销"。 3. **克隆新正式结算单**:`cloneFormalSettlement(原单, 调整单)` - 新号由 `nextNo` 取正式流水(`FJS`/`SJS`+日期+4 位); - 克隆明细、明细费项、汇总费项、发票、汇率等; @@ -179,6 +186,8 @@ 7. **原单收尾**:标记 `adjustStatus='已调整'` + 旧锁记录置 `unlocked`;追加重推变更记录。业务字段不动。 8. **失败处理**:新单 `kingdeeSyncStatus=failed` + 失败原因,支持复用 `retryKingdeeFailed` 重试(重试即从第 4 步继续,克隆已存在则跳过,注意幂等)。 +![重新推送(高危):点击后先弹高危二次确认,确认金蝶旧应付/应收单已由财务手工冲销后才真正生成新单](./2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).assets/04-重新推送-高危二次确认.png) + ## 9. 关键机制 ### 9.1 防重键与新单号 @@ -190,7 +199,7 @@ - 只认 `hadwrittenoff = true`(`BfiApBillVO` 158 / `BfiArBillVO` 138)。部分冲销同样放行。 - `iswrittenoff` 不单独作为放行依据。落地前需用 Apifox 实测 A102-4/A302-4 确实回传 `hadwrittenoff`。 -- **两处判定,用途不同**:操作 1 选单预检 = 软提示(第 6.2 节);操作 3 重推 = **硬闸门**(第 8 节)。预检不替代闸门——冲销在金蝶可被撤销,且建单到重推期间状态可能变化。 +- **三处判定,用途不同**:操作 1 选单预检 = 软提示(第 6.2 节);操作 3 重推 = **硬闸门**(第 8 节);定时回写 = 列表展示供数(第 15 节)。预检不替代闸门——冲销在金蝶可被撤销,且建单到重推期间状态可能变化;定时回写值**只用于展示**,闸门放行永远以实时查询为准。 ### 9.3 克隆新正式结算单 @@ -288,3 +297,49 @@ ALTER TABLE blade_formal_settlement ADD COLUMN adjust_status VARCHAR(32) NULL CO 5. **按钮与菜单**:「推送金蝶」与「重新推送」合并为**单一入口**(按金蝶状态分流:未同步/失败→推原结算单;已同步→冲销后重推新单),统一复用既有菜单 `settlement_adjustment_repush`,不新增菜单。 6. **待实测**:A102-4/A302-4 的 `hadwrittenoff` 实际回传(§13.1);克隆新单对 `PreSettlement` 占用的影响(§13.2)。 +--- + +## 15. 金蝶冲销状态落库与列表展示(2026-10-30 新增) + +### 15.1 背景与定位 + +预检(§6.2)与重推闸门(§8)是**事件触发**的单点实时校验:只有建调整单、点重推时才会查金蝶,覆盖不到结算单列表的全量展示;而列表是高频页面,不能每次刷新/翻页都实时查金蝶(外部依赖与延迟会拖垮列表)。因此把冲销状态**持久化 + 定时回写**(与 `PaymentKingdeeResultTask` 既有回写模式一致);决策时刻(预检提示、闸门放行)仍坚持实时查询。三条链路互补:**接口查询保证决策正确,定时回写保证列表有数**。 + +### 15.2 数据模型(DDL) + +脚本 `doc/sql/transport/lqj/20261102/06_blade_formal_settlement_kingdee_writeoff_20261030.sql`(可重复执行): + +```sql +ALTER TABLE blade_formal_settlement ADD COLUMN kingdee_write_off_status VARCHAR(20) NULL COMMENT '金蝶冲销状态:unwrittenoff-未冲销 writtenoff-已冲销(NULL=未推送金蝶或未校验)'; +ALTER TABLE blade_formal_settlement ADD COLUMN kingdee_write_off_time datetime NULL COMMENT '金蝶冲销状态确认时间(定时回写/预检回写时间)'; +``` + +> 列名遵循 MyBatis-Plus 驼峰转下划线默认映射(`kingdeeWriteOffStatus` → `kingdee_write_off_status`),实体无需 `@TableField`;脚本对早期误建的旧列名(`kingdee_writeoff_status`)会先重命名再补缺,可重复执行。 + +实体 `FormalSettlement` 同步加 `kingdeeWriteOffStatus` / `kingdeeWriteOffTime`;`FormalSettlementVO` 继承实体自动带出,无需改 VO。 + +### 15.3 三条查询/回写链路 + +| 链路 | 触发 | 行为 | +|---|---|---| +| ① 定时回写(兜底供数) | `PaymentKingdeeResultTask` 子流程④(默认每 10 分钟,single-flight 锁 `kingdee-writeoff-sync`) | 扫 `kingdeeSyncStatus=synced` 的结算单 → 结算明细分批一次查全(消除 N+1)组装防重键 → A102-4/A302-4 整批查询 `hadwrittenoff` → 状态有变化的 `updateBatchById` 批量回写;金蝶异常整轮告警、保持上次值 | +| ② 预检实时回写 | 操作 1 选单预检 | 查询成功即回写该单状态(已冲销/未冲销都存);异常降级"未校验"不回写 | +| ③ 闸门实时回写 | 操作 3 重推硬闸门 | 查询结果无论放行/拦截均回写(拦截=未冲销也存,提示后列表即同步) | + +回写统一走 `FormalSettlementServiceImpl.refreshKingdeeWriteOff(id, writtenOff)`:状态未变化不落库,避免预检反复点击重复写库。 + +### 15.4 列表展示口径 + +| 场景 | 显示 | +|---|---| +| 未推送金蝶(unsynced/failed) | `-` | +| 已推送,尚未回写过(存量上线初期) | 未校验 | +| 已推送,旧单未冲销 | 未冲销(黄) | +| 已推送,旧单已冲销(含部分冲销) | 已冲销(绿) | + +前端:`formalSettlementTable.js` 新增「金蝶冲销状态」列(`formalSettlement.js` COLUMN_EXTRA 标记 `slot`)、`formal-settlement-table-panel.vue` 单元格插槽渲染标签、`utils/kingdee.js` 新增 `kingdeeWriteOffTagType` 颜色映射、`formalSettlementSearch.js` 新增 `kingdeeWriteOffStatusOptions` 文案。 + +### 15.5 与硬闸门的关系(重要) + +定时回写值**只用于展示**,允许滞后一个调度周期(默认 10 分钟);重推放行与否**永远以闸门实时查询为准**(§8.2),不读取定时值——避免拿旧状态放行导致金蝶重复生成应付/应收单。 + diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/01-结算调整单列表-各状态操作入口.png b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/01-结算调整单列表-各状态操作入口.png new file mode 100644 index 00000000..992bc639 Binary files /dev/null and b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/01-结算调整单列表-各状态操作入口.png differ diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/02-新增调整单-选单回带与费用录入.png b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/02-新增调整单-选单回带与费用录入.png new file mode 100644 index 00000000..8410d6d1 Binary files /dev/null and b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/02-新增调整单-选单回带与费用录入.png differ diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/03-新增调整单-金蝶冲销预检提示.png b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/03-新增调整单-金蝶冲销预检提示.png new file mode 100644 index 00000000..4d3bf538 Binary files /dev/null and b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/03-新增调整单-金蝶冲销预检提示.png differ diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/04-提交审批二次确认.png b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/04-提交审批二次确认.png new file mode 100644 index 00000000..5c1d4282 Binary files /dev/null and b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/04-提交审批二次确认.png differ diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/05-重新推送-高危二次确认.png b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/05-重新推送-高危二次确认.png new file mode 100644 index 00000000..6f20c8d9 Binary files /dev/null and b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).assets/05-重新推送-高危二次确认.png differ diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md index 16522c1b..516b1617 100644 --- a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md +++ b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md @@ -34,7 +34,34 @@ - 审批中:只能"通过"或"驳回"。 - 审批通过:终态,只剩金蝶推送相关操作。 -### 2.2 各状态下的按钮 +### 2.2 总体流程图 + +``` + 调整单审批通过 + │ + 原结算单金蝶状态 + ┌───────────────────┴────────────────────┐ + ▼ ▼ + 未推送(场景 A) 已推送(场景 B) + ① 调整回写原结算单 ① 原结算单业务数据【零改动】 + ② 原单追加变更记录 ② 原单仅追加 1 条变更记录 + ③ 调整单 → 审批通过 ③ 调整单 → 审批通过 + ④ 自动推送金蝶 ④ 提示:请财务在金蝶手工冲销旧单 + (应付/应收单 + 落锁 + 附件) 【财务在金蝶手工冲销旧单】 + ⑤ 回填「已同步 + 金蝶单据号」 点「重新推送(高危)」并二次确认 + ↓ ① 校验调整单已审批通过 + 金蝶应付/应收单(含调整金额) ② 旧单必须已冲销,否则拒绝 + ③ 克隆新正式结算单(应用调整) + ④ 用新单号推金蝶 → 全新应付/应收单 + ⑤ 新单落锁 + 附件 + ⑥ 调整单改挂新单 + 回填金蝶单据号 + ⑦ 原单标「已调整」+ 旧锁解锁 + ⑧ 推送失败置「生成失败」,可重试 + ↓ + 金蝶【全新】应付/应收单(旧单已冲销) +``` + +### 2.3 各状态下的按钮 | 状态 | 可用操作 | 说明 | |---|---|---| @@ -43,7 +70,9 @@ | 审批中 | 通过 / 驳回 | 审批人操作 | | 审批通过 | 推送金蝶 / 重新推送(合一按钮)| 按金蝶状态自动分流,高危确认 | -### 2.3 金蝶同步状态(调整单上的展示字段) +![结算调整单列表:草稿行显示「查看 / 编辑 / 删除 / 提交」,审批通过行只剩「查看」与推送类操作;金蝶状态为「已同步」时按钮变为红色的「重新推送(高危)」,未同步时为「推送金蝶」](./2026-10-17-结算调整单操作流程说明(分场景).assets/01-结算调整单列表-各状态操作入口.png) + +### 2.4 金蝶同步状态(调整单上的展示字段) | 状态 | 含义 | |---|---| @@ -51,6 +80,25 @@ | 已同步 | 已在金蝶生成应付/应收单,调整单上可看到金蝶单据号 | | 生成失败 | 推送失败,可用"推送金蝶"重试或等定时重推 | +### 2.4 正式结算单列表的「金蝶冲销状态」(2026-10-30 新增) + +正式结算单列表在"金蝶单据状态"旁新增一列,显示该单在金蝶侧的旧应付/应收单是否已被财务冲销: + +| 显示 | 含义 | +|---|---| +| 已冲销(绿) | 金蝶侧旧单已冲销(含部分冲销),场景 B 此时可以点"重新推送" | +| 未冲销(黄) | 金蝶侧旧单还在,需先由财务在金蝶完成冲销 | +| 未校验(灰) | 已推送金蝶但系统还没查过(上线初期存量单的初始态,跑一轮定时任务后自动补齐) | +| - | 未推送金蝶的单,没有冲销状态可言 | + +状态由系统在三个途径自动更新,**无需人工维护**: + +1. 定时任务每约 10 分钟批量核对一次金蝶; +2. 新建调整单**选单预检**时即时刷新该单; +3. 点**"重新推送"**时即时刷新该单(被拦截未冲销也会同步为"未冲销")。 + +> 列表状态允许滞后约一个定时周期(10 分钟);点"重新推送"时系统以**当下实时查询**结果为准,不会因列表滞后而误放行。 + --- ## 三、通用操作说明(每个操作:前置条件 → 系统动作 → 结果) @@ -62,11 +110,15 @@ | 入口 | 结算调整单列表 → 新增 | | 前置 | 必须先选定一张**可调整的正式结算单**,条件缺一不可:① 审批通过;② 未完成收款/付款;③ 非尾款结算;④ 未发起尾款付款/付款申请。不满足的单在选单列表里根本搜不到 | | 系统动作 | 选定结算单后自动回带:客商、调整类型(应收/应付)、合同、原结算金额、原单费用明细;同时做一次**金蝶冲销预检(软提示)** | -| 预检提示 | ① 未推送金蝶 → 正常继续;② 已推送且旧单已冲销 → 提示"审批后可重新推送新单";③ 已推送且旧单未冲销 → 醒目提示"审批后需先由财务在金蝶完成冲销,才能重新推送";④ 金蝶查不到/超时 → 提示"未校验",**不阻断建单** | +| 预检提示 | ① 未推送金蝶 → 正常继续;② 已推送且旧单已冲销 → 提示"审批后可重新推送新单";③ 已推送且旧单未冲销 → 醒目提示"审批后需先由财务在金蝶完成冲销,才能重新推送";④ 金蝶查不到/超时 → 提示"未校验",**不阻断建单**。预检查询结果同时回写该结算单列表的「金蝶冲销状态」(见 2.4) | | 结果 | 生成一张草稿,获得单号 | > 预检只是提示,不拦人;真正的拦截在"重新推送"那一步。填单时必须至少录入一条调整费用,否则无法提交。 +![新增调整单:选定关联正式结算单后自动回带客商、结算单类型、合同、原结算金额,并带出原单费用明细供录入调整金额](./2026-10-17-结算调整单操作流程说明(分场景).assets/02-新增调整单-选单回带与费用录入.png) + +![选单后的金蝶冲销预检:该结算单已推送金蝶且旧单未冲销时,顶部弹出醒目提示"审批通过后需先由财务在金蝶完成冲销,才能重新推送",但建单不受影响](./2026-10-17-结算调整单操作流程说明(分场景).assets/03-新增调整单-金蝶冲销预检提示.png) + ### 3.2 保存草稿 / 编辑 / 删除 - 草稿和已驳回的单可反复编辑;删除仅限草稿,删除后单号作废、不留痕迹。 @@ -77,6 +129,8 @@ - 草稿 / 已驳回 → 审批中,进入审批人的待办。 - 提交后单据锁定,提交人不能再改。 +![提交审批:草稿行点「提交」后需二次确认,确认后单据进入审批中并锁定](./2026-10-17-结算调整单操作流程说明(分场景).assets/04-提交审批二次确认.png) + ### 3.4 审批通过 ★ 核心分流点 审批人点"通过"后,系统按**原结算单是否推送过金蝶**自动走两条完全不同的路(见第四、五节场景 A / B): @@ -102,6 +156,8 @@ 按钮点击均有高危二次确认弹窗。 +![重新推送(高危):确认金蝶旧应付/应收单已由财务手工冲销后才继续,系统将克隆新单并生成全新应付/应收单](./2026-10-17-结算调整单操作流程说明(分场景).assets/05-重新推送-高危二次确认.png) + --- ## 四、分场景流程 @@ -129,7 +185,7 @@ |---|---|---|---| | 1 | 业务 | 新增调整单(选单时预检会提示"旧单未冲销"或"已冲销"),提交 | 同场景 A 步骤 1-2 | | 2 | 审批人 | 审批通过 | ① 原结算单业务数据**零改动**,只追加 1 条变更记录("调整单XX审批通过,调整金额XX,待重推金蝶");② 调整单 → 审批通过,金蝶状态保持"已同步"快照("重新推送"按钮因此亮起);③ 前端提示:**"原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理"** | -| 3 | 财务 | **在金蝶系统内手工冲销**旧的应付/应收单 | TMS 不参与,这是金蝶侧的人工操作 | +| 3 | 财务 | **在金蝶系统内手工冲销**旧的应付/应收单 | TMS 不参与,这是金蝶侧的人工操作;冲销完成后**无需通知 TMS**——正式结算单列表的「金蝶冲销状态」约 10 分钟内自动变为"已冲销"(见 2.4),看到变绿即可回 TMS 重推 | | 4 | 业务 | 回到 TMS,点"重新推送(高危)"并二次确认 | 系统依次:① 硬闸门——按原结算单号去金蝶查旧单,**必须已冲销**(部分冲销也算已冲销),未冲销直接拒绝;② **克隆一张新的正式结算单**(全新 FJS/SJS 单号),把调整应用上去,直接置为审批通过(新单不再走审批流);③ 用新单号推送金蝶,生成**全新**的财务应付/应收单 + 落锁 + 附件;④ 调整单改挂到新单,回填"已同步 + 金蝶单据号";⑤ 原单标记"已调整",原单在金蝶的旧锁解锁 | | 5 | 系统 | — | 完成。此时列表里**新旧两张正式结算单并存**,带标识区分(见第六节) | @@ -165,6 +221,7 @@ | 部分冲销 | 视同已冲销,放行重推 | | 审批时原单刚好已被推送到金蝶 | 按**当下**状态走场景 B,选单时的预检结论不作数 | | 选单时金蝶超时/不可用 | 预检降级"未校验",不阻断建单 | +| 列表「金蝶冲销状态」与金蝶实际有出入 | 列表为定时同步值,允许滞后约 10 分钟;重推一律以闸门实时校验为准,不影响拦截准确性 | | 重推成功前原单旧锁残留 | 重推成功时统一解锁 | | 应收结算单 | 与应付完全同一套流程,仅接口不同(应收 A302 系 / 应付 A102 系) | diff --git a/doc/docs/业务流程/2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步).md b/doc/docs/业务流程/2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步).md new file mode 100644 index 00000000..b0954288 --- /dev/null +++ b/doc/docs/业务流程/2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步).md @@ -0,0 +1,201 @@ +# 收款流水菜单改造:数据源 A301-2 改 A301-1,认领只登记本地并推送合同合规(C107-1 / C107-3) + +> 日期:2026-10-29 +> 范围:tms-api(blade-transport、blade-hcs-api、blade-transport-api)+ tms-erp-web(收款流水 / 认领记录页面) +> 关联文档: +> - [2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写)](./2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md)(本改造后 A301-3 链路整体下线) +> - [2026-10-22-收款认领推送金蝶完成后同步合同合规系统(C103-9履约计划-C107-1认领记录-C108-1履约流水)业务流程梳理](../hcs合同/2026-10-22-收款认领推送金蝶完成后同步合同合规系统(C103-9履约计划-C107-1认领记录-C108-1履约流水)业务流程梳理.md)(触发点由 A301-3 成功后改为认领事务提交后) + +--- + +## 1. 需求与决策结论 + +收款流水菜单做两点改造: + +1. **数据源改为 A301-1 收款单查询**(原 A301-2 收款认领公告查询下线); +2. **认领动作只做本地认领记录 + 推送合同合规系统**(原 A301-3 推送金蝶链路整体下线,合规推送链保持 C103-9 + C107-1 + C108-1 全链)。 + +已确认的业务决策: + +| # | 决策点 | 结论 | +| --- | --- | --- | +| 1 | 认领上限口径 | **收款金额 `receipt_amount`(A301-1 `actrecamt`)**。原定 `unmatchamountrec` 与收款金额取 min,因金蝶侧财务先核销导致大量收款单未匹配金额为 0、TMS 永久无法认领,2026-10-29 修正:认领只登记本地,额度由本地累计认领控制,`unmatchamountrec` 仅参考展示 | +| 2 | 合规推送范围 | **C103-9(履约计划金额累加)、C107-1(认领记录)、C108-1(履约流水)全保留** | +| 3 | C107-1 推送失败补偿 | **不做定时重试,只落推送标记**(blade_receipt_claim_hcs_record.push_status = failed + 失败原因) | +| 4 | 认领作废 | **需要 C107-3 作废同步**(fdStatus=1),C107-1 返回的合规 fdId 必须落库 | +| 5 | 同步可见范围 | A301-1 无认领人维度,**全公司流水均进入页面,可接受** | + +--- + +## 2. 现状与目标链路对比 + +``` +【现状】 +A301-2 认领公告同步(按认领人姓名拉取) + → blade_kingdee_receipt_flow 落库(幂等键 detail_serial_no) + → 页面认领(只登记本地) + → A301-3 推送金蝶(手动按钮 + 10分钟定时任务,推送前重查 A301-2 对账) + → A301-3 成功后推合规链(C103-9 → C107-1 → C108-1) + → A301-1 按 awrp_src_code 回写金蝶单据状态(refreshKingdeeResult) + +【目标】 +A301-1 收款单查询(业务日期窗口取 `thirdParty.bfi.recBillSync.days`,默认 60 天,全量) + → blade_kingdee_receipt_flow 落库(幂等键 detail_serial_no,金蝶单号/状态随同步刷新) + → 页面认领(只登记本地) + → 认领事务提交后按认领主单推合规链(C103-9 → C107-1 → C108-1,C107-1 结果落库标记) + → 认领作废事务提交后推 C107-3(fdStatus=1 已作废) + → (A301-3 推送、awrp_src_code、A301-1 回写、定时任务、推送按钮全部下线) +``` + +关键变化: + +- 金蝶收款单在认领前就已存在(A301-1 即金蝶收款处理单),因此**金蝶收款单号 / 单据状态随同步直接落库**,不再依赖推送回填与回写推进; +- 合规推送触发点从「A301-3 推送成功后(流水级)」改为「认领事务提交后(认领主单级)」,继续认领时每次认领各自推送一次,累计金额口径不变。 + +--- + +## 3. 后端改造明细(tms-api) + +### 3.1 HCS Feign(blade-third-party-api/blade-hcs-api) + +| 文件 | 改动 | +| --- | --- | +| `IHcsClient` | 新增 `CLAIM_RECORD_UPDATE_URL`(`/openapi/eop-data/eopDataCollectionFlowClaimRecordOpen/update`)与 `claimRecordUpdate` 方法(C107-3 修改认领记录) | +| `HcsClaimRecordAddDTO` | 新增 `fdId` 字段:C107-1 新增不传,C107-3 修改必填(作废传 fdStatus=1),一个 DTO 复用两个接口 | + +### 3.2 收款流水同步:A301-2 → A301-1 + +`ReceiptFlowServiceImpl#sync()` 重写,`BfiUtil.queryAllPagesByTotal`(按 totalCount 推导总页数,兜底 500 页)+ `bfiService.queryRecBill` 全量翻页(pageSize 200),请求参数 `begin_bizdate` / `end_bizdate`(窗口天数取 Nacos `thirdParty.bfi.recBillSync.days`,默认 60)。 + +**字段映射(A301-1 → blade_kingdee_receipt_flow)**: + +| 流水表字段 | A301-1 来源 | 说明 | +| --- | --- | --- | +| receipt_notice_no / kingdee_bill_no | `billno` | 金蝶收款单号(两处同值) | +| kingdee_bill_status | `billstatus` 映射 | A/B已提交→pushed、C已审核→approved、D已收款→received、其余(含G退单)→failed | +| detail_serial_no | 主单 `billno` | 明细流水号=收款单号,**幂等键**;主单粒度,一张收款单一条流水,不展开流水分录 | +| receipt_amount | 主单 `actrecamt` | 收款金额(金蝶收款金额口径) | +| unmatch_amount_rec | `unmatchamountrec` | 未匹配收款金额(金蝶参考展示,**不参与认领额度**;原列名 unclaimed_amount 已改名) | +| match_amount_rec | `matchamountrec` | 已匹配收款金额 | +| match_amount_pay | `matchamountpay` | 已匹配付款金额 | +| unmatch_amount_pay | `unmatchamountpay` | 未匹配付款金额 | +| settle_amount | `settleamount` | 已结算金额 | +| unsettle_amount | `unsettleamount` | 未结算金额 | +| payer_name / counterparty_name | `payername` | 付款人名称 | +| payee_name | `org_name` | 收款人(收款公司名称) | +| org_number | `org_number` | 收款人(公司).编码 | +| open_org_number | `openorg_number` | 核算组织.编码 | +| counterparty_account | `payeracctbanknum` | 付款账户 | +| counterparty_bank | `payerbankname` | 付款银行 | +| summary | `txt_description` | 摘要 | +| transaction_time | `bizdate` | 业务日期 | +| fee | `fee` | 手续费 | +| source_updated_time | 同步时间 | — | +| bill_id / payer_id / kingdee_claim_status / claim_me | — | **废弃不再写入**(历史 A301-2 专属字段,列保留兼容存量) | +| push_status / push_fail_reason / pushed_time / awrp_src_code | — | **不再写入**(A301-3 下线;存量已推送数据保留展示) | + +同步保持原有框架:明细流水号重复抛异常、金额 ≤0 跳过、FOR UPDATE 批量防并发、按幂等键 upsert、批量操作日志;本地认领字段(claimed_amount / claim_status)不被同步覆盖,认领后状态按「累计认领 vs 收款金额」重算。 + +### 3.3 认领与合规推送链 + +`ReceiptFlowServiceImpl#claim()`: + +- 删除「认领公告id缺失」「公告未认领金额缺失」拦截(A301-1 无公告概念); +- 认领上限:`claimCeiling = receipt_amount`(收款金额 `actrecamt`,金蝶未匹配金额不参与额度控制); +- 认领主单冗余展示字段直取流水:`kingdeeBillNo` / `kingdeeBillStatus`(金蝶单据认领前已存在); +- 事务提交后(`TransactionSynchronizationManager.afterCommit`)按**认领主单**触发 `HcsClaimSyncSupport#pushClaimToHcs(claimId)`,失败仅落标记不影响认领。 + +`HcsClaimSyncSupport` 重写(编排顺序不变、触发维度改变): + +1. 归集**本认领主单**有效分摊行 → 按合同分组金额; +2. C104-2 按明细流水号定位合规侧收款流水 ID(一次定位全部合同共用); +3. 逐合同:C103-9 履约计划金额累加重推 → C107-1 认领记录(返回 fdId)→ C108-1 履约流水; +4. **C107-1 结果落库** `blade_receipt_claim_hcs_record`(认领主单×合同一条):成功持久化合规 fdId(供 C107-3 引用),失败落 failed + 原因(只记标记,无定时重试); +5. C108-1 的申请编号兜底、金蝶单据号(fd_jd_doc_no)取流水 `kingdee_bill_no`(A301-1 同步落库),金额改为本次认领分摊金额。 + +开关复用既有配置:`exe-stage.sync-enabled` 门禁 C103-9/C107-1,`perf-record.sync-enabled` 门禁 C108-1。 + +### 3.4 认领作废:C107-3 同步 + +`ReceiptClaimRecordServiceImpl#voidClaim()`: + +- 删除「已推送金蝶不允许作废」拦截(不再有 TMS→金蝶推送,作废只回退本地 + 同步合规); +- 事务提交后按认领主单推 C107-3:查 `blade_receipt_claim_hcs_record` 中 push_status=success 的记录,逐条 `claimRecordUpdate(fdId, fdStatus=1)`,成功后本地记录置 voided;单条失败独立记日志不回滚作废,合规侧需人工处置。 + +### 3.5 A301-3 链路整体下线 + +| 项 | 处置 | +| --- | --- | +| `pushClaimToKingdee` / `pushClaimByTask` / `executeReceiptClaimPush` / `pushClaimLocked` / `doPushClaimToKingdee` / `buildClaimPushDTO` / `extractClaimPushBillNo` / `applyClaimPushSuccess` / `applyClaimPushFailure` | 删除(ReceiptFlowServiceImpl) | +| `ReceiptClaimPushTask`(10 分钟定时推送) | 文件删除 | +| `ReceiptFlowController` `/push-kingdee` 端点 | 删除 | +| `refreshKingdeeResult`(A301-1 按 awrp_src_code 回写)+ `/receipt-claim-record/query-kingdee-result` 端点 | 删除(A301-1 已是数据源,状态随同步刷新) | +| `awrpSrcCode` 生成逻辑 | 删除 | +| `BfiProperties` | `claimAnnounce` / `receiptClaimPush` 配置类删除,新增 `RecBillSync`(`days` 默认 30) | +| `KingdeeReceiptFlow` 实体 | 字段注释更新为 A301-1 口径(bill_id 等标注废弃) | + +### 3.6 新增表 + +`blade_receipt_claim_hcs_record`(收款认领合规推送记录,DDL 见 `doc/sql/transport/lqj/20261102/01_blade_receipt_claim_hcs_record_20261028.sql`): + +| 字段 | 说明 | +| --- | --- | +| receipt_claim_id / receipt_flow_id | 认领主单 / 收款流水 | +| contract_id / contract_no | 合同 | +| claim_amount | 本认领主单分摊到本合同的金额 | +| hcs_claim_record_fd_id | 合规侧认领记录 fdId(C107-1 返回,C107-3 必填引用) | +| push_status | success 推送成功 / failed 推送失败 / voided 已作废 | +| fail_reason / pushed_time | 失败原因 / 成功时间 | + +--- + +## 4. 前端改造明细(tms-erp-web) + +| 页面 / 文件 | 改动 | +| --- | --- | +| `views/payment/receipt-flow.vue` | 删「推送金蝶」操作按钮及 `canPush`/`handlePush`;删搜索「推送状态」;详情删「推送状态」「推送失败原因」,「金蝶未认领金额」→「未匹配金额」;同步提示改为"收款流水" | +| `views/payment/receipt-claim-record.vue` | 删「查询金蝶认领结果」按钮;删搜索「推送状态」;作废按钮去掉 `pushStatus !== 'pushed'` 限制;作废确认提示增加"同步作废合同合规系统认领记录" | +| `views/payment/receipt-flow-form.vue` / `receipt-flow-batch-form.vue` | 「认领通知单」→「收款单号」;「金蝶未认领金额」→「未匹配金额」;校验提示"公告未认领金额"→"未匹配金额" | +| `views/payment/receipt-claim-record-form.vue` | 「认领通知单」→「收款单号」 | +| `views/settlement/components/formal-settlement-editor.vue` | 认领列表列「认领通知单」→「收款单号」 | +| `api/payment/receiptFlow.js` | 删 `pushKingdee`、`pushStatusOptions` | +| `api/payment/receiptClaimRecord.js` | 删 `queryKingdeeResult`、`pushStatusOptions` | +| `option/payment/receiptFlow.js` | 删「公告状态」「推送状态」「推送失败原因」「金蝶收款单号」(与单号列重复)列;「认领通知单」→「收款单号」、「付款金额」→「收款金额」、「金蝶未认领金额」→「未匹配金额」 | +| `option/payment/receiptClaimRecord.js` | 「认领通知单」→「收款单号」、「付款金额」→「收款金额」 | + +菜单按钮权限提示:`receipt_flow_push` 权限码随按钮下线,可在菜单配置中清理。 + +--- + +## 5. 配置与存量数据 + +**Nacos(thirdParty.bfi.*)**: + +- 新增(可选):`recBillSync.days`,默认 60(A301-1 业务日期窗口天数); +- 可清理:`claimAnnounce.*`、`receiptClaimPush.*`(代码已不再读取)。 + +**存量数据**: + +- A301-2 时代同步的流水(bill_id、kingdee_claim_status、claim_me 有值)与新口径并存,页面不再展示这些列;历史 push_status=pushed / awrp_src_code 仅保留展示,不再参与任何闸门; +- 明细流水号为全链唯一衔接键:A301-1 `bei_detailid` ≈ A301-2 `detailid` ≈ 合规 C104-2 `fdDetailSerialNumber`,同源同值时新旧数据 upsert 自然衔接。 + +--- + +## 6. 风险与待验证项 + +| # | 项 | 说明 | 等级 | +| --- | --- | --- | --- | +| 1 | **明细流水号同源性** | A301-1 `bei_detailid` 必须与原 A301-2 `detailid`、合规侧 `fdDetailSerialNumber` 同值,否则 C104-2 定位失败 → C107-1 全部跳过(仅记日志/落失败标记)。需联调拿真实报文核对 | 高 | +| 2 | 认领额度与金蝶匹配解耦 | 上限改用收款金额后,金蝶侧已匹配(核销)的收款单同样可认领并推合规;金蝶匹配状态变化不影响 TMS 已认领额度(按 `claimed_amount` 本地累计)。原 `unmatchamountrec` 口径下 797 条流水有 704 条额度为 0,需求无法闭环 | 中 | +| 3 | 合规履约计划金额不回退 | C103-9 认领时累加,作废仅同步 C107-3,不回退履约计划行金额(与改造前口径一致,未新增回退逻辑) | 中 | +| 4 | 全公司可见范围 | A301-1 按组织/日期拉全量,不再按认领人过滤,页面数据范围扩大(业务已确认可接受) | 低 | +| 5 | C107-1 失败无自动重试 | 仅落 failed 标记与原因,需人工在合规侧核对补录(业务已确认) | 低 | + +## 7. 验证清单(交由测试/联调执行) + +1. 手动同步:A301-1 拉取、流水按 infoentry 展开、重复明细流水号拦截、金额/单号/状态落库正确; +2. 认领:上限按收款金额控制、认领完成状态判定、合规链推送成功后 `blade_receipt_claim_hcs_record` 有 success 记录且 fdId 非空; +3. 继续认领:第二次认领各自推送一次 C107-1/C108-1,C103-9 累计金额正确; +4. 作废:本地回退 + C107-3 同步、记录置 voided;无合规记录(推送失败)时作废静默跳过; +5. 合规开关关闭(exe-stage/perf-record sync-enabled=false)时认领/作废主流程不受影响; +6. 回归:认领记录页、结算单收款汇总刷新、认领人筛选。 diff --git a/doc/docs/业务流程/2026-10-29-收款流水认领操作流程(收款金额本地额度·单条批量与作废).md b/doc/docs/业务流程/2026-10-29-收款流水认领操作流程(收款金额本地额度·单条批量与作废).md new file mode 100644 index 00000000..080ea58c --- /dev/null +++ b/doc/docs/业务流程/2026-10-29-收款流水认领操作流程(收款金额本地额度·单条批量与作废).md @@ -0,0 +1,254 @@ +# 收款流水认领操作流程(收款金额本地额度 · 单条/批量/作废) + +> 日期:2026-10-29 +> 范围:`tms-api` blade-transport 收款流水(同步、认领、认领记录)+ `tms-erp-web` 收款流水 / 认领记录页面 +> 关联文档: +> - [2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步)](./2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步).md)(改造设计与字段映射) +> - [2026-10-16-收款流水多次认领与作废口径梳理](./2026-10-16-收款流水多次认领与作废口径梳理.md)(多次认领、逐条作废口径) + +--- + +## 0. 一页速览 + +| 项 | 内容 | +| --- | --- | +| 菜单入口 | 首付款管理 → **收款流水**(`/payment/receipt-flow`)、**认领记录**(`/payment/receipt-claim-record`) | +| 数据来源 | 金蝶 A301-1 收款单查询(收款处理单),**手动同步**触发,无定时任务 | +| 流水粒度 | 一张金蝶收款单 = 一条流水(`detail_serial_no` = 收款单号,幂等键) | +| 可认领上限 | **收款金额 `receipt_amount`(金蝶 `actrecamt`)** | +| 剩余可认领 | `max(收款金额 − 已认领金额 claimed_amount, 0)` | +| 未匹配金额 | 金蝶 `unmatchamountrec`,**仅参考展示,不参与额度控制** | +| 认领动作 | 只登记本地(认领主单 + 结算单分摊),**不推金蝶** | +| 合规推送 | 认领事务提交后推 C103-9 → C107-1 → C108-1;作废推 C107-3 | +| 状态 | `unclaimed` 未认领 / `partial` 部分认领 / `claimed` 认领完成 | + +--- + +## 1. 角色与权限 + +| 操作 | 权限码 | 说明 | +| --- | --- | --- | +| 查看收款流水 | `receipt_flow_view` | 菜单权限 `receipt_flow` | +| 认领 / 批量认领 | `receipt_flow_claim` | 无此权限时操作列显示 `-`,顶部无「批量认领」按钮 | +| 手动同步流水 | `receipt_flow_sync` | 无此权限时顶部无「手动同步流水」按钮 | +| 查看认领记录 | `receipt_claim_record` | **只显示当前登录人自己的认领记录** | +| 作废认领记录 | `receipt_claim_record_void` | 仅本人、且状态为「已认领」的记录可作废 | + +管理员(`admin`)默认直通按钮权限判断;认领记录页的数据范围仍按当前登录人过滤,管理员也只能看到自己做的认领。 + +--- + +## 2. 流程总览 + +``` +【金蝶】A301-1 收款单 + │ ① 手动同步(可按窗口重复执行,幂等) + ▼ +【TMS】收款流水列表(blade_kingdee_receipt_flow) + │ ② 勾选/单条 → 选择应收正式结算单 → 填分摊金额 + ▼ +【TMS】认领(blade_receipt_claim + blade_receipt_claim_settlement) + │ ③ 更新流水累计认领金额/状态、刷新结算单收款汇总 + │ ④ 事务提交后推合规:C103-9 → C107-1 → C108-1(失败只落标记) + ▼ +合规侧认领记录(C107-1 返回 fdId 落库) + │ ⑤ 需要撤销时在「认领记录」页作废 + ▼ +本地回退金额 + 推 C107-3(fdStatus=1 已作废) +``` + +--- + +## 3. 场景一:同步收款流水 + +**入口**:收款流水页顶部「手动同步流水」。 + +**前置条件**:组织管理中已维护“是否平台公司 = 是”且已填金蝶编码的组织,否则直接报错「组织管理中未维护平台公司金蝶编码,无法同步金蝶收款流水,请先检查组织数据」。 + +**动作**: + +1. 按业务日期窗口取数:结束日期 = 当天,开始日期 = 当天 − `thirdParty.bfi.recBillSync.days`(默认 60 天),起止时间格式 `yyyy-MM-dd HH:mm:ss`; +2. 按上述组织编码过滤,全量翻页拉取(pageSize 200,兜底 500 页,结束条件为末页/不满一页); +3. 以收款单号去重,按幂等键 upsert:新增初始化 `claimed_amount = 0`、`claim_status = unclaimed`;已存在只更新金蝶来源字段,**不覆盖本地认领金额与状态**; +4. 收款金额 ≤ 0 的收款单跳过; +5. 每批 5000 条独立事务入库,并写同步操作日志。 + +**结果提示**:「流水同步完成,共同步 N 条收款流水」,列表自动刷新。 + +**注意**: + +- 同步窗口从今天倒推,历史更早的单据不会进入;需要更早数据时调整 Nacos `thirdParty.bfi.recBillSync.days` 后重新同步(该配置支持热更新)。 +- 金蝶侧改动(金额、状态、匹配/结算金额)每次同步都会刷新;**TMS 已认领金额与状态不受影响**,但状态会按新的收款金额重算。 + +--- + +## 4. 场景二:单条认领 + +### 4.1 入口与显示条件 + +列表操作列「认领」需同时满足: + +1. 有 `receipt_flow_claim` 权限(admin 直通); +2. 剩余可认领金额 > 0(即收款金额 > 累计已认领金额)。 + +不满足时操作列显示 `-`。列表列「未匹配金额」为金蝶口径参考值,**该列为 0 不影响认领**。 + +### 4.2 操作步骤 + +1. 点「认领」进入认领页面,顶部只读展示:收款单号、付款人、收款金额、交易时间、对方户名/开户行/账号、明细流水号、已认领金额、未匹配金额(金蝶参考)、剩余可认领金额、摘要、认领人、认领日期、认领人部门; +2. 点「关联结算单」选择器,弹窗按结算单号/项目/合同搜索: + - 候选范围:应收类型 + 已审批通过 + 有效状态 + 该结算单自身仍有剩余可认领金额; + - 多选时必须**同一项目、同一组织、同付款方、同收款方**,否则提示「关联结算单必须属于同一项目、组织及收付款方」; +3. 在「结算信息」明细中逐张填写「分摊收款金额」: + - 单张结算单分摊上限 = 结算总金额 − 已认领收款金额; + - 本次分摊合计必须 ≤ 当前页面的「剩余可认领金额」; +4. 维护附件(可选)、备注(≤200 字); +5. 点「确认」。 + +### 4.3 确认后系统做了什么 + +| 步骤 | 内容 | +| --- | --- | +| 1 | 行锁流水与所选结算单,防止并发超额 | +| 2 | 校验:每张结算单「已认领 + 本次分摊 ≤ 结算总金额」;流水「已认领 + 本次分摊 ≤ 收款金额」 | +| 3 | 插入 1 条认领主单 `blade_receipt_claim`(`claim_amount` = 本次分摊合计) | +| 4 | 按结算单插入 N 条分摊 `blade_receipt_claim_settlement`(记录认领前已认领金额、本次分摊金额) | +| 5 | 刷新各结算单收款汇总;流水 `claimed_amount` 累加,重算 `claim_status`(0 → unclaimed,< 收款金额 → partial,≥ 收款金额 → claimed) | +| 6 | 写流水操作记录(认领动作、金额、关联结算单数) | +| 7 | **事务提交后**按认领主单推合规:C103-9(履约计划金额累加)→ C107-1(认领记录,返回 fdId 落库)→ C108-1(履约流水);任一步失败只落 `blade_receipt_claim_hcs_record` 失败标记,不回滚认领 | +| 8 | 返回收款流水列表,提示「认领成功」 | + +> 合规开关关闭(`exe-stage.sync-enabled` / `perf-record.sync-enabled`)时,认领主流程不受影响,只是不推合规。 + +--- + +## 5. 场景三:批量认领 + +**入口**:列表勾选多条流水 → 顶部「批量认领」(需 `receipt_flow_claim` 权限)。 + +**前置校验**:所选流水的**付款人 + 对方账号必须完全一致**,否则提示「批量认领所选流水的付款人、对方账号必须一致」。 + +**操作步骤**: + +1. 进入「批量认领收款流水」页面,顶部明细列出所选流水(收款单号、收款金额、已认领金额、未匹配金额、剩余可认领金额、摘要); +2. 选择关联应收结算单(候选规则同单条认领,多选同样要求同项目/组织/收付款方); +3. 在「结算单 × 流水」分摊矩阵中逐格填写金额: + - 单格上限 = min(该结算单剩余可认领 − 该结算单其它格已填, 该流水剩余可认领 − 该流水其它格已填); +4. 附件、备注同单条认领; +5. 点「确认」。 + +**提交逻辑**: + +- 先校验结算单维度(累计分摊 ≤ 结算单剩余)与流水维度(累计分摊 ≤ 流水剩余可认领); +- 按流水逐条提交认领:**每条流水生成一条认领主单**,各自推一次合规(C103-9 累计金额按各次认领累加); +- 分摊为 0 的格子不提交;全部为 0 时提示「没有需要入库的分摊收款金额」; +- 全部成功提示「批量认领成功,共 N 条流水」并返回列表。 + +> 批量提交是逐条循环调用认领接口,非单一事务:中途某条失败时,之前的流水已认领成功,需要按提示逐条核查(可在「认领记录」页确认已成功的部分)。 + +--- + +## 6. 场景四:部分认领与继续认领 + +1. 首次分摊合计小于收款金额 → 流水状态变「部分认领」(`partial`),剩余可认领金额 = 收款金额 − 累计已认领; +2. 再次点「认领」进入页面时,会**自动预填本流水历史关联且仍有剩余可认领金额的结算单**(分摊金额默认 0,需重新填写); +3. 每次认领都是一条新的认领主单,合规侧每次各推一次 C107-1/C108-1,C103-9 按合同累计; +4. 累计达到收款金额 → 状态「认领完成」,操作列不再显示「认领」。 + +--- + +## 7. 场景五:作废认领记录 + +**入口**:认领记录页(仅本人记录)→ 操作列「作废」(需 `receipt_claim_record_void`,且记录状态为「已认领」)。 + +**操作**:点击后二次确认,提示「作废后将回退本次认领金额,并同步作废合同合规系统认领记录」。 + +**作废后系统做了什么**: + +| 步骤 | 内容 | +| --- | --- | +| 1 | 行锁认领主单、收款流水、关联结算单 | +| 2 | 本次认领的分摊行批量置为无效(`status = 0`) | +| 3 | 刷新关联结算单收款汇总 | +| 4 | 流水 `claimed_amount` 回退本次认领金额(不小于 0),并按收款金额重算状态(可能回到 unclaimed / partial) | +| 5 | 认领主单置「已作废」,记录作废人、作废时间与操作日志 | +| 6 | 事务提交后逐条推 C107-3(按落库 fdId 传 `fdStatus=1`),成功记录置 `voided`;单条失败只记日志,**不回滚作废**,需人工在合规侧处置 | + +**注意**: + +- 已作废记录不可重复作废; +- 作废不生成金蝶冲销单,金蝶收款单本身不受影响(金蝶侧红冲由财务人工处置); +- 认领记录允许本人维护附件(`/receipt-claim-record/attachments`)。 + +--- + +## 8. 状态与金额口径 + +### 8.1 流水认领状态 + +| 状态 | 含义 | 判定 | +| --- | --- | --- | +| `unclaimed` 未认领 | 尚未认领 | 累计认领金额 = 0 | +| `partial` 部分认领 | 已认领一部分 | 0 < 累计认领金额 < 收款金额 | +| `claimed` 认领完成 | 额度用尽 | 累计认领金额 ≥ 收款金额 | + +### 8.2 关键金额 + +| 金额 | 来源/公式 | 用途 | +| --- | --- | --- | +| 收款金额 `receipt_amount` | A301-1 `actrecamt` | 认领上限 | +| 已认领金额 `claimed_amount` | 本地认领累加(作废回退) | 已用额度 | +| 剩余可认领金额 `remainingAmount` | `max(收款金额 − 已认领金额, 0)` | 按钮显隐、前置校验 | +| 未匹配金额 `unmatch_amount_rec` | A301-1 `unmatchamountrec` | 金蝶参考展示,**不参与校验** | +| 结算单剩余可认领 | 结算总金额 − 该结算单已认领收款金额 | 分摊上限(结算单维度) | + +### 8.3 金蝶单据状态(随同步刷新) + +| `billstatus` | 本地 `kingdee_bill_status` | 页面展示 | +| --- | --- | --- | +| A 暂存 / B 已提交 | `pushed` | 已推送 | +| C 已审核 | `approved` | 审核通过 | +| D 已收款 | `received` | 已收款 | +| G 已退单 / E 变更中 / 其它 | `failed` | 处理失败(需人工在金蝶核查) | + +--- + +## 9. 常见提示与处置 + +| 提示 | 原因 | 处置 | +| --- | --- | --- | +| 操作列显示 `-` | 无 `receipt_flow_claim` 权限,或已认领完成(剩余可认领 = 0) | 配权限;或确认是否已全额认领 | +| 「该流水可认领金额为0,无需认领」 | 收款金额 ≤ 0(数据异常,正常同步已跳过此类单据) | 核查金蝶收款单金额 | +| 「累计认领金额不能超过可认领上限,剩余可认领 X」 | 分摊合计超过剩余可认领 | 按提示减少分摊 | +| 前端「累计认领金额不能超过剩余可认领金额」 | 同上,前端预校验拦截 | 同上 | +| 「结算单XXX的累计认领金额不能超过结算总应收含税金额」 | 单张结算单分摊超过其剩余 | 调整该结算单分摊 | +| 「关联结算单必须属于同一项目、组织及收付款方」 | 多选结算单不一致 | 重新选择同项目同收付款方的结算单 | +| 「批量认领所选流水的付款人、对方账号必须一致」 | 勾选流水不属于同一付款账户 | 拆分批次 | +| 「组织管理中未维护平台公司金蝶编码…」 | 同步前置缺失 | 维护组织金蝶编码后再同步 | +| 未匹配金额为 0 但可以认领 | 金蝶侧财务已核销,属正常 | 忽略该列,按剩余可认领操作 | +| 认领记录页看不到某条记录 | 只显示当前登录人自己的记录 | 由本人查看/作废,或到结算单认领明细核查 | +| 合规推送失败 | C103-9/C107-1/C108-1 返回异常 | 查 `blade_receipt_claim_hcs_record` 失败原因,人工在合规侧补录(无自动重试) | + +--- + +## 10. 与 2026-10-29 之前的差异 + +| 项 | 改造前 | 现在 | +| --- | --- | --- | +| 数据源 | A301-2 收款认领公告 | A301-1 收款单查询 | +| 认领上限 | `min(未匹配收款金额, 收款金额)` | **收款金额** | +| 未匹配金额的作用 | 决定是否可认领(大量单据为 0 → 无法认领) | 仅参考展示 | +| 认领后 | A301-3 推金蝶(手动/定时) | 只登记本地 + 推合规,金蝶不写 | +| 推送失败 | 定时重试 | 只落失败标记,人工处理 | + +--- + +## 11. 操作自查清单 + +1. 同步后列表条数、收款单号、收款金额、金蝶状态与金蝶一致; +2. 剩余可认领金额 = 收款金额 − 已认领金额,且列表/详情/认领页三处一致; +3. 单条认领:分摊合计 = 收款金额 → 状态「认领完成」,按钮消失;小于 → 「部分认领」; +4. 继续认领:剩余额度正确、历史结算单预填、合规侧新增一条认领记录; +5. 批量认领:同一付款账户多流水一次提交,各自生成认领记录; +6. 作废:金额回退、状态回退、合规侧记录变为已作废(`blade_receipt_claim_hcs_record.push_status = voided`); +7. 超额场景(流水维度、结算单维度)均被拦截并给出可读提示。 diff --git a/doc/docs/业务流程/2026-10-30-客商黑名单与风险等级业务说明(业务侧).md b/doc/docs/业务流程/2026-10-30-客商黑名单与风险等级业务说明(业务侧).md new file mode 100644 index 00000000..33787616 --- /dev/null +++ b/doc/docs/业务流程/2026-10-30-客商黑名单与风险等级业务说明(业务侧).md @@ -0,0 +1,103 @@ +# 客商黑名单与风险等级业务说明(业务侧) + +> 更新日期:2026-10-30 | 适用系统:运输 TMS | 读者:业务/运营人员 +> +> 关联文档:《客商黑名单与风险等级运维手册(运维侧)》(同目录) + +## 1. 这是什么 + +TMS 从**两个外部系统**获取客商风险数据,每日自动更新到客商档案,用于客商档案的展示、筛选,以及合同、付款环节的风险管控: + +| 数据 | 来源系统 | 说明 | +|---|---|---| +| **客商风险等级** | 合同合规系统(HCS) | 低风险 / 中风险 / 高风险,另含"黑名单"标识 | +| **金蝶黑名单** | 金蝶(星瀚)· 国资委黑名单 | 命中后记录**失信类别**(禁止交易类 / 风险警示类)与**列入事由** | + +## 2. 名词解释 + +| 名词 | 含义 | +|---|---| +| 黑名单 | 客商被列入外部(国资委)黑名单,**以金蝶黑名单查询(A801-5)命中为准**,命中记录含失信类别与列入事由。金蝶分 **A 类(禁止交易类,不允许交易)** 和 **B 类(风险警示类,需关注)** | +| 失信类别 | 黑名单的分类名称(如"禁止交易类"),来自金蝶黑名单记录 | +| 列入事由 | 被列入黑名单的具体原因(如"涉贪污受贿裁判相关提及方(3年以上及其他)"),来自金蝶黑名单记录 | +| 风险等级 | 合同合规系统对客商的风险评级:0=低风险、1=中风险、2=高风险 | +| 内部组织客商 | 集团内我方签约主体(客商类型=内部组织),**不查询风险,默认无风险** | + +## 3. 总体流程 + +```mermaid +flowchart TD + A[每日凌晨自动刷新
02:30] --> B[取合同期限内生效合同的
甲乙方客商] + B --> C{内部组织客商?} + C -->|是| D[默认无风险, 跳过] + C -->|否| E[查合同合规系统风险等级] + C -->|否| F[查金蝶黑名单] + E --> G[结果写入客商档案
供列表展示与筛选] + F --> G +``` + +- 刷新范围:**合同期限内**(合同起止日期或临时期限覆盖当天)且**审核通过、未终止**的合同,其甲乙方关联客商。 +- 数据**每日更新一次**;当天新增的客商在表单里有实时提示(见 4.1)。 + +## 4. 各业务环节说明 + +### 4.1 新增 / 编辑客商(表单实时提示) + +- 录入**统一社会信用代码**满 18 位后,系统自动查金蝶客商档案,回填客商名称、法人等工商信息。 +- **客商名称**回填或填写后失焦时,系统实时查询该客商的风险;若为高风险/黑名单客商,名称下方显示红色提示,悬浮可见明细,格式: + + > 【客商名称】:被列入外部黑名单(失信类别:xx);列入事由 xx;风险等级:高风险 + +### 4.2 提交准入审批(金蝶黑名单硬校验) + +提交客商准入审批时,系统按客商**发票信息中的纳税人识别号**实时查询金蝶黑名单: + +| 命中情况 | 结果 | +|---|---| +| **A 类(禁止交易类)** | ❌ **禁止提交**,提示失信类别与列入事由 | +| **B 类(风险警示类)** | ⚠️ 默认放行并记录告警(是否拦截由系统配置决定) | +| 查询异常 | ❌ 暂缓提交(防止漏拦),稍后重试 | + +> 因此:维护客商**发票信息(税号)是黑名单校验的前提**,未维护税号时提审不会做黑名单校验。 + +### 4.3 客商档案列表(展示 / 筛选 / 悬浮) + +**客商风险列**显示规则: + +| 客商情况 | 显示 | 颜色 | +|---|---|---| +| 金蝶黑名单命中 + 风险等级(组合展示,等级在前) | 高风险、黑名单 / 中风险、黑名单 / 低风险、黑名单 | 红 | +| 金蝶黑名单命中(无等级数据) | 黑名单 | 红 | +| 仅高风险 | 高风险 | 红 | +| 中风险 | 中风险 | 黄 | +| 低风险 | 低风险 | 绿 | +| 内部组织客商 | 无风险(默认,不查询) | 绿 | +| 尚未查询到数据 | - | — | + +> "黑名单"仅指**金蝶黑名单命中**;合同合规系统返回的黑名单状态字段不用于列表展示(仅在付款提交强制校验中使用)。 + +- 红色标签(黑名单/高风险)**鼠标悬浮**可查看明细:失信类别、列入事由、风险等级。 +- 搜索栏新增**客商风险**下拉筛选:黑名单 / 高风险 / 中风险 / 低风险。 +- 数据为每日凌晨刷新结果,当天有变动的以次日刷新为准。 + +### 4.4 合同推送(合同合规系统) + +合同推送到合规系统前,甲乙双方客商各做一次风险校验:**高风险/黑名单客商的合同不能直接签约**,系统自动发起合同特批流程,特批通过后方可继续。 + +### 4.5 付款申请 + +提交付款申请时,若收款方为外部客商且属**高风险/黑名单**: + +- 系统弹出风险提示; +- **必须上传「特批附件」**类型附件,否则无法提交审批; +- 审批通过后自动发起付款特批,特批完成前禁止推送金蝶。 + +## 5. 常见问题 + +| 问题 | 说明 | +|---|---| +| 为什么客商风险列显示"-"? | 该客商不在任何合同期限内生效合同的关联范围内,尚未被每日刷新任务查询过;或系统刚上线数据尚未生成 | +| 为什么内部组织客商显示"无风险"? | 内部组织为我方主体,按集团规定不查询风险,默认无风险 | +| 新客商当天会出现在风险筛选里吗? | 表单实时提示即时生效;列表落库数据次日刷新后生效 | +| 提交审批被黑名单拦截怎么办? | 客商属 A 类禁止交易,不可线上提交,走线下处置流程 | +| 黑名单数据多久更新? | 每日凌晨 02:30 自动刷新(合同期限内关联客商) | diff --git a/doc/docs/业务流程/2026-10-30-客商黑名单与风险等级运维手册(运维侧).md b/doc/docs/业务流程/2026-10-30-客商黑名单与风险等级运维手册(运维侧).md new file mode 100644 index 00000000..60e6d87b --- /dev/null +++ b/doc/docs/业务流程/2026-10-30-客商黑名单与风险等级运维手册(运维侧).md @@ -0,0 +1,137 @@ +# 客商黑名单与风险等级运维手册(运维侧) + +> 更新日期:2026-10-30 | 适用系统:运输 TMS(blade-transport) | 读者:运维/实施人员 +> +> 关联文档:《客商黑名单与风险等级业务说明(业务侧)》(同目录) + +## 1. 链路总览 + +``` +┌──────────────┐ C101-1 客商风险等级 ┌────────────────┐ +│ 合同合规系统 │ ◄────────────────────── │ │ +│ (HCS) │ A801-5 金蝶黑名单 │ TMS │ +└──────────────┘ ◄────────────────────── │ blade-transport│ + └──────┬─────────┘ + 每日 02:30 CustomerRiskRefreshTask │ + ┌──────▼─────────┐ + │ blade_customer_archive + │ hcs_risk_level / kingdee_black_status + │ hcs_risk_time + │ kingdee_black_category / kingdee_black_reason + └────────────────┘ +``` + +**两条数据线**: + +| 线 | 接口 | 查询键 | 落库字段 | +|---|---|---|---| +| 合同合规风险(C101-1) | `getRelativeRisk` | 客商编号(fdNumber) | `hcs_risk_level`、`kingdee_black_status`、`hcs_risk_time` | +| 金蝶黑名单(A801-5) | `queryBlacklist` | 税号/统一社会信用代码(number) | `kingdee_black_category`、`kingdee_black_reason` | + +**调用场景**(C101-1): + +| 场景 | 入口 | 行为 | +|---|---|---| +| 每日定时刷新 | `CustomerRiskRefreshTask` → `HcsCreditCheckSupport.refreshCustomerRiskForActiveContracts()` | 批量落库 | +| 表单实时提示 | `GET /hcs/sync/risk/by-name`(名称反查编号) | 实时查 C101-1 + A801-5 组装 riskTip | +| 列表单查/批量 | `GET /hcs/sync/risk`、`GET /hcs/sync/risk/batch` | 实时查(展示用) | +| 合同推送校验 | `HcsContractSyncSupport.checkRelativeRisk` | 甲乙双方各查一次,高风险转特批 | +| 付款提交校验 | `HcsCreditCheckSupport.checkRelativeRisk` | 高风险/黑名单须特批附件 | + +**调用场景**(A801-5): + +| 场景 | 入口 | 行为 | +|---|---|---| +| 每日定时刷新 | 同上(200 税号/批) | 落失信类别/列入事由 | +| 提审黑名单校验 | `CustomerArchiveServiceImpl.validateBlacklist` | 按客商发票信息税号查询;**A 类硬拦截,B 类默认放行告警,查询异常阻断提审** | + +## 2. 定时任务 + +`CustomerRiskRefreshTask`(每天 02:30,错开金蝶客商同步 02:00): + +1. 取合同期限内生效合同(`approval_status IN (approved, change_approved)`、`current_node != '已终止'`、正式或临时期限覆盖当天)的甲乙方名称——1 次 SQL; +2. 按名称 `IN` 反查客商档案——1 次 SQL;内部组织客商跳过(默认无风险); +3. 外部客商按 **200/批**:每批 A801-5 一次批量查询 + C101-1 逐个远程查询(接口仅支持单编号); +4. 批内成功结果立即 `Db.updateBatchById` 落库 + `Db.saveBatch` 留痕,**批间互不影响,单批失败不中断其余批次**。 + +## 3. 配置项(Nacos) + +| 配置键 | 环境变量 | 默认 | 说明 | +|---|---|---|---| +| `third-party.hcs.risk.refresh-enabled` | `HCS_RISK_REFRESH_ENABLED` | false | 每日刷新总开关(**上线需置 true**) | +| `third-party.hcs.risk.refresh-cron` | `HCS_RISK_REFRESH_CRON` | `0 30 2 * * ?` | 刷新调度 | +| `third-party.hcs.risk.check-on-contract` | `HCS_RISK_CHECK_ON_CONTRACT` | true | 合同推送前风险校验 | +| `third-party.hcs.risk.check-on-payment` | `HCS_RISK_CHECK_ON_PAYMENT` | true | 付款提交前风险校验 | +| `thirdparty.bfi.blacklist.blockRiskWarning` | `BFI_BLACKLIST_BLOCK_RISK_WARNING` | false | 金蝶黑名单 **B 类(风险警示)提审是否拦截**(A 类恒拦截) | + +配置模板:`doc/nacos/third-party-api.yaml`(hcs.risk 节点)。 + +## 4. 数据库 + +### 4.1 表结构(blade_customer_archive 新增列) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `hcs_risk_level` | varchar(8) | C101-1 风险等级:0=低/1=中/2=高风险 | +| `kingdee_black_status` | varchar(8) | C101-1 黑名单状态:yes/no/handing(数据归金蝶口径,故用 kingdee_ 前缀) | +| `hcs_risk_time` | datetime | 风险最近查询时间 | +| `kingdee_black_category` | varchar(200) | 金蝶黑名单失信类别(awrp_combofield_title 兜底 awrp_combofield) | +| `kingdee_black_reason` | varchar(1000) | 金蝶黑名单列入事由(awrp_textareafield) | + +索引:`idx_hcs_risk_level`、`idx_kingdee_black_status`。 + +### 4.2 脚本(幂等可重复执行) + +`doc/sql/transport/lqj/temp/`: + +| 脚本 | 内容 | +|---|---| +| `02_blade_customer_archive_hcs_risk_20261030.sql` | 加 5 列 + 2 索引(information_schema 判断,已存在跳过) | +| `03_blade_customer_risk_dict_20261030.sql` | 业务字典 `customer_risk`(列表筛选下拉)与 `risk_level`(等级翻译) | + +> 若早期版本已执行过含 `hcs_black_category/reason` 的脚本,需手动删除残留列(现名已改 `kingdee_black_*`)。 + +### 4.3 字典 + +| code | dictKey → dictValue | 用途 | +|---|---|---| +| `customer_risk` | blacklist/high/middle/low → 黑名单/高风险/中风险/低风险 | 客商列表搜索栏筛选下拉 | +| `risk_level` | 0/1/2 → 低风险/中风险/高风险 | 风险等级名称翻译(明细文案) | + +字典缺失时前端筛选走兜底选项、后端明细少拼等级段,不报错。 + +## 5. 明细组装口径(后台统一) + +唯一出口:`HcsCreditCheckSupport.buildRiskDetail(customerName, blackStatusYes, riskLevel, blackCategory, blackReason)`, +格式:`【客商名称】:被列入外部黑名单(失信类别:xx);{列入事由};风险等级:xx` + +- 黑名单段:**仅金蝶命中**(类别/事由任一非空);C101-1 的 `hcs_black_status`(fdBlackStatus)不参与展示,仅付款提交强制校验使用; +- 等级段:`risk_level` 字典翻译(`DictBizCache` 未命中兜底 `DictCache`); +- 列表(`hcs_risk_detail`,列表查询时组装不落库)与表单实时提示(`riskTip`)同口径。 + +列表显示规则(前端 `customerRowRiskName`):高风险+黑名单 → "高风险、黑名单"(红);高风险、黑名单单项各显示(红);中风险(黄);低风险(绿);内部组织 → "无风险"(绿);无数据 → "-"。 + +## 6. 日志与排查 + +| 排查点 | 位置/关键字 | +|---|---| +| 刷新汇总 | 应用日志:`定时刷新客商风险:生效合同 X 份,覆盖客商 Y 个,刷新 Z 个…` | +| 单客商刷新失败 | `定时刷新客商风险失败:customerCode=…`(error 级,含堆栈) | +| 黑名单批量查询失败 | `定时刷新客商风险批量查询金蝶黑名单失败:size=…`(该批黑名单段缺失,下轮重刷) | +| 留痕写入失败 | `定时刷新客商风险留痕批量写入失败:size=…` | +| 全量留痕 | 表 `blade_hcs_check_log`(`biz_type=contract`,`check_type=risk`,`result=pass/fail/skip`,含 `trace_id`、`msg`) | +| 提审黑名单拦截 | 提审报错文案含"国资委黑名单中(失信类别:…,列入事由:…)" | + +## 7. 常见操作 + +| 操作 | 方法 | +|---|---| +| 开启/关闭每日刷新 | Nacos 改 `refresh-enabled`(`@RefreshScope`,改后自动生效,无需重启) | +| 立即手动刷新一次 | 临时把 `refresh-cron` 改为近时刻(如 `0 5 14 * * ?`),执行后改回;或本地调用 `refreshCustomerRiskForActiveContracts()` | +| 补字典 | 执行 `03_blade_customer_risk_dict_20261030.sql`(幂等) | +| 某客商风险不更新 | 核对:客商名称是否与合同甲乙方**完全一致**(按名称匹配);客商是否内部组织(跳过);合同是否期限内且审核通过 | +| C101-1 不可用 | 刷新任务该客商记失败跳过,不影响其他客商;列表显示上轮数据 | +| A801-5 不可用 | 该批黑名单字段缺失(等级仍更新);提审黑名单校验会阻断提交(防止漏拦),恢复后重试 | + +--- +**注意**:黑名单/风险数据仅作展示与流程管控,强制安全校验在提审(A801-5 硬拦)、合同推送、付款提交链路的服务端事务内,前端展示不作为安全边界。 diff --git a/doc/docs/业务流程/2026-10-30-金蝶单据锁定与解锁说明(业务版).md b/doc/docs/业务流程/2026-10-30-金蝶单据锁定与解锁说明(业务版).md new file mode 100644 index 00000000..6437bca6 --- /dev/null +++ b/doc/docs/业务流程/2026-10-30-金蝶单据锁定与解锁说明(业务版).md @@ -0,0 +1,201 @@ +# 金蝶单据锁定与解锁说明(业务版) + +> 业务域:正式结算单、开票申请、付款申请在「同步金蝶」成功后的单据锁定、金蝶侧解锁/删单通知、锁定失败自动补偿 +> 核心规则:**单据推送金蝶成功后,本系统会请金蝶"锁单";锁定期间本系统对应单据被冻结,不能作废或改动。金蝶侧解锁或删单后,会通知本系统,本系统据此解除锁定或自动还原单据。** +> 最后更新:2026-10-30 + +--- + +## 一、为什么要锁单 + +一张单在 TMS 和金蝶各存一份。如果 TMS 推完单后继续改、继续作废,金蝶那边还是老单,就会出现"两套账"。 + +锁单就是把两边"绑住": + +1. 推送成功后,本系统立即请求金蝶把这张单锁住(金蝶侧不允许随意改动); +2. 同时本系统把这边的单冻结(不能作废、不能改); +3. 金蝶那边解锁或删单后,会主动通知本系统,本系统再解除冻结或把单据还原。 + +锁定与否**不影响已推送成功的结果**:金蝶单一旦生成,不会因为锁单没成功而重复生成。 + +--- + +## 二、适用范围(总览) + +| 业务单据 | 什么时候推送 | 金蝶侧单据 | 对应关系 | +| --- | --- | --- | --- | +| 正式结算单(应付) | 审批通过后系统自动推送,也可页面点「同步金蝶」 | 财务应付单 | 一张结算单 → 一张金蝶单 | +| 正式结算单(应收) | 同上 | 财务应收单 | 一张结算单 → 一张金蝶单 | +| 开票申请单 | 审批通过后系统自动推送,也可页面点「同步金蝶」 | 开票单 | 一张申请单 → **按发票张次 N 张金蝶单,逐张锁定** | +| 付款申请单 | 审批通过后系统自动推送(正式结算付款、预付款均适用),也可页面点「同步金蝶」 | 付款申请单 | 一张付款申请 → 一张金蝶单 | +| 收票单 | — | — | **暂未纳入锁定范围**(接口已预留) | + +> 开票申请按"张"锁:一张申请里有几张发票,就有几条锁定记录,各张单独锁、单独补偿,互不影响。但只要其中任意一张被金蝶删除,整张开票申请会被作废(见场景三)。 + +--- + +## 三、页面上的"金蝶锁状态"怎么读 + +列表和详情页的「金蝶锁状态」列会显示以下标签,悬停可看原因或操作人: + +| 标签 | 含义 | 单据能不能操作 | 业务该做什么 | +| --- | --- | --- | --- | +| **金蝶锁定中**(红) | 金蝶已锁单,单据被冻结 | 不能作废、不能改动(开票申请/付款申请点作废会提示"该单据已被金蝶锁定,不可修改";结算单本身"已同步金蝶"就不可编辑/作废) | 确实要改,先去金蝶侧解锁,等状态变为"金蝶锁单"后再处理 | +| **锁定失败·补偿中**(橙) | 金蝶单已生成,但"锁单"这一步没成功,系统正在自动重试 | 不建议作废或重建 | **不要作废单据**,等待自动补偿;着急可在页面点「同步金蝶」立即补锁 | +| **锁定失败·已达上限**(红) | 自动补偿已重试满 5 次仍未成功 | 同上 | 点「同步金蝶」立即补锁(会重新开始补偿);仍失败请把金蝶单号和失败原因给技术排查 | +| **金蝶删单**(红) | 金蝶侧把这张单删除了 | 结算单/开票申请已自动作废;付款申请转为"生成失败" | 结算单/开票申请:重新走业务流程;付款申请:核实后重新推送 | +| **金蝶锁单**(橙) | 金蝶侧解锁了,但单据还在 | 锁已解除,按单据自身状态规则操作 | 注意两边不再自动联动,改动/作废前先确认金蝶侧如何处理 | +| **结算调整重推**(橙) | 结算调整生成新单后,原单的旧锁自动解除 | 原单只做标记,不删不改 | 无需处理,以调整后的新单为准 | +| 空白 | 没有锁记录(从未推送,或锁定早已解除且无留痕) | 按单据自身状态规则操作 | — | + +--- + +## 四、主流程(业务视角) + +``` +单据审批通过 + └─ 系统自动推送金蝶(失败不影响审批结果) + ├─ 推送失败 → 金蝶状态记"生成失败",可重推,不产生金蝶单 + └─ 推送成功 → 回填金蝶单号 → 请求金蝶锁单 + ├─ 锁定成功 → 单据冻结(页面:金蝶锁定中) + └─ 锁定失败 → 系统每5分钟自动重锁,最多5次 + (页面:锁定失败·补偿中 / 已达上限) + +金蝶侧后续动作: + ├─ 解锁(单据还在) → 通知本系统 → 解除锁定(页面:金蝶锁单) + └─ 删单 → 通知本系统 → 结算单/开票申请自动作废并还原 + 付款申请转"生成失败",等人工重推 +``` + +--- + +## 五、场景一:推送成功后锁定 + +**触发方式** + +1. 审批通过后系统自动推送(不需要人工点); +2. 页面手动点「同步金蝶」(补推、重推、补锁都走这里)。 + +**系统做了什么** + +1. 推送成功即回填金蝶单号,随后逐张请求金蝶锁单; +2. 每张金蝶单对应一条锁定记录,先登记"待锁"再调金蝶,保证漏不掉; +3. 单张锁失败不影响其他张,也不影响主单的"已同步"状态。 + +**重复操作会怎样** + +- 已经推送成功、已有金蝶单号的,再点「同步金蝶」不会重复生成金蝶单; +- 已锁定成功的单据不会重复锁。 + +**锁定期间的业务影响** + +- 开票申请、付款申请:作废入口被拦截; +- 正式结算单:本身"已同步金蝶"就不能编辑、调整、作废,锁定期同样被拦截。 + +--- + +## 六、场景二:金蝶解锁(单据还在) + +1. 金蝶侧对单据执行解锁后,通知本系统; +2. 本系统把锁定记录置为"已解锁",页面显示橙色「金蝶锁单」标签,悬停显示**谁在什么时间解锁**; +3. 本系统单据的业务状态**不会因此改变**(结算单仍是"已同步",开票申请/付款申请仍保持原审批状态); +4. 重复收到同一条解锁通知不会重复处理,也不会报错。 + +> 注意:解锁只解除"金蝶锁单"这一层限制。正式结算单即使解锁,仍受"已同步金蝶不可直接作废"的约束;要作废/调整需走结算调整,或让金蝶侧删单由系统自动作废。 + +--- + +## 七、场景三:金蝶删单(三种单据处理不同) + +金蝶侧删除单据后,系统按单据类型分别处理: + +| 业务单据 | 本系统处理 | 单据状态 | 后续 | +| --- | --- | --- | --- | +| 正式结算单 | 自动作废,并还原到推送前的同步信息:清空金蝶单号、金蝶内码、同步时间,同步状态改回"未同步" | 已作废(终态),当前节点"已作废" | 变更流水记录"金蝶删单回调自动废除",操作人显示金蝶侧操作人;需重新推单请新单重走流程 | +| 开票申请 | 自动作废,清空金蝶单号、同步时间,同步状态改回"未同步",并刷新关联结算单的开票状态 | 已作废(终态) | 操作流水记"金蝶删单回调作废",操作人为金蝶侧操作人;需重新开票请重新发起申请 | +| 付款申请 | **不作废源单**:金蝶状态由"已生成/付款中"改为"生成失败",清空金蝶单号、内码(原值留日志) | 审批状态不变 | **不自动重推**,人工核实后重新推送;页面显示红色「金蝶删单」标签,以便与普通推送失败区分 | + +补充说明: + +- 开票申请有多张金蝶单时,任意一张被删,**整张开票申请作废**; +- 付款申请只有"已生成/付款中"状态会转为"生成失败",已完成付款等终态不受影响; +- 结算单作废时会同步释放项目预付核销占用,避免残留; +- 重复的删单通知不会重复作废、重复还原。 + +--- + +## 八、场景四:锁定失败与自动补偿 + +**什么情况会锁定失败**:推送成功后,请求金蝶锁单时接口报错或超时。 + +**系统怎么处理** + +1. 该张单据保留"锁定失败"记录并记录原因,页面显示橙色「锁定失败·补偿中」; +2. 定时任务自动重锁,默认每 5 分钟一轮,最多自动重试 5 次; +3. 5 次仍未成功,页面转为红色「锁定失败·已达上限」,不再自动重试。 + +**业务注意事项** + +- **锁定失败不影响已推送成功的结果**,单据仍是"已同步金蝶",不会重复生成金蝶单; +- 补偿期间建议**先不要作废或重建单据**,避免本系统与金蝶不一致; +- 已达上限时,在页面点「同步金蝶」会立即补锁,并重新开始补偿计数; +- 仍失败时,请提供单据号、金蝶单号、页面上的失败原因给技术排查。 + +> 自动补偿是否开启由各环境配置决定(默认开启锁定、默认关闭定时补偿),如发现"一直没人补锁",请联系技术确认环境配置。 + +--- + +## 九、场景五:结算调整重推(本系统主动解锁) + +已推送金蝶的正式结算单需要调整时,走结算调整:系统生成一张新的结算单并推送金蝶,**原单的旧锁自动解除**,页面显示橙色「结算调整重推」标签。 + +- 原单只加"已调整"标记,不删除、不改业务字段; +- 后续收付款、开票以新单为准; +- 这是本系统主动解锁,不是金蝶回调,操作人显示发起调整的人。 + +--- + +## 十、常见问题 + +**1. 单据显示"金蝶锁定中",我要改内容/作废怎么办?** +先到金蝶侧解锁,等本系统状态变成「金蝶锁单」后再处理。正式结算单还要注意"已同步金蝶"本身就不能直接作废,需要走结算调整或由金蝶删单。 + +**2. 金蝶那边已经解锁了,为什么本系统还不能作废?** +解锁只解除"金蝶锁单",不改变单据的业务状态。结算单仍受"已同步不可作废"限制;开票申请/付款申请解锁后恢复可作废,但作废不会反向同步金蝶,请确认金蝶侧单据的处理方式。 + +**3. 显示"金蝶删单"是什么意思?** +金蝶侧把单据删了。结算单、开票申请已自动作废,需要重新走流程;付款申请没有作废,但变成了"生成失败",核实后重新推送即可。 + +**4. 显示"锁定失败"要不要人工处理?** +先等系统自动补偿(每 5 分钟一次,最多 5 次)。已达上限时点「同步金蝶」立即补锁;补偿期间不要作废或重建单据。 + +**5. 金蝶重复发解锁/删单通知,会不会把单据作废两次?** +不会。系统按"当前是否仍锁定/是否已作废"判断,重复通知自动跳过。 + +**6. 开票申请有多张发票,其中一张锁失败怎么办?** +各张独立锁定、独立补偿,其他张照常冻结。页面会提示哪一张失败;该张补偿成功前,整张申请仍按"锁定失败"提示。 + +**7. 已推送的单据能不能直接在 TMS 作废?** +不能。开票申请、付款申请在锁定中被拦截;正式结算单已同步即不可作废。要么金蝶侧删单由系统自动作废,要么走结算调整生成新单。 + +--- + +## 十一、留痕在哪里查 + +| 想查什么 | 在哪看 | +| --- | --- | +| 单据当前锁状态、失败原因、重试次数、解锁操作人与时间 | 列表/详情「金蝶锁状态」列,悬停查看提示 | +| 结算单被金蝶删单自动作废的过程 | 结算单变更流水(内容含"金蝶删单回调自动废除") | +| 开票申请被金蝶删单自动作废的过程 | 开票申请操作流水(动作"金蝶删单回调作废") | +| 付款申请解锁原因 | 付款申请金蝶失败原因("金蝶侧单据已删除解锁……等待重新推送") | +| 每张金蝶单的完整锁定/解锁明细 | 技术侧锁定记录表 `blade_kingdee_bill_lock`(一张金蝶单一行) | + +--- + +## 十二、口径边界(给管理员/技术) + +1. 锁定总开关关闭时:推送后不锁单,金蝶的解锁/删单通知只应答、不处理; +2. 补偿定时任务需按环境开启,默认每 5 分钟一轮,重试上限 5 次; +3. 金蝶回调为无登录态公开接口,需保持网关白名单配置有效(异常时金蝶会重试); +4. 锁单与解锁均为逐张调用,单张成败互不影响;解锁回调整批处理,任一张还原失败则整批回滚、由金蝶重试闭环; +5. 后续新增单据(如收票单)接入锁定时,锁定状态、解锁标识与本文口径保持一致即可复用。 diff --git a/doc/nacos/blade-dev.yaml b/doc/nacos/blade-dev.yaml index 7607c247..dfdec81f 100644 --- a/doc/nacos/blade-dev.yaml +++ b/doc/nacos/blade-dev.yaml @@ -92,8 +92,12 @@ thirdParty: ocr: # OCR磅单、车牌识别接口地址 baseUrl: http://127.0.0.1:8080 - # OCR证件识别接口地址 + # OCR证件识别接口地址(银行卡等通用证件) baseOtherUrl: http://127.0.0.1:8080 + # 身份证 OCR 服务地址(POST /ShenFenZheng,formData 字段 image_file) + idCardBaseUrl: http://124.71.22.123:8500 + # 营业执照 OCR 服务地址(POST /businessLicense,JSON:img_path、data_type) + businessLicenseBaseUrl: http://124.71.22.123:8888 wps: # WPS开放接口地址 baseUrl: http://127.0.0.1:8080 @@ -155,6 +159,11 @@ thirdParty: # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) srcSys: ${BFI_SRC_SYS:WLTMS} + # 金蝶黑名单查询(A801-5 客商提审拦截):禁止交易类(A)始终拦截; + # 风险警示类(B)默认放行告警,开启后一并拦截 + blacklist: + blockRiskWarning: ${BFI_BLACKLIST_BLOCK_RISK_WARNING:false} + # 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值): # 运输类按项目业务模式映射——国内运输按运单运输方式映射、国际运输映射国际货代服务; # 装卸大类及手动添加的非运费杂费映射装卸物料 @@ -179,12 +188,18 @@ thirdParty: exratetable: ${BFI_PAY_APPLY_EXRATE_TABLE:01} # 外部单据类型 srcType: ${BFI_PAY_APPLY_SRC_TYPE:0KzXs} + # 收款流水同步配置(A301-1 收款单查询) + recBillSync: + # 业务日期窗口天数 + days: ${BFI_RECBILL_SYNC_DAYS:60} # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) payResult: # 回写任务开关 syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} + # A103-4 付款处理查询业务日期窗口天数 + queryWindowDays: ${BFI_PAY_RESULT_QUERY_WINDOW_DAYS:60} # 收款认领公告查询配置(A301-2) claimAnnounce: # 查询时间窗口(月数) diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index a444dde6..9eae1638 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -54,8 +54,12 @@ thirdParty: ocr: # OCR磅单、车牌识别接口地址 baseUrl: http://127.0.0.1:8080 - # OCR证件识别接口地址 + # OCR证件识别接口地址(银行卡等通用证件) baseOtherUrl: http://127.0.0.1:8080 + # 身份证 OCR 服务地址(POST /ShenFenZheng,formData 字段 image_file) + idCardBaseUrl: http://124.71.22.123:8500 + # 营业执照 OCR 服务地址(POST /businessLicense,JSON:img_path、data_type) + businessLicenseBaseUrl: http://124.71.22.123:8888 wps: # WPS开放接口地址 baseUrl: http://127.0.0.1:8080 @@ -120,6 +124,10 @@ thirdParty: language: ${BFI_LANGUAGE:zh_CN} # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) srcSys: ${BFI_SRC_SYS:WLTMS} + # 金蝶黑名单查询(A801-5 客商提审拦截):禁止交易类(A)始终拦截; + # 风险警示类(B)默认放行告警,开启后一并拦截 + blacklist: + blockRiskWarning: ${BFI_BLACKLIST_BLOCK_RISK_WARNING:false} # 付款申请推送配置(A103-3 付款申请保存) payApply: # 单据类型编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请 @@ -129,12 +137,18 @@ thirdParty: exratetable: ${BFI_PAY_APPLY_EXRATE_TABLE:01} # 外部单据类型 srcType: ${BFI_PAY_APPLY_SRC_TYPE:0KzXs} + # 收款流水同步配置(A301-1 收款单查询) + recBillSync: + # 业务日期窗口天数 + days: ${BFI_RECBILL_SYNC_DAYS:60} # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) payResult: # 回写任务开关 syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} + # A103-4 付款处理查询业务日期窗口天数 + queryWindowDays: ${BFI_PAY_RESULT_QUERY_WINDOW_DAYS:60} # 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) receiptClaimPush: # 推送任务开关,默认关闭(联调稳定后开启) diff --git a/doc/nacos/blade.yaml b/doc/nacos/blade.yaml index 8515a4fb..1917bf5f 100644 --- a/doc/nacos/blade.yaml +++ b/doc/nacos/blade.yaml @@ -246,6 +246,9 @@ blade: - /hcs/projectLegal/result - /blade-transport/hcs/contractKingdee/result - /hcs/contractKingdee/result + # HCS 合规附件下载(C103-5 代理):前端附件面板凭合规附件ID 查看/下载,fdId 32位随机不可枚举,免登免授权 + - /blade-transport/hcs/attachment/download/** + - /hcs/attachment/download/** #授权认证配置 auth: - method: ALL @@ -298,6 +301,13 @@ blade: - method: POST pattern: /hcs/contractKingdee/result expression: "permitAll()" + # HCS 合规附件下载(C103-5 代理):前端附件面板凭合规附件ID 查看/下载,fdId 32位随机不可枚举,免登免授权 + - method: GET + pattern: /blade-transport/hcs/attachment/download/** + expression: "permitAll()" + - method: GET + pattern: /hcs/attachment/download/** + expression: "permitAll()" #基础认证配置 basic: - method: ALL diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index cda8991b..10b832e0 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -3,8 +3,12 @@ thirdParty: ocr: # OCR磅单、车牌识别接口地址 baseUrl: ${OCR_BASE_URL:http://127.0.0.1:8080} - # OCR证件识别接口地址 + # OCR证件识别接口地址(银行卡等通用证件) baseOtherUrl: ${OCR_BASE_OTHER_URL:http://127.0.0.1:8080} + # 身份证 OCR 服务地址(POST /ShenFenZheng,formData 字段 image_file) + idCardBaseUrl: ${OCR_ID_CARD_BASE_URL:http://124.71.22.123:8500} + # 营业执照 OCR 服务地址(POST /businessLicense,JSON:img_path、data_type) + businessLicenseBaseUrl: ${OCR_BUSINESS_LICENSE_BASE_URL:http://124.71.22.123:8888} wps: # WPS开放接口地址 baseUrl: ${WPS_BASE_URL:http://127.0.0.1:8080} @@ -57,6 +61,10 @@ thirdParty: # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) srcSys: ${BFI_SRC_SYS:WLTMS} + # 金蝶黑名单查询(A801-5 客商提审拦截):禁止交易类(A)始终拦截; + # 风险警示类(B)默认放行告警,开启后一并拦截 + blacklist: + blockRiskWarning: ${BFI_BLACKLIST_BLOCK_RISK_WARNING:false} # 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值): # 运输类按项目业务模式映射——国内运输按运单运输方式映射、国际运输映射国际货代服务; # 装卸大类及手动添加的非运费杂费映射装卸物料 @@ -82,12 +90,18 @@ thirdParty: exratetable: ${BFI_PAY_APPLY_EXRATE_TABLE:01} # 外部单据类型 srcType: ${BFI_PAY_APPLY_SRC_TYPE:0KzXs} + # 收款流水同步配置(A301-1 收款单查询) + recBillSync: + # 业务日期窗口天数 + days: ${BFI_RECBILL_SYNC_DAYS:60} # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) payResult: # 回写任务开关 syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:false} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 1/10 * * * ?} + # A103-4 付款处理查询业务日期窗口天数 + queryWindowDays: ${BFI_PAY_RESULT_QUERY_WINDOW_DAYS:60} # 金蝶收票单结果定时回写(A801-3 异常回收/指定补偿/单号补登) receivedInvoiceResult: @@ -241,6 +255,10 @@ thirdParty: risk: check-on-contract: ${HCS_RISK_CHECK_ON_CONTRACT:true} check-on-payment: ${HCS_RISK_CHECK_ON_PAYMENT:true} + # 客商风险每日定时刷新开关(刷新合同期限内生效合同关联客商并落库,支撑列表展示与筛选,默认关) + refresh-enabled: ${HCS_RISK_REFRESH_ENABLED:false} + # 客商风险刷新调度 cron,默认每天 02:30(错开金蝶客商同步 02:00) + refresh-cron: ${HCS_RISK_REFRESH_CRON:0 30 2 * * ?} # 黑名单是否阻断(合同置 failed / 付款提交拦截) # block-blacklist: ${HCS_RISK_BLOCK_BLACKLIST:true} # 额度校验(C106-1,一期暂不对接) diff --git a/doc/sql/transport/blade_base_menu_view_permission_patch_20261104.sql b/doc/sql/transport/blade_base_menu_view_permission_patch_20261104.sql new file mode 100644 index 00000000..d595f321 --- /dev/null +++ b/doc/sql/transport/blade_base_menu_view_permission_patch_20261104.sql @@ -0,0 +1,44 @@ +-- ---------------------------- +-- 补齐基础/系统业务模块「查看」按钮权限(角色授权用) +-- 执行前请确认目标库尚未存在同 code 菜单 +-- ---------------------------- + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2075429330000000001, 2075421555200860162, 'port_terminal_view', '查看', 'port_terminal_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'port_terminal_view' AND `is_deleted` = 0); + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2075429280000000001, 2075427962415456258, 'railway_station_view', '查看', 'railway_station_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'railway_station_view' AND `is_deleted` = 0); + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2075431850000000001, 2075431169720033282, 'airport_master_view', '查看', 'airport_master_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'airport_master_view' AND `is_deleted` = 0); + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2075449000000000009, 2075449000000000001, 'currency_view', '查看', 'currency_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'currency_view' AND `is_deleted` = 0); + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2075449100000000006, 2075449100000000001, 'customer_type_view', '查看', 'customer_type_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'customer_type_view' AND `is_deleted` = 0); + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2075449200000000009, 2075449200000000001, 'fee_item_view', '查看', 'fee_item_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'fee_item_view' AND `is_deleted` = 0); + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2075449300000000006, 2075449300000000001, 'measurement_unit_view', '查看', 'measurement_unit_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'measurement_unit_view' AND `is_deleted` = 0); + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2080110200000000105, 2080110200000000101, 'business_dept_config_view', '查看', 'business_dept_config_view', '', '', 4, 2, 0, 1, NULL, '', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'business_dept_config_view' AND `is_deleted` = 0); diff --git a/doc/sql/transport/blade_biz_dept_backfill_by_creator_20261104.sql b/doc/sql/transport/blade_biz_dept_backfill_by_creator_20261104.sql new file mode 100644 index 00000000..7f623bdf --- /dev/null +++ b/doc/sql/transport/blade_biz_dept_backfill_by_creator_20261104.sql @@ -0,0 +1,511 @@ +-- ---------------------------- +-- 业务数据「所属组织」旧残留批量订正 +-- 口径:按创建人当前所属组织(blade_user.dept_id 首个)回填 dept_id / dept_name(或 organization_name) +-- 适用:各环境 OA/组织同步后,业务表仍挂着已删/不存在/空组织的存量数据 +-- 可重复执行;update_time 不改,保留原值 +-- ---------------------------- +-- +-- 订正范围(仅处理残留): +-- 1) dept_id 为空或 <=0 +-- 2) dept_id 在 blade_dept 中不存在或已逻辑删除 +-- 仅名称字段的表:organization_name / dept_name 为空,或名称在当前未删除组织中找不到 +-- +-- 取值规则: +-- create_user → blade_user.dept_id(多组织取逗号分隔第一个,与登录态 AuthUtil.firstLong 一致) +-- → blade_dept.dept_name +-- 创建人无组织、或组织已失效:跳过该行 +-- +-- 特殊: +-- blade_customer_archive 客商所属组织业务上固定为「广西现代物流集团有限公司」, +-- 不按创建人组织回填,残留时改挂该顶级组织。 +-- 合同负责部门(organization_id)/ 票据使用部门(blade_bill_payment.dept_id)不在本脚本范围。 +-- +-- 执行顺序: +-- 0 预览 → 1 备份 → 2 订正 → 3 校验;出问题用 4 回退 +-- ---------------------------- + +SET NAMES utf8mb4; + +-- ---------------------------- +-- 0. 预览:各表待订正条数(执行订正前先看) +-- ---------------------------- +-- DROP TEMPORARY TABLE IF EXISTS tmp_user_current_dept; +-- CREATE TEMPORARY TABLE tmp_user_current_dept AS +-- SELECT +-- u.id AS user_id, +-- CAST(SUBSTRING_INDEX(TRIM(u.dept_id), ',', 1) AS UNSIGNED) AS dept_id, +-- d.dept_name AS dept_name +-- FROM blade_user u +-- INNER JOIN blade_dept d +-- ON d.id = CAST(SUBSTRING_INDEX(TRIM(IFNULL(u.dept_id, '')), ',', 1) AS UNSIGNED) +-- AND IFNULL(d.is_deleted, 0) = 0 +-- WHERE IFNULL(u.is_deleted, 0) = 0 +-- AND IFNULL(TRIM(u.dept_id), '') <> '' +-- AND CAST(SUBSTRING_INDEX(TRIM(u.dept_id), ',', 1) AS UNSIGNED) > 0; +-- ALTER TABLE tmp_user_current_dept ADD PRIMARY KEY (user_id); +-- +-- SELECT 'blade_common_route' AS table_name, COUNT(*) AS pending_cnt +-- FROM blade_common_route t +-- INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +-- WHERE IFNULL(t.is_deleted, 0) = 0 +-- AND (t.dept_id IS NULL OR t.dept_id <= 0 +-- OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); +-- -- 其余表同理,可按第 2 节表清单自行复制预览 + +-- ---------------------------- +-- 1. 备份表(不存在则创建;可重复执行) +-- ---------------------------- +CREATE TABLE IF NOT EXISTS blade_biz_dept_backfill_bak_20261104 ( + bak_id BIGINT NOT NULL AUTO_INCREMENT, + table_name VARCHAR(64) NOT NULL, + biz_id BIGINT NOT NULL, + old_dept_id BIGINT DEFAULT NULL, + old_dept_name VARCHAR(255) DEFAULT NULL, + new_dept_id BIGINT DEFAULT NULL, + new_dept_name VARCHAR(255) DEFAULT NULL, + create_user BIGINT DEFAULT NULL, + bak_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (bak_id), + KEY idx_biz_dept_bak_table_biz (table_name, biz_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='所属组织订正备份20261104'; + +START TRANSACTION; + +-- 创建人 → 当前所属组织(多组织取第一个) +DROP TEMPORARY TABLE IF EXISTS tmp_user_current_dept; +CREATE TEMPORARY TABLE tmp_user_current_dept AS +SELECT + u.id AS user_id, + CAST(SUBSTRING_INDEX(TRIM(u.dept_id), ',', 1) AS UNSIGNED) AS dept_id, + d.dept_name AS dept_name +FROM blade_user u +INNER JOIN blade_dept d + ON d.id = CAST(SUBSTRING_INDEX(TRIM(IFNULL(u.dept_id, '')), ',', 1) AS UNSIGNED) + AND IFNULL(d.is_deleted, 0) = 0 +WHERE IFNULL(u.is_deleted, 0) = 0 + AND IFNULL(TRIM(u.dept_id), '') <> '' + AND CAST(SUBSTRING_INDEX(TRIM(u.dept_id), ',', 1) AS UNSIGNED) > 0; +ALTER TABLE tmp_user_current_dept ADD PRIMARY KEY (user_id); + +-- 客商固定顶级组织 +DROP TEMPORARY TABLE IF EXISTS tmp_fixed_owner_dept; +CREATE TEMPORARY TABLE tmp_fixed_owner_dept AS +SELECT d.id AS dept_id, d.dept_name +FROM blade_dept d +WHERE IFNULL(d.is_deleted, 0) = 0 + AND d.dept_name = '广西现代物流集团有限公司' +ORDER BY d.id +LIMIT 1; +ALTER TABLE tmp_fixed_owner_dept ADD PRIMARY KEY (dept_id); + +-- ---------------------------- +-- 2. 订正:dept_id + dept_name 类表 +-- ---------------------------- + +-- 2.1 常用线路 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_common_route', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_common_route t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_common_route t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.2 常用货物 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_common_cargo', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_common_cargo t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_common_cargo t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.3 过程配置 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_process_config', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_process_config t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_process_config t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.4 发货模板 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_shipping_template', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_shipping_template t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_shipping_template t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.5 运输计划 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_transport_plan', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_transport_plan t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_transport_plan t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.6 运单 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_waybill', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_waybill t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_waybill t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.7 配载 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_loading_manage', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_loading_manage t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_loading_manage t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.8 常用地址 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_common_address', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_common_address t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_common_address t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.9 预结算 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_pre_settlement', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_pre_settlement t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_pre_settlement t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.10 正式结算 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_formal_settlement', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_formal_settlement t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_formal_settlement t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.11 应收应付明细 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_receivable_payable_detail', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_receivable_payable_detail t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_receivable_payable_detail t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.12 异常处置 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_exception_disposal', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_exception_disposal t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_exception_disposal t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.13 风险处置 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_risk_disposal', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_risk_disposal t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_risk_disposal t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.14 开票申请 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_invoice_application', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_invoice_application t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_invoice_application t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.15 收票登记 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_invoice_receipt', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_invoice_receipt t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_invoice_receipt t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.16 付款申请 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_payment_application', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_payment_application t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_payment_application t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.17 运输对账 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_transport_reconciliation', t.id, t.dept_id, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_transport_reconciliation t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +UPDATE blade_transport_reconciliation t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- 2.18 客商档案:固定挂顶级「广西现代物流集团有限公司」 +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_customer_archive', t.id, t.dept_id, t.dept_name, f.dept_id, f.dept_name, t.create_user +FROM blade_customer_archive t +CROSS JOIN tmp_fixed_owner_dept f +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0) + OR IFNULL(t.dept_id, 0) <> f.dept_id); + +UPDATE blade_customer_archive t +CROSS JOIN tmp_fixed_owner_dept f +SET t.dept_id = f.dept_id, + t.dept_ids = CAST(f.dept_id AS CHAR), + t.dept_name = f.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_id IS NULL OR t.dept_id <= 0 + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0) + OR IFNULL(t.dept_id, 0) <> f.dept_id); + +-- ---------------------------- +-- 2.19 仅名称字段:司机 / 车辆 / 船舶 / 派车 +-- ---------------------------- + +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_transport_driver', t.id, NULL, t.organization_name, c.dept_id, c.dept_name, t.create_user +FROM blade_transport_driver t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +UPDATE blade_transport_driver t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.organization_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_transport_vehicle', t.id, NULL, t.organization_name, c.dept_id, c.dept_name, t.create_user +FROM blade_transport_vehicle t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +UPDATE blade_transport_vehicle t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.organization_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_transport_ship', t.id, NULL, t.organization_name, c.dept_id, c.dept_name, t.create_user +FROM blade_transport_ship t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +UPDATE blade_transport_ship t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.organization_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_vehicle_dispatch', t.id, NULL, t.organization_name, c.dept_id, c.dept_name, t.create_user +FROM blade_vehicle_dispatch t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +UPDATE blade_vehicle_dispatch t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.organization_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.organization_name IS NULL OR TRIM(t.organization_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.organization_name)); + +-- 2.20 结算调整单(仅 dept_name) +INSERT INTO blade_biz_dept_backfill_bak_20261104 (table_name, biz_id, old_dept_id, old_dept_name, new_dept_id, new_dept_name, create_user) +SELECT 'blade_settlement_adjustment', t.id, NULL, t.dept_name, c.dept_id, c.dept_name, t.create_user +FROM blade_settlement_adjustment t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_name IS NULL OR TRIM(t.dept_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.dept_name)); + +UPDATE blade_settlement_adjustment t +INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +SET t.dept_name = c.dept_name +WHERE IFNULL(t.is_deleted, 0) = 0 + AND (t.dept_name IS NULL OR TRIM(t.dept_name) = '' + OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE IFNULL(d.is_deleted, 0) = 0 AND d.dept_name = t.dept_name)); + +COMMIT; + +-- ---------------------------- +-- 3. 校验(订正后执行) +-- ---------------------------- +-- SELECT table_name, COUNT(*) AS bak_rows +-- FROM blade_biz_dept_backfill_bak_20261104 +-- GROUP BY table_name +-- ORDER BY table_name; +-- +-- -- 仍挂无效组织、且创建人当前有有效组织的运单(示例,期望 0) +-- SELECT COUNT(*) AS still_bad +-- FROM blade_waybill t +-- INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +-- WHERE IFNULL(t.is_deleted, 0) = 0 +-- AND (t.dept_id IS NULL OR t.dept_id <= 0 +-- OR NOT EXISTS (SELECT 1 FROM blade_dept d WHERE d.id = t.dept_id AND IFNULL(d.is_deleted, 0) = 0)); + +-- ---------------------------- +-- 4. 回退(按备份表恢复;按需取消注释) +-- ---------------------------- +-- UPDATE blade_waybill t +-- INNER JOIN blade_biz_dept_backfill_bak_20261104 b +-- ON b.table_name = 'blade_waybill' AND b.biz_id = t.id +-- SET t.dept_id = b.old_dept_id, t.dept_name = b.old_dept_name; +-- -- 其他表同理替换 table_name / 字段名 + +-- ---------------------------- +-- 5. 可选:全量强制按创建人当前组织覆盖(不仅残留) +-- 默认不要执行;确认业务需要「全部改挂创建人当前组织」时再跑 +-- ---------------------------- +-- UPDATE blade_waybill t +-- INNER JOIN tmp_user_current_dept c ON c.user_id = t.create_user +-- SET t.dept_id = c.dept_id, t.dept_name = c.dept_name +-- WHERE IFNULL(t.is_deleted, 0) = 0; diff --git a/doc/sql/transport/blade_business_dept_config_20261102.sql b/doc/sql/transport/blade_business_dept_config_20261102.sql new file mode 100644 index 00000000..e42118f9 --- /dev/null +++ b/doc/sql/transport/blade_business_dept_config_20261102.sql @@ -0,0 +1,38 @@ +-- 业务部门配置:平台公司 + 业务部门 映射合同编号前缀、初始流水号、金蝶部门代码 +CREATE TABLE IF NOT EXISTS `blade_business_dept_config` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID', + `platform_company_id` bigint NOT NULL COMMENT '平台公司ID', + `platform_company_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '平台公司名称', + `business_dept_id` bigint NOT NULL COMMENT '业务部门ID', + `business_dept_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '业务部门名称', + `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '合同编码前缀(平台公司简称+业务部门简称)', + `initial_serial` int DEFAULT NULL COMMENT '初始流水号(1-999,系统已有相同流水号则跳过)', + `kingdee_company_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '金蝶公司代码(取自平台公司组织金蝶代码)', + `kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '金蝶部门代码', + `remark` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '备注', + `create_user` bigint DEFAULT NULL COMMENT '创建人', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int DEFAULT 1 COMMENT '状态', + `is_deleted` int DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_biz_dept_config_platform` (`platform_company_id`) USING BTREE, + KEY `idx_biz_dept_config_dept` (`business_dept_id`) USING BTREE, + KEY `idx_biz_dept_config_tenant` (`tenant_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='业务部门配置'; + +-- 合同管理增加平台公司字段 +ALTER TABLE `blade_contract_manage` + ADD COLUMN `platform_company_id` bigint DEFAULT NULL COMMENT '平台公司ID' AFTER `organization_name`, + ADD COLUMN `platform_company_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '平台公司' AFTER `platform_company_id`; + +-- 系统管理菜单:业务部门配置 +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES +(2080110200000000101, 1123598815738675203, 'business_dept_config', '业务部门配置', 'business_dept_config', '/system/business-dept-config', 'iconfont iconicon_group', 25, 1, 0, 1, NULL, '', 0), +(2080110200000000102, 2080110200000000101, 'business_dept_config_add', '新增', 'business_dept_config_add', '', '', 1, 2, 0, 1, NULL, '', 0), +(2080110200000000103, 2080110200000000101, 'business_dept_config_edit', '编辑', 'business_dept_config_edit', '', '', 2, 2, 0, 1, NULL, '', 0), +(2080110200000000104, 2080110200000000101, 'business_dept_config_delete', '删除', 'business_dept_config_delete', '', '', 3, 2, 0, 1, NULL, '', 0), +(2080110200000000105, 2080110200000000101, 'business_dept_config_view', '查看', 'business_dept_config_view', '', '', 4, 2, 0, 1, NULL, '', 0); diff --git a/doc/sql/transport/blade_contract_party_multi_20261102.sql b/doc/sql/transport/blade_contract_party_multi_20261102.sql new file mode 100644 index 00000000..6be20f99 --- /dev/null +++ b/doc/sql/transport/blade_contract_party_multi_20261102.sql @@ -0,0 +1,53 @@ +-- 合同甲/乙/丙多方客商明细 + 运单/配载承运客商明细定位字段 +-- utf8mb4_general_ci + +-- 合同管理:多方客商 JSON 与丙方汇总 +ALTER TABLE `blade_contract_manage` + ADD COLUMN `party_a_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '甲方客商明细JSON' AFTER `party_b`, + ADD COLUMN `party_b_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '乙方客商明细JSON' AFTER `party_a_json`, + ADD COLUMN `party_c_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '丙方客商明细JSON' AFTER `party_b_json`, + ADD COLUMN `party_c` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '丙方' AFTER `party_c_json`; + +-- 合同变更:同步多方字段 +ALTER TABLE `blade_contract_change` + ADD COLUMN `party_a_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '甲方客商明细JSON' AFTER `party_b`, + ADD COLUMN `party_b_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '乙方客商明细JSON' AFTER `party_a_json`, + ADD COLUMN `party_c_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '丙方客商明细JSON' AFTER `party_b_json`, + ADD COLUMN `party_c` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '丙方' AFTER `party_c_json`; + +-- 历史数据迁移:原甲/乙方名称生成单行明细(仅名称,后续编辑补全客商ID) +UPDATE `blade_contract_manage` +SET `party_a_json` = CASE + WHEN `party_a` IS NOT NULL AND TRIM(`party_a`) <> '' AND (`party_a_json` IS NULL OR TRIM(`party_a_json`) = '') + THEN CONCAT('[{"id":"', REPLACE(UUID(), '-', ''), '","customerName":"', REPLACE(REPLACE(`party_a`, '\\', '\\\\'), '"', '\\"'), '"}]') + ELSE `party_a_json` +END, +`party_b_json` = CASE + WHEN `party_b` IS NOT NULL AND TRIM(`party_b`) <> '' AND (`party_b_json` IS NULL OR TRIM(`party_b_json`) = '') + THEN CONCAT('[{"id":"', REPLACE(UUID(), '-', ''), '","customerName":"', REPLACE(REPLACE(`party_b`, '\\', '\\\\'), '"', '\\"'), '"}]') + ELSE `party_b_json` +END +WHERE `is_deleted` = 0; + +UPDATE `blade_contract_change` +SET `party_a_json` = CASE + WHEN `party_a` IS NOT NULL AND TRIM(`party_a`) <> '' AND (`party_a_json` IS NULL OR TRIM(`party_a_json`) = '') + THEN CONCAT('[{"id":"', REPLACE(UUID(), '-', ''), '","customerName":"', REPLACE(REPLACE(`party_a`, '\\', '\\\\'), '"', '\\"'), '"}]') + ELSE `party_a_json` +END, +`party_b_json` = CASE + WHEN `party_b` IS NOT NULL AND TRIM(`party_b`) <> '' AND (`party_b_json` IS NULL OR TRIM(`party_b_json`) = '') + THEN CONCAT('[{"id":"', REPLACE(UUID(), '-', ''), '","customerName":"', REPLACE(REPLACE(`party_b`, '\\', '\\\\'), '"', '\\"'), '"}]') + ELSE `party_b_json` +END +WHERE `is_deleted` = 0; + +-- 运单:承运客商明细定位 +ALTER TABLE `blade_waybill` + ADD COLUMN `carrier_party_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '承运客商明细行ID' AFTER `carrier_contract_id`, + ADD COLUMN `carrier_party_role` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '承运客商角色 B/C' AFTER `carrier_party_id`; + +-- 配载:承运客商明细定位 +ALTER TABLE `blade_loading_manage` + ADD COLUMN `carrier_party_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '承运客商明细行ID' AFTER `carrier_contract_id`, + ADD COLUMN `carrier_party_role` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '承运客商角色 B/C' AFTER `carrier_party_id`; diff --git a/doc/sql/transport/blade_currency.sql b/doc/sql/transport/blade_currency.sql index 8bdd98d5..287ea1b0 100644 --- a/doc/sql/transport/blade_currency.sql +++ b/doc/sql/transport/blade_currency.sql @@ -40,3 +40,4 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000006, 2075449000000000001, 'currency_edit', '编辑', 'currency_edit', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000007, 2075449000000000001, 'currency_status', '修改状态', 'currency_status', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000008, 2075449000000000001, 'currency_add', '新增', 'currency_add', '', '', 1, 2, 0, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000009, 2075449000000000001, 'currency_view', '查看', 'currency_view', '', '', 1, 2, 0, 1, NULL, '', 0); diff --git a/doc/sql/transport/blade_customer_archive.sql b/doc/sql/transport/blade_customer_archive.sql index 0cec14d9..1c0268fd 100644 --- a/doc/sql/transport/blade_customer_archive.sql +++ b/doc/sql/transport/blade_customer_archive.sql @@ -46,6 +46,11 @@ CREATE TABLE `blade_customer_archive` ( `kingdee_status` varchar(20) NOT NULL DEFAULT 'none' COMMENT '金蝶协同状态:none未推送/exist金蝶已有/synced已推送/failed推送失败', `kingdee_fail_reason` varchar(500) NULL DEFAULT NULL COMMENT '金蝶推送失败原因', `kingdee_sync_time` datetime NULL DEFAULT NULL COMMENT '金蝶推送时间', + `hcs_risk_level` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合规客商风险等级(C101-1,0=低风险/1=中风险/2=高风险)', + `kingdee_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合规客商黑名单状态(C101-1,yes=是/no=否/handing=处理中)', + `hcs_risk_time` datetime DEFAULT NULL COMMENT '客商风险最近查询时间(C101-1)', + `kingdee_black_category` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '金蝶黑名单失信类别(A801-5 awrp_combofield_title,缺失兜底 awrp_combofield)', + `kingdee_black_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '金蝶黑名单列入事由(A801-5 awrp_textareafield)', `create_user` bigint NULL DEFAULT NULL COMMENT '创建人', `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', @@ -58,7 +63,9 @@ CREATE TABLE `blade_customer_archive` ( UNIQUE KEY `uk_customer_archive_credit_code` (`tenant_id`, `unified_credit_code`, `is_deleted`) USING BTREE, KEY `idx_customer_archive_access` (`tenant_id`, `access_type`, `approval_status`) USING BTREE, KEY `idx_customer_archive_name` (`tenant_id`, `full_name`, `short_name`) USING BTREE, - KEY `idx_kingdee_status` (`kingdee_status`) USING BTREE + KEY `idx_kingdee_status` (`kingdee_status`) USING BTREE, + KEY `idx_hcs_risk_level` (`hcs_risk_level`) USING BTREE, + KEY `idx_kingdee_black_status` (`kingdee_black_status`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '客商档案'; -- ---------------------------- diff --git a/doc/sql/transport/blade_customer_type.sql b/doc/sql/transport/blade_customer_type.sql index 4e4d8563..de47ddbb 100644 --- a/doc/sql/transport/blade_customer_type.sql +++ b/doc/sql/transport/blade_customer_type.sql @@ -26,3 +26,4 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000003, 2075449100000000001, 'customer_type_edit', '编辑', 'customer_type_edit', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000004, 2075449100000000001, 'customer_type_status', '修改状态', 'customer_type_status', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000005, 2075449100000000001, 'customer_type_add', '新增', 'customer_type_add', '', '', 1, 2, 0, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000006, 2075449100000000001, 'customer_type_view', '查看', 'customer_type_view', '', '', 1, 2, 0, 1, NULL, '', 0); diff --git a/doc/sql/transport/blade_dept_tree_permission_patch_20261104.sql b/doc/sql/transport/blade_dept_tree_permission_patch_20261104.sql new file mode 100644 index 00000000..274b3520 --- /dev/null +++ b/doc/sql/transport/blade_dept_tree_permission_patch_20261104.sql @@ -0,0 +1,9 @@ +-- ---------------------------- +-- 组织树 /dept/tree 独立权限菜单(角色「数据权限」中配置) +-- 未配置该菜单数据权限时,组织树接口默认返回空(不展示) +-- ---------------------------- + +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +SELECT 2076110415738675232, 1123598815738675205, 'dept_tree', '树形结构', 'dept_tree', '/api/blade-system/dept/tree', 'file-text', 5, 2, 2, 1, '', '组织树接口独立数据权限配置项;未配置时组织树默认不展示', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `blade_menu` WHERE `code` = 'dept_tree' AND `is_deleted` = 0); diff --git a/doc/sql/transport/blade_fee_item.sql b/doc/sql/transport/blade_fee_item.sql index b2f69e80..ae50d9e4 100644 --- a/doc/sql/transport/blade_fee_item.sql +++ b/doc/sql/transport/blade_fee_item.sql @@ -33,3 +33,4 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000006, 2075449200000000001, 'fee_item_import', '批量导入', 'fee_item_import', '/api/blade-system/fee-item/import-fee-item', 'upload', 5, 2, 3, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000007, 2075449200000000001, 'fee_item_template', '下载模板', 'fee_item_template', '/api/blade-system/fee-item/export-template', 'download', 6, 2, 2, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000008, 2075449200000000001, 'fee_item_export', '批量导出', 'fee_item_export', '/api/blade-system/fee-item/export-fee-item', 'download', 7, 2, 2, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000009, 2075449200000000001, 'fee_item_view', '查看', 'fee_item_view', '', '', 1, 2, 0, 1, NULL, '', 0); diff --git a/doc/sql/transport/blade_invoice_application_invoice_perf_20261028.sql b/doc/sql/transport/blade_invoice_application_invoice_perf_20261028.sql new file mode 100644 index 00000000..153b2b77 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_invoice_perf_20261028.sql @@ -0,0 +1,5 @@ +-- 开票申请金蝶发票结果表新增履约开票记录推送字段(C108-1:金蝶开票完成后每张发票推送一条履约开票记录) +ALTER TABLE `blade_invoice_application_invoice` + ADD COLUMN `hcs_perf_fd_id` varchar(64) DEFAULT NULL COMMENT '履约开票记录ID(C108-1返回,非空即已推送)' AFTER `raw_json`, + ADD COLUMN `hcs_perf_retry_count` int DEFAULT NULL COMMENT '履约开票记录推送失败重试次数' AFTER `hcs_perf_fd_id`, + ADD COLUMN `hcs_perf_sync_msg` varchar(512) DEFAULT NULL COMMENT '履约开票记录最近推送失败原因' AFTER `hcs_perf_retry_count`; diff --git a/doc/sql/transport/blade_invoice_receipt_settlement_perf_20261028.sql b/doc/sql/transport/blade_invoice_receipt_settlement_perf_20261028.sql new file mode 100644 index 00000000..6684c793 --- /dev/null +++ b/doc/sql/transport/blade_invoice_receipt_settlement_perf_20261028.sql @@ -0,0 +1,5 @@ +-- 收票登记结算单分摊表新增履约收票记录推送字段(C108-1:收票单每条结算信息推送一条履约收票记录) +ALTER TABLE `blade_invoice_receipt_settlement` + ADD COLUMN `hcs_perf_fd_id` varchar(64) DEFAULT NULL COMMENT '履约收票记录ID(C108-1返回,非空即已推送)' AFTER `allocated_invoice_amount`, + ADD COLUMN `hcs_perf_retry_count` int DEFAULT NULL COMMENT '履约收票记录推送失败重试次数' AFTER `hcs_perf_fd_id`, + ADD COLUMN `hcs_perf_sync_msg` varchar(512) DEFAULT NULL COMMENT '履约收票记录最近推送失败原因' AFTER `hcs_perf_retry_count`; diff --git a/doc/sql/transport/blade_measurement_unit.sql b/doc/sql/transport/blade_measurement_unit.sql index 175be766..6c213b00 100644 --- a/doc/sql/transport/blade_measurement_unit.sql +++ b/doc/sql/transport/blade_measurement_unit.sql @@ -30,4 +30,5 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s (2075449300000000002, 2075449300000000001, 'measurement_unit_delete', '删除', 'measurement_unit_delete', '', '', 1, 2, 0, 1, NULL, '', 0), (2075449300000000003, 2075449300000000001, 'measurement_unit_edit', '编辑', 'measurement_unit_edit', '', '', 1, 2, 0, 1, NULL, '', 0), (2075449300000000004, 2075449300000000001, 'measurement_unit_status', '修改状态', 'measurement_unit_status', '', '', 1, 2, 0, 1, NULL, '', 0), -(2075449300000000005, 2075449300000000001, 'measurement_unit_add', '新增', 'measurement_unit_add', '', '', 1, 2, 0, 1, NULL, '', 0); +(2075449300000000005, 2075449300000000001, 'measurement_unit_add', '新增', 'measurement_unit_add', '', '', 1, 2, 0, 1, NULL, '', 0), +(2075449300000000006, 2075449300000000001, 'measurement_unit_view', '查看', 'measurement_unit_view', '', '', 1, 2, 0, 1, NULL, '', 0); diff --git a/doc/sql/transport/lqj/20261029/01_blade_kingdee_receipt_flow_org_fields_20261029.sql b/doc/sql/transport/lqj/20261029/01_blade_kingdee_receipt_flow_org_fields_20261029.sql new file mode 100644 index 00000000..53f31618 --- /dev/null +++ b/doc/sql/transport/lqj/20261029/01_blade_kingdee_receipt_flow_org_fields_20261029.sql @@ -0,0 +1,28 @@ +-- 收款流水表增加金蝶收款单组织/公司编码字段(A301-1 收款单查询同步落库) +-- openorg_number 核算组织.编码、org_number 收款人(公司).编码;org_name 已由 payee_name 承载不加列 + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `org_number` varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收款人(公司).编码(A301-1 org_number)'' AFTER `payee_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'org_number' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `open_org_number` varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''核算组织.编码(A301-1 openorg_number)'' AFTER `org_number`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'open_org_number' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261029/02_blade_kingdee_receipt_flow_match_settle_amounts_20261029.sql b/doc/sql/transport/lqj/20261029/02_blade_kingdee_receipt_flow_match_settle_amounts_20261029.sql new file mode 100644 index 00000000..3dcd5bfa --- /dev/null +++ b/doc/sql/transport/lqj/20261029/02_blade_kingdee_receipt_flow_match_settle_amounts_20261029.sql @@ -0,0 +1,67 @@ +-- 收款流水表增加金蝶收款单匹配/结算金额字段(A301-1 收款单查询同步落库) +-- unmatchamountrec 未匹配收款金额已由 unclaimed_amount 承载,不重复加列 + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `match_amount_rec` decimal(19, 2) DEFAULT NULL COMMENT ''已匹配收款金额(A301-1 matchamountrec)'' AFTER `unclaimed_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'match_amount_rec' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `match_amount_pay` decimal(19, 2) DEFAULT NULL COMMENT ''已匹配付款金额(A301-1 matchamountpay)'' AFTER `match_amount_rec`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'match_amount_pay' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `unmatch_amount_pay` decimal(19, 2) DEFAULT NULL COMMENT ''未匹配付款金额(A301-1 unmatchamountpay)'' AFTER `match_amount_pay`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'unmatch_amount_pay' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `settle_amount` decimal(19, 2) DEFAULT NULL COMMENT ''已结算金额(A301-1 settleamount)'' AFTER `unmatch_amount_pay`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'settle_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `unsettle_amount` decimal(19, 2) DEFAULT NULL COMMENT ''未结算金额(A301-1 unsettleamount)'' AFTER `settle_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'unsettle_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261029/03_blade_kingdee_receipt_flow_unmatch_amount_rec_rename_20261029.sql b/doc/sql/transport/lqj/20261029/03_blade_kingdee_receipt_flow_unmatch_amount_rec_rename_20261029.sql new file mode 100644 index 00000000..23ba1916 --- /dev/null +++ b/doc/sql/transport/lqj/20261029/03_blade_kingdee_receipt_flow_unmatch_amount_rec_rename_20261029.sql @@ -0,0 +1,15 @@ +-- 收款流水表未匹配收款金额列改名:unclaimed_amount(A301-2 公告未认领金额遗留列名) +-- → unmatch_amount_rec(与 A301-1 字段口径及其他匹配金额列命名统一) + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 1, + 'ALTER TABLE `blade_kingdee_receipt_flow` RENAME COLUMN `unclaimed_amount` TO `unmatch_amount_rec`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_receipt_flow' + AND COLUMN_NAME = 'unclaimed_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261102/01_blade_payment_application_kingdee_fail_reason_20261028.sql b/doc/sql/transport/lqj/20261102/01_blade_payment_application_kingdee_fail_reason_20261028.sql new file mode 100644 index 00000000..50280ba2 --- /dev/null +++ b/doc/sql/transport/lqj/20261102/01_blade_payment_application_kingdee_fail_reason_20261028.sql @@ -0,0 +1,17 @@ +-- 付款管理:金蝶推送失败原因留档展示 +-- 新增 kingdee_fail_reason:markKingdeeSyncFailed 落"生成失败"时同步落失败原因, +-- 列表"金蝶单据状态"列悬浮提示展示;重推成功转 synced 后残留原因不再回显 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application` ADD COLUMN `kingdee_fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送失败原因'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application' + AND COLUMN_NAME = 'kingdee_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261102/01_blade_payment_spec_attach_fd_id_20261027.sql b/doc/sql/transport/lqj/20261102/01_blade_payment_spec_attach_fd_id_20261027.sql new file mode 100644 index 00000000..4cb562eb --- /dev/null +++ b/doc/sql/transport/lqj/20261102/01_blade_payment_spec_attach_fd_id_20261027.sql @@ -0,0 +1,11 @@ +-- 付款特批结果回传附件(paymentLegal/result 回调 fileUrl=合规附件ID): +-- blade_payment_application 追加合规特批结果附件ID 字段,供 C103-5 下载与金蝶附件联带上传。 +-- 通过 information_schema 判断字段是否存在,可重复执行。 +SET @db_name = DATABASE(); + +SET @sql = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = @db_name AND table_name = 'blade_payment_application' AND column_name = 'hcs_spec_attach_fd_id'), + 'SELECT 1', + 'ALTER TABLE blade_payment_application ADD COLUMN hcs_spec_attach_fd_id varchar(128) DEFAULT NULL COMMENT ''合规付款特批结果附件ID(paymentLegal/result 回调 fileUrl,C103-5 下载入参)''' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/doc/sql/transport/lqj/20261102/01_blade_receipt_claim_hcs_record_20261028.sql b/doc/sql/transport/lqj/20261102/01_blade_receipt_claim_hcs_record_20261028.sql new file mode 100644 index 00000000..457ec803 --- /dev/null +++ b/doc/sql/transport/lqj/20261102/01_blade_receipt_claim_hcs_record_20261028.sql @@ -0,0 +1,27 @@ +-- 收款认领合规推送记录表:C107-1 认领记录推送结果落库(认领主单 × 合同一条) +-- 成功持久化合规侧 fdId 供认领作废 C107-3 引用;失败落推送标记与原因(仅记标记,不做定时重试) +-- 配套改造:收款流水菜单数据源 A301-2 → A301-1,认领事务提交后推送合规链(C103-9/C107-1/C108-1) + +CREATE TABLE IF NOT EXISTS `blade_receipt_claim_hcs_record` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID', + `receipt_claim_id` bigint NOT NULL COMMENT '认领主单id', + `receipt_flow_id` bigint NOT NULL COMMENT '收款流水id', + `contract_id` bigint DEFAULT NULL COMMENT '合同id', + `contract_no` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同编号', + `claim_amount` decimal(19, 2) DEFAULT NULL COMMENT '该认领主单分摊到本合同的认领金额', + `hcs_claim_record_fd_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合规侧认领记录fdId(C107-1返回,C107-3作废必填引用)', + `push_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '推送状态(success推送成功/failed推送失败/voided已作废)', + `fail_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '推送失败原因(成功后清空)', + `pushed_time` datetime DEFAULT NULL COMMENT '推送成功时间', + `create_user` bigint DEFAULT NULL COMMENT '创建人', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int DEFAULT NULL COMMENT '状态', + `is_deleted` int DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`), + KEY `idx_receipt_claim_id` (`receipt_claim_id`), + KEY `idx_receipt_flow_id` (`receipt_flow_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='收款认领合规推送记录表'; diff --git a/doc/sql/transport/lqj/20261102/02_blade_customer_archive_hcs_risk_20261030.sql b/doc/sql/transport/lqj/20261102/02_blade_customer_archive_hcs_risk_20261030.sql new file mode 100644 index 00000000..40b08f5f --- /dev/null +++ b/doc/sql/transport/lqj/20261102/02_blade_customer_archive_hcs_risk_20261030.sql @@ -0,0 +1,87 @@ +-- 客商档案:合规客商风险(C101-1)落库字段 +-- 用途:每日定时刷新合同期限内生效合同关联客商的风险等级/黑名单状态并落库,支持列表按客商风险筛选 +-- 口径:内部组织客商默认无风险不落值;筛选值映射 blacklist=黑名单/high=高风险/middle=中风险/low=低风险 +-- 幂等可重复执行:逐列/逐索引查 information_schema,已存在则跳过 + +-- ① 列 hcs_risk_level +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND COLUMN_NAME = 'hcs_risk_level') = 0, + 'ALTER TABLE `blade_customer_archive` ADD COLUMN `hcs_risk_level` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''合规客商风险等级(C101-1,0=低风险/1=中风险/2=高风险)'' AFTER `kingdee_sync_time`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ② 列 kingdee_black_status(C101-1 黑名单状态;此前误用 hcs_ 前缀,存在旧列时 CHANGE 改名迁移数据) +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND COLUMN_NAME = 'kingdee_black_status') = 0, + (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND COLUMN_NAME = 'hcs_black_status') > 0, + 'ALTER TABLE `blade_customer_archive` CHANGE COLUMN `hcs_black_status` `kingdee_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''合规客商黑名单状态(C101-1,yes=是/no=否/handing=处理中)''', + 'ALTER TABLE `blade_customer_archive` ADD COLUMN `kingdee_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''合规客商黑名单状态(C101-1,yes=是/no=否/handing=处理中)'' AFTER `hcs_risk_level`')), + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ③ 列 hcs_risk_time +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND COLUMN_NAME = 'hcs_risk_time') = 0, + 'ALTER TABLE `blade_customer_archive` ADD COLUMN `hcs_risk_time` datetime DEFAULT NULL COMMENT ''客商风险最近查询时间(C101-1)'' AFTER `kingdee_black_status`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ④ 索引 idx_hcs_risk_level +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND INDEX_NAME = 'idx_hcs_risk_level') = 0, + 'ALTER TABLE `blade_customer_archive` ADD INDEX idx_hcs_risk_level (hcs_risk_level)', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ⑤ 索引 idx_kingdee_black_status +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND INDEX_NAME = 'idx_kingdee_black_status') = 0, + 'ALTER TABLE `blade_customer_archive` ADD INDEX idx_kingdee_black_status (kingdee_black_status)', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ⑥ 旧索引清理:② CHANGE 改名后旧索引 idx_hcs_black_status 会自动跟随新列残留,若存在则删除(由 idx_kingdee_black_status 取代) +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND INDEX_NAME = 'idx_hcs_black_status') > 0, + 'ALTER TABLE `blade_customer_archive` DROP INDEX idx_hcs_black_status', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ⑥ 列 hcs_black_category / hcs_black_reason(A801-5 金蝶黑名单原始字段:失信类别、列入事由,明细文案由后台组装) +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND COLUMN_NAME = 'kingdee_black_category') = 0, + 'ALTER TABLE `blade_customer_archive` ADD COLUMN `kingdee_black_category` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶黑名单失信类别(A801-5 awrp_combofield_title,缺失兜底 awrp_combofield)'' AFTER `hcs_risk_time`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_customer_archive' AND COLUMN_NAME = 'kingdee_black_reason') = 0, + 'ALTER TABLE `blade_customer_archive` ADD COLUMN `kingdee_black_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶黑名单列入事由(A801-5 awrp_textareafield)'' AFTER `kingdee_black_category`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/doc/sql/transport/lqj/20261102/02_blade_kingdee_fail_reason_20261028.sql b/doc/sql/transport/lqj/20261102/02_blade_kingdee_fail_reason_20261028.sql new file mode 100644 index 00000000..ccf684c3 --- /dev/null +++ b/doc/sql/transport/lqj/20261102/02_blade_kingdee_fail_reason_20261028.sql @@ -0,0 +1,43 @@ +-- 开票申请 / 正式结算单 / 结算调整单:金蝶推送失败原因留档展示 +-- markKingdeeSyncFailed 落"同步失败"时同步落失败原因,列表"金蝶单据状态"列悬浮提示展示; +-- 重推成功转 synced 后残留原因不再回显。付款管理 blade_payment_application 已单独处理(20261028) + +-- 以下结构升级支持重复执行(三张表逐一判断) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application` ADD COLUMN `kingdee_fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送失败原因'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送失败原因'' AFTER `kingdee_sync_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_settlement_adjustment` ADD COLUMN `kingdee_fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送失败原因'' AFTER `kingdee_sync_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_settlement_adjustment' + AND COLUMN_NAME = 'kingdee_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261102/03_blade_customer_risk_dict_20261030.sql b/doc/sql/transport/lqj/20261102/03_blade_customer_risk_dict_20261030.sql new file mode 100644 index 00000000..1cfeb626 --- /dev/null +++ b/doc/sql/transport/lqj/20261102/03_blade_customer_risk_dict_20261030.sql @@ -0,0 +1,89 @@ +-- 客商风险筛选字典 customer_risk(客商档案列表搜索栏「客商风险」下拉)。 +-- dict_key=筛选值(与后端列表筛选入参一致:blacklist/high/middle/low),dict_value=展示名称。 +-- 字典缺失时前端保留固定兜底选项,落库后各环境与前端兜底保持一致;id 由 UUID_SHORT() 动态生成,不写死,幂等可重复执行。 + +-- ① 字典父项 +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', 0, 'customer_risk', '-1', '客商风险', 1, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'customer_risk' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ② 子项:黑名单 +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', + (SELECT id FROM blade_dict_biz WHERE code = 'customer_risk' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 LIMIT 1), + 'customer_risk', 'blacklist', '黑名单', 1, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'customer_risk' AND dict_key = 'blacklist' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ③ 子项:高风险 +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', + (SELECT id FROM blade_dict_biz WHERE code = 'customer_risk' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 LIMIT 1), + 'customer_risk', 'high', '高风险', 2, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'customer_risk' AND dict_key = 'high' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ④ 子项:中风险 +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', + (SELECT id FROM blade_dict_biz WHERE code = 'customer_risk' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 LIMIT 1), + 'customer_risk', 'middle', '中风险', 3, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'customer_risk' AND dict_key = 'middle' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ⑤ 子项:低风险 +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', + (SELECT id FROM blade_dict_biz WHERE code = 'customer_risk' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 LIMIT 1), + 'customer_risk', 'low', '低风险', 4, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'customer_risk' AND dict_key = 'low' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ⑥ 客商风险等级字典 risk_level(C101-1 风险等级翻译,dictKey=等级 0/1/2):父项 +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', 0, 'risk_level', '-1', '客商风险等级', 1, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'risk_level' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ⑦ 子项:低风险(0) +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', + (SELECT id FROM blade_dict_biz WHERE code = 'risk_level' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 LIMIT 1), + 'risk_level', '0', '低风险', 1, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'risk_level' AND dict_key = '0' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ⑧ 子项:中风险(1) +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', + (SELECT id FROM blade_dict_biz WHERE code = 'risk_level' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 LIMIT 1), + 'risk_level', '1', '中风险', 2, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'risk_level' AND dict_key = '1' AND tenant_id = '000000' AND is_deleted = 0 +); + +-- ⑨ 子项:高风险(2) +INSERT INTO blade_dict_biz (id, tenant_id, parent_id, code, dict_key, dict_value, sort, remark, is_sealed, status, is_deleted) +SELECT UUID_SHORT(), '000000', + (SELECT id FROM blade_dict_biz WHERE code = 'risk_level' AND dict_key = '-1' AND tenant_id = '000000' AND is_deleted = 0 LIMIT 1), + 'risk_level', '2', '高风险', 3, NULL, 0, 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM blade_dict_biz + WHERE code = 'risk_level' AND dict_key = '2' AND tenant_id = '000000' AND is_deleted = 0 +); diff --git a/doc/sql/transport/lqj/20261102/04_blade_contract_black_status_rename_20261030.sql b/doc/sql/transport/lqj/20261102/04_blade_contract_black_status_rename_20261030.sql new file mode 100644 index 00000000..bc0a7a3d --- /dev/null +++ b/doc/sql/transport/lqj/20261102/04_blade_contract_black_status_rename_20261030.sql @@ -0,0 +1,32 @@ +-- 合同表黑名单状态列改 kingdee_ 前缀(黑名单数据归金蝶口径,与客商档案表字段命名统一): +-- blade_contract_manage.hcs_black_status -> kingdee_black_status +-- blade_contract_change.hcs_black_status -> kingdee_black_status +-- 幂等可重复执行:新列不存在时,旧列存在则 CHANGE 改名迁移数据,否则新增;旧索引自动跟随列名,无需处理。 + +-- ① blade_contract_manage +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_manage' AND COLUMN_NAME = 'kingdee_black_status') = 0, + (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_manage' AND COLUMN_NAME = 'hcs_black_status') > 0, + 'ALTER TABLE `blade_contract_manage` CHANGE COLUMN `hcs_black_status` `kingdee_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''合规客商黑名单状态(C101-1,任一方黑名单记是)''', + 'ALTER TABLE `blade_contract_manage` ADD COLUMN `kingdee_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''合规客商黑名单状态(C101-1,任一方黑名单记是)'' AFTER `hcs_risk_level`')), + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ② blade_contract_change +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_change' AND COLUMN_NAME = 'kingdee_black_status') = 0, + (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_change' AND COLUMN_NAME = 'hcs_black_status') > 0, + 'ALTER TABLE `blade_contract_change` CHANGE COLUMN `hcs_black_status` `kingdee_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''合规客商黑名单状态(展示用)''', + 'ALTER TABLE `blade_contract_change` ADD COLUMN `kingdee_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''合规客商黑名单状态(展示用)'' AFTER `hcs_risk_level`')), + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/doc/sql/transport/lqj/20261102/05_blade_contract_risk_fields_drop_20261030.sql b/doc/sql/transport/lqj/20261102/05_blade_contract_risk_fields_drop_20261030.sql new file mode 100644 index 00000000..35482e7e --- /dev/null +++ b/doc/sql/transport/lqj/20261102/05_blade_contract_risk_fields_drop_20261030.sql @@ -0,0 +1,54 @@ +-- 合同表移除客商风险留痕列(风险展示改为列表/详情实时关联客商档案组装,不再落库): +-- blade_contract_manage DROP hcs_risk_level / kingdee_black_status / hcs_risk_time +-- blade_contract_change DROP hcs_risk_level / kingdee_black_status +-- 幂等可重复执行:列存在才 DROP。 + +-- ① blade_contract_manage.hcs_risk_level +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_manage' AND COLUMN_NAME = 'hcs_risk_level') > 0, + 'ALTER TABLE `blade_contract_manage` DROP COLUMN `hcs_risk_level`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ② blade_contract_manage.kingdee_black_status +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_manage' AND COLUMN_NAME = 'kingdee_black_status') > 0, + 'ALTER TABLE `blade_contract_manage` DROP COLUMN `kingdee_black_status`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ③ blade_contract_manage.hcs_risk_time +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_manage' AND COLUMN_NAME = 'hcs_risk_time') > 0, + 'ALTER TABLE `blade_contract_manage` DROP COLUMN `hcs_risk_time`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ④ blade_contract_change.hcs_risk_level +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_change' AND COLUMN_NAME = 'hcs_risk_level') > 0, + 'ALTER TABLE `blade_contract_change` DROP COLUMN `hcs_risk_level`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ⑤ blade_contract_change.kingdee_black_status +SET @ddl = (SELECT IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_contract_change' AND COLUMN_NAME = 'kingdee_black_status') > 0, + 'ALTER TABLE `blade_contract_change` DROP COLUMN `kingdee_black_status`', + 'SELECT 1')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/doc/sql/transport/lqj/20261102/06_blade_formal_settlement_kingdee_writeoff_20261030.sql b/doc/sql/transport/lqj/20261102/06_blade_formal_settlement_kingdee_writeoff_20261030.sql new file mode 100644 index 00000000..a5fc952a --- /dev/null +++ b/doc/sql/transport/lqj/20261102/06_blade_formal_settlement_kingdee_writeoff_20261030.sql @@ -0,0 +1,68 @@ +-- 正式结算单:新增金蝶冲销状态字段 +-- 列名遵循 MyBatis-Plus 驼峰转下划线默认映射(kingdeeWriteOffStatus -> kingdee_write_off_status,kingdeeWriteOffTime -> kingdee_write_off_time); +-- 若存在早期脚本误建的旧列名 kingdee_writeoff_status / kingdee_writeoff_time 则先重命名,否则按缺失新增 +-- 结算单列表展示金蝶侧旧应付/应收单冲销状态:定时任务按 A102-4/A302-4 hadwrittenoff 批量回写, +-- 选单预检/重推闸门实时查询后顺带回写;默认 unwrittenoff-未冲销(金蝶单据在未冲销前即视为未冲销,避免首跑全量初始化回写),writtenoff-已冲销(部分冲销视同已冲销) + +-- ==================== kingdee_write_off_status ==================== +-- 旧列名重命名(早期脚本版本,不存在则跳过) +SET @old_col := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_writeoff_status' +); +SET @ddl_sql = IF(@old_col = 1, + 'ALTER TABLE `blade_formal_settlement` CHANGE COLUMN `kingdee_writeoff_status` `kingdee_write_off_status` varchar(20) COLLATE utf8mb4_general_ci DEFAULT ''unwrittenoff'' COMMENT ''金蝶冲销状态:unwrittenoff-未冲销(默认) writtenoff-已冲销''', + 'SELECT 1'); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 缺失则新增(支持重复执行) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_write_off_status` varchar(20) COLLATE utf8mb4_general_ci DEFAULT ''unwrittenoff'' COMMENT ''金蝶冲销状态:unwrittenoff-未冲销(默认) writtenoff-已冲销'' AFTER `kingdee_fail_reason`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_write_off_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 存量已同步结算单回填默认未冲销(可重复执行:仅补 NULL 行;未同步单据保持默认值即可,前端按同步状态展示'-') +UPDATE `blade_formal_settlement` +SET `kingdee_write_off_status` = 'unwrittenoff' +WHERE `kingdee_write_off_status` IS NULL + AND `kingdee_sync_status` = 'synced' + AND `is_deleted` = 0; + +-- ==================== kingdee_write_off_time ==================== +-- 旧列名重命名(早期脚本版本,不存在则跳过) +SET @old_col := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_writeoff_time' +); +SET @ddl_sql = IF(@old_col = 1, + 'ALTER TABLE `blade_formal_settlement` CHANGE COLUMN `kingdee_writeoff_time` `kingdee_write_off_time` datetime DEFAULT NULL COMMENT ''金蝶冲销状态确认时间(定时回写/预检回写时间)''', + 'SELECT 1'); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 缺失则新增(支持重复执行) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_write_off_time` datetime DEFAULT NULL COMMENT ''金蝶冲销状态确认时间(定时回写/预检回写时间)'' AFTER `kingdee_write_off_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_write_off_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261102/blade_customer_archive_add_supplier_code_20261030.sql b/doc/sql/transport/lqj/20261102/blade_customer_archive_add_supplier_code_20261030.sql new file mode 100644 index 00000000..8c696089 --- /dev/null +++ b/doc/sql/transport/lqj/20261102/blade_customer_archive_add_supplier_code_20261030.sql @@ -0,0 +1,16 @@ +-- 客商档案:新增供应商编码字段 +-- 新增 supplier_code:金蝶A901-1供应商查询返回number,新增/同步客商时落库,金蝶单据推送直接复用,不再实时查接口 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_customer_archive` ADD COLUMN `supplier_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''供应商编码(金蝶A901-1供应商查询返回number)'' AFTER `customer_code`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_customer_archive' + AND COLUMN_NAME = 'supplier_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/transport.sql b/doc/sql/transport/transport.sql index cc1fab90..2aa8621f 100644 --- a/doc/sql/transport/transport.sql +++ b/doc/sql/transport/transport.sql @@ -836,6 +836,7 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (1123598815738675230, 1123598815738675205, 'dept_edit', '修改', 'edit', '/system/dept/edit', 'form', 2, 2, 2, 1, '', NULL, 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (1123598815738675231, 1123598815738675205, 'dept_delete', '删除', 'delete', '/api/blade-system/dept/remove', 'delete', 3, 2, 3, 1, '', NULL, 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (1123598815738675232, 1123598815738675205, 'dept_view', '查看', 'view', '/system/dept/view', 'file-text', 4, 2, 2, 1, '', NULL, 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2076110415738675232, 1123598815738675205, 'dept_tree', '树形结构', 'dept_tree', '/api/blade-system/dept/tree', 'file-text', 5, 2, 2, 1, '', '组织树接口独立数据权限配置项;未配置时组织树默认不展示', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (1123598815738675233, 1123598815738675206, 'dict_add', '新增', 'add', '/system/dict/add', 'plus', 1, 2, 1, 1, '', NULL, 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (1123598815738675234, 1123598815738675206, 'dict_edit', '修改', 'edit', '/system/dict/edit', 'form', 2, 2, 2, 1, '', NULL, 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (1123598815738675235, 1123598815738675206, 'dict_delete', '删除', 'delete', '/api/blade-system/dict/remove', 'delete', 3, 2, 3, 1, '', NULL, 0); @@ -998,7 +999,9 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075429169922023425, 2075427962415456258, 'railway_station_edit', '编辑', 'railway_station_edit', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075429216734650369, 2075427962415456258, 'railway_station_status', '修改状态', 'railway_station_status', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075429274242752513, 2075427962415456258, 'railway_station_add', '新增', 'railway_station_add', '', '', 1, 2, 0, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075429280000000001, 2075427962415456258, 'railway_station_view', '查看', 'railway_station_view', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075429329544650754, 2075421555200860162, 'port_terminal_add', '新增', 'port_terminal_add', '', '', 1, 2, 0, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075429330000000001, 2075421555200860162, 'port_terminal_view', '查看', 'port_terminal_view', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075431169720033282, 1164733399668962201, 'airport_master', '空港机场主数据', 'airport_master', '/base/airport-master', 'iconfont icon-fengche', 30, 1, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075431461312241665, 2075431169720033282, 'airport_master_import', '批量导入', 'airport_master_import', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075431522544885762, 2075431169720033282, 'airport_master_template', '下载模板', 'airport_master_template', '', '', 1, 2, 0, 1, NULL, '', 0); @@ -1007,6 +1010,7 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075431710063828994, 2075431169720033282, 'airport_master_edit', '编辑', 'airport_master_edit', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075431753298714626, 2075431169720033282, 'airport_master_status', '修改状态', 'airport_master_status', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075431820189474818, 2075431169720033282, 'airport_master_add', '新增', 'airport_master_add', '', '', 1, 2, 0, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075431850000000001, 2075431169720033282, 'airport_master_view', '查看', 'airport_master_view', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000001, 1164733399668962201, 'currency', '币种汇率管理', 'currency', '/base/currency', 'iconfont iconicon_exchange', 40, 1, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000002, 2075449000000000001, 'currency_import', '批量导入', 'currency_import', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000003, 2075449000000000001, 'currency_template', '下载模板', 'currency_template', '', '', 1, 2, 0, 1, NULL, '', 0); @@ -1015,11 +1019,13 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000006, 2075449000000000001, 'currency_edit', '编辑', 'currency_edit', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000007, 2075449000000000001, 'currency_status', '修改状态', 'currency_status', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000008, 2075449000000000001, 'currency_add', '新增', 'currency_add', '', '', 1, 2, 0, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449000000000009, 2075449000000000001, 'currency_view', '查看', 'currency_view', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000001, 1164733399668962201, 'customer_type', '客商类型', 'customer_type', '/base/customer-type', 'iconfont icon-hezuohuobanmiyueguanli', 50, 1, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000002, 2075449100000000001, 'customer_type_delete', '批量删除', 'customer_type_delete', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000003, 2075449100000000001, 'customer_type_edit', '编辑', 'customer_type_edit', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000004, 2075449100000000001, 'customer_type_status', '修改状态', 'customer_type_status', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000005, 2075449100000000001, 'customer_type_add', '新增', 'customer_type_add', '', '', 1, 2, 0, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449100000000006, 2075449100000000001, 'customer_type_view', '查看', 'customer_type_view', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000001, 1164733399668962201, 'fee_item', '费用项', 'fee_item', '/base/fee-item', 'iconfont icon-shoucang', 60, 1, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000002, 2075449200000000001, 'fee_item_delete', '批量删除', 'fee_item_delete', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000003, 2075449200000000001, 'fee_item_edit', '编辑', 'fee_item_edit', '', '', 1, 2, 0, 1, NULL, '', 0); @@ -1028,6 +1034,7 @@ INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `s INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000006, 2075449200000000001, 'fee_item_import', '批量导入', 'fee_item_import', '/api/blade-system/fee-item/import-fee-item', 'upload', 5, 2, 3, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000007, 2075449200000000001, 'fee_item_template', '下载模板', 'fee_item_template', '/api/blade-system/fee-item/export-template', 'download', 6, 2, 2, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000008, 2075449200000000001, 'fee_item_export', '批量导出', 'fee_item_export', '/api/blade-system/fee-item/export-fee-item', 'download', 7, 2, 2, 1, NULL, '', 0); +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075449200000000009, 2075449200000000001, 'fee_item_view', '查看', 'fee_item_view', '', '', 1, 2, 0, 1, NULL, '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075436235826896898, 0, 'blade-transport', '车/船务管理', 'blade-transport', '/vehicle', 'iconfont icon-shebeiguanli', 10, 1, 0, 1, '', '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075437082342301697, 2075436235826896898, 'maintenance_record', '维修记录', 'maintenance_record', '/vehicle/maintenance-record', 'iconfont icon-wxbgongju', 20, 1, 0, 1, '', '', 0); INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES (2075437237678350338, 2075436235826896898, 'maintenance_plan', '保养记录', 'maintenance_plan', '/vehicle/maintenance-plan', 'iconfont iconicon_exchange', 30, 1, 0, 1, '', '', 0);