From 777102485c36eea9c9623d56b81b24fa1206a093 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 21 Oct 2026 11:51:13 +0800 Subject: [PATCH 1/8] =?UTF-8?q?:sparkles:=20=E7=BB=84=E7=BB=87=E6=9C=BA?= =?UTF-8?q?=E6=9E=84=E6=94=AF=E6=8C=81=E5=90=88=E5=90=8C=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/system/pojo/entity/Dept.java | 7 +++ .../springblade/system/mapper/DeptMapper.xml | 2 + .../system/service/impl/DeptServiceImpl.java | 11 +++++ .../impl/ContractManageServiceImpl.java | 43 +++++++++++++++++-- doc/sql/bladex/bladex.mysql.all.create.sql | 1 + ..._dept_contract_initial_serial_20261020.sql | 3 ++ 6 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java index 15a3e94a..e5958c76 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java @@ -150,6 +150,13 @@ public class Dept extends TenantEntity { @Schema(description = "合同编码前缀") private String contractCodePrefix; + /** + * 合同初始流水号(该部门当年首个合同时从此号起编,范围1-999) + */ + @Schema(description = "合同初始流水号") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Integer contractInitialSerial; + /** * 组织简称 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml index f45626dd..bfbaf61f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml @@ -14,6 +14,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index 3dda7348..dd24564f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -687,6 +687,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID } validateDeptCategory(dept); validateDeptCode(dept, parent); + validateContractInitialSerial(dept); return saveOrUpdate(dept); } @@ -874,6 +875,16 @@ public class DeptServiceImpl extends ServiceImpl implements ID } } + private void validateContractInitialSerial(Dept dept) { + Integer serial = dept.getContractInitialSerial(); + if (serial == null) { + return; + } + if (serial < 1 || serial > 999) { + throw new ServiceException("合同初始流水号范围为1到999"); + } + } + private void validateDeptCode(Dept dept, Dept parent) { String deptCode = dept.getDeptCode(); // 组织编码非必填,为空时存 null,避免唯一索引冲突 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java index 1ec56fd9..9d36d811 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java @@ -298,6 +298,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl beforeData = buildContractChangeSnapshot(source); + applyContractNo(source, request.getContractNo(), false); source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB()); source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat()); source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount()); @@ -507,8 +508,7 @@ public class ContractManageServiceImpl extends BaseServiceImpllambdaQuery() + .eq(ContractManage::getIsDeleted, 0) + .eq(ContractManage::getContractNo, normalizedContractNo) + .ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId())); + if (duplicatedCount != null && duplicatedCount > 0) { + throw new ServiceException("合同编号已存在"); + } + contractManage.setContractNo(normalizedContractNo); + } + @Override public synchronized String nextContractNo(Long organizationId) { if (Func.isEmpty(organizationId)) { @@ -558,7 +580,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl 999) { + throw new ServiceException("组织机构中配置的合同初始流水号不能超过999"); + } + return serial; + } + private void refreshArchiveStatusOnApprove(ContractManage contractManage) { if (hasSealArchiveFile(contractManage.getContractFileJson())) { contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED); @@ -1015,6 +1049,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl buildContractChangeSnapshot(ContractManage contractManage) { Map data = new LinkedHashMap<>(); + data.put("contractNo", contractManage.getContractNo()); data.put("contractName", contractManage.getContractName()); data.put("partyB", contractManage.getPartyB()); data.put("startDate", contractManage.getStartDate()); diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 7a6e69cc..fed3b1b5 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -305,6 +305,7 @@ CREATE TABLE `blade_dept` ( `dept_code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织编码', `kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '金蝶部门代码', `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '合同编码前缀', + `contract_initial_serial` int NULL DEFAULT NULL COMMENT '合同初始流水号', `short_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织简称', `pinyin_mnemonic` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '拼音助记码', `mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码', diff --git a/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql b/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql new file mode 100644 index 00000000..7b4ac000 --- /dev/null +++ b/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql @@ -0,0 +1,3 @@ +-- 组织机构新增合同初始流水号:该部门当年首个合同从此流水号起编,后续递增 +ALTER TABLE `blade_dept` + ADD COLUMN `contract_initial_serial` int(11) DEFAULT NULL COMMENT '合同初始流水号' AFTER `contract_code_prefix`; From c31e5f98ed5448905efa99686683bd542cc7699e Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 21 Oct 2026 11:51:13 +0800 Subject: [PATCH 2/8] =?UTF-8?q?:sparkles:=20=E4=BA=BA=E5=91=98=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=8C=89OA=E7=99=BB=E5=BD=95=E5=90=8D=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=88=96=E6=96=B0=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../system/convert/UserConvert.java | 7 +- .../service/impl/OaUserListSyncHelper.java | 129 ++++++++---------- 2 files changed, 64 insertions(+), 72 deletions(-) diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java index abf59acf..2dabbf04 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -148,7 +148,7 @@ public interface UserConvert { /** * oa人员转本系统用户部门 * @param person - * @param userMap 所有用户手机号与id map + * @param userMap OA登录名与用户id * @return */ default UserDept person2userDept(OAPersonResponse person, Map userMap) { @@ -162,8 +162,9 @@ public interface UserConvert { userDept.setStatus(status); // 排序 userDept.setSort(OAUtils.parseInt(person.getDsporder())); - // 用户id - userDept.setUserId(userMap.get(resolveAccount(person))); + // 用户id,按 OA 登录名关联本批已新增或更新的用户 + String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid()); + userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId)); userDept.setSyncTime(new Date()); return userDept; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index 87e3cd50..46f8af2b 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -403,70 +403,37 @@ public class OaUserListSyncHelper { Map uniquePersonMap = new LinkedHashMap<>(); int skippedCount = 0; for (OAPersonResponse oaPerson : oaPersons) { - String account = userConvert.resolveAccount(oaPerson); - if (StringUtils.isBlank(account)) { + String oaLoginId = resolveOaLoginId(oaPerson); + if (StringUtils.isBlank(oaLoginId)) { skippedCount++; continue; } - uniquePersonMap.putIfAbsent(account, oaPerson); + uniquePersonMap.put(oaLoginId, oaPerson); } if (uniquePersonMap.isEmpty()) { - ComposeLogUtil.getLastLog().warn("OA人员均缺少loginid/工号/手机号,跳过人员同步"); + ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步"); return new PersonSyncCount(0, skippedCount); } String tenantId = resolveTenantId(); String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); - List users = uniquePersonMap.values().stream() - .map(person -> { - User user = userConvert.person2user(person, defaultPassword); + List users = uniquePersonMap.entrySet().stream() + .map(entry -> { + User user = userConvert.person2user(entry.getValue(), defaultPassword); user.setTenantId(tenantId); + user.setOaLoginId(entry.getKey()); + user.setAccount(entry.getKey()); return user; }) .toList(); - List accounts = users.stream() - .map(User::getAccount) - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - List phones = users.stream() - .map(User::getPhone) - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - Map existingByAccount = new HashMap<>(); - Map existingByPhone = new HashMap<>(); - userService.list(Wrappers.lambdaQuery() - .and(wrapper -> { - wrapper.in(User::getAccount, accounts); - if (CollectionUtil.isNotEmpty(phones)) { - wrapper.or().in(User::getPhone, phones); - } - }) - ).stream() - .peek(userService::decryptPhone) - .forEach(user -> { - if (StringUtils.isNotBlank(user.getAccount())) { - existingByAccount.put(user.getAccount(), user); - } - if (StringUtils.isNotBlank(user.getPhone())) { - existingByPhone.put(user.getPhone(), user); - } - }); + Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet()); Map userMap = new HashMap<>(); Set existsUserIds = new HashSet<>(); - Set unchangedUserIds = new HashSet<>(); users.forEach(user -> { - User existingUser = existingByAccount.get(user.getAccount()); - if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) { - existingUser = existingByPhone.get(user.getPhone()); - } + User existingUser = existingByOaLoginId.get(user.getOaLoginId()); if (existingUser != null) { user.setId(existingUser.getId()); user.setPassword(null); existsUserIds.add(existingUser.getId()); - if (shouldSkipOaUpdate(existingUser, user)) { - unchangedUserIds.add(existingUser.getId()); - } } else { user.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); user.setPostId("-1"); @@ -474,9 +441,9 @@ public class OaUserListSyncHelper { user.setDataScopeRange(1); user.setDataLevelRange(1); user.setIncludeNewCustomer(0); - userService.encryptPhone(user); } - userMap.put(user.getAccount(), user.getId()); + userService.encryptPhone(user); + userMap.put(user.getOaLoginId(), user.getId()); }); List addUsers = users.stream() @@ -488,18 +455,13 @@ public class OaUserListSyncHelper { } List updateUsers = users.stream() .filter(user -> existsUserIds.contains(user.getId())) - .filter(user -> !unchangedUserIds.contains(user.getId())) .toList(); if (CollectionUtil.isNotEmpty(updateUsers)) { ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size()); userService.updateBatchById(updateUsers); } - if (!unchangedUserIds.isEmpty()) { - ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size()); - } - ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount); + ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount); int syncedCount = addUsers.size() + updateUsers.size(); - skippedCount += unchangedUserIds.size(); if (userMap.isEmpty()) { return new PersonSyncCount(0, skippedCount); } @@ -511,11 +473,8 @@ public class OaUserListSyncHelper { Set existsUserDeptIds = new HashSet<>(userDeptMap.values()); List userDeptList = oaPersons.stream() .filter(oaPerson -> { - String account = userConvert.resolveAccount(oaPerson); - if (!userMap.containsKey(account)) { - return false; - } - return !unchangedUserIds.contains(userMap.get(account)); + String oaLoginId = resolveOaLoginId(oaPerson); + return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId); }) .map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex)) .filter(Objects::nonNull) @@ -554,7 +513,6 @@ public class OaUserListSyncHelper { .collect(Collectors.toSet()); String defaultRoleId = getDefaultRoleId(); List updateUserParams = list.stream() - .filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId())) .filter(userDeptIds -> { if (StringUtils.isBlank(userDeptIds.getDeptIds())) { return false; @@ -586,18 +544,51 @@ public class OaUserListSyncHelper { } /** - * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段 + * 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。 */ - private boolean shouldSkipOaUpdate(User existing, User incoming) { - if (existing == null) { - return false; + private Map loadUsersByOaLoginId(String tenantId, Collection oaLoginIds) { + Map existingByOaLoginId = new HashMap<>(); + if (CollectionUtil.isEmpty(oaLoginIds)) { + return existingByOaLoginId; } - String localModified = StringUtils.trimToEmpty(existing.getOaModified()); - String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); - if (localModified.isEmpty() || remoteModified.isEmpty()) { - return false; + List oaLoginIdList = oaLoginIds.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + int batchSize = 500; + for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) { + List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size())); + userService.list(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .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 localModified.equals(remoteModified); + return existingByOaLoginId; + } + + private String resolveOaLoginId(OAPersonResponse oaPerson) { + if (oaPerson == null) { + return null; + } + return StringUtils.trimToNull(oaPerson.getLoginid()); } private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) { @@ -638,8 +629,8 @@ public class OaUserListSyncHelper { Dept department = orgIndex.findDept(oaPerson); if (department == null || department.getId() == null) { ComposeLogUtil.getLastLog().warn( - "人员未匹配到已有部门,请先同步组织架构 account={} departmentid={} departmentname={} subcompanyid1={}", - userConvert.resolveAccount(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(), + "人员未匹配到已有部门,请先同步组织架构 oaLoginId={} departmentid={} departmentname={} subcompanyid1={}", + resolveOaLoginId(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(), oaPerson.getSubcompanyid1()); return null; } From 1b69d2b792d584e53e06aaad98c5c2e76e8a4c84 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 19 Oct 2026 23:28:50 +0800 Subject: [PATCH 3/8] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E9=95=BF=E6=9C=9F=E6=9C=89=E6=95=88=E3=80=81=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E5=AF=BC=E5=85=A5=E4=B8=8E=E8=B0=83=E5=BA=A6=E7=AB=AF?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 车辆勾选长期有效不再强校验有效期;结算导入模板去掉结算金额并自动合计,取消运费合计勾稽;调度端所属组织为空不拦截,并新增仅登录态的 manage/track。 Co-authored-by: Cursor --- .../controller/ManageWaybillController.java | 11 +++++ .../excel/SettlementImportFeeReader.java | 40 ++++++++++++++----- .../excel/SettlementImportTemplateExcel.java | 3 +- .../SettlementImportTemplateExporter.java | 5 +-- .../impl/DriverWaybillServiceImpl.java | 6 +++ .../impl/ManageWaybillServiceImpl.java | 13 ++++-- .../impl/TransportVehicleServiceImpl.java | 4 +- .../impl/WaybillImportBatchServiceImpl.java | 40 +++---------------- .../impl/ManageWaybillOrgScopeTest.java | 12 +++--- 9 files changed, 75 insertions(+), 59 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index ee10e0b6..67c2d15c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -39,6 +39,7 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO; import org.springblade.transport.pojo.vo.AdminWaybillCardVO; import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IManageWaybillService; import org.springblade.transport.service.IWaybillService; import org.springframework.web.bind.annotation.GetMapping; @@ -159,4 +160,14 @@ public class ManageWaybillController extends BladeController { return R.status(waybillService.savePunchRecord(request)); } + @PostMapping("/track") + @ApiOperationSupport(order = 11) + @Operation(summary = "车辆历史轨迹", description = "小程序调度端:仅需登录态,按运单绑定车牌和日期区间查询 LBS 轨迹") + public R track( + @Parameter(description = "运单ID", required = true) @RequestParam Long id, + @Parameter(description = "开始日期 YYYY-MM-DD", required = true) @RequestParam String startDate, + @Parameter(description = "结束日期 YYYY-MM-DD", required = true) @RequestParam String endDate) { + return R.data(waybillService.trackVehicle(id, startDate, endDate)); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java index cc43d6e1..12b44580 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java @@ -54,7 +54,8 @@ import java.util.Set; /** * 导入结算单 Excel 读取器(Apache POI)。 *

- * 定位「*运输费」或「运输费」列,其后直到「结算金额」为动态费用列。 + * 定位「*运输费」或「运输费」列,其后直到日期/备注等固定列(兼容旧模板中的「结算金额」)为动态费用列。 + * 结算金额不由模板填写,解析时按「运输费 + 各费用项」自动合计。 * * @author Chill */ @@ -66,6 +67,14 @@ public final class SettlementImportFeeReader { "actualStartDate", "actualEndDate", "planStartDate", "planEndDate" ); private static final Set OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice"); + /** 费用列结束边界:其后为固定业务列;含旧模板「结算金额」以便兼容历史文件 */ + private static final Set FEE_END_HEADERS = Set.of( + "结算金额", + "*实际发货时间", "实际发货时间", + "*实际完成时间", "实际完成时间", + "预计发货时间", "预计完成时间", + "备注", "同一运单标识号" + ); private SettlementImportFeeReader() { } @@ -119,15 +128,12 @@ public final class SettlementImportFeeReader { if (transportFeeColumn < 0) { throw new ServiceException("未找到「*运输费」或「运输费」列"); } - int settlementAmountColumn = findColumnExact(headers, "结算金额"); List feeColumns = new ArrayList<>(); List feeItemNames = new ArrayList<>(); - int endColumn = settlementAmountColumn > transportFeeColumn - ? settlementAmountColumn - : headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1; + int endColumn = findFeeEndColumn(headers, transportFeeColumn); for (int column = transportFeeColumn + 1; column < endColumn; column++) { String name = headers.get(column); - if (name == null || name.isBlank() || "结算金额".equals(name)) { + if (name == null || name.isBlank() || FEE_END_HEADERS.contains(name)) { continue; } feeColumns.add(column); @@ -229,7 +235,8 @@ public final class SettlementImportFeeReader { } else if ("transportFee".equals(field) || "quantity".equals(field)) { mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex)); } else if ("settlementAmount".equals(field)) { - mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex)); + // 旧模板可能仍含结算金额列,读入后忽略,统一按运输费+费用项自动合计 + continue; } else if ("serialNumber".equals(field)) { mapped.put(field, intValue(cellString(poiCell))); } else { @@ -255,9 +262,7 @@ public final class SettlementImportFeeReader { BigDecimal feeItemTotal = feeItems.values().stream() .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(2, RoundingMode.HALF_UP); - BigDecimal settlementAmount = mapped.get("settlementAmount") == null - ? transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP) - : toDecimal(mapped.get("settlementAmount")); + BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP); mapped.put("settlementAmount", settlementAmount); mapped.put("结算金额", settlementAmount); mapped.put("feeItems", feeItems); @@ -284,6 +289,21 @@ public final class SettlementImportFeeReader { return findColumnExact(headers, "运输费"); } + /** 费用列右边界:取运输费之后第一个固定结束表头列下标;无则取表头最大下标 + 1。 */ + private static int findFeeEndColumn(Map headers, int transportFeeColumn) { + int endColumn = headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1; + for (Map.Entry entry : headers.entrySet()) { + int column = entry.getKey(); + if (column <= transportFeeColumn) { + continue; + } + if (FEE_END_HEADERS.contains(entry.getValue()) && column < endColumn) { + endColumn = column; + } + } + return endColumn; + } + private static int findColumnExact(Map headers, String header) { return headers.entrySet().stream() .filter(entry -> header.equals(entry.getValue())) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java index a060b15e..871599eb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java @@ -100,7 +100,8 @@ public class SettlementImportTemplateExcel implements Serializable { private BigDecimal loadingFee; @ExcelProperty("过路费") private BigDecimal tollFee; - @ExcelProperty("结算金额") + /** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */ + @ExcelIgnore private BigDecimal settlementAmount; @ExcelProperty("*实际发货时间") private String actualStartDate; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java index a33dbb6c..91eb317e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java @@ -85,7 +85,6 @@ public final class SettlementImportTemplateExporter { ); private static final List FIXED_HEADERS_AFTER_FEE = List.of( - "结算金额", "*实际发货时间", "*实际完成时间", "预计发货时间", @@ -159,8 +158,8 @@ public final class SettlementImportTemplateExporter { lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。"); lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。"); lines.add("4. 里程(km) 可为空;填写时须为正数。"); - lines.add("5. 「*运输费」之后、「结算金额」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); - lines.add("6. 结算金额 = 运输费 + 各费用项合计,系统会按此重算。"); + lines.add("5. 「*运输费」之后、「*实际发货时间」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); + lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。"); lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。"); if (feeItemNames != null && !feeItemNames.isEmpty()) { lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index e132f332..ae825828 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -350,9 +350,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** * 按打卡记录 / 计划时间推导轨迹查询起止日期 + *

+ * TODO(临时):当前仅查当天,便于联调 LBS;恢复正式逻辑时删掉本段 early-return。 */ private LocalDate[] resolveTrackDateRange(Waybill waybill) { LocalDate today = LocalDate.now(); + // TEMP: 行驶轨迹时间段临时改为仅当天 + return new LocalDate[]{today, today}; + /* List punchDates = new ArrayList<>(); nodePunchMapper.selectList(Wrappers.lambdaQuery() .eq(WaybillNodePunch::getWaybillId, waybill.getId()) @@ -384,6 +389,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return new LocalDate[]{waybill.getStartDate(), today}; } return new LocalDate[]{today.minusDays(3), today}; + */ } @Override diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index f8701641..4b1e5c93 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -886,11 +886,18 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { /** * 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。 *

- * 与后台运单列表同一条规则({@link TransportBusinessSupport#resolveDeptFilterIds}), - * 小程序端不提供「全部组织」开关,故两个入参固定传空;详情与重新派单不按组织收口。 + * 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。 + * 详情与重新派单本身不走这里。 */ private List manageableDeptIds() { - return TransportBusinessSupport.resolveDeptFilterIds(null, null, "运单管理"); + if (TransportBusinessSupport.isAdministrator()) { + return List.of(); + } + Long deptId = Func.firstLong(AuthUtil.getDeptId()); + if (Func.isEmpty(deptId) || deptId <= 0) { + return List.of(); + } + return List.of(deptId); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index e28d7329..d9e1c734 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java @@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl row, List errors) { - String freightTotalStr = stringValue(row, "freightTotal", "运费合计"); - if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) { - return; - } - - try { - BigDecimal freightTotal = new BigDecimal(freightTotalStr); - String freightStr = stringValue(row, "freight", "运费"); - String otherFeeTotalStr = stringValue(row, "otherFeeTotal", "其他费用合计"); - - BigDecimal freight = Func.isNotEmpty(freightStr) && !isImportOptionalNumberEmpty(freightStr) - ? new BigDecimal(freightStr) : BigDecimal.ZERO; - BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) && !isImportOptionalNumberEmpty(otherFeeTotalStr) - ? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO; - - BigDecimal calculatedTotal = freight.add(otherFeeTotal); - if (freightTotal.compareTo(calculatedTotal) != 0) { - errors.add("运费合计应等于运费+其他费用合计"); - } - } catch (NumberFormatException e) { - errors.add("运费相关字段格式不正确"); - } - } - private void validateActualStartDate(Map row, boolean isDraft, List errors) { Object actualStartDate = row.get("actualStartDate"); if (actualStartDate == null || String.valueOf(actualStartDate).isBlank()) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java index 15a31a27..95346246 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java @@ -52,7 +52,7 @@ import static org.mockito.Mockito.when; * 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现 * 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。 *

- * 组织取不到时报错而不是放行:放行等于把全租户的运单发给一个没有组织的账号。 + * 所属组织为空时不拦截:小程序调度端不要求账号必须挂组织。 */ class ManageWaybillOrgScopeTest { @@ -190,13 +190,13 @@ class ManageWaybillOrgScopeTest { } @Test - @DisplayName("组织取不到时报错,不退回不带组织条件") - void blankDeptFailsClosed() { + @DisplayName("组织取不到时不拦截,小程序接口不按组织收口") + void blankDeptDoesNotFail() { authUtil.when(AuthUtil::getDeptId).thenReturn(""); - assertThrows(ServiceException.class, - () -> service.pageList(1, 10, null, null, null, null, null, null), - "没有组织的账号不能拿到全租户运单"); + service.pageList(1, 10, null, null, null, null, null, null); + + assertTrue(capturedPageQuery().getDeptIds().isEmpty(), "没有组织时不报错、不按组织过滤"); } // ------------------------------------------------------------ 夹具 From 939569e6239cc150f138488e5f14adf869541a37 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 20 Oct 2026 01:50:55 +0800 Subject: [PATCH 4/8] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E9=A2=84=E4=BB=98=E9=A9=B3=E5=9B=9E=E9=87=8D=E6=8F=90?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=A0=A1=E6=AD=A3=E7=BB=93=E7=AE=97=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E4=B8=8E=E8=BF=90=E8=BE=93=E8=AE=A1=E5=88=92=E7=BB=84?= =?UTF-8?q?=E7=BB=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../dto/PaymentApplicationSaveRequest.java | 2 + .../controller/BillLedgerController.java | 11 +- .../transport/service/IBillLedgerService.java | 5 +- .../service/impl/BillLedgerServiceImpl.java | 47 ++++-- .../impl/PaymentApplicationServiceImpl.java | 131 +++++++++++++--- .../impl/PreSettlementServiceImpl.java | 147 ++++++++++-------- .../ReceivablePayableDetailServiceImpl.java | 38 +++-- .../impl/TransportPlanServiceImpl.java | 32 +++- 8 files changed, 302 insertions(+), 111 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java index d608d7cb..d69690f8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java @@ -36,6 +36,8 @@ public class PaymentApplicationSaveRequest implements Serializable { private List settlementIds; private Long preSettlementId; private List preSettlementIds; + /** 预结算单号。驳回重提时用于在主键失真后找回预结算单 */ + private String preSettlementNo; private Long projectId; private String projectName; private Long deptId; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java index 24d695ca..7cce3ffc 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java @@ -61,8 +61,9 @@ public class BillLedgerController extends BladeController { @ApiOperationSupport(order = 4) @Operation(summary = "付款申请可用汇票") public R> availableOptions(@RequestParam(required = false) String keyword, - @RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId) { - return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId)); + @RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId, + @RequestParam(required = false) String payerName, @RequestParam(required = false) String payeeName) { + return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId, payerName, payeeName)); } @GetMapping("/available-page") @@ -70,8 +71,10 @@ public class BillLedgerController extends BladeController { @Operation(summary = "付款申请可用汇票分页") public R> availablePage(Query pageQuery, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long deptId, - @RequestParam(required = false) Long selectedId) { - return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId)); + @RequestParam(required = false) Long selectedId, @RequestParam(required = false) String payerName, + @RequestParam(required = false) String payeeName) { + return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId, + payerName, payeeName)); } @PostMapping("/submit") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java index abd6e5d2..c46522e3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java @@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService { IPage selectPage(IPage page, BillLedgerVO query); BillLedgerVO detail(Long id); Map expiryCounts(); - List availableOptions(String keyword, Long deptId, Long selectedId); - IPage availablePage(IPage page, String keyword, Long deptId, Long selectedId); + List availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName); + IPage availablePage(IPage page, String keyword, Long deptId, Long selectedId, + String payerName, String payeeName); Long submit(BillLedgerSaveRequest request); void removeLedger(Long id); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java index a2728da7..a20a4e0e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java @@ -86,14 +86,15 @@ public class BillLedgerServiceImpl extends BaseServiceImpl availableOptions(String keyword, Long deptId, Long selectedId) { - return availableList(keyword, deptId, selectedId); + public List availableOptions(String keyword, Long deptId, Long selectedId, String payerName, + String payeeName) { + return availableList(keyword, deptId, selectedId, payerName, payeeName); } @Override public IPage availablePage(IPage page, String keyword, Long deptId, - Long selectedId) { - List available = availableList(keyword, deptId, selectedId); + Long selectedId, String payerName, String payeeName) { + List available = availableList(keyword, deptId, selectedId, payerName, payeeName); long current = Math.max(page.getCurrent(), 1); long size = Math.max(page.getSize(), 1); long from = Math.min((current - 1) * size, available.size()); @@ -103,27 +104,50 @@ public class BillLedgerServiceImpl extends BaseServiceImpl availableList(String keyword, Long deptId, Long selectedId) { + private List availableList(String keyword, Long deptId, Long selectedId, String payerName, + String payeeName) { LocalDate today = LocalDate.now(); - // 付款申请仅可选:收票类型、未到期、有可用余额;已选中的汇票始终带回以便回显 + // 未到期且有余额;收票且部门可用,或出票/收票单位与结算收付款方一致(开票、收票都可) return list(Wrappers.lambdaQuery() .and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword) .or().like(BillLedger::getIssuerName, keyword) .or().like(BillLedger::getReceiverName, keyword)) .and(wrapper -> wrapper .and(available -> available - .eq(BillLedger::getBillType, "received") .ge(BillLedger::getMaturityDate, today) .gt(BillLedger::getAvailableBalance, BigDecimal.ZERO)) .or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId))) .orderByAsc(BillLedger::getMaturityDate) .orderByDesc(BillLedger::getCreateTime)).stream() .filter(item -> selectedId != null && Objects.equals(item.getId(), selectedId) - || departmentAvailable(item, deptId)) + || selectableBill(item, deptId, payerName, payeeName)) .map(item -> BillLedgerWrapper.build().entityVO(item)) .toList(); } + /** + * 收票且当前部门在可用范围内,或出票单位、收票单位与结算付款方、收款方一致(顺序不限)。 + */ + private boolean selectableBill(BillLedger ledger, Long deptId, String payerName, String payeeName) { + boolean receivedAndDept = "received".equals(ledger.getBillType()) && departmentAvailable(ledger, deptId); + return receivedAndDept || partiesMatch(ledger.getIssuerName(), ledger.getReceiverName(), payerName, payeeName); + } + + private boolean partiesMatch(String issuerName, String receiverName, String payerName, String payeeName) { + String issuer = trimName(issuerName); + String receiver = trimName(receiverName); + String payer = trimName(payerName); + String payee = trimName(payeeName); + if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) { + return false; + } + return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer)); + } + + private String trimName(String value) { + return value == null ? "" : value.trim(); + } + @Override @Transactional(rollbackFor = Exception.class) public Long submit(BillLedgerSaveRequest request) { @@ -141,6 +165,11 @@ public class BillLedgerServiceImpl extends BaseServiceImpl candidates = new java.util.ArrayList<>(); + if (request.getPreSettlementId() != null) { + candidates.add(request.getPreSettlementId()); + } + if (request.getPreSettlementIds() != null) { + request.getPreSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add); + } + if (request.getSettlementId() != null) { + candidates.add(request.getSettlementId()); + } + if (request.getSettlementIds() != null) { + request.getSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add); + } + if (entity != null && entity.getPreSettlementId() != null) { + candidates.add(entity.getPreSettlementId()); + } + for (Long candidate : candidates) { + PreSettlement preSettlement = preSettlementMapper.selectById(candidate); + if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) { + return preSettlement.getId(); + } + } + String preSettlementNo = request.getPreSettlementNo(); + if (Func.isEmpty(preSettlementNo) && entity != null) { + preSettlementNo = entity.getPreSettlementNo(); + } + if (Func.isNotEmpty(preSettlementNo)) { + String settlementNo = preSettlementNo.split("、")[0].trim(); + PreSettlement preSettlement = preSettlementMapper.selectOne(Wrappers.lambdaQuery() + .eq(PreSettlement::getPreSettlementNo, settlementNo) + .eq(PreSettlement::getIsDeleted, 0) + .last("limit 1")); + if (preSettlement != null) { + return preSettlement.getId(); + } + } + return null; + } + private void fillReference(PaymentApplication entity, PaymentApplicationSaveRequest request) { - if ("project_advance".equals(request.getPaymentType())) { + if ("progress_advance".equals(request.getPaymentType())) { + Long preSettlementId = resolveProgressPreSettlementId(request, entity); + request.setPreSettlementId(preSettlementId); + if (preSettlementId == null) { + throw new ServiceException("进度预付必须关联预结算单"); + } + request.setSettlementId(null); + request.setSettlementIds(null); + fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId())); + } else if ("project_advance".equals(request.getPaymentType())) { ProjectApply project = projectApplyMapper.selectById(request.getProjectId()); if (project == null || Objects.equals(project.getIsDeleted(), 1)) throw new ServiceException("所属项目不存在"); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setPreSettlementId(null); entity.setPreSettlementNo(null); @@ -1981,6 +2037,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0) throw new ServiceException("申请付款金额超过汇票可用余额"); - if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); + if (!partyMatched && !departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); + } + + /** 出票单位、收票单位与结算付款方、收款方一致(顺序不限) */ + private boolean settlementPartiesMatch(BillLedger ledger, Long settlementId, Long preSettlementId) { + String payerName = null; + String payeeName = null; + if (settlementId != null) { + FormalSettlement settlement = formalSettlementMapper.selectById(settlementId); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + payerName = settlement.getPayerName(); + payeeName = settlement.getPayeeName(); + } + } + if ((payerName == null || payeeName == null) && preSettlementId != null) { + PreSettlement settlement = preSettlementMapper.selectById(preSettlementId); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + payerName = settlement.getPayerName(); + payeeName = settlement.getPayeeName(); + } + } + String issuer = ledger.getIssuerName() == null ? "" : ledger.getIssuerName().trim(); + String receiver = ledger.getReceiverName() == null ? "" : ledger.getReceiverName().trim(); + String payer = payerName == null ? "" : payerName.trim(); + String payee = payeeName == null ? "" : payeeName.trim(); + if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) { + return false; + } + return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer)); } private void useBillBalance(PaymentApplication entity) { BillLedger ledger = lockedBill(entity.getBillLedgerId()); - validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null); + validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null, + entity.getSettlementId(), entity.getPreSettlementId()); BillLedgerUsage exists = lockedBillUsage(entity.getId()); if (exists != null && TransportBusiConstant.APPROVED.equals(exists.getUsageStatus())) { throw new ServiceException("该付款申请已生成汇票使用记录"); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index 2b98c34a..f828826f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -206,6 +206,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementChangeRecord::getPreSettlementId, id) .eq(PreSettlementChangeRecord::getIsDeleted, 0) + .and(wrapper -> wrapper.isNull(PreSettlementChangeRecord::getChangeContent) + .or() + .ne(PreSettlementChangeRecord::getChangeContent, "预结算审批通过")) .orderByDesc(PreSettlementChangeRecord::getChangeTime))); vo.setPrintTemplates(resolvePrintTemplates(settlement)); return vo; @@ -355,8 +358,10 @@ public class PreSettlementServiceImpl extends BaseServiceImpl summaryBefore = created ? new ArrayList<>() : listSummaryFees(settlement.getId()); rebuildSummaryFees(settlement.getId(), true); applySummaryRequest(settlement.getId(), contractChanged ? List.of() : request.getSummaryFees()); + recordSummaryFeeChanges(settlement.getId(), summaryBefore); refreshSettlementAmount(settlement); projectAdvanceWriteOffService.replaceWriteOffs( TransportBusiConstant.SETTLEMENT_BILL_PRE, @@ -425,7 +430,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl beforeData = new LinkedHashMap<>(); - Map afterData = new LinkedHashMap<>(); - beforeData.put("费用项", row.getFeeItem()); - beforeData.put("调整金额", before); - beforeData.put("结算金额", money(row.getOriginalAmount().add(before))); - afterData.put("费用项", row.getFeeItem()); - afterData.put("调整金额", row.getAdjustAmount()); - afterData.put("结算金额", money(row.getSettlementAmount())); - saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", - "【" + row.getFeeItem() + "-调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】", - "", beforeData, afterData); - } } for (PreSettlementSummaryFee manualRow : manualRows) { if (!retainedManualIds.contains(manualRow.getId())) { summaryFeeMapper.deleteById(manualRow.getId()); - Map beforeData = new LinkedHashMap<>(); - Map afterData = new LinkedHashMap<>(); - beforeData.put("费用类型", manualRow.getFeeType()); - beforeData.put("费用项", manualRow.getFeeItem()); - beforeData.put("调整金额", money(manualRow.getAdjustAmount())); - beforeData.put("结算金额", money(manualRow.getSettlementAmount())); - beforeData.put("备注", manualRow.getRemark()); - afterData.put("费用类型", ""); - afterData.put("费用项", ""); - afterData.put("调整金额", BigDecimal.ZERO.setScale(2)); - afterData.put("结算金额", BigDecimal.ZERO.setScale(2)); - afterData.put("备注", ""); - saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除", - "删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】", - "", beforeData, afterData); } } for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) { @@ -1563,38 +1531,87 @@ public class PreSettlementServiceImpl extends BaseServiceImpl changes = new ArrayList<>(); + private void recordSummaryFeeChanges(Long settlementId, List beforeRows) { + List> beforeLines = summaryFeeLines(beforeRows); + List> afterLines = summaryFeeLines(listSummaryFees(settlementId)); + if (beforeLines.equals(afterLines)) { + return; + } + String content = summaryFeeChangeContent(beforeLines, afterLines); + if (Func.isEmpty(content)) { + return; + } Map beforeData = new LinkedHashMap<>(); Map afterData = new LinkedHashMap<>(); - if (creating) { - changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】"); - beforeData.put("费用类型", ""); - beforeData.put("费用项", ""); - beforeData.put("调整金额", BigDecimal.ZERO.setScale(2)); - beforeData.put("结算金额", BigDecimal.ZERO.setScale(2)); - beforeData.put("备注", ""); - afterData.put("费用类型", row.getFeeType()); - afterData.put("费用项", row.getFeeItem()); - afterData.put("调整金额", money(row.getAdjustAmount())); - afterData.put("结算金额", money(row.getSettlementAmount())); - afterData.put("备注", row.getRemark()); - saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "", - beforeData, afterData); - return; + beforeData.put("结算合计", beforeLines); + afterData.put("结算合计", afterLines); + String operationType = beforeLines.isEmpty() ? "新增" : afterLines.isEmpty() ? "删除" : "调整"; + saveChange(settlementId, "结算合计", null, operationType, content, "", beforeData, afterData); + } + + private List> summaryFeeLines(List rows) { + Map categoryNames = DictBizCache.getList("fee_category").stream() + .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); + List> lines = new ArrayList<>(); + for (PreSettlementSummaryFee row : rows) { + String feeType = row.getFeeType() == null ? "" : row.getFeeType(); + Map line = new LinkedHashMap<>(); + line.put("来源", Integer.valueOf(1).equals(row.getManualFlag()) ? "手工新增" : "系统归集"); + line.put("费用类型", categoryNames.getOrDefault(feeType, feeType)); + line.put("费用项", row.getFeeItem() == null ? "" : row.getFeeItem()); + line.put("原金额", money(row.getOriginalAmount())); + line.put("调整金额", money(row.getAdjustAmount())); + line.put("结算金额", money(row.getSettlementAmount())); + line.put("备注", row.getRemark() == null ? "" : row.getRemark()); + lines.add(line); } - appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType()); - appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem()); - appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount()); - appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount()); - appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark()); - if (changes.isEmpty()) { - return; + return lines; + } + + private String summaryFeeChangeContent(List> beforeLines, List> afterLines) { + Map> beforeMap = indexSummaryLines(beforeLines); + Map> afterMap = indexSummaryLines(afterLines); + List changes = new ArrayList<>(); + afterMap.forEach((key, afterLine) -> { + Map beforeLine = beforeMap.get(key); + String label = summaryLineLabel(afterLine); + if (beforeLine == null) { + changes.add("新增结算合计【" + label + "】结算金额【" + afterLine.get("结算金额") + "】"); + return; + } + if (!Objects.equals(beforeLine.get("原金额"), afterLine.get("原金额")) + || !Objects.equals(beforeLine.get("调整金额"), afterLine.get("调整金额")) + || !Objects.equals(beforeLine.get("结算金额"), afterLine.get("结算金额")) + || !Objects.equals(String.valueOf(beforeLine.get("备注")), String.valueOf(afterLine.get("备注")))) { + changes.add("调整结算合计【" + label + "】结算金额从【" + beforeLine.get("结算金额") + + "】调整为【" + afterLine.get("结算金额") + "】"); + } + }); + beforeMap.forEach((key, beforeLine) -> { + if (!afterMap.containsKey(key)) { + changes.add("删除结算合计【" + summaryLineLabel(beforeLine) + "】结算金额【" + beforeLine.get("结算金额") + "】"); + } + }); + return String.join(";", changes); + } + + private Map> indexSummaryLines(List> lines) { + Map> indexed = new LinkedHashMap<>(); + Map counts = new HashMap<>(); + for (Map line : lines) { + String base = String.valueOf(line.get("来源")) + "|" + line.get("费用类型") + "|" + line.get("费用项"); + int index = counts.merge(base, 1, Integer::sum); + indexed.put(base + "#" + index, line); } - saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "", - beforeData, afterData); + return indexed; + } + + private String summaryLineLabel(Map line) { + String feeType = String.valueOf(line.getOrDefault("费用类型", "")); + String feeItem = String.valueOf(line.getOrDefault("费用项", "")); + String source = String.valueOf(line.getOrDefault("来源", "")); + String name = feeType.isBlank() ? feeItem : feeType + "-" + feeItem; + return source + " " + name; } private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index f27388f7..8ce2f5f7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -424,9 +424,7 @@ public class ReceivablePayableDetailServiceImpl } }); ReceivablePayableFeeDetailVO result = buildFeeDetail(rows); - LinkedHashSet feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId())); - feeItemNames.addAll(result.getFeeItemNames()); - result.setFeeItemNames(new ArrayList<>(feeItemNames)); + // 只展示本明细已生成的费用项。合同上其他计费方案的费用项不能叠到当前明细。 return result; } @@ -1968,21 +1966,35 @@ public class ReceivablePayableDetailServiceImpl } private List calculatedFees(Waybill waybill, ContractManage contract, String planId) { - return calculatedFees(waybill, contract, planId, false); + return calculatedFees(waybill, contract, planId, false, false); } - private List calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) { + private List calculatedFees(Waybill waybill, ContractManage contract, String planId, + boolean matchOnly) { + return calculatedFees(waybill, contract, planId, matchOnly, false); + } + + /** + * @param strictPlan 更新费用时为 true:选定方案有规则但未命中时,不再回退运单历史费用,避免旧费用项叠加 + */ + private List calculatedFees(Waybill waybill, ContractManage contract, String planId, + boolean matchOnly, boolean strictPlan) { List> plans = parseList(contract == null ? null : contract.getBillingPlanJson()); Map plan = "__matched__".equals(planId) ? resolveDefaultBillingPlan(plans, waybill.getTransportType()) : resolveBillingPlan(plans, planId); - return calculatedFees(waybill, plan, matchOnly); + return calculatedFees(waybill, plan, matchOnly, strictPlan); } private List calculatedFees(Waybill waybill, Map plan, boolean matchOnly) { + return calculatedFees(waybill, plan, matchOnly, false); + } + + private List calculatedFees(Waybill waybill, Map plan, + boolean matchOnly, boolean strictPlan) { if (plan == null || !(plan.get("rules") instanceof List)) { - return matchOnly ? List.of() : buildCargoFees(waybill); + return matchOnly || strictPlan ? List.of() : buildCargoFees(waybill); } Map, ReceivablePayableCargoFee> feesByCargo = new LinkedHashMap<>(); Map, Map> feeItemsByCargo = new LinkedHashMap<>(); @@ -2024,7 +2036,11 @@ public class ReceivablePayableDetailServiceImpl ReceivablePayableCargoFee fee = result.get(index); fee.setLineNo(String.format("%04d", index + 1)); } - return result.isEmpty() && !matchOnly ? buildCargoFees(waybill) : result; + // 已选定计费方案时,只保留该方案算出的费用项,不用运单上的历史费用回填。 + if (strictPlan || matchOnly) { + return result; + } + return result.isEmpty() ? buildCargoFees(waybill) : result; } private ReceivablePayableCargoFee buildCalculatedCargoFee(Waybill waybill, Waybill feeWaybill, @@ -2934,7 +2950,7 @@ public class ReceivablePayableDetailServiceImpl throw new ServiceException("关联运单不存在"); } ContractManage contract = contractManageService.getById(detail.getContractId()); - List fees = calculatedFees(waybill, contract, billingPlanId); + List fees = calculatedFees(waybill, contract, billingPlanId, false, true); cargoFeeMapper.delete(Wrappers.lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); }); BigDecimal freight = fees.stream().map(fee -> money(fee.getFreightAmount())) @@ -2959,7 +2975,7 @@ public class ReceivablePayableDetailServiceImpl ContractManage contract = contractManageService.getById(detail.getContractId()); List masterGoods = masterOrderGoods(masterOrder); List fees = masterGoods.stream() - .flatMap(goods -> calculatedFees(goods, contract, billingPlanId).stream()).toList(); + .flatMap(goods -> calculatedFees(goods, contract, billingPlanId, false, true).stream()).toList(); normalizeMasterFeeLines(fees); cargoFeeMapper.delete(Wrappers.lambdaQuery() .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); @@ -2995,7 +3011,7 @@ public class ReceivablePayableDetailServiceImpl } ContractManage contract = contractManageService.getById(detail.getContractId()); List fees = waybills.stream() - .flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId).stream()).toList(); + .flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId, false, true).stream()).toList(); normalizeWaybillFeeLines(fees); cargoFeeMapper.delete(Wrappers.lambdaQuery() .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java index 5e0fb473..34d9d35f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java @@ -81,6 +81,8 @@ public class TransportPlanServiceImpl extends BaseServiceImpl selectTransportPlanPage(IPage page, TransportPlanVO transportPlan) { + // 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除 + backfillMissingDept(transportPlan); IPage entityPage = page(page, buildQuery(transportPlan)); IPage resultPage = TransportPlanWrapper.build().pageVO(entityPage); fillCustomerNamesFromContracts(resultPage.getRecords()); @@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpllambdaUpdate() + .set(TransportPlan::getDeptId, dept.getId()) + .set(TransportPlan::getDeptName, dept.getDeptName()) + .isNull(TransportPlan::getDeptId) + .eq(TransportPlan::getCreateDept, dept.getId()) + .eq(TransportPlan::getIsDeleted, 0)); + } + private LambdaQueryWrapper buildQuery(TransportPlanVO transportPlan) { TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(TransportPlan::getIsDeleted, 0); if (!Objects.equals(transportPlan.getAllDept(), 1)) { - queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划")); + Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划"); + // dept_id 为空的历史数据按创建组织归入当前组织 + queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId) + .or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId))); } else if (Func.isNotEmpty(transportPlan.getDeptId())) { queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId()); } From 604ae47b4f844c75eb81a21a120e2210866ebc0e Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 20 Oct 2026 14:43:51 +0800 Subject: [PATCH 5/8] =?UTF-8?q?:sparkles:=20=E5=AF=BC=E5=85=A5=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E6=8C=81=E6=9C=AA=E7=BB=93=E7=AE=97=E6=95=B4?= =?UTF-8?q?=E6=89=B9=E5=88=A0=E9=99=A4=E5=B9=B6=E8=B0=83=E6=95=B4=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E5=8D=95=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../pojo/vo/WaybillImportBatchVO.java | 6 + .../excel/SettlementImportTemplateExcel.java | 6 +- .../SettlementImportTemplateExporter.java | 56 +++--- .../SettlementImportBatchServiceImpl.java | 32 +--- .../impl/WaybillImportBatchServiceImpl.java | 180 +++++++++++++++++- 5 files changed, 212 insertions(+), 68 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java index 841236aa..b30c1973 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java @@ -20,6 +20,12 @@ public class WaybillImportBatchVO extends WaybillImportBatch { private static final long serialVersionUID = 1L; private String importTypeName; private String statusName; + /** 结算状态:unsettled/partial/settled */ + @Schema(description = "结算状态") + private String settlementStatus; + /** 结算状态名称:未结算/部分结算/已结算 */ + @Schema(description = "结算状态名称") + private String settlementStatusName; private String createUserName; private String updateUserName; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java index 871599eb..8ed3f35a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java @@ -37,7 +37,7 @@ import java.io.Serializable; import java.math.BigDecimal; /** - * 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项) + * 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。 * * @author Chill */ @@ -96,10 +96,6 @@ public class SettlementImportTemplateExcel implements Serializable { private BigDecimal unitPrice; @ExcelProperty("*运输费") private BigDecimal transportFee; - @ExcelProperty("装卸费") - private BigDecimal loadingFee; - @ExcelProperty("过路费") - private BigDecimal tollFee; /** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */ @ExcelIgnore private BigDecimal settlementAmount; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java index 91eb317e..cee7d476 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java @@ -47,12 +47,12 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; /** - * 导入结算单模板导出:填写说明 + 数据表头 + 运输方式下拉 + 系统费用项列。 + * 导入结算单模板导出:填写说明 + 固定数据表头 + 运输方式下拉。 + *

+ * 模板不预置费用项列;用户可在「*运输费」与后续固定列之间自行插入系统已启用的费用项列。 * * @author Chill */ @@ -93,15 +93,18 @@ public final class SettlementImportTemplateExporter { "同一运单标识号" ); - private static final List DEFAULT_FEE_ITEMS = List.of("装卸费", "过路费"); - private SettlementImportTemplateExporter() { } public static void export(HttpServletResponse response, List transportTypeOptions, List feeItemNames) { - List headers = buildHeaders(feeItemNames); - List transportOptions = defaultTransportOptions(transportTypeOptions); + List headers = buildHeaders(); + // 运输方式由调用方传入业务字典 transport_type 的字典名称,禁止写死 + List transportOptions = transportTypeOptions == null ? List.of() : transportTypeOptions.stream() + .filter(item -> item != null && !item.isBlank()) + .map(String::trim) + .distinct() + .toList(); try (Workbook workbook = new XSSFWorkbook()) { writeInstructionSheet(workbook, transportOptions, feeItemNames); Sheet dataSheet = workbook.createSheet("导入结算单模板"); @@ -127,20 +130,9 @@ public final class SettlementImportTemplateExporter { } } - private static List buildHeaders(List feeItemNames) { + /** 固定列模板:不预置费用项列,由用户自行插入。 */ + private static List buildHeaders() { List headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE); - Set feeNames = new LinkedHashSet<>(); - if (feeItemNames != null) { - for (String name : feeItemNames) { - if (name != null && !name.isBlank()) { - feeNames.add(name.trim()); - } - } - } - if (feeNames.isEmpty()) { - feeNames.addAll(DEFAULT_FEE_ITEMS); - } - headers.addAll(feeNames); headers.addAll(FIXED_HEADERS_AFTER_FEE); return headers; } @@ -154,18 +146,24 @@ public final class SettlementImportTemplateExporter { titleStyle.setFont(titleFont); List lines = new ArrayList<>(); lines.add("导入结算单填写说明"); - lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。"); - lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。"); + lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。"); + lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。"); lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。"); lines.add("4. 里程(km) 可为空;填写时须为正数。"); - lines.add("5. 「*运输费」之后、「*实际发货时间」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); + lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。"); - lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。"); + if (transportOptions != null && !transportOptions.isEmpty()) { + lines.add("7. 当前可选运输方式(业务字典 transport_type 名称):" + + String.join("、", transportOptions) + "。"); + } else { + lines.add("7. 运输方式请从业务字典 transport_type 维护的名称中选择。"); + } if (feeItemNames != null && !feeItemNames.isEmpty()) { - lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList()) + lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用费用项一致;当前系统已启用费用项:" + + String.join("、", feeItemNames.stream().limit(30).toList()) + (feeItemNames.size() > 30 ? " 等" : "") + "。"); } else { - lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。"); + lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用的费用项主数据名称完全一致。"); } for (int index = 0; index < lines.size(); index++) { Row row = sheet.createRow(index); @@ -201,10 +199,4 @@ public final class SettlementImportTemplateExporter { return style; } - private static List defaultTransportOptions(List transportTypeOptions) { - if (transportTypeOptions != null && !transportTypeOptions.isEmpty()) { - return transportTypeOptions; - } - return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); - } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java index 6270dbc4..31f0a8b0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java @@ -41,7 +41,6 @@ import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.DictBizCache; -import org.springblade.system.cache.DictCache; import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.DictBiz; @@ -824,7 +823,7 @@ public class SettlementImportBatchServiceImpl for (Map.Entry entry : feeItems.entrySet()) { String name = normalizeFeeName(entry.getKey()); BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue(); - // 模板自带的装卸费、过路费等列未填写时金额为 0,不要求费用项主数据存在同名档案 + // 用户自行增列的费用项金额为 0 时跳过校验;金额不为 0 须匹配系统已启用费用项 if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) { continue; } @@ -1149,9 +1148,6 @@ public class SettlementImportBatchServiceImpl excel.setMileage(decimalValue(row, "mileage", "里程(km)", "里程")); excel.setUnitPrice(decimalValue(row, "unitPrice", "运输单价", "单价")); excel.setTransportFee(decimalValue(row, "transportFee", "*运输费", "运输费")); - Map feeItems = extractFeeItems(row, List.of("装卸费", "过路费")); - excel.setLoadingFee(feeItems.get("装卸费")); - excel.setTollFee(feeItems.get("过路费")); excel.setSettlementAmount(decimalValue(row, "settlementAmount", "结算金额")); excel.setActualStartDate(stringValue(row, "actualStartDate", "*实际发货时间", "实际发货时间")); excel.setActualEndDate(stringValue(row, "actualEndDate", "*实际完成时间", "实际完成时间")); @@ -1330,34 +1326,26 @@ public class SettlementImportBatchServiceImpl return sanitized; } + /** + * 运输方式取业务字典 transport_type 的字典名称(dictValue),不写死枚举。 + */ private List loadTransportTypeLabels() { LinkedHashSet labels = new LinkedHashSet<>(); try { List dictBizList = DictBizCache.getList("transport_type"); if (Func.isNotEmpty(dictBizList)) { dictBizList.stream() - .map(DictBiz::getDictValue) + .filter(dict -> dict != null && Func.isNotEmpty(dict.getDictValue()) + && !"-1".equals(Func.toStr(dict.getDictKey()))) + .map(dict -> dict.getDictValue().trim()) .filter(Func::isNotEmpty) - .map(String::trim) .forEach(labels::add); } - } catch (Exception ignored) { - // 业务字典不可用时继续读取系统字典 - } - try { - List dictList = DictCache.getList("transport_type"); - if (Func.isNotEmpty(dictList)) { - dictList.stream() - .map(org.springblade.system.pojo.entity.Dict::getDictValue) - .filter(Func::isNotEmpty) - .map(String::trim) - .forEach(labels::add); - } - } catch (Exception ignored) { - // 忽略 + } catch (Exception exception) { + throw new ServiceException("业务字典 transport_type 读取失败,请检查业务字典配置"); } if (labels.isEmpty()) { - return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); + throw new ServiceException("业务字典 transport_type 未配置运输方式,请先维护字典名称"); } return new ArrayList<>(labels); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index debfa16d..61604120 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -30,7 +30,9 @@ import org.springblade.transport.excel.WaybillImportBatchExcel; import org.springblade.transport.mapper.WaybillImportBatchMapper; import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.entity.CustomerArchive; +import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.pojo.entity.ReceivablePayableDetail; import org.springblade.transport.pojo.entity.TransportPlan; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillImportBatch; @@ -76,6 +78,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl entityPage = page(page, queryWrapper); List records = entityPage.getRecords().stream().map(this::toVO).toList(); + fillSettlementStatus(records); Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); resultPage.setRecords(records); return resultPage; @@ -389,18 +399,33 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl batches = listByIds(idList).stream() .filter(batch -> !Objects.equals(batch.getIsDeleted(), 1)) .toList(); - // 与前端一致:仅草稿可删;选中含已完成/进行中时整批拒绝 - boolean hasNonDraft = batches.stream() - .anyMatch(batch -> !STATUS_DRAFT.equals(batch.getImportStatus())); - if (hasNonDraft) { - throw new ServiceException("已完成运单不可删除"); + if (Func.isEmpty(batches)) { + throw new ServiceException("运单批次不存在或已删除"); + } + Map> waybillsByBatchId = listWaybillsByBatchIds( + batches.stream().map(WaybillImportBatch::getId).toList()); + List allWaybillIds = waybillsByBatchId.values().stream() + .flatMap(List::stream) + .map(Waybill::getId) + .filter(Objects::nonNull) + .toList(); + Set settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds); + // 仅未结算批次可整批删除;选中含部分结算/已结算时整批拒绝 + for (WaybillImportBatch batch : batches) { + List waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of()); + String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds); + if (!SETTLEMENT_UNSETTLED.equals(settlementStatus)) { + throw new ServiceException("仅未结算的运单批次可以删除"); + } } BusinessRemoveResultVO result = new BusinessRemoveResultVO(); for (WaybillImportBatch batch : batches) { - List waybills = waybillService.list(Wrappers.lambdaQuery() - .eq(Waybill::getImportBatchId, batch.getId()).eq(Waybill::getIsDeleted, 0)); - if (Func.isNotEmpty(waybills)) { - waybillService.deleteLogic(waybills.stream().map(Waybill::getId).toList()); + List waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of()); + List waybillIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList(); + if (Func.isNotEmpty(waybillIds)) { + clearUnsettledReceivableDetails(waybillIds); + clearImportedLoadings(waybills); + waybillService.deleteLogic(waybillIds); } deleteLogic(List.of(batch.getId())); result.setSuccessCount(result.getSuccessCount() + 1); @@ -671,11 +696,148 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl records) { + if (Func.isEmpty(records)) { + return; + } + List batchIds = records.stream().map(WaybillImportBatchVO::getId).filter(Objects::nonNull).toList(); + Map> waybillsByBatchId = listWaybillsByBatchIds(batchIds); + List allWaybillIds = waybillsByBatchId.values().stream() + .flatMap(List::stream) + .map(Waybill::getId) + .filter(Objects::nonNull) + .toList(); + Set settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds); + for (WaybillImportBatchVO record : records) { + List waybills = waybillsByBatchId.getOrDefault(record.getId(), List.of()); + String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds); + record.setSettlementStatus(settlementStatus); + record.setSettlementStatusName(settlementStatusName(settlementStatus)); + } + } + + private Map> listWaybillsByBatchIds(List batchIds) { + if (Func.isEmpty(batchIds)) { + return Map.of(); + } + List waybills = waybillService.list(Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getImportBatchId, Waybill::getLoadingNo) + .in(Waybill::getImportBatchId, batchIds) + .eq(Waybill::getIsDeleted, 0)); + return waybills.stream() + .filter(waybill -> waybill.getImportBatchId() != null) + .collect(Collectors.groupingBy(Waybill::getImportBatchId)); + } + + private String resolveSettlementStatus(List waybills, Set settledWaybillIds) { + if (Func.isEmpty(waybills)) { + return SETTLEMENT_UNSETTLED; + } + int settledCount = 0; + for (Waybill waybill : waybills) { + if (waybill.getId() != null && settledWaybillIds.contains(waybill.getId())) { + settledCount++; + } + } + if (settledCount <= 0) { + return SETTLEMENT_UNSETTLED; + } + if (settledCount >= waybills.size()) { + return SETTLEMENT_SETTLED; + } + return SETTLEMENT_PARTIAL; + } + + private String settlementStatusName(String settlementStatus) { + if (SETTLEMENT_SETTLED.equals(settlementStatus)) { + return "已结算"; + } + if (SETTLEMENT_PARTIAL.equals(settlementStatus)) { + return "部分结算"; + } + return "未结算"; + } + + /** 删除批次前清理尚未进入预/正式结算的应收应付明细,避免残留待结算数据。 */ + private void clearUnsettledReceivableDetails(List waybillIds) { + if (Func.isEmpty(waybillIds)) { + return; + } + List details = receivablePayableDetailService.list( + Wrappers.lambdaQuery() + .in(ReceivablePayableDetail::getWaybillId, waybillIds) + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getPreSettlementNo) + .or().eq(ReceivablePayableDetail::getPreSettlementNo, "")) + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getFormalSettlementNo) + .or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))); + if (Func.isNotEmpty(details)) { + receivablePayableDetailService.deleteLogic( + details.stream().map(ReceivablePayableDetail::getId).toList()); + } + } + + /** 清理本批次运单关联的批量导入配载单(配载单运单均属本批时整单删除)。 */ + private void clearImportedLoadings(List waybills) { + if (Func.isEmpty(waybills)) { + return; + } + Set batchWaybillIds = waybills.stream() + .map(Waybill::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + Set loadingNos = waybills.stream() + .map(Waybill::getLoadingNo) + .filter(Func::isNotEmpty) + .collect(Collectors.toCollection(HashSet::new)); + if (Func.isEmpty(loadingNos)) { + return; + } + List loadings = loadingManageService.list(Wrappers.lambdaQuery() + .in(LoadingManage::getLoadingNo, loadingNos) + .eq(LoadingManage::getDataSource, LOADING_DATA_SOURCE_IMPORT) + .eq(LoadingManage::getIsDeleted, 0)); + List deleteLoadingIds = new ArrayList<>(); + for (LoadingManage loading : loadings) { + List loadingWaybillIds = parseLoadingWaybillIds(loading.getWaybillIdsJson()); + if (Func.isEmpty(loadingWaybillIds) || batchWaybillIds.containsAll(loadingWaybillIds)) { + deleteLoadingIds.add(loading.getId()); + } + } + if (Func.isNotEmpty(deleteLoadingIds)) { + loadingManageService.deleteLogic(deleteLoadingIds); + } + } + + private List parseLoadingWaybillIds(String waybillIdsJson) { + if (Func.isEmpty(waybillIdsJson)) { + return List.of(); + } + try { + Object parsed = JsonUtil.parse(waybillIdsJson, List.class); + if (parsed instanceof List list) { + return list.stream() + .filter(Objects::nonNull) + .map(item -> Long.valueOf(String.valueOf(item))) + .toList(); + } + } catch (Exception ignored) { + // 兼容历史逗号分隔存储 + } + return Func.toLongList(waybillIdsJson); + } + @Override public String nextBatchNo() { return nextCode(); From 21ed3e842fe8153040e9ed117f02920cdbe2a2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Tue, 20 Oct 2026 18:48:00 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20HCS=20=E5=90=88=E8=A7=84=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=20Mock=20=E7=AB=AF=E7=82=B9,=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/HcsMockController.java | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java new file mode 100644 index 00000000..247f0e87 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java @@ -0,0 +1,123 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import cn.hutool.core.util.StrUtil; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tenant.annotation.TenantIgnore; +import org.springblade.core.tool.api.R; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Objects; + +/** + * HCS 合规回调 Mock 端点 + *

+ * 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、 + * 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果, + * 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回 + * {@link } 并调用 Support 方法即可。 + * + * @author Chill + */ +@Slf4j +@RestController +@TenantIgnore +@RequestMapping("hcs") +@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)") +public class HcsMockController { + + /** + * 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致) + */ + private static final String SOURCE_SYSTEM = "WLTMS"; + + /** + * 合同法务审批结果回调(D103) + */ + @PostMapping("/contractLegal/result") + @ApiOperationSupport(order = 15) + @Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据") + public R contractLegalResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("合同法务审批结果", param); + return R.data("mock特批结果已处理:approved"); + } + + /** + * 付款法务审批结果回调(D104) + */ + @PostMapping("/paymentLegal/result") + @ApiOperationSupport(order = 16) + @Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据") + public R paymentLegalResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("付款法务审批结果", param); + return R.data("mock付款特批结果接收已处理:approved"); + } + + /** + * 项目金蝶同步结果回调(D902) + */ + @PostMapping("/projectLegal/result") + @ApiOperationSupport(order = 18) + @Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据") + public R projectLegalResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("项目金蝶同步结果", param); + return R.data("mock项目金蝶同步结果接收已处理:approved"); + } + + /** + * 合同金蝶同步结果回传接收(D903) + */ + @PostMapping("/contractKingdee/result") + @ApiOperationSupport(order = 19) + @Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据") + public R contractKingdeeResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("合同金蝶同步结果", param); + return R.data("mock合同金蝶同步结果接收处理:approved"); + } + + /** + * 来源系统校验:仅接受合规方约定的来源标识,防伪造回传 + * + * @param bizName 业务名称(日志用) + * @param param 回调报文 + */ + private void checkSourceSystem(String bizName, Map param) { + String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null); + if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) { + log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem); + throw new ServiceException("来源系统标识错误"); + } + } +} From 0208d6612ad23032bedc3dd58b9d8d73124f1f7a Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 21 Oct 2026 13:57:32 +0800 Subject: [PATCH 7/8] =?UTF-8?q?:bug:=20OA=E9=83=A8=E9=97=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=90=8D=E7=A7=B0=E6=94=B9=E7=94=A8departmentmark?= =?UTF-8?q?=EF=BC=8C=E5=90=8D=E7=A7=B0=E4=B8=8D=E4=B8=80=E8=87=B4=E6=97=B6?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/system/convert/DeptConvert.java | 4 ++-- .../system/service/impl/OASyncServiceImpl.java | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java index ba5bdf43..dc045868 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java @@ -49,8 +49,8 @@ public interface DeptConvert { @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); - @Mapping(source = "departmentname", target = "deptName") - @Mapping(source = "departmentmark", target = "fullName") + @Mapping(source = "departmentmark", target = "deptName") + @Mapping(source = "departmentname", target = "fullName") @Mapping(source = "supdepid", target = "parentCode") @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index e133b6c5..90588373 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -761,7 +761,7 @@ public class OASyncServiceImpl implements IOASyncService { .filter(oaDept -> syncAll || rootCompanyId == null || !rootCompanyId.equals(oaDept.getSubcompanyid1()) - || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname()))) + || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentmark()))) .map(dept -> deptConvert.dept2dept(dept, companyByOaId)) .toList(); int filteredSkipCount = oaDepts.size() - allParam.size(); @@ -921,8 +921,9 @@ public class OASyncServiceImpl implements IOASyncService { .eq(Dept::getDeptCategory, deptCategory.getCode()) .isNotNull(Dept::getOaId) .ne(Dept::getOaId, "") - .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId, - Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId)); + .select(Dept::getId, Dept::getDeptCode, Dept::getDeptName, Dept::getFullName, + Dept::getOaModified, Dept::getParentId, Dept::getOaId, Dept::getBelongCompanyCode, + Dept::getParentCode, Dept::getOaSupSubComId)); Map byOaId = new HashMap<>(); for (Dept dept : deptList) { if (StringUtils.isNotBlank(dept.getOaId())) { @@ -1047,7 +1048,7 @@ public class OASyncServiceImpl implements IOASyncService { /** * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。 - * 组织编码已按新规则重算、或父子挂载与库中不一致时,即使 modified 相同也要更新。 + * 组织编码已按新规则重算、父子挂载不一致、或名称字段与当前映射不一致时,即使 modified 相同也要更新。 */ private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) { if (existing == null) { @@ -1066,6 +1067,14 @@ public class OASyncServiceImpl implements IOASyncService { StringUtils.trimToEmpty(incoming.getParentCode()))) { return false; } + if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getDeptName()), + StringUtils.trimToEmpty(incoming.getDeptName()))) { + return false; + } + if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getFullName()), + StringUtils.trimToEmpty(incoming.getFullName()))) { + return false; + } String localModified = StringUtils.trimToEmpty(existing.getOaModified()); String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); if (localModified.isEmpty() || remoteModified.isEmpty()) { From 89bfeddb121124397a401d05b5eaf33989920038 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 21 Oct 2026 15:36:34 +0800 Subject: [PATCH 8/8] =?UTF-8?q?:sparkles:=20OA=E4=BA=BA=E5=91=98=E6=97=A0?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=90=8D=E6=97=B6=E6=8C=89=E5=B7=A5=E5=8F=B7?= =?UTF-8?q?=E5=85=A5=E5=BA=93=EF=BC=8C=E5=85=A8=E9=87=8F=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=BD=93=E5=89=8D=E9=83=A8=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../system/pojo/vo/OaPersonSyncPageVO.java | 3 + .../system/mapper/UserDeptMapper.xml | 2 +- .../service/impl/OASyncServiceImpl.java | 24 +- .../service/impl/OaUserListSyncHelper.java | 480 ++++++++++++++++-- 4 files changed, 456 insertions(+), 53 deletions(-) diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java index d92f413b..2e7e75f3 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java @@ -61,6 +61,9 @@ public class OaPersonSyncPageVO implements Serializable { @Schema(description = "本页跳过条数") private Integer skippedCount; + @Schema(description = "全量同步结束时删除的、不在本次 OA 数据中的 is_oa=2 人员数") + private Integer removedCount; + @Schema(description = "是否已到最后一页") private Boolean finished; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml index 76c059bf..24b2d01f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml @@ -13,7 +13,7 @@ bud.user_id from blade_user_dept bud - left join blade_dept bd on bd.dept_code = bud.dept_code + left join blade_dept bd on bd.id = bud.dept_id and bd.is_deleted = 0 where bd.id is not null and bud.is_deleted = 0 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index 90588373..7978f376 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -277,6 +277,11 @@ public class OASyncServiceImpl implements IOASyncService { }, 10000, ComposeLogUtil.getLastLog()::info).accept(oaPersons::addAll); OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons); OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex); + if (startTime == null) { + oaUserListSyncHelper.removeOaUsersAbsentFrom( + oaUserListSyncHelper.extractOaLoginIds(oaPersons), + oaUserListSyncHelper.extractWorkcodes(oaPersons)); + } CacheUtil.clear(USER_CACHE); CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); @@ -295,9 +300,10 @@ 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); - // 全量同步首页开启原始数据收集,结束后按父级 id 写入 JSON + // 全量同步首页开启原始数据收集,并开始累积登录名,最后一页再删除本地多余人员 if (!incremental && pageNo == 1) { oaSyncRawDumpHelper.beginFullDump(); + oaUserListSyncHelper.beginFullSyncReconcile(); } IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince); OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime()); @@ -314,9 +320,17 @@ public class OASyncServiceImpl implements IOASyncService { long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); if (!incremental) { oaSyncRawDumpHelper.collectPersons(oaPersons); + oaUserListSyncHelper.collectFullSyncPersons(oaPersons); } OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons); OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex); + boolean finished = oaPersons.isEmpty() + || oaPersons.size() < pageSize + || (long) pageNo * pageSize >= totalSize; + int removedCount = personSyncCount.getRemovedCount(); + if (!incremental && finished) { + removedCount += oaUserListSyncHelper.finishFullSyncReconcile(); + } CacheUtil.clear(USER_CACHE); CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); @@ -327,17 +341,15 @@ public class OASyncServiceImpl implements IOASyncService { pageVO.setFetchedCount(oaPersons.size()); pageVO.setSyncedCount(personSyncCount.getSyncedCount()); pageVO.setSkippedCount(personSyncCount.getSkippedCount()); + pageVO.setRemovedCount(removedCount); pageVO.setModifiedSince(incrementalStart.getModifiedSince()); - boolean finished = oaPersons.isEmpty() - || oaPersons.size() < pageSize - || (long) pageNo * pageSize >= totalSize; pageVO.setFinished(finished); if (!incremental && finished) { pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump()); } - ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}", + ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{},删除{}", incremental ? "incremental" : "full", pageNo, totalSize, - personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); + personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount(), removedCount); return pageVO; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index 46f8af2b..f7382827 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springblade.common.constant.CommonConstant; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.core.tool.utils.DigestUtil; @@ -55,10 +56,12 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD; @@ -76,6 +79,22 @@ public class OaUserListSyncHelper { private static final int MAX_DEPT_ID_LENGTH = 2000; + private static final int OA_LOGIN_ID_BATCH_SIZE = 500; + + /** + * OA 人员状态:5 离职。离职人员不入库,已有 is_oa=2 账号直接删除。 + */ + private static final String OA_PERSON_STATUS_RESIGNED = "5"; + + /** + * 全量同步跨页累积的 OA 登录名。分页接口每页独立事务,删除必须等最后一页再执行。 + */ + private final Set fullSyncLoginIds = ConcurrentHashMap.newKeySet(); + + private final Set fullSyncWorkcodes = ConcurrentHashMap.newKeySet(); + + private volatile boolean fullSyncCollecting; + private final IDeptService deptService; private final IUserService userService; private final UserConvert userConvert; @@ -390,48 +409,325 @@ public class OaUserListSyncHelper { } /** - * 同步人员并绑定到三级部门 + * 全量同步从第一页开始收集 OA 登录名,最后一页再删除本地多余的 is_oa=2 人员。 + */ + public synchronized void beginFullSyncReconcile() { + this.fullSyncLoginIds.clear(); + this.fullSyncWorkcodes.clear(); + this.fullSyncCollecting = true; + } + + /** + * 累积本页 OA 人员登录名,供全量同步结束后比对删除。 + * + * @param oaPersons 本页 OA 人员 + */ + public synchronized void collectFullSyncPersons(List oaPersons) { + if (!this.fullSyncCollecting || CollectionUtil.isEmpty(oaPersons)) { + return; + } + this.fullSyncLoginIds.addAll(this.extractOaLoginIds(oaPersons)); + this.fullSyncWorkcodes.addAll(this.extractWorkcodes(oaPersons)); + } + + /** + * 全量同步结束后,删除登录名不在本次 OA 数据中的 is_oa=2 人员。 + * + * @return 删除人数;未开始收集或登录名集合为空时返回 0,避免误删全部 OA 人员 + */ + public synchronized int finishFullSyncReconcile() { + if (!this.fullSyncCollecting) { + return 0; + } + this.fullSyncCollecting = false; + Set oaLoginIds = new LinkedHashSet<>(this.fullSyncLoginIds); + Set workcodes = new LinkedHashSet<>(this.fullSyncWorkcodes); + this.fullSyncLoginIds.clear(); + this.fullSyncWorkcodes.clear(); + return this.removeOaUsersAbsentFrom(oaLoginIds, workcodes); + } + + /** + * 提取仍在职的 OA 人员登录名。离职人员不计入,全量同步结束时会删掉对应本地账号。 + * + * @param oaPersons OA人员 + * @return 去重后的登录名 + */ + public Set extractOaLoginIds(List oaPersons) { + Set oaLoginIds = new LinkedHashSet<>(); + if (CollectionUtil.isEmpty(oaPersons)) { + return oaLoginIds; + } + for (OAPersonResponse oaPerson : oaPersons) { + String oaLoginId = this.resolveOaLoginId(oaPerson); + if (oaLoginId != null && !this.isOaResigned(oaPerson)) { + oaLoginIds.add(oaLoginId); + } + } + return oaLoginIds; + } + + /** + * 提取仍在职人员的工号。没有登录名时靠工号保留本地账号。 + * + * @param oaPersons OA人员 + * @return 去重后的工号 + */ + public Set extractWorkcodes(List oaPersons) { + Set workcodes = new LinkedHashSet<>(); + if (CollectionUtil.isEmpty(oaPersons)) { + return workcodes; + } + for (OAPersonResponse oaPerson : oaPersons) { + String workcode = this.resolveWorkcode(oaPerson); + if (workcode != null && !this.isOaResigned(oaPerson)) { + workcodes.add(workcode); + } + } + return workcodes; + } + + /** + * 删除当前租户中 is_oa=2、且既不在本次登录名里、也不是无登录名工号人员的账号。 + * 登录名和工号都没收集到时不删除。当前登录账号即使不在同步数据中也保留。 + * + * @param oaLoginIds 本次全量同步出现过的 OA 登录名 + * @param workcodes 本次全量同步出现过的工号 + * @return 删除人数 + */ + public int removeOaUsersAbsentFrom(Set oaLoginIds, Set workcodes) { + boolean noLoginIds = CollectionUtil.isEmpty(oaLoginIds); + boolean noWorkcodes = CollectionUtil.isEmpty(workcodes); + if (noLoginIds && noWorkcodes) { + ComposeLogUtil.getLastLog().warn("全量同步未收集到OA登录名和工号,跳过删除本地 is_oa=2 人员"); + return 0; + } + Set loginIdSet = noLoginIds ? Set.of() : oaLoginIds; + Set workcodeSet = noWorkcodes ? Set.of() : workcodes; + String tenantId = this.resolveTenantId(); + List oaUsers = this.userService.list(Wrappers.lambdaQuery() + .select(User::getId, User::getOaLoginId, User::getCode) + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES)); + Long currentUserId = AuthUtil.getUserId(); + List removeIds = new ArrayList<>(); + for (User oaUser : oaUsers) { + if (oaUser.getId() == null) { + continue; + } + if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) { + 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) { + removeIds.add(oaUser.getId()); + } + } + if (removeIds.isEmpty()) { + ComposeLogUtil.getLastLog().info("全量同步无需删除 is_oa=2 人员"); + return 0; + } + return this.deleteOaUsers(removeIds, "全量同步删除不在OA数据中的 is_oa=2 人员"); + } + + /** + * 按 OA 登录名删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。 + * + * @param tenantId 租户 + * @param oaLoginIds 离职人员登录名 + * @return 删除人数 + */ + private int removeOaUsersByLoginIds(String tenantId, Collection oaLoginIds) { + if (CollectionUtil.isEmpty(oaLoginIds)) { + return 0; + } + List oaLoginIdList = oaLoginIds.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + if (oaLoginIdList.isEmpty()) { + return 0; + } + Long currentUserId = AuthUtil.getUserId(); + List removeIds = new ArrayList<>(); + for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size())); + this.userService.list(Wrappers.lambdaQuery() + .select(User::getId, User::getOaLoginId) + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) + .in(User::getOaLoginId, oaLoginIdBatch)) + .forEach(oaUser -> { + if (oaUser.getId() == null) { + return; + } + if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) { + ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId()); + return; + } + removeIds.add(oaUser.getId()); + }); + } + return this.deleteOaUsers(removeIds, "同步删除OA离职人员"); + } + + /** + * 删除没有登录名、且工号属于离职人员的 is_oa=2 账号。 + * + * @param tenantId 租户 + * @param workcodes 离职人员工号 + * @return 删除人数 + */ + private int removeOaUsersByBlankLoginWorkcodes(String tenantId, Collection workcodes) { + if (CollectionUtil.isEmpty(workcodes)) { + return 0; + } + List workcodeList = workcodes.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + if (workcodeList.isEmpty()) { + return 0; + } + Long currentUserId = AuthUtil.getUserId(); + List removeIds = new ArrayList<>(); + for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size())); + this.userService.list(Wrappers.lambdaQuery() + .select(User::getId, User::getOaLoginId, User::getCode) + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) + .in(User::getCode, workcodeBatch) + .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, ""))) + .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离职人员"); + } + + /** + * 逻辑删除用户及其部门关系。 + * + * @param removeIds 用户 id + * @param logMessage 日志说明 + * @return 删除人数 + */ + private int deleteOaUsers(List removeIds, String logMessage) { + if (CollectionUtil.isEmpty(removeIds)) { + return 0; + } + for (int fromIndex = 0; fromIndex < removeIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List removeIdBatch = removeIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, removeIds.size())); + this.userService.deleteLogic(removeIdBatch); + this.userDeptService.remove(Wrappers.lambdaQuery().in(UserDept::getUserId, removeIdBatch)); + } + ComposeLogUtil.getLastLog().info("{}:{}", logMessage, removeIds.size()); + return removeIds.size(); + } + + /** + * OA 状态 5 表示离职。 + */ + private boolean isOaResigned(OAPersonResponse oaPerson) { + return oaPerson != null && OA_PERSON_STATUS_RESIGNED.equals(StringUtils.trimToEmpty(oaPerson.getStatus())); + } + + /** + * 同步人员并绑定到三级部门。有登录名按登录名更新,没有登录名但有工号则按工号更新且不回填登录名。离职人员直接删除。 * * @param oaPersons OA人员 * @param orgIndex 组织索引 - * @return 本批同步成功与跳过数量 + * @return 本批同步成功、跳过与删除数量 */ public PersonSyncCount handlePerson(List oaPersons, OaOrgIndex orgIndex) { if (CollectionUtil.isEmpty(oaPersons)) { - return new PersonSyncCount(0, 0); + return new PersonSyncCount(0, 0, 0); } Map uniquePersonMap = new LinkedHashMap<>(); + Set resignedLoginIds = new LinkedHashSet<>(); + Set resignedWorkcodes = new LinkedHashSet<>(); int skippedCount = 0; for (OAPersonResponse oaPerson : oaPersons) { - String oaLoginId = resolveOaLoginId(oaPerson); - if (StringUtils.isBlank(oaLoginId)) { + String syncKey = this.resolveSyncKey(oaPerson); + if (syncKey == null) { skippedCount++; continue; } - uniquePersonMap.put(oaLoginId, oaPerson); - } - if (uniquePersonMap.isEmpty()) { - ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步"); - return new PersonSyncCount(0, skippedCount); + 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) { + resignedWorkcodes.add(workcode); + } + continue; + } + if (oaLoginId != null) { + resignedLoginIds.remove(oaLoginId); + } else if (workcode != null) { + resignedWorkcodes.remove(workcode); + } + uniquePersonMap.put(syncKey, oaPerson); } String tenantId = resolveTenantId(); + int removedCount = this.removeOaUsersByLoginIds(tenantId, resignedLoginIds) + + this.removeOaUsersByBlankLoginWorkcodes(tenantId, resignedWorkcodes); + if (uniquePersonMap.isEmpty()) { + if (resignedLoginIds.isEmpty() && resignedWorkcodes.isEmpty()) { + ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名和工号,跳过人员同步"); + } + return new PersonSyncCount(0, skippedCount, removedCount); + } String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); - List users = uniquePersonMap.entrySet().stream() - .map(entry -> { - User user = userConvert.person2user(entry.getValue(), defaultPassword); - user.setTenantId(tenantId); - user.setOaLoginId(entry.getKey()); - user.setAccount(entry.getKey()); - return user; - }) - .toList(); - Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet()); + Set oaLoginIds = new LinkedHashSet<>(); + Set workcodes = new LinkedHashSet<>(); + for (OAPersonResponse oaPerson : uniquePersonMap.values()) { + String oaLoginId = this.resolveOaLoginId(oaPerson); + if (oaLoginId != null) { + oaLoginIds.add(oaLoginId); + continue; + } + String workcode = this.resolveWorkcode(oaPerson); + if (workcode != null) { + workcodes.add(workcode); + } + } + Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, oaLoginIds); + Map existingByWorkcode = this.loadUsersByBlankLoginWorkcode(tenantId, workcodes); + List users = new ArrayList<>(); Map userMap = new HashMap<>(); Set existsUserIds = new HashSet<>(); - users.forEach(user -> { - User existingUser = existingByOaLoginId.get(user.getOaLoginId()); + for (Map.Entry entry : uniquePersonMap.entrySet()) { + OAPersonResponse oaPerson = entry.getValue(); + String oaLoginId = this.resolveOaLoginId(oaPerson); + 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); + if (oaLoginId != null) { + user.setOaLoginId(oaLoginId); + user.setAccount(oaLoginId); + } else { + user.setOaLoginId(null); + user.setAccount(existingUser == null ? workcode : null); + } if (existingUser != null) { user.setId(existingUser.getId()); + // 全局更新策略为 not_null,置空后不会回写密码和登录名 user.setPassword(null); existsUserIds.add(existingUser.getId()); } else { @@ -442,9 +738,10 @@ public class OaUserListSyncHelper { user.setDataLevelRange(1); user.setIncludeNewCustomer(0); } - userService.encryptPhone(user); - userMap.put(user.getOaLoginId(), user.getId()); - }); + this.userService.encryptPhone(user); + users.add(user); + userMap.put(entry.getKey(), user.getId()); + } List addUsers = users.stream() .filter(user -> !existsUserIds.contains(user.getId())) @@ -460,33 +757,58 @@ public class OaUserListSyncHelper { ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size()); userService.updateBatchById(updateUsers); } - ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount); + ComposeLogUtil.getLastLog().info("缺少登录名和工号已跳过的人员:{}", skippedCount); int syncedCount = addUsers.size() + updateUsers.size(); if (userMap.isEmpty()) { - return new PersonSyncCount(0, skippedCount); + return new PersonSyncCount(0, skippedCount, removedCount); } - Map userDeptMap = userDeptService.list(Wrappers.lambdaQuery() - .in(UserDept::getUserId, userMap.values()) - ).stream() + List existingUserDeptList = userDeptService.list(Wrappers.lambdaQuery() + .in(UserDept::getUserId, userMap.values())); + Map userDeptMap = existingUserDeptList.stream() .collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second)); Set existsUserDeptIds = new HashSet<>(userDeptMap.values()); - List userDeptList = oaPersons.stream() + Map currentUserDeptMap = new LinkedHashMap<>(); + oaPersons.stream() .filter(oaPerson -> { - String oaLoginId = resolveOaLoginId(oaPerson); - return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId); + String syncKey = this.resolveSyncKey(oaPerson); + return syncKey != null && userMap.containsKey(syncKey) && !this.isOaResigned(oaPerson); }) .map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex)) .filter(Objects::nonNull) + .forEach(userDept -> { + String userDeptKey = getUserDeptKey(userDept); + if (userDeptMap.containsKey(userDeptKey)) { + userDept.setId(userDeptMap.get(userDeptKey)); + } else { + UserDept duplicated = currentUserDeptMap.get(userDeptKey); + userDept.setId(duplicated == null + ? DefaultIdentifierGenerator.getInstance().nextId(null) + : duplicated.getId()); + } + currentUserDeptMap.put(userDeptKey, userDept); + }); + List userDeptList = new ArrayList<>(currentUserDeptMap.values()); + Set currentUserDeptIds = userDeptList.stream() + .map(UserDept::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Set reboundUserIds = userDeptList.stream() + .map(UserDept::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List staleUserDeptIds = existingUserDeptList.stream() + .filter(userDept -> userDept.getId() != null && reboundUserIds.contains(userDept.getUserId())) + .map(UserDept::getId) + .filter(userDeptId -> !currentUserDeptIds.contains(userDeptId)) .toList(); - userDeptList.forEach(userDept -> { - String userDeptKey = getUserDeptKey(userDept); - if (userDeptMap.containsKey(userDeptKey)) { - userDept.setId(userDeptMap.get(userDeptKey)); - } else { - userDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); + if (CollectionUtil.isNotEmpty(staleUserDeptIds)) { + ComposeLogUtil.getLastLog().info("移除不再属于当前OA部门的人员关系:{}", staleUserDeptIds.size()); + for (int fromIndex = 0; fromIndex < staleUserDeptIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List staleIdBatch = staleUserDeptIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, staleUserDeptIds.size())); + userDeptService.removeByIds(staleIdBatch); } - }); + } List addList = userDeptList.stream() .filter(userDept -> !existsUserDeptIds.contains(userDept.getId())) .toList(); @@ -540,11 +862,11 @@ public class OaUserListSyncHelper { if (CollectionUtil.isNotEmpty(updateUserParams)) { userService.updateBatchById(updateUserParams); } - return new PersonSyncCount(syncedCount, skippedCount); + return new PersonSyncCount(syncedCount, skippedCount, removedCount); } /** - * 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。 + * 按 OA 登录名加载已有 is_oa=2 用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。 */ private Map loadUsersByOaLoginId(String tenantId, Collection oaLoginIds) { Map existingByOaLoginId = new HashMap<>(); @@ -555,11 +877,11 @@ public class OaUserListSyncHelper { .filter(StringUtils::isNotBlank) .distinct() .toList(); - int batchSize = 500; - for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) { - List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size())); + for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size())); userService.list(Wrappers.lambdaQuery() .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) .in(User::getOaLoginId, oaLoginIdBatch)) .forEach(user -> { String oaLoginId = StringUtils.trimToNull(user.getOaLoginId()); @@ -584,6 +906,39 @@ public class OaUserListSyncHelper { return existingByOaLoginId; } + /** + * 按工号加载没有登录名的 is_oa=2 用户。同一工号有多条时保留较早的一条。 + */ + private Map loadUsersByBlankLoginWorkcode(String tenantId, Collection workcodes) { + Map existingByWorkcode = new HashMap<>(); + if (CollectionUtil.isEmpty(workcodes)) { + return existingByWorkcode; + } + List workcodeList = workcodes.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size())); + this.userService.list(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) + .in(User::getCode, workcodeBatch) + .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, ""))) + .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())) { + existingByWorkcode.put(workcode, user); + } + }); + } + return existingByWorkcode; + } + private String resolveOaLoginId(OAPersonResponse oaPerson) { if (oaPerson == null) { return null; @@ -591,6 +946,28 @@ public class OaUserListSyncHelper { return StringUtils.trimToNull(oaPerson.getLoginid()); } + private String resolveWorkcode(OAPersonResponse oaPerson) { + if (oaPerson == null) { + return null; + } + return StringUtils.trimToNull(oaPerson.getWorkcode()); + } + + /** + * 有登录名用登录名,没有登录名但有工号用工号。两者都没有则不同步。 + */ + 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; + } + return null; + } + private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) { if (StringUtils.isBlank(deptCode)) { return true; @@ -635,6 +1012,11 @@ public class OaUserListSyncHelper { return null; } UserDept userDept = userConvert.person2userDept(oaPerson, userMap); + String syncKey = this.resolveSyncKey(oaPerson); + userDept.setUserId(syncKey == null ? null : userMap.get(syncKey)); + if (StringUtils.isBlank(this.resolveOaLoginId(oaPerson))) { + userDept.setOaLoginId(null); + } if (userDept.getUserId() == null) { return null; } @@ -684,10 +1066,12 @@ public class OaUserListSyncHelper { public static class PersonSyncCount { private final int syncedCount; private final int skippedCount; + private final int removedCount; - public PersonSyncCount(int syncedCount, int skippedCount) { + public PersonSyncCount(int syncedCount, int skippedCount, int removedCount) { this.syncedCount = syncedCount; this.skippedCount = skippedCount; + this.removedCount = removedCount; } public int getSyncedCount() { @@ -697,6 +1081,10 @@ public class OaUserListSyncHelper { public int getSkippedCount() { return skippedCount; } + + public int getRemovedCount() { + return removedCount; + } } /**