Merge remote-tracking branch 'origin/feature/20261103-bfi-warehouse-material' into feature/20261103-bfi-warehouse-material

This commit is contained in:
刘泉佳
2026-11-04 19:27:18 +08:00
214 changed files with 10110 additions and 1362 deletions
@@ -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<BusinessDeptConfig> result = getSysClient().getBusinessDeptConfig(platformCompanyId, businessDeptId);
return result.getData();
});
}
}
@@ -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<List<InvoiceItem>> getInvoiceItems();
/**
* 按平台公司 + 业务部门获取业务部门配置
*
* @param platformCompanyId 平台公司ID
* @param businessDeptId 业务部门ID
* @return 业务部门配置
*/
@GetMapping(BUSINESS_DEPT_CONFIG)
R<BusinessDeptConfig> getBusinessDeptConfig(@RequestParam("platformCompanyId") Long platformCompanyId,
@RequestParam("businessDeptId") Long businessDeptId);
}
@@ -189,4 +189,9 @@ public class ISysClientFallback implements ISysClient {
return R.fail("获取数据失败");
}
@Override
public R<BusinessDeptConfig> getBusinessDeptConfig(Long platformCompanyId, Long businessDeptId) {
return R.fail("获取数据失败");
}
}
@@ -0,0 +1,87 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.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;
}
@@ -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;
/**
@@ -0,0 +1,57 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.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;
}
@@ -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());
@@ -0,0 +1,62 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 管理端认证审核请求
* <p>
* 同一字段 body 与 query 都可传,两处都给时以 body 为准。
*
* @author Chill
*/
@Data
@Schema(description = "管理端认证审核请求")
public class CertAuditRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "认证记录主键")
private Long id;
@Schema(description = "true 通过,false 驳回")
private Boolean pass;
@Schema(description = "驳回原因;驳回时必填,≤200 字")
private String rejectReason;
/**
* 审核员修订后的全量认证内容(JSON);空或空白表示不修订
* <p>
* 通过时才生效:它既是写回档案的依据,也会原样留痕到认证记录上。
*/
@Schema(description = "审核员修订后的全量认证内容JSON;空表示不修订")
private String auditedSnapshotJson;
}
@@ -0,0 +1,90 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 异常处置 → 录入维修记录请求
* <p>
* 车牌号与车船类型不接收前端传值:车牌取自异常处置,车船类型固定「车辆」,
* 避免维修记录挂到与异常无关的车上。
*
* @author Chill
*/
@Data
@Schema(description = "异常处置录入维修记录请求")
public class ExceptionMaintenanceRecordRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "异常处置ID")
private Long id;
@Schema(description = "维修时间")
private LocalDateTime maintenanceTime;
@Schema(description = "维修位置")
private String location;
@Schema(description = "维修人")
private String maintainer;
@Schema(description = "维修单位")
private String company;
@Schema(description = "联系方式")
private String contact;
@Schema(description = "更换零件")
private String replacedPart;
@Schema(description = "费用")
private BigDecimal cost;
@Schema(description = "里程数(公里)")
private BigDecimal mileage;
@Schema(description = "出厂时间")
private LocalDateTime factoryTime;
@Schema(description = "详细地址")
private String address;
@Schema(description = "备注说明")
private String remark;
@Schema(description = "附件JSON,[{\"name\":\"...\",\"url\":\"...\"}]")
private String attachments;
}
@@ -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;
@@ -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;
@@ -164,6 +164,14 @@ public class FormalSettlement extends TenantEntity {
* 金蝶推送失败原因(仅 failed 态展示;重推成功转 synced 后残留原因不再回显)
*/
private String kingdeeFailReason;
/**
* 金蝶冲销状态:unwrittenoff-未冲销 writtenoff-已冲销(null=未推送金蝶或未校验)
*/
private String kingdeeWriteOffStatus;
/**
* 金蝶冲销状态确认时间(定时回写/预检回写时间)
*/
private LocalDateTime kingdeeWriteOffTime;
/**
* 附件JSON
*/
@@ -68,6 +68,9 @@ public class LoadingManage extends TenantEntity {
@Schema(description = "联系电话")
private String driverPhone;
@Schema(description = "司机ID")
private Long driverId;
@Schema(description = "押运人")
private String escortName;
@@ -83,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;
@@ -61,6 +61,12 @@ public class MaintenanceRecord extends TenantEntity {
*/
@Schema(description = "车牌号/船号")
private String vehicleNo;
/**
* 关联异常处置ID(小程序录入时写入)
*/
@Schema(description = "关联异常处置ID(小程序录入时写入)")
@TableField(updateStrategy = FieldStrategy.NEVER)
private Long exceptionId;
/**
* 维修人
*/
@@ -28,6 +28,7 @@ package org.springblade.transport.pojo.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -125,6 +126,19 @@ public class TransportCertRecord extends TenantEntity {
@TableField(updateStrategy = FieldStrategy.NEVER)
@Schema(description = "该次提交的全量认证内容JSON")
private String snapshotJson;
/**
* 审核通过时确定的修订版内容(JSON)
* <p>
* 审核员可以在通过时直接改内容:司机提交的是 {@code snapshotJson}(永不可改),
* 最终写回档案、司机端读到的「当前生效内容」以本字段优先,为 null 才回退到
* {@code snapshotJson}。为 null 即「审核员没有修订」。
* <p>
* {@code @JsonIgnore}:司机端的 {@code TransportCertRecordVO} 继承本实体,
* 修订版是审核过程的留痕,不该下发给司机。
*/
@JsonIgnore
@Schema(description = "审核通过时确定的修订版内容JSON;null 表示未修订")
private String auditedSnapshotJson;
/**
* 驳回原因,仅当审核状态为已驳回时有值
*/
@@ -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;
@@ -38,7 +38,7 @@ public class AdminHomeBadgesVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "异常处置待办数(disposalStatus≠completed)")
@Schema(description = "异常处置待办数(disposalStatus=pending)")
private long exception;
@Schema(description = "风险记录待办数(disposalStatus=pending)")
@@ -0,0 +1,47 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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;
/**
* 调度端档案统计(司机 + 车辆证件有效期)
*/
@Data
@Schema(description = "调度端档案统计")
public class ArchiveManageStatsVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "司机证件有效期统计")
private DriverExpiryStatVO driver = new DriverExpiryStatVO();
@Schema(description = "车辆证件有效期统计")
private TransportVehicleExpiryStatVO vehicle = new TransportVehicleExpiryStatVO();
}
@@ -0,0 +1,54 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.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<String, String> fields = new LinkedHashMap<>();
}
@@ -81,6 +81,13 @@ public class CertAuditDetailVO implements Serializable {
@Schema(description = "待审快照:本次提交的全量内容")
private String pendingSnapshotJson;
/**
* 审核员在本次审核中填写的修订版内容;未修订时为 null
* <p>
* 只在「通过」时才会落库,驳回不产生生效内容。审核页据此回显审核员改了什么。
*/
@Schema(description = "本次审核提交的修订版内容;未修订时为 null")
private String auditedSnapshotJson;
@Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null")
private String effectiveSnapshotJson;
@Schema(description = "生效快照记录ID;null 表示该对象从未认证通过")
@@ -64,9 +64,13 @@ public class ContractManageVO extends ContractManage {
private Boolean hcsSynced;
@TableField(exist = false)
@Schema(description = "生效类型筛选")
@Schema(description = "合同状态筛选")
private String effectiveType;
@TableField(exist = false)
@Schema(description = "客商(匹配甲/乙/丙方)")
private String partyName;
@TableField(exist = false)
@Schema(description = "创建人姓名")
private String createUserName;
@@ -48,6 +48,12 @@ public class DriverVO extends Driver {
@Serial
private static final long serialVersionUID = 1L;
/**
* 搜索关键字:同时模糊匹配姓名与手机号(小程序列表用,管理端不传)
*/
@TableField(exist = false)
@Schema(description = "搜索关键字(姓名 / 手机号)")
private String keyword;
/**
* 证件有效期状态:within30-30天内到期,expired-已到期
*/
@@ -106,6 +106,10 @@ public class ExceptionDisposalVO extends ExceptionDisposal {
@Schema(description = "可调整的路线站点,少于两个站时为空")
private List<RouteNode> routeNodes;
@TableField(exist = false)
@Schema(description = "关联的维修记录(车辆故障维修异常录入,未录入时为空对象,判定须看 id)")
private MaintenanceRecordVO maintenanceRecord;
/**
* 一个可调整的站点:站序声明、地址、该站的装卸标记与项目名
*/
@@ -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;
}
@@ -76,4 +76,25 @@ public class MaintenanceRecordVO extends MaintenanceRecord {
@Schema(description = "更新人姓名")
private String updateUserName;
/**
* 关联运单号(由 exception_id 关联异常处置带出)
*/
@TableField(exist = false)
@Schema(description = "关联运单号")
private String waybillNo;
/**
* 关联异常类型(一级)
*/
@TableField(exist = false)
@Schema(description = "关联异常类型")
private String exceptionType;
/**
* 关联异常原因(二级)
*/
@TableField(exist = false)
@Schema(description = "关联异常原因")
private String exceptionReason;
}
@@ -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;
}
@@ -96,5 +96,13 @@ public class TransportCertRecordVO extends TransportCertRecord {
@TableField(exist = false)
@Schema(description = "司机手机号")
private String driverMobile;
/**
* 该条记录是否经过审核员修订
* <p>
* 由服务层按 {@code auditedSnapshotJson} 是否有内容算出,前端据此打「已修订」标记。
*/
@TableField(exist = false)
@Schema(description = "是否经过审核员修订")
private Boolean revised;
}
@@ -48,6 +48,12 @@ public class TransportVehicleVO extends TransportVehicle {
@Serial
private static final long serialVersionUID = 1L;
/**
* 搜索关键字:同时模糊匹配车牌、VIN 与所有人(小程序列表用,管理端不传)
*/
@TableField(exist = false)
@Schema(description = "搜索关键字(车牌 / VIN / 所有人)")
private String keyword;
/**
* 证件有效期状态:within30-30天内到期,expired-已到期
*/
@@ -34,8 +34,10 @@ import java.util.Set;
/**
* 运次查询条件
* <p>
* 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。
* 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。
* 司机端用 {@link #forDriver(Long)} 按司机档案 ID 收口;管理端用 {@link #forAdmin()} 不按司机收口。
* 两者都不给时收口为空集。
* <p>
* 归属判据只有司机档案 ID;driver_phone 是派单时的号码快照,不参与判定。
*/
@Data
@Schema(description = "运次查询条件")
@@ -47,8 +49,8 @@ public class TripQueryVO implements Serializable {
@Schema(description = "运次主键(详情按它取单个运次)")
private String tripId;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "司机档案 ID(司机端收口判据,空或非正数一律不放行)")
private Long driverId;
@Schema(description = "是否不按司机收口(管理端)")
private boolean allDrivers;
@@ -92,9 +94,9 @@ public class TripQueryVO implements Serializable {
@Schema(description = "每页条数(按运次,不是运单)")
private Integer size;
public static TripQueryVO forDriver(String driverPhone) {
public static TripQueryVO forDriver(Long driverId) {
TripQueryVO query = new TripQueryVO();
query.setDriverPhone(driverPhone);
query.setDriverId(driverId);
return query;
}
@@ -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<Long> getManageDeptIds(Long userId) {
if (userId == null || userId <= 0) {
return Collections.emptyList();
}
try {
List<Long> 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<Long> loadManageDeptIds(Long userId) {
R<List<Long>> 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);
@@ -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<User> userInfoById(@RequestParam("userId") Long userId);
/**
* 解析用户「数据管理权限」组织(含下级)
*
* @param userId 用户主键
* @return 可管理组织 ID 集合
*/
@GetMapping(MANAGE_DEPT_IDS)
R<List<Long>> manageDeptIds(@RequestParam("userId") Long userId);
/**
* 根据账号获取用户信息
@@ -0,0 +1,133 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.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<BusinessDeptConfigVO> 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<IPage<BusinessDeptConfigVO>> list(BusinessDeptConfigVO businessDeptConfig, Query query) {
IPage<BusinessDeptConfigVO> 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<BusinessDeptConfigVO> 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));
}
}
@@ -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 {
/**
* 获取部门树形结构
* <p>
* 登录即可调用;数据范围由角色「数据权限」中 {@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 {
* 懒加载获取部门树形结构
* <p>
* tenantId 由 Service 层完整决策:超管入参为空表示查全部,非超管强制覆盖为会话租户。
* 数据范围与 {@link #tree} 相同,由 {@code dept_tree} 单独配置,未配置时返回空树。
*/
@PreAuth(menu = "dept")
@PreAuth(AuthConstant.PERMIT_ALL)
@GetMapping("/lazy-tree")
@ApiOperationSupport(order = 5)
@Operation(summary = "懒加载树形结构", description = "树形结构")
@@ -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<String, Long> 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;
}
@@ -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<BusinessDeptConfig> getBusinessDeptConfig(Long platformCompanyId, Long businessDeptId) {
return R.data(businessDeptConfigService.getByPlatformAndDept(platformCompanyId, businessDeptId));
}
}
@@ -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<List<Long>> manageDeptIds(@RequestParam("userId") Long userId) {
return R.data(service.resolveManageDeptIds(userId));
}
@Override
@GetMapping(USER_BY_ACCOUNT)
public R<User> userByAccount(String tenantId, String account) {
@@ -0,0 +1,53 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.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<BusinessDeptConfig> {
/**
* 自定义分页
*
* @param page 分页
* @param businessDeptConfig 查询条件
* @return 分页数据
*/
List<BusinessDeptConfigVO> selectBusinessDeptConfigPage(IPage<BusinessDeptConfigVO> page,
@Param("businessDeptConfig") BusinessDeptConfigVO businessDeptConfig);
}
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.system.mapper.BusinessDeptConfigMapper">
<resultMap id="businessDeptConfigResultMap" type="org.springblade.system.pojo.vo.BusinessDeptConfigVO">
<result column="id" property="id"/>
<result column="tenant_id" property="tenantId"/>
<result column="platform_company_id" property="platformCompanyId"/>
<result column="platform_company_name" property="platformCompanyName"/>
<result column="business_dept_id" property="businessDeptId"/>
<result column="business_dept_name" property="businessDeptName"/>
<result column="contract_code_prefix" property="contractCodePrefix"/>
<result column="initial_serial" property="initialSerial"/>
<result column="kingdee_company_code" property="kingdeeCompanyCode"/>
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
<result column="remark" property="remark"/>
<result column="create_user" property="createUser"/>
<result column="create_dept" property="createDept"/>
<result column="create_time" property="createTime"/>
<result column="update_user" property="updateUser"/>
<result column="update_time" property="updateTime"/>
<result column="status" property="status"/>
<result column="is_deleted" property="isDeleted"/>
<result column="create_user_name" property="createUserName"/>
<result column="update_user_name" property="updateUserName"/>
</resultMap>
<select id="selectBusinessDeptConfigPage" resultMap="businessDeptConfigResultMap">
SELECT
bbc.*,
cu.real_name AS create_user_name,
uu.real_name AS update_user_name
FROM blade_business_dept_config bbc
LEFT JOIN blade_user cu ON cu.id = bbc.create_user
LEFT JOIN blade_user uu ON uu.id = bbc.update_user
WHERE bbc.is_deleted = 0
<if test="businessDeptConfig.platformCompanyId != null">
AND bbc.platform_company_id = #{businessDeptConfig.platformCompanyId}
</if>
<if test="businessDeptConfig.businessDeptId != null">
AND bbc.business_dept_id = #{businessDeptConfig.businessDeptId}
</if>
<if test="businessDeptConfig.platformCompanyName != null and businessDeptConfig.platformCompanyName != ''">
<bind name="platformCompanyNameLike" value="'%' + businessDeptConfig.platformCompanyName + '%'"/>
AND bbc.platform_company_name LIKE #{platformCompanyNameLike}
</if>
<if test="businessDeptConfig.businessDeptName != null and businessDeptConfig.businessDeptName != ''">
<bind name="businessDeptNameLike" value="'%' + businessDeptConfig.businessDeptName + '%'"/>
AND bbc.business_dept_name LIKE #{businessDeptNameLike}
</if>
ORDER BY bbc.create_time DESC
</select>
</mapper>
@@ -90,6 +90,12 @@
<if test="param3.fullName!=null and param3.fullName!=''">
and dept.full_name like concat(concat('%', #{param3.fullName}),'%')
</if>
<if test="param3.isPlatformCompany!=null and param3.isPlatformCompany!=''">
and dept.is_platform_company = #{param3.isPlatformCompany}
</if>
<if test="param3.isBusinessDept!=null and param3.isBusinessDept!=''">
and dept.is_business_dept = #{param3.isBusinessDept}
</if>
ORDER BY dept.sort
</select>
@@ -0,0 +1,66 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.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<BusinessDeptConfig> {
/**
* 自定义分页
*
* @param page 分页
* @param businessDeptConfig 查询条件
* @return 分页数据
*/
IPage<BusinessDeptConfigVO> selectBusinessDeptConfigPage(IPage<BusinessDeptConfigVO> page, BusinessDeptConfigVO businessDeptConfig);
/**
* 新增或修改
*
* @param businessDeptConfig 配置
* @return 是否成功
*/
boolean submit(BusinessDeptConfig businessDeptConfig);
/**
* 按平台公司 + 业务部门查询配置
*
* @param platformCompanyId 平台公司ID
* @param businessDeptId 业务部门ID
* @return 配置,不存在返回 null
*/
BusinessDeptConfig getByPlatformAndDept(Long platformCompanyId, Long businessDeptId);
}
@@ -66,6 +66,16 @@ public interface IRoleMenuDataScopeService extends BaseService<RoleMenuDataScope
*/
RoleDataScopeVO resolveByMenuCode(List<Long> roleIds, String menuCode, List<Long> userDeptIds);
/**
* 按菜单编码解析角色数据权限;菜单不存在或未配置时返回空权限(不回落为全部组织)
*
* @param roleIds 角色ID集合
* @param menuCode 菜单编码
* @param userDeptIds 用户所属组织
* @return 解析结果
*/
RoleDataScopeVO resolveByMenuCodeOrEmpty(List<Long> roleIds, String menuCode, List<Long> userDeptIds);
/**
* 合并用户全部角色菜单配置为最宽数据权限
*
@@ -257,6 +257,14 @@ public interface IUserService extends BaseService<User> {
*/
boolean removeUser(String userIds);
/**
* 解析用户「数据管理权限」组织(含下级展开)
*
* @param userId 用户主键
* @return 可管理组织 ID 集合;无配置时返回空列表
*/
List<Long> resolveManageDeptIds(Long userId);
/**
* 导入用户数据(仅超管 + Excel 显式指定时允许跨租户,否则归属当前会话租户)
*
@@ -0,0 +1,208 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.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<BusinessDeptConfigMapper, BusinessDeptConfig>
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<BusinessDeptConfigVO> selectBusinessDeptConfigPage(IPage<BusinessDeptConfigVO> 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.<BusinessDeptConfig>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<Long> 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.<BusinessDeptConfig>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;
}
}
@@ -224,13 +224,18 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
.toList();
}
/**
* 组织树专用数据权限菜单编码(角色「数据权限」中单独配置;未配置时组织树默认不展示)。
*/
public static final String MENU_DEPT_TREE = "dept_tree";
/**
* 解析当前登录用户的组织树数据权限。
* <p>
* 超管或角色菜单数据权限最宽合并为「全部组织」时不过滤;
* 其余按角色配置的组织并集过滤。仅「本人创建」时按用户所属组织及下级展示组织树。
* 仅读取 {@link #MENU_DEPT_TREE}「树形结构」菜单的数据权限配置,不再合并其他业务菜单;
* 超管不过滤;未配置该菜单数据权限时返回空树(默认不显示)。
*
* @return 过滤上下文;无法识别用户时按空权限处理
* @return 过滤上下文;无法识别用户或未配置时按空权限处理
*/
private DeptDataScopeFilter resolveDeptDataScopeFilter() {
if (AuthUtil.isAdministrator()) {
@@ -246,8 +251,11 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> 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<Long> allowedDeptIds = new LinkedHashSet<>();
@@ -665,12 +673,9 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> 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<DeptMapper, Dept> 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.<Dept>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<DeptMapper, Dept> 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<DeptMapper, Dept> 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);
}
@@ -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();
@@ -87,10 +87,8 @@ public class OaUserListSyncHelper {
private static final String OA_PERSON_STATUS_RESIGNED = "5";
/**
* 全量同步跨页累积的 OA 登录名。分页接口每页独立事务,删除必须等最后一页再执行。
* 全量同步跨页累积的 OA 工号。分页接口每页独立事务,删除必须等最后一页再执行。
*/
private final Set<String> fullSyncLoginIds = ConcurrentHashMap.newKeySet();
private final Set<String> 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<String> oaLoginIds = new LinkedHashSet<>(this.fullSyncLoginIds);
Set<String> 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<String> extractOaLoginIds(List<OAPersonResponse> oaPersons) {
Set<String> 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<String> oaLoginIds, Set<String> 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<String> workcodes) {
if (CollectionUtil.isEmpty(workcodes)) {
ComposeLogUtil.getLastLog().warn("全量同步未收集到OA工号,跳过删除本地 is_oa=2 人员");
return 0;
}
Set<String> loginIdSet = noLoginIds ? Set.of() : oaLoginIds;
Set<String> workcodeSet = noWorkcodes ? Set.of() : workcodes;
Set<String> workcodeSet = workcodes;
String tenantId = this.resolveTenantId();
List<User> oaUsers = this.userService.list(Wrappers.<User>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<String> oaLoginIds) {
if (CollectionUtil.isEmpty(oaLoginIds)) {
return 0;
}
List<String> oaLoginIdList = oaLoginIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
if (oaLoginIdList.isEmpty()) {
return 0;
}
Long currentUserId = AuthUtil.getUserId();
List<Long> removeIds = new ArrayList<>();
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
this.userService.list(Wrappers.<User>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<String> workcodes) {
private int removeOaUsersByWorkcodes(String tenantId, Collection<String> 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<String> workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size()));
this.userService.list(Wrappers.<User>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<String, OAPersonResponse> uniquePersonMap = new LinkedHashMap<>();
Set<String> resignedLoginIds = new LinkedHashSet<>();
Set<String> 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<String> oaLoginIds = new LinkedHashSet<>();
Set<String> 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<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, oaLoginIds);
Map<String, User> existingByWorkcode = this.loadUsersByBlankLoginWorkcode(tenantId, workcodes);
Map<String, User> existingByWorkcode = this.loadUsersByWorkcode(tenantId, workcodes);
List<User> users = new ArrayList<>();
Map<String, Long> userMap = new HashMap<>();
Set<Long> 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<String, User> loadUsersByOaLoginId(String tenantId, Collection<String> oaLoginIds) {
Map<String, User> existingByOaLoginId = new HashMap<>();
if (CollectionUtil.isEmpty(oaLoginIds)) {
return existingByOaLoginId;
}
List<String> oaLoginIdList = oaLoginIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
userService.list(Wrappers.<User>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<String, User> loadUsersByBlankLoginWorkcode(String tenantId, Collection<String> workcodes) {
private Map<String, User> loadUsersByWorkcode(String tenantId, Collection<String> workcodes) {
Map<String, User> existingByWorkcode = new HashMap<>();
if (CollectionUtil.isEmpty(workcodes)) {
return existingByWorkcode;
@@ -939,26 +810,41 @@ public class OaUserListSyncHelper {
this.userService.list(Wrappers.<User>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;
@@ -184,20 +184,44 @@ public class RoleMenuDataScopeServiceImpl extends BaseServiceImpl<RoleMenuDataSc
if (Func.isEmpty(roleIds) || Func.isBlank(menuCode)) {
return RoleDataScopeVO.all();
}
Menu menu = menuMapper.selectOne(Wrappers.<Menu>lambdaQuery()
.eq(Menu::getCode, menuCode)
.eq(Menu::getIsDeleted, 0)
.last("limit 1"));
Menu menu = findMenuByCode(menuCode);
if (Func.isEmpty(menu)) {
return RoleDataScopeVO.all();
}
List<RoleMenuDataScope> scopeList = list(Wrappers.<RoleMenuDataScope>lambdaQuery()
.in(RoleMenuDataScope::getRoleId, roleIds)
.eq(RoleMenuDataScope::getMenuId, menu.getId())
.eq(RoleMenuDataScope::getIsDeleted, 0));
List<RoleMenuDataScope> scopeList = listScopes(roleIds, menu.getId());
return mergeScopes(scopeList, userDeptIds);
}
@Override
public RoleDataScopeVO resolveByMenuCodeOrEmpty(List<Long> roleIds, String menuCode, List<Long> userDeptIds) {
if (Func.isEmpty(roleIds) || Func.isBlank(menuCode)) {
return RoleDataScopeVO.none();
}
Menu menu = findMenuByCode(menuCode);
if (Func.isEmpty(menu)) {
return RoleDataScopeVO.none();
}
List<RoleMenuDataScope> 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.<Menu>lambdaQuery()
.eq(Menu::getCode, menuCode)
.eq(Menu::getIsDeleted, 0)
.last("limit 1"));
}
private List<RoleMenuDataScope> listScopes(List<Long> roleIds, Long menuId) {
return list(Wrappers.<RoleMenuDataScope>lambdaQuery()
.in(RoleMenuDataScope::getRoleId, roleIds)
.eq(RoleMenuDataScope::getMenuId, menuId)
.eq(RoleMenuDataScope::getIsDeleted, 0));
}
@Override
public RoleDataScopeVO resolveWidest(List<Long> roleIds, List<Long> userDeptIds) {
if (Func.isEmpty(roleIds)) {
@@ -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<UserMapper, User> implement
boolean tempUser = this.deleteLogic(Func.toLongList(userIds));
boolean tempUserDept = userDeptService.remove(Wrappers.<UserDept>lambdaQuery().in(UserDept::getUserId, Func.toLongList(userIds)));
if (tempUser && tempUserDept) {
userDataScopeMapper.delete(Wrappers.<UserDataScope>lambdaQuery().in(UserDataScope::getUserId, idList));
UserOauth userOauth = new UserOauth();
userOauth.delete(Wrappers.<UserOauth>lambdaQuery().in(UserOauth::getUserId, Func.toLongList(userIds)));
UserWeb userWeb = new UserWeb();
@@ -672,6 +674,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
UserOther userOther = new UserOther();
userOther.delete(Wrappers.<UserOther>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<UserMapper, User> 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<UserMapper, User> 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.<UserDataScope>lambdaQuery().eq(UserDataScope::getUserId, user.getId()));
List<Long> 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.<User>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<UserMapper, User> implement
if (user == null || user.getId() == null) {
return user;
}
user.setDataScopeDeptIds(Collections.emptyList());
List<Long> deptIdList = userDataScopeMapper.selectList(Wrappers.<UserDataScope>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<Long> resolveManageDeptIds(Long userId) {
if (Func.isEmpty(userId) || userId <= 0) {
return Collections.emptyList();
}
List<Long> baseDeptIds = userDataScopeMapper.selectList(Wrappers.<UserDataScope>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<Long> allowedDeptIds = new LinkedHashSet<>();
for (Long deptId : baseDeptIds) {
List<Long> childIds = SysCache.getDeptChildIds(deptId);
if (Func.isNotEmpty(childIds)) {
allowedDeptIds.addAll(childIds);
} else {
allowedDeptIds.add(deptId);
}
}
return List.copyOf(allowedDeptIds);
}
/**
* 统一的租户范围过滤:超管放行,其他用户强制限定为当前会话租户。
*/
@@ -0,0 +1,56 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.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<BusinessDeptConfig, BusinessDeptConfigVO> {
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;
}
}
@@ -110,6 +110,11 @@ public class BfiProperties {
*/
private ArResult arResult = new ArResult();
/**
* 金蝶冲销状态回写配置(A102-4/A302-4)
*/
private WriteOffResult writeOffResult = new WriteOffResult();
/**
* 金蝶单据锁定补偿配置
*/
@@ -254,7 +259,7 @@ public class BfiProperties {
private long lockLeaseSeconds = 1800;
/**
* A103-4 付款处理查询业务日期窗口天数,默认 60
* A103-4 付款处理查询业务日期窗口天数(接口必填参数,按 awrp_sourcebillno 精确过滤单据),默认 60
*/
private int queryWindowDays = 60;
}
@@ -364,6 +369,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;
}
/**
* 金蝶单据锁定补偿配置项
*/
@@ -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";
@@ -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<BusinessLicenseOcrVO> recognizeBusinessLicense(
@Parameter(description = "图片地址", required = true) @RequestParam String imageUrl) {
return R.data(businessLicenseOcrService.recognizeUrl(imageUrl));
}
private BaiduOcrType resolveType(String type) {
try {
return BaiduOcrType.from(type);
@@ -36,6 +36,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.pojo.dto.CertAuditRequest;
import org.springblade.transport.pojo.vo.CertAuditCapabilityVO;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
@@ -43,10 +44,13 @@ import org.springblade.transport.service.ICertAuditService;
import org.springblade.transport.support.CertAuditPermissions;
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;
import java.util.function.Function;
/**
* 认证审核(管理端)
* <p>
@@ -92,13 +96,26 @@ public class CertificationAuditController extends BladeController {
@PostMapping("/audit")
@ApiOperationSupport(order = 3)
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变。通过时可携带修订后的全量认证内容,它将成为新的生效内容并原样留痕")
@PreAuth(CertAuditPermissions.AUDIT_ACTION)
public R<Boolean> audit(
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id,
@Parameter(description = "是否通过", required = true) @RequestParam Boolean pass,
@Parameter(description = "审核请求体(推荐):id、pass、rejectReason、auditedSnapshotJson")
@RequestBody(required = false) CertAuditRequest body,
@Parameter(description = "认证记录主键(兼容老调用)") @RequestParam(required = false) Long id,
@Parameter(description = "是否通过(兼容老调用)") @RequestParam(required = false) Boolean pass,
@Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) {
return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason));
// 同一字段 body 与 query 都可传,两处都给时以 body 为准。
Long recordId = preferBody(body, CertAuditRequest::getId, id);
Boolean approved = preferBody(body, CertAuditRequest::getPass, pass);
String reason = preferBody(body, CertAuditRequest::getRejectReason, rejectReason);
String revision = preferBody(body, CertAuditRequest::getAuditedSnapshotJson, null);
if (recordId == null) {
return R.fail("缺少认证记录ID");
}
if (approved == null) {
return R.fail("缺少审核结果");
}
return R.status(certAuditService.audit(recordId, Boolean.TRUE.equals(approved), reason, revision));
}
// 本接口不挂 @PreAuth;无权限时返回 false。
@@ -109,4 +126,21 @@ public class CertificationAuditController extends BladeController {
return R.data(certAuditService.capability());
}
/**
* 取一个审核参数:body 里给了就用 body 的,否则用 query 的
*
* @param body 审核请求体,可为 null
* @param getter 从请求体取该字段
* @param queryValue 该字段的 query 参数值
* @param <T> 字段类型
* @return body 字段值;body 为 null 或该字段为 null 时返回 query 参数值
*/
private static <T> T preferBody(CertAuditRequest body, Function<CertAuditRequest, T> getter, T queryValue) {
if (body == null) {
return queryValue;
}
T bodyValue = getter.apply(body);
return bodyValue == null ? queryValue : bodyValue;
}
}
@@ -188,10 +188,11 @@ public class ContractManageController extends BladeController {
@GetMapping("/next-contract-no")
@ApiOperationSupport(order = 16)
@Operation(summary = "预览下一合同编号", description = "按合同负责部门编码前缀+年份+三位流水预览")
@Operation(summary = "预览下一合同编号", description = "按平台公司+业务部门配置的前缀+年份+三位流水预览")
public R<String> 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")
@@ -54,9 +54,10 @@ public class DriverAppController extends BladeController {
@GetMapping("/mine")
@ApiOperationSupport(order = 1)
@Operation(summary = "当前登录司机档案", description = "按手机号匹配 blade_transport_driver.mobile;可传 mobile,未传则从登录态解析")
@Operation(summary = "当前登录司机档案", description = "按登录用户的 user_id 匹配 blade_transport_driver.user_id")
// mobile:旧版小程序仍会带这个参数,已不参与解析,保留只为不让老客户端报错
public R<DriverVO> mine(@RequestParam(required = false) String mobile) {
return R.data(driverAppService.currentByPhone(mobile));
return R.data(driverAppService.current());
}
@GetMapping("/vehicles")
@@ -65,14 +65,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/current-task")
@ApiOperationSupport(order = 1)
@Operation(summary = "首页:当前运输中任务", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=running 的最新一条运单")
@Operation(summary = "首页:当前运输中任务", description = "当前司机档案(按登录用户解析)名下、businessStatus=running 的最新一条运单;归属判据是运单 driver_id")
public R<DriverWaybillCardVO> currentTask() {
return R.data(driverWaybillService.currentTask());
}
@GetMapping("/pending-preview")
@ApiOperationSupport(order = 2)
@Operation(summary = "首页:待接运单预览", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=pending 的预览列表与总数")
@Operation(summary = "首页:待接运单预览", description = "当前司机档案名下、businessStatus=pending 的预览列表与总数;归属判据是运单 driver_id")
public R<DriverWaybillPreviewVO> pendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.pendingPreview(size));
@@ -80,14 +80,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/counts")
@ApiOperationSupport(order = 3)
@Operation(summary = "运单 Tab 统计", description = "仅统计当前用户手机号匹配运单 driverPhone 的运单:全部 / 待接单 / 进行中 / 已完成")
@Operation(summary = "运单 Tab 统计", description = "仅统计当前司机档案名下(运单 driver_id)的运单:全部 / 待接单 / 进行中 / 已完成")
public R<DriverWaybillTabCountsVO> counts() {
return R.data(driverWaybillService.tabCounts());
}
@GetMapping("/page")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单分页列表", description = "仅返回运单 driverPhone 等于当前用户手机号的数据;status:空=全部,0待接单,1运输中,2已完成")
@Operation(summary = "运单分页列表", description = "仅返回运单 driver_id 等于当前司机档案的数据;status:空=全部,0待接单,1运输中,2已完成")
public R<IPage<DriverWaybillCardVO>> page(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@@ -107,14 +107,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/task/current")
@ApiOperationSupport(order = 21)
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条")
@Operation(summary = "首页:当前运输中运次", description = "当前司机档案可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条;配载段按配载单 driver_id,未配载段按运单 driver_id")
public R<DriverWaybillCardVO> tripCurrentTask() {
return R.data(driverWaybillService.tripCurrentTask());
}
@GetMapping("/task/pending-preview")
@ApiOperationSupport(order = 22)
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
@Operation(summary = "首页:待接运次预览", description = "当前司机档案可见的待接运次预览与总数(按运次计)")
public R<DriverWaybillPreviewVO> tripPendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.tripPendingPreview(size));
@@ -166,7 +166,7 @@ public class DriverWaybillController extends BladeController {
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
@Operation(summary = "司机端行驶轨迹", description = "按当前司机档案校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
public R<WaybillTrackVO> track(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.data(driverWaybillService.track(id));
}
@@ -36,6 +36,7 @@ import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.dto.ExceptionMaintenanceRecordRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IExceptionDisposalService;
@@ -140,4 +141,12 @@ public class ExceptionDisposalController extends BladeController {
return R.success("已调整路线");
}
@PostMapping("/maintenance-record")
@ApiOperationSupport(order = 11)
@Operation(summary = "录入维修记录", description = "车辆故障维修类异常的子流程:写一条关联维修记录并追加跟进记录,不结案;一条异常至多一条;仅需登录态")
public R submitMaintenanceRecord(@RequestBody ExceptionMaintenanceRecordRequest request) {
exceptionDisposalService.submitMaintenanceRecord(request);
return R.success("已保存维修记录");
}
}
@@ -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<Map<String, Object>> pushConfig() {
boolean immediateEnabled = hcsProperties.getPush() != null && hcsProperties.getPush().isImmediateEnabled();
Map<String, Object> config = new HashMap<>(2);
config.put("immediateEnabled", immediateEnabled);
return R.data(config);
}
/**
* 单条重推(bizType:project=项目立项 / contract=合同 / payment=付款申请 / invoice-application=开票申请 / invoice-receipt=收票单)
@@ -56,7 +56,7 @@ public class LoadingManageController extends BladeController {
@GetMapping("/carrier-contracts")
@ApiOperationSupport(order = 13)
@Operation(summary = "可选承运商合同", description = "查询已审核生效的承运商合同;传入 waybillIds 时按子运单客户合同甲方匹配后对乙方取交集")
@Operation(summary = "可选承运商合同", description = "查询已审核生效的承运商合同;传入 waybillIds 时按子运单客户合同乙方=承运商合同甲方匹配后对乙方取交集")
public R<List<LoadingCarrierContractVO>> carrierContracts(
@RequestParam List<Long> projectIds,
@RequestParam(required = false) List<Long> waybillIds) {
@@ -0,0 +1,94 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.vo.ArchiveManageStatsVO;
import org.springblade.transport.pojo.vo.DeptTreeNodeVO;
import org.springblade.transport.service.IManageArchiveService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 调度端档案(小程序管理端)
* <p>
* 对外完整路径:{@code /api/blade-transport/archive/manage/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/**})。
* 同时兼容未去前缀直连({@code /blade-transport/archive/manage/**})。
* 仅需登录态,不挂管理端菜单鉴权。
* <p>
* 为什么单独一个接口:管理端 {@code /driver/expiry-stat}、{@code /transport-vehicle/expiry-stat}
* 挂在 {@code @PreAuth(menu = "driver")} / {@code @PreAuth(menu = "transport_vehicle")} 下,
* 小程序登录的令牌 roleId 是占位值 "0"、查不到 blade_role_menu,必然被拒(401 请求未授权)。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/archive/manage", "/blade-transport/archive/manage"})
@Tag(name = "调度端档案", description = "小程序调度端资料页档案统计")
public class ManageArchiveController extends BladeController {
/**
* 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。
*/
private static final int FORBIDDEN_CODE = 403;
private static final String FORBIDDEN_MSG = "仅调度端管理员可选择组织";
private final IManageArchiveService manageArchiveService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
@GetMapping("/stats")
@ApiOperationSupport(order = 1)
@Operation(summary = "档案统计", description = "司机档案与车辆档案的总数、30 天内到期数、已到期数;不按组织收口")
public R<ArchiveManageStatsVO> stats() {
return R.data(manageArchiveService.stats());
}
// 本接口不挂 @PreAuth;无权限返回 false,不抛 403,供小程序决定空态。
@GetMapping("/capability")
@ApiOperationSupport(order = 2)
@Operation(summary = "档案管理能力判定", description = "当前登录是否为调度端管理员;false 时小程序显示无权限空态,不踢登录")
public R<Boolean> capability() {
return R.data(mpUserDataScopeSupport.isMpDispatcher());
}
@GetMapping("/dept/tree")
@ApiOperationSupport(order = 3)
@Operation(summary = "组织树", description = "小程序组织选择器用:启用中的部门树,已剔除「外部组织」整支;非管理员回业务 403")
public R<List<DeptTreeNodeVO>> deptTree() {
if (!mpUserDataScopeSupport.isMpDispatcher()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
return R.data(manageArchiveService.deptTree());
}
}
@@ -0,0 +1,180 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springblade.transport.wrapper.DriverWrapper;
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;
import java.time.LocalDate;
/**
* 调度端司机档案管理(小程序)
* <p>
* 对外完整路径:{@code /api/blade-transport/archive/manage/driver/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/driver/**}),
* 同时兼容未去前缀直连。
* <p>
* 为什么不复用管理端的 {@code /driver/**}:那些接口挂在 {@code @PreAuth(menu = "driver")} 下,
* 小程序令牌的 roleId 是占位值 "0"、查不到 blade_role_menu,对任何小程序账号都必然被拒。
* 这里不挂 {@code @PreAuth},改成方法内显式判定管理员身份,且**权限不足必须回业务 403**:
* 小程序请求层把 401 一律当会话失效(清令牌 + 回登录页),回 401 会把越权访问变成被踢下线。
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/archive/manage/driver", "/blade-transport/archive/manage/driver"})
@Tag(name = "调度端司机档案", description = "小程序调度端司机管理")
public class ManageArchiveDriverController extends BladeController {
private static final int DEFAULT_CURRENT = 1;
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 100;
/**
* 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。
*/
private static final int FORBIDDEN_CODE = 403;
private static final String FORBIDDEN_MSG = "仅调度端管理员可管理司机档案";
private final IDriverService driverService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
/**
* 分页
*/
@GetMapping("/page")
@ApiOperationSupport(order = 1)
@Operation(summary = "司机档案分页", description = "keyword 同时模糊匹配姓名与手机号;status 不传为启用停用全量;expireStatus 取 within30 / expired")
public R<IPage<DriverVO>> page(DriverVO driver, Query query) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
fillExpiryDate(driver);
return R.data(driverService.selectDriverPage(Condition.getPage(normalizeQuery(query)), driver));
}
/**
* 详情
*/
@GetMapping("/detail")
@ApiOperationSupport(order = 2)
@Operation(summary = "司机档案详情")
public R<DriverVO> detail(@Parameter(description = "司机主键", required = true) @RequestParam Long id) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
Driver query = new Driver();
query.setId(id);
return R.data(DriverWrapper.build().entityVO(driverService.getOne(Condition.getQueryWrapper(query))));
}
/**
* 新增或修改
*/
@PostMapping("/submit")
@ApiOperationSupport(order = 3)
@Operation(summary = "新增或修改司机档案", description = "直接写档案、立即生效,不生成待审快照")
public R submit(@Valid @RequestBody Driver driver) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
// 生效指针只由认证审核流水写(通过才建立生效快照),不接受管理端写入
driver.setEffectiveCertRecordId(null);
boolean saved = driverService.submit(driver);
// submit 内部把空串 trimToNull,update-strategy=not_null 会跳过该字段,导致删不掉大头照;这里显式清空
if (saved && driver.getId() != null && Func.isBlank(driver.getHeadPhoto())) {
driverService.update(Wrappers.<Driver>lambdaUpdate()
.eq(Driver::getId, driver.getId())
.set(Driver::getHeadPhoto, null));
}
return R.status(saved);
}
/**
* 启停用
*/
@PostMapping("/status")
@ApiOperationSupport(order = 4)
@Operation(summary = "启用 / 停用司机", description = "status:1 启用,2 停用。停用司机不进调度检索,但仍可登录")
public R status(@Parameter(description = "主键", required = true) @RequestParam Long id,
@Parameter(description = "状态:1 启用,2 停用", required = true) @RequestParam Integer status) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
return R.status(driverService.changeStatus(id, status));
}
private boolean canManage() {
return mpUserDataScopeSupport.isMpDispatcher();
}
private Query normalizeQuery(Query query) {
if (query == null) {
query = new Query();
}
if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) {
query.setCurrent(DEFAULT_CURRENT);
}
if (query.getSize() == null || query.getSize() <= 0) {
query.setSize(DEFAULT_SIZE);
}
if (query.getSize() > MAX_SIZE) {
query.setSize(MAX_SIZE);
}
return query;
}
private void fillExpiryDate(DriverVO driver) {
if (driver.getToday() == null) {
driver.setToday(LocalDate.now());
}
if (driver.getWarningDate() == null) {
driver.setWarningDate(driver.getToday().plusDays(30));
}
}
}
@@ -0,0 +1,169 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import 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.tool.api.R;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.TransportVehicleVO;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springblade.transport.wrapper.TransportVehicleWrapper;
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;
import java.time.LocalDate;
/**
* 调度端车辆档案管理(小程序)
* <p>
* 对外完整路径:{@code /api/blade-transport/archive/manage/vehicle/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/vehicle/**}),
* 同时兼容未去前缀直连。
* <p>
* 与管理端 {@code /transport-vehicle/**} 的关系、以及为什么必须回业务 403 而不是 401,
* 见 {@link ManageArchiveDriverController} 的类注释。
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/archive/manage/vehicle", "/blade-transport/archive/manage/vehicle"})
@Tag(name = "调度端车辆档案", description = "小程序调度端车辆管理")
public class ManageArchiveVehicleController extends BladeController {
private static final int DEFAULT_CURRENT = 1;
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 100;
/**
* 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。
*/
private static final int FORBIDDEN_CODE = 403;
private static final String FORBIDDEN_MSG = "仅调度端管理员可管理车辆档案";
private final ITransportVehicleService transportVehicleService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
/**
* 分页
*/
@GetMapping("/page")
@ApiOperationSupport(order = 1)
@Operation(summary = "车辆档案分页", description = "keyword 同时模糊匹配车牌、VIN 与所有人;status 不传为启用停用全量;expireStatus 取 within30 / expired")
public R<IPage<TransportVehicleVO>> page(TransportVehicleVO vehicle, Query query) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
fillExpiryDate(vehicle);
return R.data(transportVehicleService.selectTransportVehiclePage(Condition.getPage(normalizeQuery(query)), vehicle));
}
/**
* 详情
*/
@GetMapping("/detail")
@ApiOperationSupport(order = 2)
@Operation(summary = "车辆档案详情")
public R<TransportVehicleVO> detail(@Parameter(description = "车辆主键", required = true) @RequestParam Long id) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
TransportVehicle query = new TransportVehicle();
query.setId(id);
return R.data(TransportVehicleWrapper.build().entityVO(transportVehicleService.getOne(Condition.getQueryWrapper(query))));
}
/**
* 新增或修改
*/
@PostMapping("/submit")
@ApiOperationSupport(order = 3)
@Operation(summary = "新增或修改车辆档案", description = "直接写档案、立即生效,不生成待审快照,也不改动认证状态")
public R submit(@Valid @RequestBody TransportVehicle vehicle) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
// 生效指针只由认证审核流水写(通过才建立生效快照),不接受管理端写入
vehicle.setEffectiveCertRecordId(null);
return R.status(transportVehicleService.submit(vehicle));
}
/**
* 启停用
*/
@PostMapping("/status")
@ApiOperationSupport(order = 4)
@Operation(summary = "启用 / 停用车辆", description = "status:1 启用,2 停用。停用车辆不进车辆检索、司机端车辆列表不再出现")
public R status(@Parameter(description = "主键", required = true) @RequestParam Long id,
@Parameter(description = "状态:1 启用,2 停用", required = true) @RequestParam Integer status) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
return R.status(transportVehicleService.changeStatus(id, status));
}
private boolean canManage() {
return mpUserDataScopeSupport.isMpDispatcher();
}
private Query normalizeQuery(Query query) {
if (query == null) {
query = new Query();
}
if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) {
query.setCurrent(DEFAULT_CURRENT);
}
if (query.getSize() == null || query.getSize() <= 0) {
query.setSize(DEFAULT_SIZE);
}
if (query.getSize() > MAX_SIZE) {
query.setSize(MAX_SIZE);
}
return query;
}
private void fillExpiryDate(TransportVehicleVO vehicle) {
if (vehicle.getToday() == null) {
vehicle.setToday(LocalDate.now());
}
if (vehicle.getWarningDate() == null) {
vehicle.setWarningDate(vehicle.getToday().plusDays(30));
}
}
}
@@ -114,7 +114,7 @@ public class PaymentApplicationController extends BladeController {
}
PaymentApplication payment = paymentApplicationService.getById(id);
String specStatus = payment == null ? null : payment.getHcsSpecStatus();
if (HcsSyncConstant.SPEC_STATUS_NEED.equals(specStatus)) {
if (hcsImmediatePushSupport.isSpecPayEnabled() && HcsSyncConstant.SPEC_STATUS_NEED.equals(specStatus)) {
return R.success("审批通过,付款特批待发起,可在详情页重推合规");
}
if (Arrays.asList(HcsSyncConstant.SPEC_STATUS_PUSHING, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(specStatus)) {
@@ -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("结束日期")
@@ -24,6 +24,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tool.api.R;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.service.IFormalSettlementService;
@@ -65,6 +66,7 @@ public class KingdeeApprovalSyncListener {
private final IInvoiceApplicationService invoiceApplicationService;
private final IInvoiceReceiptService invoiceReceiptService;
private final BfiProperties bfiProperties;
private final HcsProperties hcsProperties;
/** 当前审批请求线程的自动推送失败原因,供 Controller 取走提示前端(见类注释线程说明) */
private static final ThreadLocal<String> SYNC_FAILURE = new ThreadLocal<>();
@@ -183,9 +185,10 @@ public class KingdeeApprovalSyncListener {
return;
}
// 付款特批未完成(高风险/黑名单/额度超额)时金蝶必然拒推:跳过而非置失败,
// 特批通过后由 HcsPaymentSyncSupport 重新发布本事件自动补推
// 特批通过后由 HcsPaymentSyncSupport 重新发布本事件自动补推;
// 特批不走合同系统(spec-pay.enabled=false)时不跳过
PaymentApplication payment = paymentApplicationService.getById(id);
if (payment != null && payment.getHcsSpecStatus() != null
if (hcsProperties.getSpecPay().isEnabled() && payment != null && payment.getHcsSpecStatus() != null
&& List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PUSHING,
HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(payment.getHcsSpecStatus())) {
log.info("付款申请[{}]审批通过自动同步金蝶跳过(付款特批未完成):hcsSpecStatus={}", id, payment.getHcsSpecStatus());
@@ -147,6 +147,11 @@
<sql id="QueryCondition">
is_deleted = 0
<!-- 小程序列表的单框搜索:姓名或手机号命中其一即可。管理端不传 keyword,条件不生效。 -->
<if test="driver.keyword != null and driver.keyword != ''">
<bind name="driverKeywordLike" value="'%' + driver.keyword + '%'"/>
AND (driver_name LIKE #{driverKeywordLike} OR mobile LIKE #{driverKeywordLike})
</if>
<if test="driver.driverName != null and driver.driverName != ''">
<bind name="driverNameLike" value="'%' + driver.driverName + '%'"/>
AND driver_name LIKE #{driverNameLike}
@@ -14,6 +14,7 @@
<result column="is_deleted" property="isDeleted"/>
<result column="vehicle_type" property="vehicleType"/>
<result column="vehicle_no" property="vehicleNo"/>
<result column="exception_id" property="exceptionId"/>
<result column="maintainer" property="maintainer"/>
<result column="maintenance_time" property="maintenanceTime"/>
<result column="location" property="location"/>
@@ -27,55 +28,63 @@
<result column="mileage_unit" property="mileageUnit"/>
<result column="attachments" property="attachments"/>
<result column="remark" property="remark"/>
<result column="waybill_no" property="waybillNo"/>
<result column="exception_type" property="exceptionType"/>
<result column="exception_reason" property="exceptionReason"/>
</resultMap>
<select id="selectMaintenanceRecordPage" resultMap="maintenanceRecordResultMap">
SELECT
id,
tenant_id,
create_user,
create_dept,
create_time,
update_user,
update_time,
status,
is_deleted,
vehicle_type,
vehicle_no,
maintainer,
maintenance_time,
location,
replaced_part,
cost,
company,
contact,
address,
factory_time,
CASE WHEN mileage = -1 THEN NULL WHEN mileage &lt; 0 THEN 0 ELSE mileage END AS mileage,
mileage_unit,
attachments,
remark
r.id,
r.tenant_id,
r.create_user,
r.create_dept,
r.create_time,
r.update_user,
r.update_time,
r.status,
r.is_deleted,
r.vehicle_type,
r.vehicle_no,
r.exception_id,
r.maintainer,
r.maintenance_time,
r.location,
r.replaced_part,
r.cost,
r.company,
r.contact,
r.address,
r.factory_time,
CASE WHEN r.mileage = -1 THEN NULL WHEN r.mileage &lt; 0 THEN 0 ELSE r.mileage END AS mileage,
r.mileage_unit,
r.attachments,
r.remark,
d.waybill_no,
d.exception_type,
d.exception_reason
FROM
blade_vehicle_maintenance_record
blade_vehicle_maintenance_record r
LEFT JOIN blade_exception_disposal d ON d.id = r.exception_id AND d.is_deleted = 0
WHERE
is_deleted = 0
r.is_deleted = 0
<if test="maintenanceRecord.createDept != null">
AND create_dept = #{maintenanceRecord.createDept}
AND r.create_dept = #{maintenanceRecord.createDept}
</if>
<if test="maintenanceRecord.vehicleType != null and maintenanceRecord.vehicleType != ''">
AND vehicle_type = #{maintenanceRecord.vehicleType}
AND r.vehicle_type = #{maintenanceRecord.vehicleType}
</if>
<if test="maintenanceRecord.vehicleNo != null and maintenanceRecord.vehicleNo != ''">
<bind name="vehicleNoLike" value="'%' + maintenanceRecord.vehicleNo + '%'"/>
AND vehicle_no LIKE #{vehicleNoLike}
AND r.vehicle_no LIKE #{vehicleNoLike}
</if>
<if test="maintenanceRecord.createTimeStart != null">
AND create_time &gt;= #{maintenanceRecord.createTimeStart}
AND r.create_time &gt;= #{maintenanceRecord.createTimeStart}
</if>
<if test="maintenanceRecord.createTimeEnd != null">
AND create_time &lt;= #{maintenanceRecord.createTimeEnd}
AND r.create_time &lt;= #{maintenanceRecord.createTimeEnd}
</if>
ORDER BY create_time DESC
ORDER BY r.create_time DESC
</select>
</mapper>
@@ -0,0 +1,26 @@
package org.springblade.transport.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.system.pojo.entity.Dept;
import java.util.List;
/**
* 组织树只读查询(小程序组织选择器)
* <p>
* 不复用 blade-system 的部门接口:那些接口全挂在 {@code @PreAuth(menu = "dept")} 下,
* 小程序令牌的 roleId 是占位值 "0"、查不到 blade_role_menu,任何小程序账号都会被拒。
* 这里直接按租户读表,只取组织选择需要的四列。
*/
public interface ManageArchiveDeptMapper {
/**
* 租户内启用中的部门,按排序号升序(不含已删除)
*/
@Select("SELECT id, parent_id AS parentId, dept_name AS deptName, sort FROM blade_dept "
+ "WHERE is_deleted = 0 AND status = 1 AND tenant_id = #{tenantId} "
+ "ORDER BY sort, id")
List<Dept> selectEnabledByTenant(@Param("tenantId") String tenantId);
}
@@ -116,6 +116,11 @@
<sql id="QueryCondition">
is_deleted = 0
<!-- 小程序列表的单框搜索:车牌、VIN 或所有人命中其一即可。管理端不传 keyword,条件不生效。 -->
<if test="vehicle.keyword != null and vehicle.keyword != ''">
<bind name="vehicleKeywordLike" value="'%' + vehicle.keyword + '%'"/>
AND (plate_no LIKE #{vehicleKeywordLike} OR vin LIKE #{vehicleKeywordLike} OR owner_name LIKE #{vehicleKeywordLike})
</if>
<if test="vehicle.organizationName != null and vehicle.organizationName != ''">
<!-- 「未分配组织」哨兵值定义在 TransportBusiConstant.UNASSIGNED_ORG_FILTER 与前端
tms-erp-web/src/mixins/organization-search.js,改这里必须同步常量与 DriverMapper.xml 的同名分支。
@@ -0,0 +1,45 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.ocr.service;
import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO;
/**
* 营业执照 OCR 服务。
*
* @author Chill
*/
public interface IBusinessLicenseOcrService {
/**
* 根据图片地址识别营业执照。
*
* @param imageUrl 图片地址
* @return 识别结果
*/
BusinessLicenseOcrVO recognizeUrl(String imageUrl);
}
@@ -0,0 +1,189 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.ocr.service.impl;
import com.fasterxml.jackson.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<String, String> 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<String, String> 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<String> 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<String, String> extractFields(JsonNode dataNode) {
Map<String, String> 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;
}
}
@@ -0,0 +1,62 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import com.fasterxml.jackson.annotation.JsonInclude;
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 java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 组织树节点(小程序组织选择器)
*
* @author Chill
*/
@Data
@Schema(description = "组织树节点")
public class DeptTreeNodeVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 部门主键:雪花 ID 超出 JS 安全整数,序列化成字符串,前端不做 Number()
*/
@JsonSerialize(using = ToStringSerializer.class)
@Schema(description = "部门主键")
private Long id;
@Schema(description = "部门名称")
private String name;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@Schema(description = "子部门")
private List<DeptTreeNodeVO> children = new ArrayList<>();
}
@@ -74,12 +74,13 @@ public interface ICertAuditService {
/**
* 审核一条认证记录
*
* @param recordId 认证记录主键
* @param pass true 通过,false 驳回
* @param rejectReason 驳回原因;驳回时必填,≤200 字
* @param recordId 认证记录主键
* @param pass true 通过,false 驳回
* @param rejectReason 驳回原因;驳回时必填,≤200 字
* @param auditedSnapshotJson 审核员修订后的全量认证内容;空或空白表示不修订,驳回时忽略
* @return 是否成功
*/
boolean audit(Long recordId, boolean pass, String rejectReason);
boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson);
/**
* 当前登录用户的审核能力:能否查看、能否审核
@@ -75,12 +75,13 @@ public interface IContractManageService extends BaseService<ContractManage> {
List<ContractManageExcel> 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);
/**
* 合同是否可用于生成结算单:已审批、未终止,且临时合同仍在临时效力期内。
@@ -34,11 +34,11 @@ import java.util.List;
public interface IDriverAppService {
/**
* 当前登录用户对应的司机档案(按手机号匹配 blade_transport_driver.mobile)
* 当前登录用户对应的司机档案(按 user_id 匹配 blade_transport_driver.user_id)
*
* @param mobile 小程序可显式传入当前用户手机号;为空时从登录态解析
* @return 司机档案;未登录抛异常,查不到返回 null
*/
DriverVO currentByPhone(String mobile);
DriverVO current();
/**
* 当前司机绑定的车辆列表(driver.driving_vehicle ↔ vehicle.plate_no)
@@ -49,10 +49,9 @@ public interface IDriverAppService {
* 当前登录用户对应的司机**实体**
* <p>
* 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle},
* 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配,
* 匹配不到再按 {@code user_id} 兜底。
* 故这里返回实体而非视图对象。解析口径与 {@link #current()} 一致。
*
* @return 司机实体;未登录或查不到时返回 null
* @return 司机实体;未登录抛异常,查不到返回 null
*/
Driver currentDriverEntity();
@@ -111,4 +111,16 @@ public interface IDriverService extends BaseService<Driver> {
*/
List<DriverExcel> exportDriver(Wrapper<Driver> queryWrapper);
/**
* 按手机号解析司机档案 ID
* <p>
* 归属主键是档案 ID,手机号只是派单时的号码快照:只有租户内唯一命中才认,
* 0 条(承运商司机、未登录过小程序的司机)与多条(历史脏数据)都返回 null,不抛异常。
*
* @param tenantId 租户ID,为空时取当前登录租户
* @param mobile 手机号
* @return 司机档案ID;无法唯一确定时为 null
*/
Long resolveDriverIdByMobile(String tenantId, String mobile);
}
@@ -28,6 +28,7 @@ import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.dto.ExceptionMaintenanceRecordRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -75,4 +76,10 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
*/
void adjustRoute(ExceptionDisposalRouteAdjustRequest request);
/**
* 录入关联维修记录:写一条维修记录并追加一条跟进记录,不结案。
* 一条异常至多一条维修记录;已撤销的异常不允许录入。
*/
void submitMaintenanceRecord(ExceptionMaintenanceRecordRequest request);
}
@@ -103,6 +103,23 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
String retryKingdeeFailed();
String syncKingdeeArResult();
/**
* 金蝶冲销状态定时回写:扫描已同步金蝶的结算单,按防重键批量查询 A102-4/A302-4 的
* hadwrittenoff(部分冲销视同已冲销),回写冲销状态供结算单列表展示
*
* @return 执行摘要
*/
String syncKingdeeWriteOffResult();
/**
* 单笔刷新金蝶冲销状态(选单预检/重推闸门实时查询后回写,状态未变化不落库)
*
* @param settlementId 正式结算单id
* @param writtenOff 金蝶侧应付/应收单是否已冲销
*/
void refreshKingdeeWriteOff(Long settlementId, boolean writtenOff);
List<FormalSettlementDetailFee> detailFees(Long detailId);
void adjustDetail(PreSettlementDetailAdjustRequest request);
void refreshPaymentSummary(Long settlementId);
@@ -29,7 +29,7 @@ public interface ILoadingManageService extends BaseService<LoadingManage> {
/**
* 查询项目下已审核生效的承运商合同。
* <p>
* 传入 waybillIds 时,按各子运单客户合同甲方匹配可用承运商合同,再对乙方名称取交集后返回。
* 传入 waybillIds 时,按各子运单客户合同乙方=承运商合同甲方匹配可用承运商合同,再对乙方名称取交集后返回。
*/
List<LoadingCarrierContractVO> carrierContracts(List<Long> projectIds, List<Long> waybillIds);
@@ -0,0 +1,45 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import org.springblade.transport.pojo.vo.ArchiveManageStatsVO;
import org.springblade.transport.pojo.vo.DeptTreeNodeVO;
import java.util.List;
/**
* 调度端档案统计
*/
public interface IManageArchiveService {
/**
* 司机与车辆档案的证件有效期统计
*/
ArchiveManageStatsVO stats();
/**
* 组织树:启用中的部门,已剔除「外部组织」整支
*/
List<DeptTreeNodeVO> deptTree();
}
@@ -41,7 +41,7 @@ public interface ITripQueryService {
* <p>
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
*
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
* @param query 查询条件,收口由 driverId(司机档案)或 allDrivers 给出
* @return 运次分页
*/
IPage<TripVO> page(TripQueryVO query);
@@ -26,6 +26,7 @@
package org.springblade.transport.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
@@ -138,20 +139,24 @@ public class CertAuditServiceImpl implements ICertAuditService {
detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser()));
detail.setRejectReason(record.getRejectReason());
detail.setPendingSnapshotJson(record.getSnapshotJson());
// 审核员上次填过的修订版:详情页要能回显,否则重开页面就丢了
detail.setAuditedSnapshotJson(record.getAuditedSnapshotJson());
// 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理
detail.setEffectiveRecordId(effectiveId);
detail.setCertified(CertificationRuleSupport.isCertified(effectiveId));
if (Func.isNotEmpty(effectiveId)) {
TransportCertRecord effective = certRecordService.getById(effectiveId);
detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson());
// 生效一侧同样修订版优先:审核页是拿「新提交」和「当前生效内容」做 diff 的,
// 这里取旧值会让审核员看到一份从未生效过的差异
detail.setEffectiveSnapshotJson(CertificationRuleSupport.effectiveSnapshotJson(effective));
}
return detail;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean audit(Long recordId, boolean pass, String rejectReason) {
public boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson) {
TransportCertRecord record = requireRecord(recordId);
// 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」,
@@ -165,6 +170,17 @@ public class CertAuditServiceImpl implements ICertAuditService {
+ TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)");
}
// 修订版只有「通过」才可能生效:驳回不产生生效内容,传了也不该留痕,
// 否则历史里会出现一条「已驳回却带着生效内容」的记录。
String revision = pass ? blankToNull(auditedSnapshotJson) : null;
// 校验放在写库之前——内容不可用或改动了身份字段就整体回滚,不能先落一条状态再报错。
if (revision != null) {
requireUsableRevision(record, revision);
requireIdentityUnchanged(record, revision);
}
// 写回档案的是「本次生效内容」:有修订用修订版,没有就用司机原提交。
String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : revision;
Long auditor = AuthUtil.getUserId();
LocalDateTime auditTime = LocalDateTime.now();
@@ -177,11 +193,13 @@ public class CertAuditServiceImpl implements ICertAuditService {
: CertificationRuleSupport.reasonForReject(rejectReason));
patch.setAuditUser(auditor);
patch.setAuditTime(auditTime);
// 未修订时留 null:框架的 not_null 更新策略会跳过该列,库里保持 NULL 而不是空串
patch.setAuditedSnapshotJson(revision);
certRecordService.updateById(patch);
// 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE,
// 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。
updateCertTarget(record, pass, rejectReason);
updateCertTarget(record, pass, rejectReason, effectiveSnapshotJson);
return true;
}
@@ -212,11 +230,12 @@ public class CertAuditServiceImpl implements ICertAuditService {
* </ul>
* 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。
*
* @param record 被审核的记录
* @param pass 是否通过
* @param rejectReason 驳回原因
* @param record 被审核的记录
* @param pass 是否通过
* @param rejectReason 驳回原因
* @param effectiveSnapshotJson 本次生效内容(修订版优先,否则司机原提交)
*/
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) {
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason, String effectiveSnapshotJson) {
boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType());
Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED;
// 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列
@@ -232,7 +251,7 @@ public class CertAuditServiceImpl implements ICertAuditService {
// 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值——
// 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。
// 与指针合并成同一次 UPDATE。
applySnapshotToVehicle(vehicle, record.getSnapshotJson());
applySnapshotToVehicle(vehicle, effectiveSnapshotJson);
} else {
vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
}
@@ -255,7 +274,7 @@ public class CertAuditServiceImpl implements ICertAuditService {
// 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空),
// 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。
// 与指针合并成同一次 UPDATE。
applySnapshotToDriver(driver, record.getSnapshotJson());
applySnapshotToDriver(driver, effectiveSnapshotJson);
} else {
driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
}
@@ -332,6 +351,122 @@ public class CertAuditServiceImpl implements ICertAuditService {
}
}
/**
* 校验审核员填写的修订版内容,不满足时抛错
* <p>
* 修订版是审核员的「最终认定」,会直接写回档案。因此必须能反序列化成当前提交结构,
* 且带上判定「这份内容有效」的关键字段,否则写回等于什么也没写:
* 司机看身份证号与驾驶证号,车辆看行驶证号与核定载质量。
* <p>
* 不满足时抛错、整体回滚——审核员填错 JSON 是操作失误,不能悄悄降级成「不修订」,
* 那样他会以为自己的修订已经生效。
*
* @param record 被审核的记录
* @param revision 已去空白的修订版内容
*/
private void requireUsableRevision(TransportCertRecord record, String revision) {
JSONObject parsed = parseRevision(revision);
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) {
VehicleCertSubmitRequest snapshot = toSnapshot(parsed, VehicleCertSubmitRequest.class);
if (snapshot == null
|| (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg()))) {
throw new ServiceException("修订内容缺少行驶证号与核定载质量,无法写入车辆档案");
}
return;
}
PersonCertSubmitRequest snapshot = toSnapshot(parsed, PersonCertSubmitRequest.class);
if (snapshot == null
|| (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo()))) {
throw new ServiceException("修订内容缺少身份证号与驾驶证号,无法写入司机档案");
}
}
/**
* 身份字段不可修订:修订版改动了身份证号、驾驶证号(司机)或车牌号(车辆)时拒绝
* <p>
* 审核只认定证件内容,身份字段标识「是谁」,改了审核的就是另一个对象。
* 原提交里该字段为空时(旧数据、空快照)没有可比对的原值,不比对,允许补全。
*
* @param record 被审核的记录
* @param revision 已去空白的修订版内容
*/
private void requireIdentityUnchanged(TransportCertRecord record, String revision) {
JSONObject parsed = parseRevision(revision);
if (parsed == null) {
return;
}
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) {
VehicleCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), VehicleCertSubmitRequest.class);
if (original == null) {
return;
}
VehicleCertSubmitRequest revised = toSnapshot(parsed, VehicleCertSubmitRequest.class);
requireSameIdentity(original.getPlateNo(), revised.getPlateNo(), "修订内容不能修改车牌号");
return;
}
PersonCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), PersonCertSubmitRequest.class);
if (original == null) {
return;
}
String message = "修订内容不能修改身份证号与驾驶证号";
PersonCertSubmitRequest revised = toSnapshot(parsed, PersonCertSubmitRequest.class);
requireSameIdentity(original.getIdCardNo(), revised.getIdCardNo(), message);
requireSameIdentity(original.getDriverFileNo(), revised.getDriverFileNo(), message);
}
/**
* 原提交有值时,修订版必须与之一致;原提交为空时允许补全
*
* @param original 原提交里的身份字段值
* @param revised 修订版里的身份字段值
* @param message 不一致时抛出的文案
*/
private void requireSameIdentity(String original, String revised, String message) {
if (Func.isEmpty(original)) {
return;
}
if (!original.equals(revised)) {
throw new ServiceException(message);
}
}
/**
* 反序列化审核员填写的修订版 JSON
*
* @param revision 已去空白的修订版内容
* @return 解析结果;内容为字面量 null 时返回 null
*/
private JSONObject parseRevision(String revision) {
try {
return JSON.parseObject(revision);
} catch (Exception exception) {
throw new ServiceException("修订内容格式不正确,请检查后重试");
}
}
/**
* 解析原提交快照;为空或无法解析时返回 null(此时没有可比对的原值)
*
* @param snapshotJson 原提交内容
* @param clazz 目标结构
* @param <T> 提交结构类型
* @return 解析结果;为空或解析失败时返回 null
*/
private <T> T parseSnapshotQuietly(String snapshotJson, Class<T> clazz) {
if (Func.isEmpty(snapshotJson)) {
return null;
}
try {
return JSON.parseObject(snapshotJson, clazz);
} catch (Exception exception) {
return null;
}
}
private <T> T toSnapshot(JSONObject parsed, Class<T> clazz) {
return parsed == null ? null : parsed.toJavaObject(clazz);
}
/**
* 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象)
* <p>
@@ -345,33 +480,34 @@ public class CertAuditServiceImpl implements ICertAuditService {
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
* 因此不会把管理端填过、司机端没填的字段清空。
* <p>
* 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**:
* 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败;
* 何况历史快照本来就存在,阻断会让这些对象再也审不过去。
* 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号,
* 必须阻断。
* 快照缺失、无法解析或不是当前字段定义时**直接拒绝本次通过并整体回滚**:
* 「通过」的语义是「把这批证件内容认定为生效值」,没有可用内容却放行,
* 等于给一条空记录盖上已通过的章,状态与内容互相矛盾。
* 内容确实为空的历史记录,由审核员在审核时补全后重审即可。
* 申报的身份证号已落在另一名司机名下时同样阻断——那会把两条档案写成同一个证件号。
*
* @param driver 待更新的司机 patch
* @param snapshotJson 该条记录的全量提交内容
*/
private void applySnapshotToDriver(Driver driver, String snapshotJson) {
if (Func.isEmpty(snapshotJson)) {
return;
throw new ServiceException("该认证记录没有提交内容,无法写入司机档案;请让司机重新提交一次认证");
}
PersonCertSubmitRequest snapshot;
try {
snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class);
} catch (Exception exception) {
return;
throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证");
}
if (snapshot == null) {
return;
throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证");
}
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况),
// 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就拒绝。
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
return;
throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照),"
+ "未写入司机档案;请重新提交一次认证");
}
// 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库,
// 建档查重与按证件号识别同一人都会失效
@@ -310,7 +310,7 @@ public class CommonAddressServiceImpl extends BaseServiceImpl<CommonAddressMappe
private void fillDisplay(CommonAddressVO address) {
address.setSiteCodeDisplay(TYPE_NORMAL.equals(address.getAddressType()) ? NORMAL_SITE_CODE : address.getSiteCode());
address.setReadonly(!TransportBusinessSupport.isAdministrator() && !Objects.equals(address.getDeptId(), currentDeptId()));
address.setReadonly(TransportBusinessSupport.isDeptReadonly(address.getDeptId(), TransportBusinessSupport.MENU_COMMON_ADDRESS));
address.setCreateUserName(UserCache.getUserRealName(address.getCreateUser()));
address.setUpdateUserName(UserCache.getUserRealName(address.getUpdateUser()));
}
@@ -44,6 +44,7 @@ import org.springblade.transport.pojo.vo.ContractChangeVO;
import org.springblade.transport.pojo.vo.ContractManageVO;
import org.springblade.transport.service.IContractChangeService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.support.ContractPartySupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.wrapper.ContractChangeWrapper;
import org.springframework.stereotype.Service;
@@ -316,9 +317,12 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
} else if (!Objects.equals(contractChange.getAllDept(), 1)) {
Long currentDeptId = TransportBusinessSupport.currentDeptId("合同变更");
Long currentUserId = AuthUtil.getUserId();
queryWrapper.and(wrapper -> 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());
}
@@ -358,7 +362,20 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
target.setSignType(source.getSignType());
target.setContractNo(source.getContractNo());
target.setContractName(source.getContractName());
target.setPartyB(source.getPartyB());
target.setPartyAJson(source.getPartyAJson());
target.setPartyBJson(source.getPartyBJson());
target.setPartyCJson(source.getPartyCJson());
ContractManage partyHolder = new ContractManage();
partyHolder.setPartyAJson(target.getPartyAJson());
partyHolder.setPartyBJson(target.getPartyBJson());
partyHolder.setPartyCJson(target.getPartyCJson());
ContractPartySupport.normalizeAndFillSummary(partyHolder);
target.setPartyAJson(partyHolder.getPartyAJson());
target.setPartyBJson(partyHolder.getPartyBJson());
target.setPartyCJson(partyHolder.getPartyCJson());
target.setPartyA(partyHolder.getPartyA());
target.setPartyB(partyHolder.getPartyB());
target.setPartyC(partyHolder.getPartyC());
target.setStartDate(source.getStartDate());
target.setEndDate(source.getEndDate());
target.setContractFormat(source.getContractFormat());
@@ -391,7 +408,12 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
data.put("contractNo", contractManage.getContractNo());
data.put("contractName", contractManage.getContractName());
data.put("signType", contractManage.getSignType());
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());
@@ -504,7 +526,8 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
return true;
}
Long currentDeptId = TransportBusinessSupport.currentDeptId("合同变更");
return currentDeptId != null && Objects.equals(contractChange.getOrganizationId(), currentDeptId);
return currentDeptId != null && (Objects.equals(contractChange.getOrganizationId(), currentDeptId)
|| TransportBusinessSupport.isProjectOwnedByBusinessDept(contractChange.getProjectId(), currentDeptId));
}
private void fillReadonly(ContractChangeVO contractChangeVO) {
@@ -36,6 +36,7 @@ import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.BusinessDeptConfig;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.excel.ContractManageExcel;
@@ -48,6 +49,7 @@ import org.springblade.transport.pojo.entity.HcsContractExeStage;
import org.springblade.transport.pojo.vo.ContractManageVO;
import org.springblade.transport.service.IContractExeStageService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.support.ContractPartySupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
import org.springblade.transport.support.hcs.HcsImmediatePushSupport;
@@ -60,7 +62,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -449,7 +450,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
request.setSignType(change.getSignType());
request.setContractNo(change.getContractNo());
request.setContractName(change.getContractName());
request.setPartyB(change.getPartyB());
request.setPartyAJson(change.getPartyAJson());
request.setPartyBJson(change.getPartyBJson());
request.setPartyCJson(change.getPartyCJson());
ContractPartySupport.normalizeAndFillSummary(request);
request.setStartDate(change.getStartDate());
request.setEndDate(change.getEndDate());
request.setContractFormat(change.getContractFormat());
@@ -510,13 +514,23 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
TransportBusinessSupport.validateRequired(request.getSettlementCurrency(), "请选择结算币种");
ContractManage candidate = new ContractManage();
BeanUtil.copyProperties(source, candidate);
candidate.setPartyB(TransportBusinessSupport.trimToNull(request.getPartyB()));
candidate.setPartyAJson(request.getPartyAJson());
candidate.setPartyBJson(request.getPartyBJson());
candidate.setPartyCJson(request.getPartyCJson());
ContractPartySupport.normalizeAndFillSummary(candidate);
ContractPartySupport.validateRequiredParties(candidate, true);
ContractPartySupport.validateBillingPlanBindings(candidate);
validateContractPartyCombinationUnique(candidate);
validatePaymentRatios(request.getPaymentRatioJson());
applyContractNo(source, request.getContractNo(), false);
source.setContractName(request.getContractName());
source.setSignType(request.getSignType());
source.setPartyB(request.getPartyB());
source.setPartyAJson(candidate.getPartyAJson());
source.setPartyBJson(candidate.getPartyBJson());
source.setPartyCJson(candidate.getPartyCJson());
source.setPartyA(candidate.getPartyA());
source.setPartyB(candidate.getPartyB());
source.setPartyC(candidate.getPartyC());
source.setStartDate(request.getStartDate());
source.setEndDate(request.getEndDate());
source.setContractFormat(request.getContractFormat());
@@ -665,9 +679,12 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
} else if (!Objects.equals(contractManage.getAllDept(), 1)) {
Long currentDeptId = TransportBusinessSupport.currentDeptId("合同管理");
Long currentUserId = AuthUtil.getUserId();
queryWrapper.and(wrapper -> 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());
}
@@ -683,6 +700,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
.eq(Func.isNotEmpty(contractManage.getArchiveStatus()), ContractManage::getArchiveStatus, contractManage.getArchiveStatus())
.eq(Func.isNotEmpty(contractManage.getContractStage()), ContractManage::getContractStage, contractManage.getContractStage())
.orderByDesc(ContractManage::getCreateTime);
applyPartyName(queryWrapper, contractManage.getPartyName());
applyEffectiveType(queryWrapper, contractManage.getEffectiveType());
applyExpireScope(queryWrapper, contractManage.getExpireScope());
// 是否同步金蝶(kingdee_id 是否已回填)/ 是否同步合规(hcs_sync_status 是否 synced)
@@ -708,6 +726,18 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
}
}
/**
* 客商筛选:匹配合同甲/乙/丙方名称(模糊)
*/
private void applyPartyName(LambdaQueryWrapper<ContractManage> 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<ContractManage> queryWrapper, String effectiveType) {
if (Func.isEmpty(effectiveType)) {
return;
@@ -737,7 +767,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
.lt(ContractManage::getTemporaryEndDate, today)
.or(wrapper2 -> wrapper2.ne(ContractManage::getContractStage, STAGE_TEMPORARY)
.lt(ContractManage::getEndDate, today)));
default -> throw new ServiceException("无效的生效类型筛选条件");
default -> throw new ServiceException("无效的合同状态筛选条件");
}
}
@@ -774,6 +804,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
contractManage.setOrganizationId(oldRecord.getOrganizationId());
contractManage.setOrganizationName(oldRecord.getOrganizationName());
}
if (Func.isEmpty(contractManage.getPlatformCompanyId())) {
contractManage.setPlatformCompanyId(oldRecord.getPlatformCompanyId());
contractManage.setPlatformCompanyName(oldRecord.getPlatformCompanyName());
}
contractManage.setHandlerUserId(oldRecord.getHandlerUserId());
contractManage.setHandlerUserName(oldRecord.getHandlerUserName());
// 归档状态仅由审核通过逻辑自动维护,保存时保留库中已有值。
@@ -798,7 +832,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
if (!generateWhenEmpty) {
throw new ServiceException("请输入合同编号");
}
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
normalizedContractNo = nextContractNo(contractManage.getOrganizationId(), contractManage.getPlatformCompanyId());
}
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
String currentContractNo = TransportBusinessSupport.trimToNull(contractManage.getContractNo());
@@ -857,30 +891,39 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
}
@Override
public synchronized String nextContractNo(Long organizationId) {
public synchronized String nextContractNo(Long organizationId, Long platformCompanyId) {
if (Func.isEmpty(organizationId)) {
throw new ServiceException("请选择合同负责部门");
}
if (Func.isEmpty(platformCompanyId)) {
throw new ServiceException("请选择平台公司");
}
Dept dept = SysCache.getDept(organizationId);
if (dept == null) {
throw new ServiceException("合同负责部门不存在");
}
String prefix = TransportBusinessSupport.trimToNull(dept.getContractCodePrefix());
Dept platformCompany = SysCache.getDept(platformCompanyId);
if (platformCompany == null) {
throw new ServiceException("平台公司不存在");
}
BusinessDeptConfig config = SysCache.getBusinessDeptConfig(platformCompanyId, organizationId);
String prefix = config == null ? null : TransportBusinessSupport.trimToNull(config.getContractCodePrefix());
if (Func.isEmpty(prefix)) {
throw new ServiceException("请先在组织机构中配置该部门的合同编码前缀");
throw new ServiceException("部门合同前缀未配置,无法自动生成,请手动输入合同编号");
}
int year = LocalDate.now().getYear();
String head = prefix + "(" + year + ")";
// 按前缀+年份扫描已占用流水号;系统已有相同流水号则跳过
List<ContractManage> latestList = list(Wrappers.<ContractManage>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)) {
@@ -890,22 +933,24 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
if (!matcher.matches()) {
continue;
}
next = Integer.parseInt(matcher.group(1)) + 1;
break;
hasMatched = true;
maxUsed = Math.max(maxUsed, Integer.parseInt(matcher.group(1)));
}
// 从初始流水号起编;若系统已有相同或更大流水号则跳到 maxUsed+1
int next = hasMatched ? Math.max(initialSerial, maxUsed + 1) : initialSerial;
if (next > 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;
}
@@ -1022,8 +1067,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
contractManage.setOrganizationName(TransportBusinessSupport.trimToNull(contractManage.getOrganizationName()));
contractManage.setContractCategory(TransportBusinessSupport.trimToNull(contractManage.getContractCategory()));
contractManage.setSignType(TransportBusinessSupport.trimToNull(contractManage.getSignType()));
contractManage.setPartyA(TransportBusinessSupport.trimToNull(contractManage.getPartyA()));
contractManage.setPartyB(TransportBusinessSupport.trimToNull(contractManage.getPartyB()));
contractManage.setPartyAJson(TransportBusinessSupport.trimToNull(contractManage.getPartyAJson()));
contractManage.setPartyBJson(TransportBusinessSupport.trimToNull(contractManage.getPartyBJson()));
contractManage.setPartyCJson(TransportBusinessSupport.trimToNull(contractManage.getPartyCJson()));
ContractPartySupport.normalizeAndFillSummary(contractManage);
contractManage.setSettlementMode(TransportBusinessSupport.trimToNull(contractManage.getSettlementMode()));
contractManage.setContractFormat(TransportBusinessSupport.trimToNull(contractManage.getContractFormat()));
contractManage.setRemark(TransportBusinessSupport.trimToNull(contractManage.getRemark()));
@@ -1080,6 +1127,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
// 合同名称允许重复,唯一性由合同编号保证。
TransportBusinessSupport.validateLength(contractManage.getMainContent(), 1000, "合同主要内容不能超过1000字");
TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "其他事项说明不能超过2000字");
// 草稿不强制甲/乙客商与计费绑定,提交临时/正式时再完整校验
}
private void validateBillingPlans(String billingPlanJson) {
@@ -1091,18 +1139,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
if (!(parsed instanceof List<?> plans)) {
throw new ServiceException("计费方案设置格式不正确");
}
Map<String, Integer> 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);
}
@@ -1164,15 +1205,6 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
return value != null && !String.valueOf(value).trim().isEmpty();
}
private boolean isDefaultBillingPlan(Map<?, ?> 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 {
@@ -1221,8 +1253,9 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类型");
TransportBusinessSupport.validateRequired(contractManage.getProjectName(), "请选择所属项目");
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择合同负责部门");
TransportBusinessSupport.validateRequired(contractManage.getPartyA(), "请选择甲方");
TransportBusinessSupport.validateRequired(contractManage.getPartyB(), "请选择乙方");
TransportBusinessSupport.validateRequired(contractManage.getPlatformCompanyName(), "请选择平台公司");
ContractPartySupport.validateRequiredParties(contractManage, true);
ContractPartySupport.validateBillingPlanBindings(contractManage);
validateContractAmountRequired(contractManage);
}
@@ -1255,6 +1288,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类型");
TransportBusinessSupport.validateRequired(contractManage.getProjectName(), "请选择所属项目");
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择合同负责部门");
TransportBusinessSupport.validateRequired(contractManage.getPlatformCompanyName(), "请选择平台公司");
TransportBusinessSupport.validateRequired(contractManage.getSettlementCurrency(), "请选择结算币种");
TransportBusinessSupport.validateRequired(contractManage.getPartyA(), "请输入甲方");
TransportBusinessSupport.validateRequired(contractManage.getPartyB(), "请输入乙方");
@@ -1422,7 +1456,12 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
Map<String, Object> 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());
@@ -1601,7 +1640,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
Long currentUserId = AuthUtil.getUserId();
return Objects.equals(contractManage.getOrganizationId(), currentDeptId)
|| Objects.equals(contractManage.getCreateUser(), currentUserId)
|| Objects.equals(contractManage.getHandlerUserId(), currentUserId);
|| Objects.equals(contractManage.getHandlerUserId(), currentUserId)
|| TransportBusinessSupport.isProjectOwnedByBusinessDept(contractManage.getProjectId(), currentDeptId);
}
}
@@ -27,10 +27,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
@@ -55,19 +52,24 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class DriverAppServiceImpl implements IDriverAppService {
/**
* 启用状态。框架基类只有 status 列与 BladeConstant.DB_STATUS_NORMAL = 1,没有枚举;
* 2 是「停用」,业务侧一律写字面量常量。
*/
private static final int ENABLED_STATUS = 1;
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final IMpUserClient mpUserClient;
@Override
public DriverVO currentByPhone(String mobile) {
Driver driver = currentDriver(mobile);
public DriverVO current() {
Driver driver = currentDriver();
return driver == null ? null : DriverWrapper.build().entityVO(driver);
}
@Override
public List<DriverVehicleCardVO> myVehicles() {
Driver driver = currentDriver(null);
Driver driver = currentDriver();
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
return List.of();
}
@@ -78,6 +80,8 @@ public class DriverAppServiceImpl implements IDriverAppService {
// 精确 + 规范化双条件匹配,统一走共享实现(避免各调用点各自实现而漏掉写法差异)
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery, plates);
// 停用车辆不再出现在司机端:管理员停用即意味着这辆车不该再被司机拿去接单
plateQuery.eq(TransportVehicle::getStatus, ENABLED_STATUS);
List<TransportVehicle> vehicles = transportVehicleService.list(
plateQuery.orderByDesc(TransportVehicle::getUpdateTime));
// 去重(精确与规范化可能命中同一条)
@@ -92,28 +96,23 @@ public class DriverAppServiceImpl implements IDriverAppService {
@Override
public Driver currentDriverEntity() {
return currentDriver(null);
return currentDriver();
}
private Driver currentDriver(String mobileHint) {
/**
* 当前司机的唯一解析入口:只按登录用户的 {@code user_id} 查档案。
* <p>
* 手机号不能用来认人:同一号码可能存在多条档案,按号码查「最近更新的一条」
* 取到的可能是别人的档案;号码本身也会被改。
*/
private Driver currentDriver() {
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
throw new ServiceException("未登录");
}
String phone = resolvePhone(userId, mobileHint);
Driver driver = null;
if (Func.isNotEmpty(phone)) {
driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getMobile, phone)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 1"));
}
if (driver == null) {
driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
}
return driver;
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
}
private DriverVehicleCardVO toCard(TransportVehicle vehicle) {
@@ -178,53 +177,4 @@ public class DriverAppServiceImpl implements IDriverAppService {
return date == null ? "" : date.toString();
}
/**
* 解析用于匹配司机档案的手机号。
* 优先级:前端传入且与本人一致的 mobile → JWT account(司机账号多为手机号)→ 用户中心 phone/account
*/
private String resolvePhone(Long userId, String mobileHint) {
String selfPhone = resolveSelfPhone(userId);
String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim();
if (Func.isNotEmpty(hint)) {
if (Func.isEmpty(selfPhone)) {
// 解析不出本人号码时不能拿前端传的 mobile 去查:
// currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。
// 这里返回空让它退回按 user_id 查,宁可查不到也不越权。
return null;
}
if (!selfPhone.equals(hint)) {
throw new ServiceException("只能查询本人司机档案");
}
return hint;
}
return selfPhone;
}
private String resolveSelfPhone(Long userId) {
String account = AuthUtil.getUserAccount();
if (isMobileLike(account)) {
return account.trim();
}
// 小程序用户 phone 带 @Sensitive(MOBILE),经 Feign 回来是脱敏串,
// 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。
R<MpUser> result = mpUserClient.userById(userId);
if (result != null && R.isSuccess(result) && result.getData() != null) {
String userAccount = result.getData().getAccount();
if (isMobileLike(userAccount)) {
return userAccount.trim();
}
}
return Func.isEmpty(account) ? null : account.trim();
}
/**
* 是否是「可作为司机档案手机号使用」的号码串。
* <p>
* 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号
* (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。
*/
private boolean isMobileLike(String value) {
return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$");
}
}
@@ -209,9 +209,11 @@ public class DriverCertServiceImpl implements IDriverCertService {
Driver driver = requireCurrentDriver();
// 当前司机实体已加载过,直接读它身上的指针,不再回查一次
TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId());
// 生效内容以修订版为准:审核员改过的值才是档案里真正生效的那份,
// 仍读 snapshot_json 会让司机端拿旧值覆盖审核员的改动
return effective == null
? null
: JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class);
: JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), PersonCertSubmitRequest.class);
}
@Override
@@ -269,10 +271,14 @@ public class DriverCertServiceImpl implements IDriverCertService {
if (!VehiclePlateSupport.isBound(requireCurrentDriver().getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权查看该车辆的认证信息");
}
// 停用车辆不再对司机端开放:司机列表里已经不显示它,详情/认证入口也不该能进
if (Func.isNotEmpty(vehicle.getStatus()) && vehicle.getStatus() != 1) {
throw new ServiceException("该车辆已停用,暂不可查看,请联系调度启用");
}
TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId());
return effective == null
? null
: JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class);
: JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), VehicleCertSubmitRequest.class);
}
@Override
@@ -406,6 +412,7 @@ public class DriverCertServiceImpl implements IDriverCertService {
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权操作该车辆的认证");
}
requireEnabledVehicle(vehicle);
return vehicle;
}
@@ -426,11 +433,24 @@ public class DriverCertServiceImpl implements IDriverCertService {
// 已登记但不在本人名下,可能属于其他司机 —— 拒绝,不能顺手改归属
throw new ServiceException("该车牌已归属其他司机,请联系管理员");
}
requireEnabledVehicle(existing);
return existing;
}
return createVehicleForDriver(driver, request, normalized);
}
/**
* 停用车辆不接受司机的认证提交
* <p>
* 停用是调度端的管理动作(车辆停驶、报废待办),司机端列表已不再显示停用车;
* 若这里放行,司机仍能凭车牌把停用车提交认证,等于绕过停用。
*/
private void requireEnabledVehicle(TransportVehicle vehicle) {
if (vehicle != null && Func.isNotEmpty(vehicle.getStatus()) && vehicle.getStatus() != 1) {
throw new ServiceException("该车辆已停用,请联系调度启用后再认证");
}
}
/**
* 按车牌(容忍写法差异)查已登记的车辆
*
@@ -25,6 +25,7 @@
*/
package org.springblade.transport.service.impl;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -171,6 +172,31 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
return bindExistingProfile(existing.getId(), mpUserId);
}
@Override
public Long resolveDriverIdByMobile(String tenantId, String mobile) {
String resolvedTenantId = trimToNull(tenantId);
if (resolvedTenantId == null) {
resolvedTenantId = trimToNull(AuthUtil.getTenantId());
}
String mobileSnapshot = trimToNull(mobile);
// 脱敏手机号(如 138****8000)与空号都不是可用业务数据,查不到也不该查
if (resolvedTenantId == null || mobileSnapshot == null || mobileSnapshot.contains("*")) {
return null;
}
// 手机号会被二次放号,同租户内可能残留多条历史档案:只有唯一命中才认,不猜
List<Driver> matchedDrivers = list(Wrappers.<Driver>lambdaQuery()
.select(Driver::getId)
.eq(Driver::getTenantId, resolvedTenantId)
.eq(Driver::getMobile, mobileSnapshot));
if (matchedDrivers.size() != 1) {
log.warn("司机档案解析未唯一命中,driver_id 留空。租户={} 手机号={} 命中={} 条",
resolvedTenantId, DesensitizedUtil.mobilePhone(mobileSnapshot), matchedDrivers.size());
return null;
}
Driver matchedDriver = matchedDrivers.get(0);
return matchedDriver == null ? null : matchedDriver.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public DriverSyncMobileVO syncMobile(String tenantId, Long mpUserId, String oldPhone, String newPhone) {
@@ -392,6 +418,9 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> 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);
}
@@ -33,10 +33,7 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -154,7 +151,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IMpUserClient mpUserClient;
private final IProcessConfigService processConfigService;
private final ITransportVehicleService transportVehicleService;
private final CertificationGate certificationGate;
@@ -166,12 +162,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillCardVO currentTask() {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return null;
}
// 进行中:running,或无需确认接单但仍为 pending 的历史数据
List<Waybill> candidates = waybillService.list(scopedQuery(phone)
List<Waybill> candidates = waybillService.list(scopedQuery(driver.getId())
.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING))
.orderByDesc(Waybill::getUpdateTime)
.last("LIMIT 20"));
@@ -187,12 +183,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillPreviewVO pendingPreview(Integer size) {
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return preview;
}
int limit = normalizePreviewSize(size);
List<Waybill> pendingList = waybillService.list(scopedQuery(phone)
List<Waybill> pendingList = waybillService.list(scopedQuery(driver.getId())
.in(Waybill::getBusinessStatus, PENDING_STATUSES)
.orderByDesc(Waybill::getCreateTime));
List<Waybill> needAccept = pendingList.stream()
@@ -207,11 +203,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillTabCountsVO tabCounts() {
DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return vo;
}
List<Waybill> waybills = waybillService.list(scopedQuery(phone)
List<Waybill> waybills = waybillService.list(scopedQuery(driver.getId())
.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES));
long pending = 0;
long doing = 0;
@@ -243,12 +239,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
Page<DriverWaybillCardVO> empty = new Page<>(pageNo, pageSize);
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return empty;
}
LambdaQueryWrapper<Waybill> wrapper = scopedQuery(phone);
LambdaQueryWrapper<Waybill> wrapper = scopedQuery(driver.getId());
// 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页
applyStatusFilterForQuery(wrapper, status);
applyKeyword(wrapper, keyword);
@@ -271,18 +267,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillCardVO detail(Long id) {
requireCurrentDriver();
Waybill waybill = loadDriverWaybill(id);
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
public DriverWaybillCardVO tripCurrentTask() {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return null;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
// 待确认(待回单确认)的趟车司机仍需回到卡片上继续操作,与进行中并列作为当前任务
query.setStatuses(List.of(TRIP_STATUS_RUNNING, TRIP_STATUS_PENDING_RETURN_CONFIRM));
query.setCurrent(1);
@@ -294,11 +289,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillPreviewVO tripPendingPreview(Integer size) {
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return preview;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
query.setStatus(TRIP_STATUS_PENDING);
query.setCurrent(1);
query.setSize(normalizePreviewSize(size));
@@ -313,11 +308,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillTabCountsVO tripTabCounts() {
DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return counts;
}
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone));
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(driver.getId()));
counts.setPending(tripCounts.getPending());
counts.setDoing(tripCounts.getDoing());
counts.setConfirm(tripCounts.getConfirm());
@@ -328,11 +323,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword) {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return new Page<>(normalizePageNo(current), normalizePageSize(size));
}
TripQueryVO query = TripQueryVO.forDriver(phone);
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
query.setStatus(status);
query.setKeyword(keyword);
query.setCurrent(current);
@@ -347,15 +342,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillCardVO tripDetail(String tripId) {
requireCurrentDriver();
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
Driver driver = requireCurrentDriver();
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
query.setTripId(tripId);
return toTripCard(tripQueryService.detail(query), true);
}
@Override
public WaybillTrackVO track(Long id) {
requireCurrentDriver();
Waybill waybill = loadDriverWaybill(id);
LocalDate[] range = resolveTrackDateRange(waybill);
return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString());
@@ -742,7 +736,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
* 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。
*/
private List<TripVO.TripWaybillVO> tripChildren(String tripId) {
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
TripQueryVO query = TripQueryVO.forDriver(requireCurrentDriver().getId());
query.setTripId(tripId);
TripVO trip = tripQueryService.detail(query);
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
@@ -822,13 +816,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
if (id == null) {
throw new ServiceException("运单ID不能为空");
}
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
throw new ServiceException("当前用户手机号为空,无法查找运单");
}
Driver driver = requireCurrentDriver();
// MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后
String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected)
Waybill waybill = waybillService.getOne(scopedQuery(driver.getId(), includeRejected)
.eq(Waybill::getId, id).last(tail));
if (waybill == null) {
throw new ServiceException("运单不存在或无权操作");
@@ -885,14 +876,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 司机可见运单范围:运单司机手机号(driver_phone)等于当前用户手机号,
* 司机可见运单范围:运单归属档案(driver_id)就是当前司机档案,
* 且该运单未被这个司机拒单。
* <p>
* driver_id 必须有值且大于 0 才算有主:存量数据的 -1 是哨兵值,与 0 一样等同无主。
* 运单上的 driver_phone 只是派单快照,不参与归属判定——司机改号不影响可见性。
* <p>
* 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。
* 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。
*/
private LambdaQueryWrapper<Waybill> scopedQuery(String phone) {
return scopedQuery(phone, false);
private LambdaQueryWrapper<Waybill> scopedQuery(Long driverId) {
return scopedQuery(driverId, false);
}
/**
@@ -900,11 +894,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
* 拒单的运单不可能处于运输中,收口拦不到合法操作,
* 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤
*/
private LambdaQueryWrapper<Waybill> scopedQuery(String phone, boolean includeRejected) {
String normalizedPhone = Func.toStr(phone, "").trim();
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery().and(w -> w
.eq(Waybill::getDriverPhone, normalizedPhone)
.or().apply("REPLACE(IFNULL(driver_phone,''),' ','') = {0}", normalizedPhone));
private LambdaQueryWrapper<Waybill> scopedQuery(Long driverId, boolean includeRejected) {
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.gt(Waybill::getDriverId, 0)
.eq(Waybill::getDriverId, driverId);
if (!includeRejected) {
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
@@ -926,69 +919,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 当前用户手机号:司机档案 mobile 优先,其次小程序用户账号。
* 当前登录司机:只按登录用户 ID 查司机档案,查不到即「无归属」。
* <p>
* 派单时运单 driver_phone 取自司机档案 mobile,故档案有手机号就以它为准。
*/
private String currentUserPhone() {
Driver driver = currentDriver();
if (driver != null && isMobileLike(driver.getMobile())) {
return driver.getMobile().trim();
}
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
return null;
}
R<MpUser> result = mpUserClient.userById(userId);
if (result != null && R.isSuccess(result) && result.getData() != null
&& isMobileLike(result.getData().getAccount())) {
return result.getData().getAccount().trim();
}
return null;
}
/**
* 当前登录司机:优先 userId,其次手机号(JWT 账号 / 小程序用户 account)匹配 driver.mobile
* 归属判据:driver_id = 当前登录人的司机档案 id;driver_phone 不参与判定。
*/
private Driver currentDriver() {
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
return null;
}
Driver driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
if (driver != null) {
return driver;
}
String phone = resolveSelfPhone(userId);
if (Func.isNotEmpty(phone)) {
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getMobile, phone)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 1"));
}
return null;
}
/**
* 解析当前小程序用户可用于匹配司机档案的手机号
*/
private String resolveSelfPhone(Long userId) {
String account = AuthUtil.getUserAccount();
if (isMobileLike(account)) {
return account.trim();
}
R<MpUser> result = mpUserClient.userById(userId);
if (result != null && R.isSuccess(result) && result.getData() != null
&& isMobileLike(result.getData().getAccount())) {
return result.getData().getAccount().trim();
}
return Func.isEmpty(account) ? null : account.trim();
}
private boolean isMobileLike(String value) {
return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$");
}
private List<String> splitPlates(String drivingVehicle) {
@@ -1308,8 +1250,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public void assertCurrentDriverOwnsWaybill(Long waybillId) {
// 归属口径与司机打卡、司机接单一致:按当前用户手机号匹配运单司机手机号
requireCurrentDriver();
// 归属口径与司机打卡、司机接单一致:运单 driver_id 必须是当前登录司机的档案(查不到档案即无归属)
loadDriverWaybill(waybillId);
}
@@ -41,22 +41,28 @@ import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.dto.ExceptionMaintenanceRecordRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.MaintenanceRecord;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.pojo.vo.MaintenanceRecordVO;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IMaintenanceRecordService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
@@ -92,6 +98,12 @@ public class ExceptionDisposalServiceImpl
/** 拒绝原因长度上限,与上报说明、异常原因同量级,且远小于跟进记录列宽 */
private static final int REJECT_REASON_MAX_LENGTH = 200;
/** 异常子流程录入的维修记录固定为车辆:入口只对「车辆故障维修」类异常开放 */
private static final String MAINTENANCE_VEHICLE_TYPE = "车辆";
/** 维修记录跟进说明前缀;金额必填,维修单位按实际填写拼在后面 */
private static final String MAINTENANCE_FOLLOW_PREFIX = "已录入维修记录:费用";
/** 路线与任务信息的列宽上限,与 PC 改路线同一口径 */
private static final int ROUTE_JSON_MAX_LENGTH = 8000;
private static final int TASK_INFO_JSON_MAX_LENGTH = 8000;
@@ -119,17 +131,20 @@ public class ExceptionDisposalServiceImpl
private final IDriverWaybillService driverWaybillService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
private final LoadingManageMapper loadingManageMapper;
private final IMaintenanceRecordService maintenanceRecordService;
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
IWaybillService waybillService,
IDriverWaybillService driverWaybillService,
MpUserDataScopeSupport mpUserDataScopeSupport,
LoadingManageMapper loadingManageMapper) {
LoadingManageMapper loadingManageMapper,
IMaintenanceRecordService maintenanceRecordService) {
this.followRecordMapper = followRecordMapper;
this.waybillService = waybillService;
this.driverWaybillService = driverWaybillService;
this.mpUserDataScopeSupport = mpUserDataScopeSupport;
this.loadingManageMapper = loadingManageMapper;
this.maintenanceRecordService = maintenanceRecordService;
}
@Override
@@ -146,6 +161,7 @@ public class ExceptionDisposalServiceImpl
ExceptionDisposalVO vo = convertVO(entity);
fillRouteNodes(vo, fillRouteAndCargo(vo, entity.getWaybillId()));
vo.setFollowRecords(followRecords(id));
vo.setMaintenanceRecord(maintenanceRecord(id));
return vo;
}
@@ -527,6 +543,76 @@ public class ExceptionDisposalServiceImpl
updateById(disposal);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void submitMaintenanceRecord(ExceptionMaintenanceRecordRequest request) {
if (request == null || request.getId() == null) {
throw new ServiceException("请选择需要录入维修记录的异常");
}
ExceptionDisposal disposal = getExisting(request.getId());
if (STATUS_REVOKED.equals(disposal.getDisposalStatus())) {
throw new ServiceException("已撤销的异常不允许录入维修记录");
}
if (Func.isBlank(disposal.getVehicleNo())) {
throw new ServiceException("异常未记录车牌号,无法录入维修记录");
}
// 唯一索引占位后无法再插入,先按 exception_id 拦住(含已逻辑删除的记录)
long recorded = maintenanceRecordService.count(Wrappers.<MaintenanceRecord>lambdaQuery()
.eq(MaintenanceRecord::getExceptionId, disposal.getId()));
if (recorded > 0) {
throw new ServiceException("该异常已录入维修记录");
}
MaintenanceRecord record = new MaintenanceRecord();
record.setExceptionId(disposal.getId());
record.setVehicleType(MAINTENANCE_VEHICLE_TYPE);
record.setVehicleNo(disposal.getVehicleNo());
record.setMaintenanceTime(request.getMaintenanceTime());
record.setLocation(request.getLocation());
record.setMaintainer(request.getMaintainer());
record.setCompany(request.getCompany());
record.setContact(request.getContact());
record.setReplacedPart(request.getReplacedPart());
record.setCost(request.getCost());
record.setMileage(request.getMileage());
record.setFactoryTime(request.getFactoryTime());
record.setAddress(request.getAddress());
record.setRemark(request.getRemark());
record.setAttachments(request.getAttachments());
// 复用维修记录自身的校验与落库(车船类型、车牌、费用、出厂时间等口径与 PC 一致)
try {
maintenanceRecordService.submit(record);
} catch (DuplicateKeyException exception) {
// 并发重复提交:前一条尚在事务中、计数看不见,唯一索引是最终防线
throw new ServiceException("该异常已录入维修记录");
}
String followContent = maintenanceFollowContent(record.getCost(), record.getCompany());
insertFollowRecord(disposal.getId(), followContent);
disposal.setLatestFollowContent(followContent);
// 录入维修记录不结案:已了结的异常只补记录与最近跟进,不回退状态
if (!WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
disposal.setDisposalStatus(STATUS_PROCESSING);
}
updateById(disposal);
}
/** 跟进说明:金额必填,维修单位为空时只写金额 */
private String maintenanceFollowContent(BigDecimal cost, String company) {
String amount = Func.isEmpty(cost) ? "" : cost.setScale(2, RoundingMode.HALF_UP).toPlainString();
String content = MAINTENANCE_FOLLOW_PREFIX + amount + "元";
return Func.isBlank(company) ? content : content + ",维修单位" + company.trim();
}
/** 异常关联的维修记录:一条异常至多一条,逻辑删除的视为未录入 */
private MaintenanceRecordVO maintenanceRecord(Long disposalId) {
MaintenanceRecord record = maintenanceRecordService.getOne(Wrappers.<MaintenanceRecord>lambdaQuery()
.eq(MaintenanceRecord::getExceptionId, disposalId)
.eq(MaintenanceRecord::getIsDeleted, 0)
.last("LIMIT 1"));
return record == null ? null : BeanUtil.copyProperties(record, MaintenanceRecordVO.class);
}
/** 已了结(已完成 / 已撤销)的异常不允许再次处置 */
private void assertNotSettled(ExceptionDisposal disposal) {
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
@@ -97,6 +97,7 @@ import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
@@ -154,6 +155,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
/** 金蝶应收单勾稽结算明细分批查询单次条数上限(in 条件分批,避免单次过滤条件过长) */
private static final int AR_RESULT_DETAIL_QUERY_BATCH = 500;
/** 金蝶冲销状态回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
private static final String WRITEOFF_SYNC_LOCK_PREFIX = "transport:formal-settlement:kingdee-writeoff-sync:";
private final FormalSettlementSourceMapper sourceMapper;
private final FormalSettlementSummaryFeeMapper summaryFeeMapper;
private final FormalSettlementPaymentMapper paymentMapper;
@@ -231,20 +235,24 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
/**
* 填充已指定应付聚合态:按本页结算单一次批量查认领关系与收票登记,避免 N+1。
* 填充已指定应付聚合态(仅付款结算单):按本页结算单一次批量查认领关系与收票登记,避免 N+1。
* 聚合规则:任一指定失败 → failed;全部已指定 → assigned;其余(有认领但未指定/未推送)→ unspecified;
* 无认领收票或全部认领票无效不填充,前端展示"-"
*
* @param rows 本页 VO 列表
*/
private void fillKingdeeAssignStatus(List<FormalSettlementVO> rows) {
if (Func.isEmpty(rows)) {
// 已指定应付仅付款结算单(先票后款 A302-6)有,收款结算单不参与聚合,避免无意义查询与字段下发
List<FormalSettlementVO> payableRows = rows.stream()
.filter(row -> "payable".equals(row.getSettlementType()))
.toList();
if (Func.isEmpty(payableRows)) {
return;
}
List<InvoiceReceiptSettlement> relations = invoiceReceiptSettlementMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.in(InvoiceReceiptSettlement::getFormalSettlementId,
rows.stream().map(FormalSettlementVO::getId).filter(Objects::nonNull).toList()));
payableRows.stream().map(FormalSettlementVO::getId).filter(Objects::nonNull).toList()));
if (relations.isEmpty()) {
return;
}
@@ -263,7 +271,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
receiptsBySettlement.computeIfAbsent(relation.getFormalSettlementId(), key -> new ArrayList<>())
.add(receipt);
}
for (FormalSettlementVO vo : rows) {
for (FormalSettlementVO vo : payableRows) {
List<InvoiceReceipt> claimed = receiptsBySettlement.get(vo.getId());
if (Func.isEmpty(claimed)) {
continue;
@@ -1342,6 +1350,113 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
return summary;
}
@Override
public String syncKingdeeWriteOffResult() {
return kingdeeSyncLockSupport.runSingleFlight(WRITEOFF_SYNC_LOCK_PREFIX, bfiProperties.getArResult().getLockLeaseSeconds(),
"金蝶冲销状态回写", this::executeWriteOffSync);
}
@Override
public void refreshKingdeeWriteOff(Long settlementId, boolean writtenOff) {
String status = writtenOff ? KingdeeBfiConstant.KINGDEE_WRITEOFF_WRITTEN : KingdeeBfiConstant.KINGDEE_WRITEOFF_UNWRITTEN;
FormalSettlement settlement = getById(settlementId);
// 状态未变化不落库,避免预检反复点击重复更新
if (settlement == null || status.equals(settlement.getKingdeeWriteOffStatus())) {
return;
}
FormalSettlement patch = new FormalSettlement();
patch.setId(settlementId);
patch.setKingdeeWriteOffStatus(status);
patch.setKingdeeWriteOffTime(LocalDateTime.now());
updateById(patch);
log.info("正式结算单[{}]金蝶冲销状态回写:{}", settlement.getFormalSettlementNo(), status);
}
/**
* 金蝶冲销状态回写主体:A102-4/A302-4 批量查询已同步结算单的 hadwrittenoff(部分冲销视同已冲销)
* 并回写冲销状态,由 {@link #syncKingdeeWriteOffResult()} 在分布式锁保护下调用。
* <p>分批与事务口径同 {@link #executeArResultSync()}:结算明细分批 in 查询、远程整批查询消除逐单 N+1,
* 仅回写状态有变化的结算单并 updateBatchById 批量落库;整轮任务不包外层事务,按防重键幂等可重跑。
*
* @return 执行摘要
*/
private String executeWriteOffSync() {
long startMillis = System.currentTimeMillis();
// 已同步金蝶的结算单才在金蝶存在旧应付/应收单,未同步/生成失败不查询(前端展示 '-');
// 已冲销为终态不再复查,仅扫未冲销单据(存量 NULL 由 06 脚本回填默认未冲销)
List<FormalSettlement> settlements = list(Wrappers.<FormalSettlement>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<Long> settlementIds = settlements.stream().map(FormalSettlement::getId).toList();
Map<Long, List<FormalSettlementDetail>> detailsBySettlement = new HashMap<>();
kingdeeApBillSyncSupport.batchForeach(settlementIds, AR_RESULT_DETAIL_QUERY_BATCH, batch ->
detailsBySettlement.putAll(detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.in(FormalSettlementDetail::getFormalSettlementId, batch)
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo)).stream()
.collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId))));
Map<String, FormalSettlement> apSettlementBySrcCode = new LinkedHashMap<>();
Map<String, FormalSettlement> arSettlementBySrcCode = new LinkedHashMap<>();
for (FormalSettlement settlement : settlements) {
List<FormalSettlementDetail> 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<String, Boolean> apWriteOffFlags = kingdeeApBillSyncSupport.queryApBillWriteOffFlags(new ArrayList<>(apSettlementBySrcCode.keySet()));
Map<String, Boolean> arWriteOffFlags = kingdeeArBillSyncSupport.queryArBillWriteOffFlags(new ArrayList<>(arSettlementBySrcCode.keySet()));
// 金蝶未返回的键按未冲销处理,返回数与组装数差异即金蝶侧查不到单据的规模,留痕便于对账
log.info("金蝶冲销状态批量查询完成:应付键返回{}个、应收键返回{}个,未返回键按未冲销处理",
apWriteOffFlags.size(), arWriteOffFlags.size());
List<FormalSettlement> 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<FormalSettlement> 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) {
@@ -2105,7 +2220,29 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
try {
Object parsed = JsonUtil.parse(json, Map.class);
if (parsed instanceof Map<?, ?> map) {
map.forEach((key, value) -> result.put(String.valueOf(key), decimal(value)));
BigDecimal otherFeeAmount = BigDecimal.ZERO;
for (Map.Entry<?, ?> entry : map.entrySet()) {
String key = String.valueOf(entry.getKey());
Object value = entry.getValue();
if (Func.isBlank(key)) {
continue;
}
if ("otherFreightAmount".equals(key) || "otherFeeTotal".equals(key) || "otherAmount".equals(key)) {
otherFeeAmount = otherFeeAmount.add(decimal(value));
continue;
}
if (TransportBusinessSupport.isFreightJsonMetaKey(key)) {
continue;
}
if (value instanceof Map<?, ?> || value instanceof java.util.Collection<?>) {
continue;
}
result.put(key, decimal(value));
}
if (otherFeeAmount.signum() > 0 && result.keySet().stream()
.noneMatch(name -> name != null && (name.contains("其它费") || name.contains("其他费")))) {
result.put("其它费用", otherFeeAmount);
}
}
} catch (RuntimeException ignored) {
// 兼容历史费用JSON,不影响正式结算保存。
@@ -43,6 +43,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveFreightDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiInvoiceVO;
@@ -733,9 +734,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
public String doSyncKingdee(Long id) {
InvoiceApplication entity = lockedExisting(id);
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
|| KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())) {
// 已开票为终态幂等返回;已同步且星瀚内码齐备幂等返回——
// 已同步但内码缺失(历史推送未落内码)放行,走下方 A303-4 勾稽反查补内码,供 A902 附件上传定位金蝶单据
if (KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())
|| (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
&& Func.isNotBlank(entity.getKingdeeBillId()))) {
return entity.getKingdeeBillNo();
}
if (Func.isBlank(entity.getApplicationNo())) throw new ServiceException("推送金蝶开票申请失败:开票申请单号为空");
@@ -782,31 +785,40 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
// 每张发票的金蝶业务单据编号(A303-1 billno 候选键,须唯一):单张=开票申请单号(兼容存量),多张=开票申请单号-张次序号
List<String> sheetBillNos = sheets.stream().map(sheet -> this.sheetBillNo(entity, sheet, sheets.size())).toList();
// A303-4 幂等预检:按全部候选 billno 查询金蝶是否已生成开票申请单(防上次推送成功但本地回滚的断链重推),
// 已生成的张次幂等跳过,未生成的张次重新推送
// 实测返回 billno 与推送值完全一致,按 billno 精确匹配;部分张次已生成时不可整单提前返回,须断点续推剩余张次
BfiInvoiceApplyQueryDTO idempotentQuery = new BfiInvoiceApplyQueryDTO();
idempotentQuery.setPageNo(1);
idempotentQuery.setPageSize(Math.max(sheets.size(), 10));
idempotentQuery.setBillno(sheetBillNos);
Set<String> existSheetBillNos = new LinkedHashSet<>();
Map<String, BfiInvoiceApplyVO> existApplyByBillNo = new LinkedHashMap<>();
BfiPageVO<BfiInvoiceApplyVO> existPage = bfiService.queryInvoiceApply(idempotentQuery);
if (existPage != null && Func.isNotEmpty(existPage.getRows())) {
for (BfiInvoiceApplyVO existApply : existPage.getRows()) {
if (existApply != null && Func.isNotBlank(existApply.getBillno())
&& sheetBillNos.contains(existApply.getBillno())) {
existSheetBillNos.add(existApply.getBillno());
// 金蝶已生成:直接回填视为同步成功(勾稽纠偏),星瀚内码一并回填(A902 附件上传定位用)
log.info("开票申请[{}]经 A303-4 查得金蝶开票申请单已生成(单据编号[{}],星瀚内码[{}]),跳过重复推送直接回填",
existApplyByBillNo.put(existApply.getBillno(), existApply);
log.info("开票申请[{}]经 A303-4 查得金蝶开票申请单已生成(单据编号[{}],星瀚内码[{}]),幂等跳过推送",
entity.getApplicationNo(), existApply.getBillno(), existApply.getId());
entity.setKingdeeBillNo(existApply.getBillno());
entity.setKingdeeBillId(existApply.getId() == null ? null : String.valueOf(existApply.getId()));
entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
entity.setSyncedTime(LocalDateTime.now());
updateById(entity);
record(entity.getId(), "sync", "同步金蝶(勾稽回填)", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, existApply.getBillno());
return existApply.getBillno();
}
}
}
// 全部张次金蝶均已生成:直接勾稽回填视为同步成功(星瀚内码一并回填,A902 附件上传定位用)
if (!existApplyByBillNo.isEmpty() && existApplyByBillNo.keySet().containsAll(sheetBillNos)) {
entity.setKingdeeBillNo(String.join(",", existApplyByBillNo.keySet()));
entity.setKingdeeBillId(existApplyByBillNo.values().stream()
.map(BfiInvoiceApplyVO::getId).filter(Objects::nonNull).findFirst().map(String::valueOf).orElse(null));
entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
entity.setSyncedTime(LocalDateTime.now());
updateById(entity);
record(entity.getId(), "sync", "同步金蝶(勾稽回填)", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, entity.getKingdeeBillNo());
return entity.getKingdeeBillNo();
}
// 部分张次已生成:先回填星瀚内码,已生成张次在下方逐张循环幂等跳过,未生成张次继续推送
Set<String> existSheetBillNos = existApplyByBillNo.keySet();
if (Func.isBlank(entity.getKingdeeBillId())) {
entity.setKingdeeBillId(existApplyByBillNo.values().stream()
.map(BfiInvoiceApplyVO::getId).filter(Objects::nonNull).findFirst().map(String::valueOf).orElse(null));
}
// 组装 A303-1 开票申请单(字段映射见设计文档 6.3/6.4)
if (Func.isBlank(entity.getIssuerName())) {
throw new ServiceException("推送金蝶开票申请失败:开票单位(销方)为空");
@@ -833,10 +845,16 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空");
if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空");
// 金蝶组织编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码;申请部门编码:项目业务部门自身金蝶编码(与付款申请单部门.编码同源,共用上下文缓存)
// 金蝶组织/申请部门:按首张关联结算单合同的平台公司 + 业务部门配置取数
KingdeeSyncContext kingdeeContext = KingdeeSyncContext.empty();
String kingdeeOrgNumber = kingdeeApBillSyncSupport.kingdeeOrgNumber(entity.getProjectId(), kingdeeContext);
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(entity.getProjectId(), kingdeeContext);
FormalSettlement firstSettlement = formalSettlementMapper.selectById(relations.get(0).getFormalSettlementId());
if (firstSettlement == null || Objects.equals(firstSettlement.getIsDeleted(), 1)) {
throw new ServiceException("推送金蝶开票申请失败:正式结算单["
+ Func.toStr(relations.get(0).getFormalSettlementNo()) + "]不存在");
}
ContractManage contract = kingdeeApBillSyncSupport.requireContract(firstSettlement.getContractId(), kingdeeContext);
String kingdeeOrgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract, kingdeeContext);
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, entity.getProjectId(), kingdeeContext);
CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId());
if (customer == null || (Func.isBlank(customer.getSupplierCode()) && Func.isBlank(customer.getCustomerCode()))) {
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护供应商编码或客商编码,请补充后重试");
@@ -873,7 +891,12 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult,
"推送金蝶开票申请发票张[" + Func.toStr(sheet.getSheetNo()) + "]");
String kingdeeNo = saveResult.getResult().get(0).getNumber();
BfiBillSaveResultItemVO saveItem = saveResult.getResult().get(0);
String kingdeeNo = saveItem.getNumber();
// 星瀚内码随 A303-1 保存结果回填(A902 附件上传定位金蝶单据用),缺失时由 A303-4 勾稽反查兜底
if (Func.isBlank(entity.getKingdeeBillId()) && saveItem.getId() != null) {
entity.setKingdeeBillId(String.valueOf(saveItem.getId()));
}
sheet.setKingdeeBillNo(kingdeeNo);
savedBillNos.add(kingdeeNo);
pushedNew = true;
@@ -924,9 +947,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items) {
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
// 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码
// 【必填】组织.编码:合同平台公司金蝶组织编码
param.setOrgidNumber(orgNumber);
// 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源)
// 【必填】申请部门.编码:合同平台公司下业务部门配置的金蝶部门代码
param.setAwrpOrgfieldNumber(deptNumber);
// 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号)
param.setBillno(billNo);
@@ -70,6 +70,7 @@ import org.springblade.transport.mapper.KingdeeInvoicePoolMapper;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.CustomerContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
@@ -691,7 +692,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过";
}
// 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写)
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.blockedReason());
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(),
plan.apBillSrcCode(), plan.blockedReason());
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={}", receipt.getInvoiceNo(), receipt.getId(),
receipt.getKingdeePushStatus());
if (Func.isNotBlank(plan.blockedReason())) {
@@ -733,8 +735,12 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
fetchMirrorItemsFromPool(plan.mirror());
}
log.info("收票登记[{}]开始 A303-2 推送金蝶收票单", receipt.getInvoiceNo());
ContractManage contract = kingdeeApBillSyncSupport.requireContract(plan.settlement().getContractId(), context);
// 明细分录与应付单 A102-2 同源同口径:复用同一结算明细解析结果,结算明细行+结算合计费用项各一条
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups =
kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context);
KingdeeReceivedInvoiceSyncSupport.ReceivedInvoicePushOutcome pushOutcome =
kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), context);
kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context);
billNo = pushOutcome.billNo();
billPkId = pushOutcome.billId();
log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}],星瀚内码={}", receipt.getInvoiceNo(), billNo, billPkId);
@@ -814,23 +820,23 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt, Map<Long, KingdeeInvoicePool> mirrorById) {
String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED);
if (!TransportBusiConstant.APPROVED.equals(receipt.getApprovalStatus())) {
return new ReceivedInvoicePushPlan(receipt, null, "仅审批通过的收票登记允许推送金蝶");
return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "仅审批通过的收票登记允许推送金蝶");
}
if (KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) {
return new ReceivedInvoicePushPlan(receipt, null, "收票单已指定应付,无需重复推送");
return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票单已指定应付,无需重复推送");
}
KingdeeInvoicePool mirror = receipt.getKingdeeInvoicePoolId() == null ? null
: mirrorById.get(receipt.getKingdeeInvoicePoolId());
if (mirror == null) {
return new ReceivedInvoicePushPlan(receipt, null, "收票登记未绑定票池镜像,请先完成同步绑定");
return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票登记未绑定票池镜像,请先完成同步绑定");
}
if ("2".equals(mirror.getCheckStatus())) {
return new ReceivedInvoicePushPlan(receipt, mirror, "金蝶发票查验不通过,请先人工核实票池发票");
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "金蝶发票查验不通过,请先人工核实票池发票");
}
if (receipt.getDeptId() == null) {
return new ReceivedInvoicePushPlan(receipt, mirror, "收票登记未选择结算组织,无法确定金蝶结算组织编码");
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "收票登记未选择结算组织,无法确定金蝶结算组织编码");
}
return new ReceivedInvoicePushPlan(receipt, mirror, null);
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, null);
}
/** A801-3 幂等预检批量查询:未终态计划按购方单位分组,窗口内翻页匹配防重键 */
@@ -897,14 +903,17 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 收票单推送预检计划(blockedReason 非空表示前置拦截,不进入推送流程)。
* 先票后款链路:分摊结算单/防重键不再参与推送前置,指定应付由结算单侧驱动
* 收票单推送预检计划(blockedReason 非空表示前置拦截,不进入推送流程)
*
* @param receipt 收票登记
* @param mirror 绑定的票池镜像(可为 null)
* @param settlement 唯一分摊的应付正式结算单(可为 null)
* @param details 结算单结算明细(防重键组装与 A303-2 分录解析共用,可为 null)
* @param apBillSrcCode 财务应付单防重键(无法组装时为 null)
* @param blockedReason 前置拦截原因
*/
private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror, String blockedReason) {
private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror,
FormalSettlement settlement, List<FormalSettlementDetail> details, String apBillSrcCode, String blockedReason) {
}
// ==================== 先票后款:发票明细查询 / 认领候选 / 结算侧关系同步与指定应付 ====================
@@ -4,6 +4,7 @@
*/
package org.springblade.transport.service.impl;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -11,6 +12,7 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
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.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
@@ -29,9 +31,11 @@ import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.service.IContractManageService;
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;
@@ -89,6 +93,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
@Resource
private IProcessConfigService processConfigService;
@Resource
private IDriverService driverService;
@Override
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
@@ -112,23 +119,27 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
return List.of();
}
List<ContractManage> contracts = availableCarrierContracts(projectIds);
Set<String> allowedCarrierNames = null;
if (Func.isNotEmpty(waybillIds)) {
List<Waybill> waybills = loadWaybills(waybillIds);
Set<String> allowedPartyBs = intersectCarrierPartyBs(waybills, contracts);
if (allowedPartyBs.isEmpty()) {
allowedCarrierNames = intersectCarrierPartyBs(waybills, contracts);
if (allowedCarrierNames.isEmpty()) {
return List.of();
}
Set<String> 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<LoadingCarrierContractVO> options = ContractPartySupport.expandCarrierOptions(contracts);
if (allowedCarrierNames == null) {
return options;
}
Set<String> allowedNames = allowedCarrierNames;
return options.stream()
.filter(option -> allowedNames.contains(normalizePartyName(option.getCarrierName())))
.toList();
}
@Override
@@ -139,16 +150,17 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
List<Long> oldWaybillIds = new ArrayList<>();
// 新建暂存为草稿;编辑已有单据时保持原业务状态(如待执行),避免被改回草稿
String businessStatus = STATUS_DRAFT;
if (Func.isNotEmpty(loadingManage.getId())) {
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
if (Func.isNotEmpty(oldRecord.getBusinessStatus())) {
businessStatus = oldRecord.getBusinessStatus();
LoadingManage stored = Func.isEmpty(loadingManage.getId()) ? null : loadEditable(loadingManage.getId(), true);
if (stored != null) {
oldWaybillIds = waybillIds(stored.getWaybillIdsJson());
loadingManage.setLoadingNo(stored.getLoadingNo());
loadingManage.setDeptId(stored.getDeptId());
loadingManage.setDeptName(stored.getDeptName());
if (Func.isNotEmpty(stored.getBusinessStatus())) {
businessStatus = stored.getBusinessStatus();
}
}
applyDriverOwnership(loadingManage, stored);
prepareCreateOrUpdate(loadingManage);
loadingManage.setBusinessStatus(businessStatus);
if (Func.isEmpty(loadingManage.getLoadingNo())) {
@@ -200,6 +212,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
}
applyDriverOwnership(loadingManage, oldRecord);
prepareCreateOrUpdate(loadingManage);
if (Func.isEmpty(loadingManage.getLoadingNo())) {
loadingManage.setLoadingNo(nextCode());
@@ -319,6 +332,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setCargoName(first.getCargoName());
loadingManage.setVehicleNo(first.getVehicleNo());
loadingManage.setTrailerVehicleNo(first.getTrailerVehicleNo());
loadingManage.setDriverId(first.getDriverId());
loadingManage.setDriverName(first.getDriverName());
loadingManage.setDriverPhone(first.getDriverPhone());
loadingManage.setEscortName(first.getEscortName());
@@ -478,6 +492,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
target.setTrailerVehicleNo(source.getTrailerVehicleNo());
target.setDriverName(source.getDriverName());
target.setDriverPhone(source.getDriverPhone());
target.setDriverId(source.getDriverId());
target.setEscortName(source.getEscortName());
target.setEscortPhone(source.getEscortPhone());
target.setCarrierType(source.getCarrierType());
@@ -505,6 +520,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
target.setBusinessStatus(STATUS_DRAFT);
target.setLoadingNo(nextCode());
prepare(target);
applyDriverOwnership(target, null);
validate(target, true);
save(target);
return detail(target.getId());
@@ -521,6 +537,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
throw new ServiceException("仅司机拒绝接单后允许重新派单");
}
prepare(loadingManage);
applyDriverOwnership(loadingManage, oldRecord);
validate(loadingManage, false);
List<Long> oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
@@ -662,7 +679,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
TransportBusinessSupport.validateAllDept(loadingManage.getAllDept(), "配载管理");
LambdaQueryWrapper<LoadingManage> queryWrapper = Wrappers.<LoadingManage>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());
}
@@ -781,6 +802,32 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
}
/**
* 写司机归属:档案 ID 只在需要时解析,已落在单据上的归属不会被无关保存抹掉。
* <p>
* 判定顺序:调用方带了正数档案 ID 就用它;更新时 driver_phone 与库中一致且库中 driver_id 非空就保留库中值;
* 其余情况(新建、换号、原本无主)按手机号在租户内唯一匹配司机档案,匹配不到落 null 并留 WARN,不阻断保存。
*/
private void applyDriverOwnership(LoadingManage loadingManage, LoadingManage stored) {
Long driverId = loadingManage.getDriverId();
if (driverId != null && driverId > 0) {
return;
}
if (stored != null && Func.isNotEmpty(stored.getDriverId())
&& Objects.equals(TransportBusinessSupport.trimToNull(stored.getDriverPhone()), loadingManage.getDriverPhone())) {
loadingManage.setDriverId(stored.getDriverId());
return;
}
String tenantId = Func.isNotEmpty(loadingManage.getTenantId()) ? loadingManage.getTenantId() : AuthUtil.getTenantId();
Long resolvedDriverId = driverService.resolveDriverIdByMobile(tenantId, loadingManage.getDriverPhone());
loadingManage.setDriverId(resolvedDriverId);
if (resolvedDriverId == null) {
log.warn("配载单 {} 的司机手机号未唯一匹配司机档案,driver_id 落空。租户={} 手机号={}",
Func.isEmpty(loadingManage.getLoadingNo()) ? "(未编号)" : loadingManage.getLoadingNo(),
tenantId, DesensitizedUtil.mobilePhone(loadingManage.getDriverPhone()));
}
}
private void validate(LoadingManage loadingManage, boolean draftMode) {
TransportBusinessSupport.validateRequired(loadingManage.getLoadingSubNos(), "配载子单号不能为空");
TransportBusinessSupport.validateRequired(loadingManage.getTransportType(), "运输类型不能为空");
@@ -907,13 +954,17 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
/**
* 同一配载单子运单除同项目外,还必须存在共同的承运商合同乙方(按客户合同甲方匹配后取交集)。
* 同一配载单子运单除同项目外,还必须存在共同的承运商合同乙方(按客户合同乙方=承运商合同甲方匹配后取交集)。
*/
private void validateCompatibleCarrierPartyB(List<Waybill> waybills) {
if (Func.isEmpty(waybills) || waybills.size() < 2) {
return;
}
List<ContractManage> contracts = availableCarrierContracts(projectIdsOf(waybills));
// 项目下没有任何可用承运商合同时不存在乙方交集约束;承运类型为「承运商」由 validateCarrierContract 要求选择合同
if (Func.isEmpty(contracts)) {
return;
}
Set<String> allowedPartyBs = intersectCarrierPartyBs(waybills, contracts);
if (allowedPartyBs.isEmpty()) {
throw new ServiceException("所选运单在同一项目下没有共同的承运商合同乙方,不能配载到同一配载单");
@@ -927,6 +978,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> 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);
}
@@ -937,6 +990,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
throw new ServiceException("请选择承运商合同");
}
loadingManage.setCarrierName(null);
loadingManage.setCarrierPartyId(null);
loadingManage.setCarrierPartyRole(null);
return;
}
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
@@ -957,19 +1012,35 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
? "所选承运商合同不属于本项目"
: "所选承运商合同不属于运单" + waybillNos + "的项目");
}
Map<String, Object> 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<ContractManage> projectContracts = availableCarrierContracts(projectIdsOf(childWaybills));
Set<String> 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) {
@@ -1008,14 +1079,13 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
/**
* 各子运单按「客户合同甲方 = 承运商合同甲方」匹配可用承运商合同,再对乙方名称取交集。
* 例:子单1可选 A/B/C,子单2可选 B,则交集为 B。
* 各子运单按「客户合同乙方 ≈ 承运商合同甲方」匹配可用承运商合同,再对乙/丙方客商名称取交集。
*/
private Set<String> intersectCarrierPartyBs(List<Waybill> waybills, List<ContractManage> projectContracts) {
Set<String> intersection = null;
Map<Long, String> customerPartyAMap = loadCustomerContractPartyAMap(waybills);
Map<Long, Set<String>> customerPartyBMap = loadCustomerContractPartyBMap(waybills);
for (Waybill waybill : waybills) {
Set<String> available = availableCarrierPartyBs(waybill, projectContracts, customerPartyAMap);
Set<String> available = availableCarrierPartyBs(waybill, projectContracts, customerPartyBMap);
if (intersection == null) {
intersection = new LinkedHashSet<>(available);
} else {
@@ -1029,21 +1099,23 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
private Set<String> availableCarrierPartyBs(Waybill waybill, List<ContractManage> projectContracts,
Map<Long, String> customerPartyAMap) {
Map<Long, Set<String>> customerPartyBMap) {
if (waybill == null || Func.isEmpty(projectContracts)) {
return Set.of();
}
String partyA = customerPartyAMap.get(waybill.getContractId());
Long contractId = waybill.getContractId();
Set<String> 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(partyA) || Objects.equals(normalizePartyName(contract.getPartyA()), partyA))
.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<Long, String> loadCustomerContractPartyAMap(List<Waybill> waybills) {
private Map<Long, Set<String>> loadCustomerContractPartyBMap(List<Waybill> waybills) {
List<Long> contractIds = waybills.stream()
.map(Waybill::getContractId)
.filter(Objects::nonNull)
@@ -1057,11 +1129,55 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.in(ContractManage::getId, contractIds))
.stream()
.collect(Collectors.toMap(ContractManage::getId,
contract -> normalizePartyName(contract.getPartyA()),
this::partyBNames,
(left, right) -> left,
LinkedHashMap::new));
}
private Set<String> partyANames(ContractManage contract) {
Set<String> 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<String> partyBNames(ContractManage contract) {
Set<String> 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<String> left, Set<String> 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();
}
@@ -1150,6 +1266,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
.set(Waybill::getCarrierContractId, loadingManage.getCarrierContractId())
.set(Waybill::getCarrierName, loadingManage.getCarrierName())
.set(Waybill::getDriverId, loadingManage.getDriverId())
.set(Waybill::getDriverName, loadingManage.getDriverName())
.set(Waybill::getDriverPhone, loadingManage.getDriverPhone())
.set(Waybill::getVehicleNo, loadingManage.getVehicleNo())
@@ -1264,6 +1381,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
.set(Waybill::getCarrierContractId, loadingManage.getCarrierContractId())
.set(Waybill::getCarrierName, loadingManage.getCarrierName())
.set(Waybill::getDriverId, loadingManage.getDriverId())
.set(Waybill::getDriverName, loadingManage.getDriverName())
.set(Waybill::getDriverPhone, loadingManage.getDriverPhone())
.set(Waybill::getVehicleNo, loadingManage.getVehicleNo())
@@ -27,6 +27,7 @@ package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.BeanUtil;
@@ -81,6 +82,18 @@ public class MaintenanceRecordServiceImpl extends BaseServiceImpl<MaintenanceRec
return saveOrUpdate(maintenanceRecord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteLogic(List<Long> ids) {
if (Func.isNotEmpty(ids)) {
// 唯一索引不因逻辑删除释放:不清空 exception_id,异常侧会永远显示「已录入」却查不到记录
update(Wrappers.<MaintenanceRecord>lambdaUpdate()
.set(MaintenanceRecord::getExceptionId, null)
.in(MaintenanceRecord::getId, ids));
}
return super.deleteLogic(ids);
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<MaintenanceRecordExcel> importMaintenanceRecord(List<MaintenanceRecordExcel> data) {
@@ -0,0 +1,102 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import lombok.AllArgsConstructor;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.transport.mapper.ManageArchiveDeptMapper;
import org.springblade.transport.pojo.vo.ArchiveManageStatsVO;
import org.springblade.transport.pojo.vo.DeptTreeNodeVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.pojo.vo.TransportVehicleVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IManageArchiveService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 调度端档案统计
*/
@Service
@AllArgsConstructor
public class ManageArchiveServiceImpl implements IManageArchiveService {
/**
* 组织选择器不提供这一支:web 端司机/车辆表单同样剔除「外部组织」。
*/
private static final String EXTERNAL_ORG_NAME = "外部组织";
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final ManageArchiveDeptMapper manageArchiveDeptMapper;
@Override
public ArchiveManageStatsVO stats() {
// 复用管理端同一套统计:today / warningDate 由各 service 的 prepareQuery 自己填,
// 不传筛选条件即全量计数(与小程序侧 searchDrivers 同口径,未按组织收口)
ArchiveManageStatsVO vo = new ArchiveManageStatsVO();
vo.setDriver(driverService.expiryStat(new DriverVO()));
vo.setVehicle(transportVehicleService.expiryStat(new TransportVehicleVO()));
return vo;
}
@Override
public List<DeptTreeNodeVO> deptTree() {
List<Dept> rows = manageArchiveDeptMapper.selectEnabledByTenant(AuthUtil.getTenantId());
Map<Long, DeptTreeNodeVO> nodes = new LinkedHashMap<>();
for (Dept row : rows) {
DeptTreeNodeVO node = new DeptTreeNodeVO();
node.setId(row.getId());
node.setName(Func.toStr(row.getDeptName()));
nodes.put(row.getId(), node);
}
List<DeptTreeNodeVO> roots = new ArrayList<>();
for (Dept row : rows) {
DeptTreeNodeVO node = nodes.get(row.getId());
DeptTreeNodeVO parent = row.getParentId() == null ? null : nodes.get(row.getParentId());
// 父部门停用或删除时该节点作为根保留,避免整支在树上消失
if (parent == null) {
roots.add(node);
} else {
parent.getChildren().add(node);
}
}
removeExternalOrg(roots);
return roots;
}
private static void removeExternalOrg(List<DeptTreeNodeVO> nodes) {
nodes.removeIf(node -> EXTERNAL_ORG_NAME.equals(Func.toStr(node.getName()).trim()));
for (DeptTreeNodeVO node : nodes) {
removeExternalOrg(node.getChildren());
}
}
}
@@ -38,6 +38,7 @@ import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.RiskDisposal;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeBadgesVO;
@@ -58,6 +59,7 @@ import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.MpUserDataScopeSupport;
@@ -122,6 +124,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final String TRANSPORT_COMMON = "common";
private static final String TRANSPORT_LOAD = "load";
/** 启用状态(base_entity.status):停用的司机不进派单检索、停用的车不进选车列表 */
private static final int ENABLED_STATUS = 1;
private static final int FEED_LIMIT = 20;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
@@ -141,6 +146,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private final IDriverWaybillService driverWaybillService;
private final ITripQueryService tripQueryService;
private final ILoadingManageService loadingManageService;
private final ITransportVehicleService transportVehicleService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
@Override
@@ -164,7 +170,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
home.setStats(stats());
AdminHomeBadgesVO badges = new AdminHomeBadgesVO();
badges.setException(home.getStats().getException());
// 快捷入口的红点是待办数,只数待处理:处置中的已经在跟,不再提醒
badges.setException(countPendingExceptions());
badges.setRisk(countPendingRisks());
badges.setPendingReassign(countPendingReassignments());
home.setBadges(badges);
@@ -859,6 +866,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
// 停用司机不可派单:不进调度检索。停用只拦派单,不拦登录(司机仍能进小程序看自己的运单)
.eq(Driver::getStatus, ENABLED_STATUS)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 20");
if (Func.isNotBlank(key)) {
@@ -877,27 +886,63 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
@Override
public List<AdminVehicleOptionVO> searchVehicles(String keyword) {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.isNotNull(Driver::getDrivingVehicle)
.ne(Driver::getDrivingVehicle, "")
.orderByDesc(Driver::getUpdateTime)
// 权威来源是车辆档案表:此前查的是司机表的 driving_vehicle 文本,既看不见没有司机绑定的车,
// 也无从判断「是否停用」——停用的车会照样出现在派单选车列表里。
LambdaQueryWrapper<TransportVehicle> wrapper = Wrappers.<TransportVehicle>lambdaQuery()
.eq(TransportVehicle::getIsDeleted, 0)
.eq(TransportVehicle::getStatus, ENABLED_STATUS)
.orderByDesc(TransportVehicle::getUpdateTime)
.last("LIMIT 30");
if (Func.isNotBlank(key)) {
wrapper.like(Driver::getDrivingVehicle, key);
wrapper.like(TransportVehicle::getPlateNo, key);
}
java.util.LinkedHashMap<String, AdminVehicleOptionVO> map = new java.util.LinkedHashMap<>();
for (Driver d : driverService.list(wrapper)) {
String plate = Func.toStr(d.getDrivingVehicle(), "").trim();
if (Func.isBlank(plate) || map.containsKey(plate)) {
List<TransportVehicle> vehicles = transportVehicleService.list(wrapper);
if (Func.isEmpty(vehicles)) {
return List.of();
}
Map<String, String> driverNameByPlate = driverNamesByPlate(vehicles);
List<AdminVehicleOptionVO> options = new ArrayList<>();
for (TransportVehicle vehicle : vehicles) {
String plate = Func.toStr(vehicle.getPlateNo(), "").trim();
if (Func.isBlank(plate)) {
continue;
}
AdminVehicleOptionVO vo = new AdminVehicleOptionVO();
vo.setVehicleNo(plate);
vo.setDriverName(Func.toStr(d.getDriverName(), ""));
map.put(plate, vo);
vo.setDriverName(Func.toStr(driverNameByPlate.get(plate.toUpperCase()), ""));
options.add(vo);
}
return new java.util.ArrayList<>(map.values());
return options;
}
/**
* 车牌 → 绑定司机姓名
* <p>
* 绑定关系是司机侧按车牌文本声明的({@code driver.driving_vehicle}),车辆行不存司机,
* 所以选车列表要回到司机表补名字;多车牌串的司机匹配不到,这条既有缺陷不在本次改动内。
*/
private Map<String, String> driverNamesByPlate(List<TransportVehicle> vehicles) {
List<String> plates = vehicles.stream()
.map(TransportVehicle::getPlateNo)
.filter(Func::isNotEmpty)
.map(String::toUpperCase)
.distinct()
.toList();
if (plates.isEmpty()) {
return Map.of();
}
List<Driver> drivers = driverService.list(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.in(Driver::getDrivingVehicle, plates)
.select(Driver::getDriverName, Driver::getDrivingVehicle));
Map<String, String> names = new LinkedHashMap<>();
for (Driver driver : drivers) {
if (Func.isEmpty(driver.getDrivingVehicle()) || Func.isEmpty(driver.getDriverName())) {
continue;
}
names.putIfAbsent(driver.getDrivingVehicle().toUpperCase(), driver.getDriverName());
}
return names;
}
/** 运输方式字典值 → 展示文案 */
@@ -1169,6 +1214,14 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return exceptionDisposalService.count(wrapper);
}
/** 快捷入口「异常处置」红点:只数待处理(pending),处置中已不算待办 */
private long countPendingExceptions() {
LambdaQueryWrapper<ExceptionDisposal> wrapper = Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING);
applyExceptionDeptScope(wrapper);
return exceptionDisposalService.count(wrapper);
}
private long countPendingRisks() {
LambdaQueryWrapper<RiskDisposal> wrapper = Wrappers.<RiskDisposal>lambdaQuery()
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING);
@@ -4,8 +4,10 @@
*/
package org.springblade.transport.service.impl;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.jackson.JsonUtil;
@@ -24,9 +26,11 @@ import org.springblade.transport.pojo.vo.MasterOrderCarrierVO;
import org.springblade.transport.pojo.vo.MasterOrderVO;
import org.springblade.transport.service.IMasterOrderService;
import org.springblade.transport.service.IContractManageService;
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;
@@ -50,6 +54,7 @@ import java.util.Objects;
* @author Chill
*/
@Service
@Slf4j
public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, MasterOrder> implements IMasterOrderService {
private static final String CARRIER_SELF = "自运";
@@ -58,13 +63,15 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
private final ITransportPlanService transportPlanService;
private final IProjectApplyService projectApplyService;
private final IContractManageService contractManageService;
private final IDriverService driverService;
public MasterOrderServiceImpl(IWaybillService waybillService, ITransportPlanService transportPlanService, IProjectApplyService projectApplyService,
IContractManageService contractManageService) {
IContractManageService contractManageService, IDriverService driverService) {
this.waybillService = waybillService;
this.transportPlanService = transportPlanService;
this.projectApplyService = projectApplyService;
this.contractManageService = contractManageService;
this.driverService = driverService;
}
@Override
@@ -81,11 +88,15 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
@Override
public List<MasterOrderCarrierVO> 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();
}
@@ -261,8 +272,23 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
waybill.setContractId(masterOrder.getContractId()); waybill.setContractName(masterOrder.getContractName());
waybill.setCustomerName(masterOrder.getCustomerName()); waybill.setMasterNo(masterOrder.getMasterNo());
waybill.setTransportType(string(dispatch, "transportType")); waybill.setCarrierType(string(dispatch, "carrierType"));
waybill.setCarrierContractId(longValue(dispatch, "carrierContractId")); waybill.setCarrierName(string(dispatch, "carrierName")); waybill.setDriverName(string(dispatch, "driverName"));
waybill.setCarrierContractId(longValue(dispatch, "carrierContractId"));
waybill.setCarrierName(string(dispatch, "carrierName"));
waybill.setCarrierPartyId(string(dispatch, "carrierPartyId"));
waybill.setCarrierPartyRole(string(dispatch, "carrierPartyRole"));
waybill.setDriverName(string(dispatch, "driverName"));
waybill.setDriverPhone(string(dispatch, "driverPhone")); waybill.setVehicleNo(string(dispatch, "vehicleNo")); waybill.setTrailerVehicleNo(string(dispatch, "trailerVehicleNo"));
// 归属主键是档案 ID:调度带了就用它,否则按手机号解析;解析入口只返回 null 或唯一命中的
// 档案 ID,承运商司机解析为空也照常派单,落空时留 WARN
Long driverId = longValue(dispatch, "driverId");
if (driverId == null || driverId <= 0) {
driverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), waybill.getDriverPhone());
if (driverId == null) {
log.warn("总单 {} 派单的司机手机号未唯一匹配司机档案,driver_id 落空(不阻断派单)。租户={} 手机号={}",
masterOrder.getMasterNo(), waybill.getTenantId(), DesensitizedUtil.mobilePhone(waybill.getDriverPhone()));
}
}
waybill.setDriverId(driverId);
waybill.setCaptainName(string(dispatch, "captainName")); waybill.setCabinNo(string(dispatch, "cabinNo")); waybill.setContainerNo(string(dispatch, "containerNo"));
waybill.setEscortName(string(dispatch, "escortName")); waybill.setEscortPhone(string(dispatch, "escortPhone")); waybill.setMileage(nullableDecimal(dispatch, "mileage"));
waybill.setDepartureName(string(dispatch, "departureName")); waybill.setDepartureAddress(string(dispatch, "departureAddress"));
@@ -586,7 +612,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
.and(wrapper -> 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<String, Object> dispatch, Map<Long, ContractManage> availableCarrierContracts) {
@@ -598,11 +624,29 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
}
}
String carrierName = string(dispatch, "carrierName");
String carrierPartyId = string(dispatch, "carrierPartyId");
Long carrierContractId = longValue(dispatch, "carrierContractId");
ContractManage carrierContract = carrierContractId == null ? null : availableCarrierContracts.get(carrierContractId);
if ("承运商".equals(carrierType) && (carrierContract == null
|| !Objects.equals(carrierContract.getPartyB(), carrierName))) {
throw new ServiceException("所选承运商不属于总单客户合同对应项目的有效承运商合同乙方");
|| !ContractPartySupport.matchesCarrier(carrierContract, carrierName, carrierPartyId))) {
throw new ServiceException("所选承运商不属于总单客户合同对应项目的有效承运商合同乙/丙方客商");
}
if ("承运商".equals(carrierType) && carrierContract != null) {
Map<String, Object> 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("公路");
@@ -51,6 +51,7 @@ import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.KingdeeLockConstant;
import org.springblade.transport.constant.TransportBusiConstant;
@@ -187,6 +188,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private final BladeRedis bladeRedis;
private final ApplicationEventPublisher eventPublisher;
private final HcsCreditCheckSupport hcsCreditCheckSupport;
private final HcsProperties hcsProperties;
private final IContractExeStageService contractExeStageService;
@Override
@@ -718,8 +720,9 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
context.paymentNoCache().put(id, entity.getPaymentNo());
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的付款申请允许生成金蝶单据");
// 高风险/黑名单客商付款特批门禁:待特批/特批审核中(挂起"特批审核")禁止推送金蝶,
// 特批通过(合规 paymentLegal/result 回调恢复"审批通过")后放行
if (entity.getHcsSpecStatus() != null && List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PUSHING,
// 特批通过(合规 paymentLegal/result 回调恢复"审批通过")后放行;
// 特批不走合同系统(spec-pay.enabled=false)时不拦截
if (hcsProperties.getSpecPay().isEnabled() && entity.getHcsSpecStatus() != null && List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PUSHING,
HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(entity.getHcsSpecStatus())) {
throw new ServiceException("收款方客商为高风险/黑名单,付款特批审核中(" + entity.getCurrentNode() + "),特批完成后方可推送金蝶");
}
@@ -952,20 +955,28 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* 金蝶付款结果回写主体:A103-2 付款申请单查询回填 + A103-4 付款处理查询补付款记录,
* 由 {@link #syncKingdeePaymentResult()} 在分布式锁保护下调用
* 金蝶付款结果回写主体,由 {@link #syncKingdeePaymentResult()} 在分布式锁保护下调用,执行流程:
* <pre>
* ① 圈定待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询),
* 生成失败单据一并纳入:金蝶侧已生成说明上次推送实际成功(如下推超时但远端已落单、本地事务回滚),
* 查得即回填纠正为已生成,避免人工重推产生重复单据,未查得则保持生成失败等待人工重推;
* ② A103-2 付款申请单查询回填:按 paymentNo(awrp_src_code)分批查询,同一来源单号取最近修改一条,
* 回填金蝶单号/内码/同步状态/已付金额并联动预结算与正式结算付款记录;连续两轮查询未命中按金蝶反审核删除解锁;
* ③ A103-4 付款处理查询补付款记录:付款中/已付款单据 + 已付款无付款记录单据(补偿),
* 按付款组织 + awrp_sourcebillno(金蝶单据号)数组精确查询,双键勾稽后回填付款记录;
* ④ 汇总执行摘要:各计数随流程累计,单笔失败仅计数不中断整轮
* </pre>
*
* @return 执行摘要
*/
private String executePaymentResultSync() {
// ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询)。
// 生成失败单据一并纳入勾稽:金蝶侧已生成说明上次推送实际成功(如下推请求超时但远端已落单、本地事务回滚),
// 查到即回填纠正为已生成,避免人工重新推送产生重复单据;未查到则保持生成失败等待异常处理后重新推送
// ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询)
List<PaymentApplication> applications = list(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
.in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED,
KingdeeBfiConstant.KINGDEE_STATUS_PAYING, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
.orderByAsc(PaymentApplication::getId));
// 自代理:applyPayApplyResult/applyPayBillRecord 带单据级事务,跨方法调用确保注解生效,单笔失败仅回滚自身
IPaymentApplicationService self = SpringUtil.getBean(IPaymentApplicationService.class);
int updated = 0;
int failed = 0;
@@ -975,19 +986,22 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
// ② A103-2 付款申请单查询:按 paymentNo(awrp_src_code)分批查询,同一来源单号取最近修改的一条
List<String> paymentNos = applications.stream()
.map(PaymentApplication::getPaymentNo).filter(Func::isNotEmpty).distinct().toList();
batchForeach(paymentNos, PAY_RESULT_QUERY_BATCH, batch ->
payApplyMap.putAll(latestPayApplyBySrcCode(queryPayApplyPages(batch))));
this.batchForeach(paymentNos, PAY_RESULT_QUERY_BATCH, batch ->
payApplyMap.putAll(this.latestPayApplyBySrcCode(this.queryPayApplyPages(batch))));
// 逐单回写:查得即回填并清零消失计数;未查得累计消失轮次(批次其余单据正常返回,排除服务整体故障)
for (PaymentApplication application : applications) {
BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo());
if (payApply == null) {
// 金蝶侧未查得已推送单据(查询批次其余单据正常返回,排除服务整体故障):
// 累计消失轮次,连续两轮视为金蝶已反审核删除,解锁置失败交人工核实重推
this.handlePayApplyMissed(application);
continue;
}
clearPayApplyMissKey(application.getId());
this.clearPayApplyMissKey(application.getId());
try {
if (self.applyPayApplyResult(application, payApply)) paidCount++;
// 返回 true 表示付款完成(终态),预结算/正式结算联动由 applyPayApplyResult 内部处理
if (self.applyPayApplyResult(application, payApply)) {
paidCount++;
}
updated++;
} catch (Exception exception) {
failed++;
@@ -995,7 +1009,9 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
}
}
// ③ A103-4 付款处理查询:付款中/已付款单据 + 已付款但尚无付款记录的单据(补偿),回填付款记录
// ③ A103-4 付款处理查询补付款记录:
// 候选一:本轮 A103-2 查得付款中/已付款(paystatus != Unpaid)的轮询单据;
// 候选二:已付款终态但尚无真实付款记录的单据(上轮付款记录回填失败的补偿)
Map<Long, PaymentApplication> recordCandidates = new LinkedHashMap<>();
for (PaymentApplication application : applications) {
BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo());
@@ -1006,18 +1022,19 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
list(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
.eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID))
.stream().filter(application -> realRecordCount(application.getId()) == 0)
.stream().filter(application -> this.realRecordCount(application.getId()) == 0)
.forEach(application -> recordCandidates.putIfAbsent(application.getId(), application));
int recordWritten = 0;
int recordFailed = 0;
if (!recordCandidates.isEmpty()) {
// 按付款组织分组(org_number 接口必传):优先取 A103-2 返回的付款组织编码,
// 兜底按合同关联项目解析(已付款补偿候选不在本轮 A103-2 返回内,须走兜底)
Map<String, List<PaymentApplication>> orgGroups = new LinkedHashMap<>();
for (PaymentApplication application : recordCandidates.values()) {
BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo());
String orgNumber = payApply == null ? null : payApply.getPayorgNumber();
if (Func.isBlank(orgNumber)) {
try {
// A103-4 付款人(公司)编码必填:优先取 A103-2 返回付款组织编码,兜底按合同关联项目解析
KingdeeSyncContext context = KingdeeSyncContext.empty();
orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(
kingdeeApBillSyncSupport.requireContract(application.getContractId(), context), context);
@@ -1028,26 +1045,25 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
orgGroups.computeIfAbsent(orgNumber, key -> new ArrayList<>()).add(application);
}
// 以付款处理单源单id/源单编号建立索引,与本地金蝶单据id/单据编号勾稽
Map<String, BfiPayBillVO> payBillBySourceId = new HashMap<>();
Map<String, BfiPayBillVO> payBillBySourceNumber = new HashMap<>();
for (String orgNumber : orgGroups.keySet()) {
for (BfiPayBillVO payBill : queryPayBillPages(orgNumber)) {
if (Func.isEmpty(payBill.getBillno())) continue;
if (payBill.getSourcebillid() != null) {
payBillBySourceId.put(String.valueOf(payBill.getSourcebillid()), payBill);
}
if (Func.isNotEmpty(payBill.getSourcebillnumber())) {
payBillBySourceNumber.put(payBill.getSourcebillnumber(), payBill);
}
}
// 按组织 + 金蝶单据号(awrp_sourcebillno 支持数组)分批精确查询付款处理单,结果汇总后统一勾稽
List<BfiPayBillVO> payBills = new ArrayList<>();
for (Map.Entry<String, List<PaymentApplication>> orgEntry : orgGroups.entrySet()) {
List<String> kingdeeBillNos = orgEntry.getValue().stream()
.map(PaymentApplication::getKingdeeBillNo).filter(Func::isNotEmpty).distinct().toList();
this.batchForeach(kingdeeBillNos, PAY_RESULT_QUERY_BATCH,
batch -> payBills.addAll(this.queryPayBillPages(orgEntry.getKey(), batch)));
}
// 逐单勾稽回填:双键匹配(源单id=付款申请金蝶单据内码 / 源单编号=金蝶单据号),未匹配保持现状等下一轮
for (PaymentApplication application : recordCandidates.values()) {
BfiPayBillVO payBill = matchPayBill(application, payBillBySourceId, payBillBySourceNumber);
if (payBill == null) {
if (Func.isBlank(application.getKingdeeBillNo())) {
log.warn("付款申请[{}]付款处理查询跳过,金蝶单据编号为空", application.getPaymentNo());
continue;
}
try {
BfiPayBillVO payBill = this.matchPayBill(application, payBills);
if (payBill == null) {
continue;
}
self.applyPayBillRecord(application, payBill);
recordWritten++;
} catch (Exception exception) {
@@ -1072,7 +1088,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
if (payApply.getId() != null) {
entity.setKingdeeBillId(String.valueOf(payApply.getId()));
}
String kingdeeStatus = kingdeeResultStatus(payApply);
String kingdeeStatus = this.kingdeeResultStatus(payApply);
entity.setKingdeeStatus(kingdeeStatus);
// 已付金额:A103-2 分录 e_paidamt 汇总;分录缺失且已付款时兜底取申请金额
BigDecimal previousPaid = entity.getPaidAmount();
@@ -1082,7 +1098,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
.reduce(BigDecimal.ZERO, BigDecimal::add);
entity.setPaidAmount(paidAmount.setScale(2, RoundingMode.HALF_UP));
} else if (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(kingdeeStatus)) {
entity.setPaidAmount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP));
entity.setPaidAmount(this.money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP));
}
// HCS 履约付款记录推进:金蝶实际付款推进(首次付款或金额变化)后置待同步,扫描任务凭
// hcs_perf_fd_id 判定走向——未建过履约记录走 C108-1 新建,已建走 C108-2 更新实际金额
@@ -1095,14 +1111,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
updateById(entity);
if (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(kingdeeStatus)) {
// 付款完成:联动履约付款数据(预结算预付记录、正式结算付款记录)
syncPreSettlementAdvance(entity, "paid", money(entity.getAppliedAmount()));
syncFormalSettlementPayments(entity);
this.syncPreSettlementAdvance(entity, "paid", this.money(entity.getAppliedAmount()));
this.syncFormalSettlementPayments(entity);
return true;
}
// 生成失败单据勾稽确认金蝶已生成(已生成/付款中):与推送成功同语义,提前落预付记录回显预付信息
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(kingdeeStatus)
|| KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(kingdeeStatus)) {
syncPreSettlementAdvance(entity, TransportBusiConstant.APPROVED, money(entity.getPaidAmount()));
this.syncPreSettlementAdvance(entity, TransportBusiConstant.APPROVED, this.money(entity.getPaidAmount()));
}
return false;
}
@@ -1128,7 +1144,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
if (payBill.getActpayamt() != null) {
record.setPaidAmount(payBill.getActpayamt().setScale(2, RoundingMode.HALF_UP));
}
record.setPaidDate(parseKingdeeDate(payBill.getPaydate()));
record.setPaidDate(this.parseKingdeeDate(payBill.getPaydate()));
record.setKingdeeBillNo(billNo);
// 付款处理单查询结果整体留档(凭证号、银行流水、电子回单等信息),便于追溯
record.setVoucherJson(JsonUtil.toJson(payBill));
@@ -1199,7 +1215,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
continue;
}
BfiPayApplyVO existing = latestBySrcCode.get(payApply.getAwrpSrcCode());
if (existing == null || strCompare(payApply.getModifytime(), existing.getModifytime()) > 0) {
if (existing == null || this.strCompare(payApply.getModifytime(), existing.getModifytime()) > 0) {
latestBySrcCode.put(payApply.getAwrpSrcCode(), payApply);
}
}
@@ -1207,7 +1223,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
private BfiPayApplyVO queryLatestPayApply(String paymentNo) {
return latestPayApplyBySrcCode(queryPayApplyPages(List.of(paymentNo))).get(paymentNo);
return this.latestPayApplyBySrcCode(this.queryPayApplyPages(List.of(paymentNo))).get(paymentNo);
}
/**
@@ -1219,7 +1235,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
* @return 星瀚付款申请单id,金蝶未生成返回 null
*/
private String reconcileExistPayApply(PaymentApplication entity) {
BfiPayApplyVO existPayApply = queryLatestPayApply(entity.getPaymentNo());
BfiPayApplyVO existPayApply = this.queryLatestPayApply(entity.getPaymentNo());
if (existPayApply == null || existPayApply.getId() == null) {
return null;
}
@@ -1247,21 +1263,27 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* A103-4 分页查询:按付款人(公司)编码 + 业务日期窗口,自动翻页
* A103-4 分页查询:按付款组织 + 外部来源单据编号(付款申请金蝶单据号)数组精确查询,自动翻页。
* <p>
* 窗口天数取 Nacos thirdParty.bfi.payResult.queryWindowDays(默认 60)
* 业务日期窗口与付款组织为接口必填参数,窗口天数取 Nacos thirdParty.bfi.payResult.queryWindowDays
* (默认 60),仅用于满足必填要求;awrp_sourcebillno 精确过滤后仅返回目标单据的付款处理单,
* 不再拉取组织内全量数据做内存勾稽
*
* @param orgNumber 付款人(公司)编码
* @param orgNumber 付款人(公司)编码,接口必传
* @param awrpSourceBillnos 付款申请金蝶单据号批次
* @return 付款处理单列表
*/
private List<BfiPayBillVO> queryPayBillPages(String orgNumber) {
private List<BfiPayBillVO> queryPayBillPages(String orgNumber, List<String> awrpSourceBillnos) {
LocalDate endDate = LocalDate.now();
int windowDays = Math.max(bfiProperties.getPayResult().getQueryWindowDays(), 1);
LocalDate beginDate = endDate.minusDays(windowDays);
BfiPayBillQueryDTO param = new BfiPayBillQueryDTO();
// 接口必填参数:付款组织编码 + 业务日期窗口(beigin_bizdate/end_bizdate,金蝶侧拼写如此)
param.setOrgNumber(List.of(orgNumber));
param.setBeginBizdate(beginDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
param.setEndBizdate(endDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
// 精确过滤参数:待回填单据的金蝶单据号数组(sourcebillnumber 与之同源)
param.setAwrpSourceBillno(awrpSourceBillnos);
return BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(PAY_RESULT_PAGE_SIZE);
@@ -1270,21 +1292,24 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* 以金蝶付款处理单源单id(=付款申请单星瀚id)/源单编号(=付款申请单单据编号)勾稽匹配
* 以金蝶付款处理单源单id(=付款申请单星瀚id)/源单编号(=付款申请单单据编号)勾稽匹配,
* 防止按单号查询返回非本单数据(如金蝶侧查询异常返回他单)
*
* @param application 付款申请实体
* @param payBillBySourceId 源单id索引
* @param payBillBySourceNumber 源单编号索引
* @param application 付款申请实体
* @param payBills 按本单金蝶单据号查询返回的付款处理单列表
* @return 匹配的付款处理单,未匹配返回 null
*/
private BfiPayBillVO matchPayBill(PaymentApplication application, Map<String, BfiPayBillVO> payBillBySourceId,
Map<String, BfiPayBillVO> payBillBySourceNumber) {
if (Func.isNotEmpty(application.getKingdeeBillId())
&& payBillBySourceId.containsKey(application.getKingdeeBillId())) {
return payBillBySourceId.get(application.getKingdeeBillId());
}
if (Func.isNotEmpty(application.getKingdeeBillNo())) {
return payBillBySourceNumber.get(application.getKingdeeBillNo());
private BfiPayBillVO matchPayBill(PaymentApplication application, List<BfiPayBillVO> payBills) {
for (BfiPayBillVO payBill : payBills) {
if (payBill.getSourcebillid() != null
&& Func.isNotEmpty(application.getKingdeeBillId())
&& String.valueOf(payBill.getSourcebillid()).equals(application.getKingdeeBillId())) {
return payBill;
}
if (Func.isNotEmpty(payBill.getSourcebillnumber())
&& payBill.getSourcebillnumber().equals(application.getKingdeeBillNo())) {
return payBill;
}
}
return null;
}
@@ -1372,8 +1397,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
param.setAwrpSrcSys(bfiProperties.getSrcSys());
// 结算组织.编码
param.setSettleorgNumber(orgNumber);
// 部门.编码:项目业务部门自身的金蝶编码(结算/申请/付款组织仍传上溯平台公司编码)
param.setAwrpDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract.getProjectId(), context));
// 部门.编码:合同平台公司下业务部门配置的金蝶部门代码
param.setAwrpDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, contract.getProjectId(), context));
// 【必填】单据类型.编码
param.setBilltypeNumber(bfiProperties.getPayApply().getBilltype());
// 【必填】申请日期:yyyy-MM-dd HH:mm:ss(各接口统一格式)
@@ -72,6 +72,7 @@ import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.wrapper.PreSettlementWrapper;
import org.springblade.system.cache.UserCache;
import org.springblade.system.feign.ISysClient;
@@ -977,11 +978,12 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
detail.setBatchNo(source.getBatchNo());
detail.setUnitPrice(source.getUnitPrice());
detail.setFreightAmount(money(source.getFreightAmount()));
detail.setFeeItemsJson(source.getFeeItemsJson());
detail.setFeeItemsJson(sanitizeFeeItemsJson(source.getFeeItemsJson()));
BigDecimal originalAmount = sourceOriginalAmount(source);
BigDecimal settlementAmount = resolveSourceSettlementAmount(source, originalAmount);
detail.setOriginalAmount(originalAmount);
detail.setAdjustAmount(money(source.getTotalAmount()).subtract(originalAmount));
detail.setSettlementAmountTax(money(source.getTotalAmount()));
detail.setAdjustAmount(settlementAmount.subtract(originalAmount));
detail.setSettlementAmountTax(settlementAmount);
detail.setCurrency(Func.isEmpty(source.getCurrency()) ? LOCAL_CURRENCY : source.getCurrency());
detail.setRemark(source.getRemark());
Waybill waybill = source.getWaybillId() == null ? null : waybillService.getById(source.getWaybillId());
@@ -1008,10 +1010,13 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
.eq(ReceivablePayableCargoFee::getDetailId, source.getId())
.eq(ReceivablePayableCargoFee::getIsDeleted, 0));
if (sourceFees.isEmpty() || sourceFees.stream().noneMatch(item -> item.getOriginalAmount() != null)) {
return money(source.getTotalAmount());
return resolveSourceSettlementAmount(source, money(source.getTotalAmount()));
}
return sourceFees.stream().map(ReceivablePayableCargoFee::getOriginalAmount)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal total = BigDecimal.ZERO;
for (ReceivablePayableCargoFee sourceFee : sourceFees) {
total = total.add(resolveCargoFeeAmount(sourceFee, sourceFee.getOriginalAmount()));
}
return money(total);
}
private void copySourceFees(PreSettlementDetail detail, ReceivablePayableDetail source) {
@@ -1031,10 +1036,11 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
fee.setMileage(source.getMileage());
fee.setUnitPrice(source.getUnitPrice());
fee.setFreightAmount(money(source.getFreightAmount()));
fee.setFeeItemsJson(source.getFeeItemsJson());
fee.setOriginalAmount(money(source.getTotalAmount()));
fee.setFeeItemsJson(sanitizeFeeItemsJson(source.getFeeItemsJson()));
BigDecimal amount = resolveSourceSettlementAmount(source, money(source.getTotalAmount()));
fee.setOriginalAmount(amount);
fee.setAdjustAmount(BigDecimal.ZERO.setScale(2));
fee.setSettlementAmountTax(money(source.getTotalAmount()));
fee.setSettlementAmountTax(amount);
fee.setRemark(source.getRemark());
detailFeeMapper.insert(fee);
return;
@@ -1045,6 +1051,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
fee.setId(null);
fee.setPreSettlementDetailId(detail.getId());
fee.setSourceFeeId(sourceFee.getId());
fee.setFeeItemsJson(sanitizeFeeItemsJson(sourceFee.getFeeItemsJson()));
// 历史应付费用可能没有 after_amount,不能在首次打开明细调整时被当成 0。
BigDecimal settlementAmount = sourceFee.getAfterAmount() != null
? money(sourceFee.getAfterAmount())
@@ -1052,8 +1059,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
? money(source.getTotalAmount())
: sourceFee.getOriginalAmount() != null
? money(sourceFee.getOriginalAmount()) : BigDecimal.ZERO.setScale(2);
settlementAmount = resolveCargoFeeAmount(sourceFee, settlementAmount);
BigDecimal originalAmount = sourceFee.getOriginalAmount() == null
? settlementAmount : money(sourceFee.getOriginalAmount());
? settlementAmount : resolveCargoFeeAmount(sourceFee, sourceFee.getOriginalAmount());
fee.setOriginalAmount(originalAmount);
fee.setAdjustAmount(settlementAmount.subtract(originalAmount));
fee.setSettlementAmountTax(settlementAmount);
@@ -1070,14 +1078,23 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
.filter(row -> !Integer.valueOf(1).equals(row.getManualFlag()))
.collect(Collectors.toMap(this::summaryKey, Function.identity(), (left, right) -> left));
Map<String, BigDecimal> aggregates = new LinkedHashMap<>();
List<Long> detailIds = listDetails(settlementId).stream().map(PreSettlementDetail::getId).toList();
List<PreSettlementDetail> details = listDetails(settlementId);
List<Long> detailIds = details.stream().map(PreSettlementDetail::getId).toList();
List<PreSettlementDetailFee> feeRows = detailIds.isEmpty() ? List.of()
: detailFeeMapper.selectList(Wrappers.<PreSettlementDetailFee>lambdaQuery()
.in(PreSettlementDetailFee::getPreSettlementDetailId, detailIds)
.eq(PreSettlementDetailFee::getIsDeleted, 0)
.orderByAsc(PreSettlementDetailFee::getCreateTime));
Set<Long> repairedDetailIds = new LinkedHashSet<>();
for (PreSettlementDetailFee feeRow : feeRows) {
boolean polluted = isPollutedFeeItemsJson(feeRow.getFeeItemsJson());
Map<String, BigDecimal> feeItems = parseFeeItems(feeRow.getFeeItemsJson());
if (polluted) {
repairDetailFeeAmounts(feeRow, feeItems);
if (feeRow.getPreSettlementDetailId() != null) {
repairedDetailIds.add(feeRow.getPreSettlementDetailId());
}
}
boolean containsFreight = feeItems.keySet().stream().anyMatch(this::isFreightFeeItem);
BigDecimal componentAmount = feeItems.values().stream().map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
@@ -1093,8 +1110,8 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
summaryKey(feeTypeMap.getOrDefault(feeItem,
""), feeItem),
money(amount), BigDecimal::add));
BigDecimal residualAmount = money(money(feeRow.getSettlementAmountTax())
.subtract(componentAmount));
// 脏数据已在上面修复结算金额;正常明细才用 settlementAmountTax 与费用项差额补「其他费用」
BigDecimal residualAmount = money(money(feeRow.getSettlementAmountTax()).subtract(componentAmount));
if (residualAmount.signum() != 0) {
aggregates.merge(
summaryKey(feeTypeMap.getOrDefault("其他费用", ""), "其他费用"),
@@ -1141,6 +1158,11 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
summaryFeeMapper.insert(row);
}
renumberSummaryFees(settlementId);
if (!repairedDetailIds.isEmpty()) {
details.stream()
.filter(detail -> repairedDetailIds.contains(detail.getId()))
.forEach(this::refreshDetail);
}
}
private void applySummaryRequest(Long settlementId, List<PreSettlementSaveRequest.SummaryFee> requestRows) {
@@ -1729,7 +1751,29 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
try {
Object parsed = JsonUtil.parse(json, Map.class);
if (parsed instanceof Map<?, ?> map) {
map.forEach((key, value) -> result.put(String.valueOf(key), decimal(value)));
BigDecimal otherFeeAmount = BigDecimal.ZERO;
for (Map.Entry<?, ?> entry : map.entrySet()) {
String key = String.valueOf(entry.getKey());
Object value = entry.getValue();
if (Func.isBlank(key)) {
continue;
}
if ("otherFreightAmount".equals(key) || "otherFeeTotal".equals(key) || "otherAmount".equals(key)) {
otherFeeAmount = otherFeeAmount.add(decimal(value));
continue;
}
if (TransportBusinessSupport.isFreightJsonMetaKey(key)) {
continue;
}
if (value instanceof Map<?, ?> || value instanceof java.util.Collection<?>) {
continue;
}
result.put(key, decimal(value));
}
if (otherFeeAmount.signum() > 0 && result.keySet().stream()
.noneMatch(name -> name != null && (name.contains("其它费") || name.contains("其他费")))) {
result.put("其它费用", otherFeeAmount);
}
}
} catch (Exception ignored) {
// 历史脏数据不影响结算单展示。
@@ -1737,6 +1781,74 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
return result;
}
private boolean isPollutedFeeItemsJson(String json) {
if (Func.isEmpty(json)) {
return false;
}
try {
Object parsed = JsonUtil.parse(json, Map.class);
if (!(parsed instanceof Map<?, ?> map)) {
return false;
}
return map.keySet().stream()
.map(String::valueOf)
.anyMatch(TransportBusinessSupport::isFreightJsonMetaKey);
} catch (Exception ignored) {
return false;
}
}
private String sanitizeFeeItemsJson(String json) {
Map<String, BigDecimal> feeItems = parseFeeItems(json);
return JsonUtil.toJson(feeItems);
}
/**
* 来源应付明细金额:若 fee_items_json 混入 freightJson 结构字段,按清洗后费用项重算,避免转预结算翻倍。
*/
private BigDecimal resolveSourceSettlementAmount(ReceivablePayableDetail source, BigDecimal fallback) {
if (source == null) {
return money(fallback);
}
if (!isPollutedFeeItemsJson(source.getFeeItemsJson())) {
return money(fallback);
}
Map<String, BigDecimal> feeItems = parseFeeItems(source.getFeeItemsJson());
boolean containsFreight = feeItems.keySet().stream().anyMatch(this::isFreightFeeItem);
BigDecimal feeItemTotal = feeItems.values().stream().map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return money(containsFreight ? feeItemTotal : money(source.getFreightAmount()).add(feeItemTotal));
}
private BigDecimal resolveCargoFeeAmount(ReceivablePayableCargoFee sourceFee, BigDecimal fallback) {
if (sourceFee == null) {
return money(fallback);
}
if (!isPollutedFeeItemsJson(sourceFee.getFeeItemsJson())) {
return money(fallback);
}
Map<String, BigDecimal> feeItems = parseFeeItems(sourceFee.getFeeItemsJson());
boolean containsFreight = feeItems.keySet().stream().anyMatch(this::isFreightFeeItem);
BigDecimal feeItemTotal = feeItems.values().stream().map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return money(containsFreight ? feeItemTotal : money(sourceFee.getFreightAmount()).add(feeItemTotal));
}
/** 重建合计时顺带修复已落库的脏费用行金额与 fee_items_json。 */
private void repairDetailFeeAmounts(PreSettlementDetailFee feeRow, Map<String, BigDecimal> sanitizedFeeItems) {
boolean containsFreight = sanitizedFeeItems.keySet().stream().anyMatch(this::isFreightFeeItem);
BigDecimal feeItemTotal = sanitizedFeeItems.values().stream().map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal corrected = money(containsFreight
? feeItemTotal
: money(feeRow.getFreightAmount()).add(feeItemTotal));
feeRow.setFeeItemsJson(JsonUtil.toJson(sanitizedFeeItems));
feeRow.setOriginalAmount(corrected);
feeRow.setSettlementAmountTax(corrected);
feeRow.setAdjustAmount(BigDecimal.ZERO.setScale(2));
detailFeeMapper.updateById(feeRow);
}
private Map<String, BigDecimal> normalizeFeeItems(Map<String, BigDecimal> feeItems) {
Map<String, BigDecimal> result = new LinkedHashMap<>();
if (feeItems == null) return result;

Some files were not shown because too many files have changed in this diff Show More