diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..83387607 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,82 @@ +# TMS(运输业务中台)—— 业财集成语境 + +TMS 与集团财务系统(金蝶星瀚)、合同合规系统(HCS)集成,单据在三个系统间流转。 +本表只收录集成语境下易混淆的业务术语,不含实现细节。 + +## Language + +**合同合规系统(HCS)**: +集团合同合规管控系统(桂物数字合同合规),接收 TMS 推送的项目、合同、履约与付款数据并做合规审批。 +_Avoid_: 桂物系统、合同系统、数字合同系统 + +**相对方**: +HCS 对签约主体(甲乙方)的称谓,与 TMS 的"客商"同指一个外部单位。 +_Avoid_: 在 HCS 集成语境用"客户/供应商" + +**来源单据键**: +TMS 单据在 HCS 侧的对账主键(接口字段 `fd_kingdee_id`,字段名沿用金蝶),统一存 TMS 业务单据主键。 +_Avoid_: 外部ID、kingdeeId(泛指金蝶单据号时易混淆) + +**来源标识**: +TMS 推送给 HCS 时声明自身身份的常量(接口字段 `fd_sources`,约定值 "TMS")。 +_Avoid_: XH(那是星瀚侧的来源标识) + +**履约规则**: +合同维度的资金计划阶段(付款/收款、款项性质、计划金额、计划日期、开票规则),由 TMS 维护并同步到 HCS。 +_Avoid_: 履约计划(旧版已废弃接口的叫法)、付款计划 + +**履约阶段**: +履约规则表中的一行;HCS 为其生成阶段ID,后续更新/删除必须凭该ID。 +_Avoid_: 阶段项、plan 行 + +**履约记录**: +付款/收款执行事实在 HCS 的登记(一笔付款申请一条记录,随实际付款推进更新),与履约规则是"计划 vs 执行"的关系。 +_Avoid_: 履约流水 + +**额度校验**: +付款提交时由 HCS 判定申请金额是否超出合同/项目剩余额度的同步检查;超额不阻断提交,转入特批链路。 +_Avoid_: 余额检查 + +**特批流程**: +额度超额(或风险不通过)时在 HCS 发起的审批流程;TMS 持有其流程ID,流程结束由 HCS 经 B103 回传结果。 +_Avoid_: 特殊审批、绿色通道 + +**特批放行**: +特批结果为"通过"后对**本次**付款申请解除推送拦截的动作;只改特批状态,不触发推送,单次有效。 +_Avoid_: 特批通过自动付款、放行推送 + +**锁单(B901)**: +金蝶确认接收 TMS 推送的单据后,经 B901 通知 TMS 将本地源单据置为不可编辑、不可作废、不可重推的状态;金蝶反审核及删除后经 B901 解锁。 +_Avoid_: 冻结、金蝶锁定(那是金蝶侧单据自己的状态) + +**解锁**: +B901 通知金蝶侧单据已反审核及删除后,TMS 解除锁单、同步状态置失败、清金蝶单号留痕;不自动重推,人工核实后重新推送。 +_Avoid_: 反审核(那是金蝶侧的动作)、自动重推 + +**合规附件ID**: +TMS 附件先上传 HCS 附件服务换取的 fdId;同一附件以 TMS 附件元数据为键缓存映射,不重复上传。 +_Avoid_: 附件fdId(指代不明) + +**同步状态**: +TMS 单据对 HCS 的推送生命周期(待同步/已同步/失败),权威状态在 TMS 侧字段,HCS 不回写业务状态。 +_Avoid_: 推送标志 + +**二次认领**: +金蝶司库完成第一轮自动认领(客户维度)后,TMS 把收款流水认领到合同/结算单维度的动作;只在 TMS 本地登记,本身不产生金蝶收款单。 +_Avoid_: 收款认领(泛指整条链路时易与金蝶第一轮自动认领混淆) + +**公告未认领金额**: +金蝶认领公告(A301-2)上尚未被认领的金额(字段 unclaimamount),是 TMS 二次认领的上限和"认领完成"的判定基准;以金蝶侧为准,推送前须重查最新值。 +_Avoid_: 剩余可认领金额(那是本地展示口径)、未认领金额(不指明公告侧/本地侧时指代不明) + +**认领完成**: +一条收款流水的累计有效认领金额等于公告未认领金额的状态;只有认领完成的流水才满足推送金蝶的条件,不认领完就不推。 +_Avoid_: 认领完毕、全部认领(未指明对照基准) + +**认领状态**: +收款流水本地认领进度(未认领/部分认领/认领完成),对照公告未认领金额计算;作废的认领分摊不计入累计。 +_Avoid_: 与推送状态混用 + +**推送状态**: +收款流水对金蝶 A301-3 的推送生命周期(未推送/已推送/推送失败),权威在流水级,认领主单仅冗余展示;已推送的流水不可二次推送。 +_Avoid_: 金蝶状态(那是 A301-1 回写的单据生命周期:已推送/审核通过/已收款) diff --git a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java index e75bdb50..f7e6386a 100644 --- a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java +++ b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java @@ -51,6 +51,7 @@ import org.springblade.core.oauth2.service.OAuth2UserService; import org.springblade.system.feign.IAuthLockClient; import org.springblade.system.feign.IAuthLogClient; import org.springblade.core.tenant.BladeTenantProperties; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.IUserClient; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -117,8 +118,8 @@ public class BladeAuthConfiguration { } @Bean - public OAuth2UserService oAuth2UserService(IUserClient userClient) { - return new BladeUserDetailService(userClient); + public OAuth2UserService oAuth2UserService(IUserClient userClient, IMpUserClient mpUserClient) { + return new BladeUserDetailService(userClient, mpUserClient); } @Primary diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java index 8e207ad1..32e1e1e3 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java @@ -33,6 +33,8 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.utils.MpUserAuth; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; @@ -72,7 +74,12 @@ public class OAuth2UserInfoResponseAdvice implements ResponseBodyAdvice if (userInfo == null) { return body; } - userInfo.setPermission(loadPermission(bladeUser.getRoleId())); + if (MpUserAuth.isMpUser(bladeUser)) { + // 小程序用户:按身份直接生成权限,不走系统角色菜单 + userInfo.setPermission(MpUserIdentity.toPermissions(bladeUser.getRoleName())); + } else { + userInfo.setPermission(loadPermission(bladeUser.getRoleId())); + } return userInfo; } diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java index b48ac804..dfa3b898 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java @@ -88,7 +88,8 @@ public class Oauth2SmsEndpoint { return R.fail(USER_PHONE_NOT_FOUND); } // 校验手机是否已注册,防止恶意发送验证码 - OAuth2Request request = OAuth2Request.create(); + // buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user + OAuth2Request request = OAuth2Request.create().buildArgs(); request.setTenantId(tenantId); OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request); if (oAuth2User == null) { diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 1bdd67bf..33de5291 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -2,10 +2,12 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.springblade.auth.utils.TokenUtil; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; import org.springblade.core.oauth2.handler.PasswordHandler; import org.springblade.core.oauth2.provider.OAuth2Request; +import org.springblade.core.oauth2.service.OAuth2Client; import org.springblade.core.oauth2.service.OAuth2ClientService; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.OAuth2UserService; @@ -13,7 +15,8 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; -import org.springblade.system.feign.IUserClient; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.thirdparty.wechat.constant.WechatMiniConstant; import org.springblade.thirdparty.wechat.exception.WechatMiniException; import org.springblade.thirdparty.wechat.pojo.vo.WechatPhoneVO; @@ -25,7 +28,8 @@ import org.springframework.stereotype.Component; * 微信小程序手机号一键登录。 *

* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。 - * 流程:换 openid + 手机号 → 按手机号查用户(不存在则拒绝)→ 记录 openid → 发令牌(与密码登录一致)。 + * 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝) + * → 记录 openid → 按身份发令牌(不依赖系统角色)。 */ @Slf4j @Component @@ -33,17 +37,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final IWechatMiniService wechatMiniService; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; public WechatMiniTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, IWechatMiniService wechatMiniService, - IUserClient userClient) { + IMpUserClient mpUserClient) { super(clientService, userService, passwordHandler); this.userService = userService; this.wechatMiniService = wechatMiniService; - this.userClient = userClient; + this.mpUserClient = mpUserClient; } @Override @@ -54,7 +58,7 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { @Override public OAuth2User user(OAuth2Request request) { // 先校验客户端是否允许该授权类型,避免先调微信再因客户端配置失败 - var oauthClient = client(request); + OAuth2Client oauthClient = client(request); HttpServletRequest httpRequest = WebUtil.getRequest(); String loginCode = httpRequest.getParameter("loginCode"); @@ -78,16 +82,21 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { throw new UserInvalidException("未获取到微信手机号"); } - OAuth2User user = userService.loadByPhone(phone, request); - if (!userService.validateUser(user)) { + R mpUserResult = mpUserClient.userInfoByPhone(request.getTenantId(), phone); + if (mpUserResult == null || !mpUserResult.isSuccess() || mpUserResult.getData() == null + || mpUserResult.getData().getUser() == null) { throw new UserInvalidException("用户不存在,无法登录"); } - R bindResult = userClient.bindWxMiniOpenId( + OAuth2User user = TokenUtil.convertMpUser(mpUserResult.getData()); + if (!userService.validateUser(user)) { + throw new UserInvalidException("用户身份无效,无法登录"); + } + + R bindResult = mpUserClient.bindOpenId( request.getTenantId(), Func.toLong(user.getUserId()), - session.getOpenid(), - phone + session.getOpenid() ); if (bindResult == null || !bindResult.isSuccess()) { String msg = bindResult != null ? bindResult.getMsg() : "绑定 openid 失败"; diff --git a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java index 1efee4d4..22904efe 100644 --- a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java +++ b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java @@ -18,9 +18,6 @@ * 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.auth.service; @@ -35,8 +32,11 @@ import org.springblade.core.oauth2.utils.OAuth2ExceptionUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.IUserClient; +import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.entity.UserInfo; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springblade.system.pojo.enums.UserType; import java.util.Optional; @@ -49,63 +49,121 @@ import java.util.Optional; @RequiredArgsConstructor public class BladeUserDetailService implements OAuth2UserService { private final IUserClient userClient; + private final IMpUserClient mpUserClient; @Override public OAuth2User loadByUserId(String userId, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); - - // 获取用户信息 - R result = userClient.userInfo(Func.toLong(userId), userType); - if (!result.isSuccess()) { - OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + // 小程序刷新/鉴权:User-Type=mp 时只查 blade_mp_user + if (isMpLoginRequest(request)) { + OAuth2User mpUser = loadMpUserById(userId); + if (mpUser == null) { + OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + } + return mpUser; } - // 构建oauth2用户信息 - return TokenUtil.convertUser(result.getData(), request); + + String userType = resolveUserType(request); + R result = userClient.userInfo(Func.toLong(userId), userType); + if (result != null && result.isSuccess() && result.getData() != null + && result.getData().getUser() != null) { + return TokenUtil.convertUser(result.getData(), request); + } + + // 兼容:刷新令牌未带 User-Type=mp 时,回落查询小程序用户 + OAuth2User mpUser = loadMpUserById(userId); + if (mpUser != null) { + return mpUser; + } + OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + return null; } @Override public OAuth2User loadByUsername(String username, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); - // 获取用户信息 + // 小程序账号密码 / 图形验证码登录:仅查 blade_mp_user + if (isMpLoginRequest(request)) { + R mpResult = mpUserClient.userInfoByAccount(tenantId, username); + if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) { + return null; + } + return TokenUtil.convertMpUser(mpResult.getData()); + } + + String userType = resolveUserType(request); R result = userClient.userInfo(tenantId, username, userType); - if (!result.isSuccess()) { + if (result == null || !result.isSuccess()) { return null; } - // 构建oauth2用户信息 return TokenUtil.convertUser(result.getData(), request); } @Override public OAuth2User loadByPhone(String phone, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); - // 获取用户信息 - R result = userClient.userInfoByPhone(tenantId, phone, userType); - if (!result.isSuccess()) { - return null; + // 小程序短信登录:仅查 blade_mp_user + if (isMpLoginRequest(request)) { + return loadMpUserByPhone(tenantId, phone); } - // 构建oauth2用户信息 - return TokenUtil.convertUser(result.getData(), request); + + String userType = resolveUserType(request); + R result = userClient.userInfoByPhone(tenantId, phone, userType); + if (result != null && result.isSuccess() && result.getData() != null) { + return TokenUtil.convertUser(result.getData(), request); + } + // 发短信校验等未带 User-Type 时,回落小程序用户,避免 mp 账号收不到验证码 + return loadMpUserByPhone(tenantId, phone); } @Override public boolean validateUser(OAuth2User user) { return Optional.ofNullable(user) - .filter(u -> u.getUserId() != null && !u.getUserId().isEmpty()) // 检查userId不为空 - .filter(u -> u.getAuthorities() != null && !u.getAuthorities().isEmpty()) // 检查authorities不为空 - .isPresent(); // 如果上述条件都满足,则返回true,否则返回false + .filter(u -> u.getUserId() != null && !u.getUserId().isEmpty()) + .filter(u -> u.getAuthorities() != null && !u.getAuthorities().isEmpty()) + .isPresent(); + } + + /** + * 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用 saber3 客户端,不能按 clientId 分流) + */ + private boolean isMpLoginRequest(OAuth2Request request) { + return request != null && isMpUserType(request.getUserType()); + } + + private boolean isMpUserType(String userType) { + return MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(userType)); + } + + private String resolveUserType(OAuth2Request request) { + return Optional.ofNullable(request.getUserType()) + .filter(s -> !StringUtil.isBlank(s)) + .orElse(UserType.WEB.getName()); + } + + private OAuth2User loadMpUserById(String userId) { + if (Func.isBlank(userId)) { + return null; + } + try { + R result = mpUserClient.userInfoById(Func.toLong(userId)); + if (result != null && result.isSuccess() && result.getData() != null + && result.getData().getUser() != null) { + return TokenUtil.convertMpUser(result.getData()); + } + } catch (Exception exception) { + // 非小程序用户场景忽略 + } + return null; + } + + private OAuth2User loadMpUserByPhone(String tenantId, String phone) { + R result = mpUserClient.userInfoByPhone(tenantId, phone); + if (result == null || !result.isSuccess() || result.getData() == null) { + return null; + } + return TokenUtil.convertMpUser(result.getData()); } } diff --git a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java index b39311da..c4380b43 100644 --- a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java +++ b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java @@ -28,12 +28,15 @@ package org.springblade.auth.utils; import org.springblade.core.oauth2.provider.OAuth2Request; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.impl.OAuth2UserDetail; +import org.springblade.core.tool.support.Kv; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.entity.UserInfo; - -import java.util.Collections; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.utils.MpUserAuth; /** * 认证工具类 @@ -75,6 +78,7 @@ public class TokenUtil { userDetail.setRealName(user.getRealName()); userDetail.setAccount(user.getAccount()); userDetail.setPassword(user.getPassword()); + userDetail.setPhone(user.getPhone()); userDetail.setDeptId(user.getDeptId()); userDetail.setPostId(user.getPostId()); userDetail.setRoleId(user.getRoleId()); @@ -85,4 +89,40 @@ public class TokenUtil { return userDetail; } + /** + * 小程序用户转换为OAuth2标准用户(身份直接写入 authorities,不走系统角色) + * + * @param userInfo 小程序用户信息 + * @return OAuth2User + */ + public static OAuth2User convertMpUser(MpUserInfo userInfo) { + if (userInfo == null || userInfo.getUser() == null) { + return null; + } + MpUser user = userInfo.getUser(); + OAuth2UserDetail userDetail = new OAuth2UserDetail(); + userDetail.setUserId(String.valueOf(user.getId())); + userDetail.setTenantId(user.getTenantId()); + userDetail.setName(user.getName()); + userDetail.setRealName(user.getRealName()); + userDetail.setAccount(user.getAccount()); + userDetail.setPassword(user.getPassword()); + userDetail.setPhone(user.getPhone()); + // 严格令牌模式要求 deptId/roleId 非空且 >=0;小程序用户用占位值,身份写在 roleName/authorities + userDetail.setDeptId(Func.toStr(user.getDeptId(), MpUserAuth.PLACEHOLDER_ID)); + if (Func.isBlank(userDetail.getDeptId()) || "-1".equals(userDetail.getDeptId())) { + userDetail.setDeptId(MpUserAuth.PLACEHOLDER_ID); + } + userDetail.setPostId(Func.toStr(user.getPostId(), MpUserAuth.PLACEHOLDER_ID)); + userDetail.setRoleId(MpUserAuth.PLACEHOLDER_ID); + userDetail.setRoleName(Func.join(userInfo.getIdentities())); + userDetail.setAvatar(user.getAvatar()); + userDetail.setAuthorities(userInfo.getIdentities()); + userDetail.setPermissions(userInfo.getPermissions()); + userDetail.setDetail(userInfo.getDetail() == null + ? Kv.create().set("type", MpUserIdentity.DETAIL_TYPE) + : userInfo.getDetail()); + return userDetail; + } + } diff --git a/blade-common/src/main/java/org/springblade/common/excel/ImportCellReadException.java b/blade-common/src/main/java/org/springblade/common/excel/ImportCellReadException.java new file mode 100644 index 00000000..7b038ad0 --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/excel/ImportCellReadException.java @@ -0,0 +1,93 @@ +/** + * 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.common.excel; + +import org.springblade.core.log.exception.ServiceException; + +import java.io.Serial; + +/** + * 单元格读取失败异常 —— 用于「异常不是 fastexcel 抛出、但同样发生在读取阶段」的场景。 + *

+ * fastexcel 自身解析失败时抛 {@code ExcelDataConvertException},位置信息齐全, + * {@link ImportFailureExcelUtil#exportReadFailure} 可以直接定位到行与列。但有些模块 + * 在读取阶段有自己的解析逻辑(例如运输对账的动态费用列:模板列不固定,由 + * {@code VehicleReconciliationFeeReader} 自行按表头解析),这些代码如果只是 + * {@code catch (NumberFormatException)} 后返回 {@code BigDecimal.ZERO}, + * 脏数据就会静默入库(费用 0.00 还会参与结算金额与自动匹配)。 + *

+ * 正确做法是改抛本异常,把位置信息补齐,让导出工具能把原因挂到正确的行、标红正确的列。 + *

+ * 本类位于 blade-common,供 blade-system 与 blade-transport 等模块共用。 + * + * @author Chill + */ +public class ImportCellReadException extends ServiceException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 出错的单元格所在的 Excel 行号,绝对 0 基(表头占 0,第一个数据行为 1)。 + * 与 {@code ExcelDataConvertException.getRowIndex()} 保持同一口径, + * 便于导出工具统一换算。 + */ + private final transient Integer rowIndex; + + /** + * 出错的列名(导入模板表头原文),会写进原因文案里,导出工具靠它标红对应列。 + */ + private final transient String columnName; + + public ImportCellReadException(Integer rowIndex, String columnName, String cellValue) { + super(buildMessage(rowIndex, columnName, cellValue)); + this.rowIndex = rowIndex; + this.columnName = columnName; + } + + /** + * 文案格式与 {@link ImportFailureExcelUtil#describeReadFailure} 保持一致: + * 「第N行「列名」格式不正确:<原值>」。列名必须包在「」里,导出工具才能标红该列。 + */ + private static String buildMessage(Integer rowIndex, String columnName, String cellValue) { + String rowText = rowIndex == null ? "第?行" : "第" + (rowIndex + 1) + "行"; + String columnText = columnName == null || columnName.isBlank() ? "该列" : "「" + columnName + "」"; + StringBuilder message = new StringBuilder(rowText).append(columnText).append("格式不正确"); + if (cellValue != null && !cellValue.isBlank()) { + message.append(":").append(cellValue); + } + return message.toString(); + } + + public Integer getRowIndex() { + return rowIndex; + } + + public String getColumnName() { + return columnName; + } + +} diff --git a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java index 6b0ad40d..0fc7d2f1 100644 --- a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java +++ b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java @@ -45,7 +45,9 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -80,6 +82,443 @@ public class ImportFailureExcelUtil { addValidationError(validationErrors, value != null && value.length() > maxLength, message); } + /** + * 把「读取 Excel 阶段」的转换异常转成可读的失败原因。 + *

+ * 背景:单元格里填了无法转换的内容(如金额列填「一万二千元」)时,EasyExcel 会在 + * {@code ExcelUtil.read(...)} 阶段就抛 {@link cn.idev.excel.exception.ExcelDataConvertException}, + * 根本走不到行级校验;若不兜底,用户看到的是 + * 「Convert data ... to class java.math.BigDecimal error」这类技术文案。 + *

+ * 本方法据异常里的行列位置与目标类型,生成「第N行「列名」格式不正确:<原值>」这样 + * 用户能看懂、能定位的文案;列名靠表头反射拿 {@code @ExcelProperty},拿不到则退回列序号。 + * + * @param exception 读取阶段抛出的异常 + * @param excelClass 导入模型类(用于按列序号反查表头名) + * @return 可读的失败原因;非转换类异常返回 null(交由上层按原样处理) + */ + public static String describeReadFailure(Throwable exception, Class excelClass) { + cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception, + cn.idev.excel.exception.ExcelDataConvertException.class); + if (convertException == null) { + // 不是 fastexcel 抛的转换异常:模块自带的读取期解析逻辑(如运输对账的动态费用列) + // 用 ImportCellReadException 补齐了位置信息,直接透传它已经拼好的原因文案。 + ImportCellReadException cellReadException = findCause(exception, ImportCellReadException.class); + return cellReadException == null ? null : cellReadException.getMessage(); + } + Integer rowIndex = convertException.getRowIndex(); + Integer columnIndex = convertException.getColumnIndex(); + String columnName = columnName(excelClass, columnIndex); + // Excel 行号:getRowIndex() 是绝对 0 基行号(表头占 0),故 Excel 行号 = rowIndex + 1 + String rowText = rowIndex == null ? "第?行" : "第" + (rowIndex + 1) + "行"; + String columnText = columnName == null ? ("第" + (columnIndex == null ? "?" : columnIndex + 1) + "列") : "「" + columnName + "」"; + String value = cellValue(convertException); + StringBuilder message = new StringBuilder(rowText).append(columnText).append("格式不正确"); + if (value != null && !value.isBlank()) { + message.append(":").append(value); + } + return message.toString(); + } + + /** + * 在异常链里查找指定类型的 cause + */ + private static T findCause(Throwable throwable, Class type) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (type.isInstance(current)) { + return type.cast(current); + } + if (current.getCause() == current) { + break; + } + } + return null; + } + + /** + * 按列序号反查表头名(@ExcelProperty 的 value,取不到则用字段名) + */ + private static String columnName(Class excelClass, Integer columnIndex) { + if (excelClass == null || columnIndex == null) { + return null; + } + List fields = excelFields(excelClass); + if (columnIndex < 0 || columnIndex >= fields.size()) { + return null; + } + Field field = fields.get(columnIndex); + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelProperty == null) { + return field.getName(); + } + String[] value = excelProperty.value(); + String columnName = value.length == 0 ? field.getName() : value[0]; + return columnName.replace("*", ""); + } + + /** + * 取触发转换失败的单元格原值 + */ + private static String cellValue(cn.idev.excel.exception.ExcelDataConvertException exception) { + try { + cn.idev.excel.metadata.data.CellData cellData = exception.getCellData(); + if (cellData == null) { + return null; + } + Object data = cellData.getData(); + if (data == null) { + data = cellData.getStringValue(); + } + return data == null ? null : String.valueOf(data).trim(); + } catch (Exception ignored) { + return null; + } + } + + /** + * 以「原始单元格文本」方式重读导入文件,得到的行用于构造失败明细。 + *

+ * 优先使用 Excel 文件真实表头(含动态费用列等模型类未声明的列),保证失败明细 + * 与原导入模板列一致,用户修正后可直接用该文件重新导入。 + *

+ * 用途:当 {@code ExcelUtil.read(file, XxxExcel.class)} 因单元格无法转换而失败时, + * 已解析的实体列表是不完整的(异常中断了整批读取)。此时用本方法重读一次: + * 不做类型转换(直接读成 Map),因此绝不会再抛转换异常,可拿到**原表全部行**, + * 供导出「原表全量 + 仅出错行带原因」的失败明细。 + * + * @param file 上传的 Excel 文件 + * @param excelClass 导入模型类(表头缺失时按列序号反查表头名) + * @return 按行组织的明细数据:每行是「表头名 → 单元格文本」的有序 Map; + * 读不出内容时返回空列表(调用方按空明细处理) + */ + public static List> readRawRows(java.io.InputStream file, Class excelClass) { + List> rawRows = new ArrayList<>(); + Map headerByIndex = new LinkedHashMap<>(); + try { + cn.idev.excel.read.builder.ExcelReaderBuilder builder = cn.idev.excel.FastExcel + .read(file, new cn.idev.excel.event.AnalysisEventListener>() { + @Override + public void invokeHeadMap(Map headMap, cn.idev.excel.context.AnalysisContext context) { + if (headMap == null) { + return; + } + headMap.forEach((index, header) -> { + if (index == null) { + return; + } + String name = header == null ? "" : header.trim(); + // 历史失败明细再导入时忽略旧的失败原因列,导出时重新追加 + if (!name.isEmpty() && !FAILURE_REASON.equals(name)) { + headerByIndex.put(index, name); + } + }); + } + + @Override + public void invoke(Map data, cn.idev.excel.context.AnalysisContext context) { + rawRows.add(data == null ? new LinkedHashMap<>() : new LinkedHashMap<>(data)); + } + + @Override + public void doAfterAllAnalysed(cn.idev.excel.context.AnalysisContext context) { + // 无需处理 + } + }); + builder.headRowNumber(1).sheet().doRead(); + } catch (Exception ignored) { + // 重读本身失败时退回空明细,由调用方按「无法读取」提示 + return new ArrayList<>(); + } + List> rows = new ArrayList<>(); + for (Map rawRow : rawRows) { + Map row = new LinkedHashMap<>(); + Set columnIndexes = new LinkedHashSet<>(headerByIndex.keySet()); + if (rawRow != null) { + columnIndexes.addAll(rawRow.keySet()); + } + List sortedIndexes = new ArrayList<>(columnIndexes); + Collections.sort(sortedIndexes); + for (Integer columnIndex : sortedIndexes) { + if (columnIndex == null) { + continue; + } + String columnName = headerByIndex.get(columnIndex); + if (columnName == null || columnName.isBlank()) { + columnName = columnName(excelClass, columnIndex); + } + if (columnName == null || columnName.isBlank()) { + columnName = "第" + (columnIndex + 1) + "列"; + } + if (FAILURE_REASON.equals(columnName)) { + continue; + } + row.put(columnName, rawRow == null ? null : rawRow.get(columnIndex)); + } + rows.add(row); + } + return rows; + } + + /** + * 按原上传文件真实表头导出失败明细(含动态列),并追加「导入失败原因」。 + *

+ * 适用于运输对账等「模板列 = 固定列 + 动态费用列」场景:按实体字段反射导出 + * 会丢掉动态列,用户无法用失败文件修正后重新导入。本方法重读原文件全量列, + * 再按 failureList 行序挂上失败原因。 + * + * @param response 响应 + * @param fileName 文件名 + * @param sheetName 工作表名 + * @param file 原导入文件 + * @param failureList 校验失败列表(与原表行序一致,成功行 errorMessage 可为空) + * @param excelClass 导入模型类(原文件重读失败时回退到按实体导出) + */ + public static void exportOriginalWithFailures(HttpServletResponse response, String fileName, String sheetName, + org.springframework.web.multipart.MultipartFile file, + List failureList, Class excelClass) { + List> rawRows = new ArrayList<>(); + if (file != null && !file.isEmpty()) { + try (java.io.InputStream inputStream = file.getInputStream()) { + rawRows = readRawRows(inputStream, excelClass); + } catch (IOException ignored) { + rawRows = new ArrayList<>(); + } + } + if (rawRows.isEmpty()) { + // 原文件读不出时回退实体导出,至少保证失败原因可下载 + if (failureList != null && !failureList.isEmpty()) { + export(response, fileName, sheetName, failureList, excelClass); + } + return; + } + Map failureMessageByRow = new HashMap<>(); + if (failureList != null) { + for (int index = 0; index < failureList.size(); index++) { + Object message = errorMessage(failureList.get(index)); + if (message != null && !String.valueOf(message).isBlank()) { + failureMessageByRow.put(index, String.valueOf(message)); + } + } + } + exportRawFailure(response, fileName, sheetName, rawRows, failureMessageByRow); + } + + /** + * 导出「读取阶段失败」明细:原表全量行 + 末尾「导入失败原因」列。 + *

+ * 与 {@link #export} 的区别:本方法直接按重读得到的「表头名 → 单元格文本」写出, + * 不依赖导入模型的字段反射,因此适用于「单元格无法转换为实体字段」的场景 + * (此时实体列表不完整,无法走常规导出)。 + * + * @param response 响应 + * @param fileName 文件名 + * @param sheetName 工作表名 + * @param rawRows {@link #readRawRows} 得到的原表全部行 + * @param failureMessageByRow 行号(0 基,对应 rawRows 下标)→ 该行失败原因;为 null 表示该行无错 + */ + public static void exportRawFailure(HttpServletResponse response, String fileName, String sheetName, + List> rawRows, Map failureMessageByRow) { + response.setContentType("application/vnd.ms-excel"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); + response.setHeader("Content-disposition", "attachment;filename=" + encodeFileName + ".xlsx"); + List headers = new ArrayList<>(); + for (Map row : rawRows) { + for (String key : row.keySet()) { + if (!headers.contains(key)) { + headers.add(key); + } + } + } + headers.add(FAILURE_REASON); + List> head = new ArrayList<>(); + for (String header : headers) { + head.add(List.of(header)); + } + List> rows = new ArrayList<>(); + Map> redColumnsByRow = new HashMap<>(); + List> headerKeywords = new ArrayList<>(); + for (int columnIndex = 0; columnIndex < headers.size() - 1; columnIndex++) { + headerKeywords.add(headerKeywords(headers.get(columnIndex))); + } + for (int rowIndex = 0; rowIndex < rawRows.size(); rowIndex++) { + Map rawRow = rawRows.get(rowIndex); + List row = new ArrayList<>(); + String failureMessage = failureMessageByRow == null ? null : failureMessageByRow.get(rowIndex); + for (int columnIndex = 0; columnIndex < headers.size() - 1; columnIndex++) { + row.add(rawRow.get(headers.get(columnIndex))); + } + row.add(failureMessage == null ? "" : failureMessage); + rows.add(row); + redColumnsByRow.put(rowIndex, resolveRawRedColumns(failureMessage, headerKeywords, headers.size() - 1)); + } + try { + FastExcel.write(response.getOutputStream()) + .registerWriteHandler(new RawFailureCellStyleHandler(rows, redColumnsByRow)) + .head(head) + .sheet(sheetName) + .doWrite(rows); + } catch (IOException exception) { + throw new IllegalStateException("导出导入失败明细失败", exception); + } + } + + /** + * 读取阶段失败明细的单元格样式处理器:统一列宽 + 标红失败原因列与出错列 + */ + private static class RawFailureCellStyleHandler implements CellWriteHandler { + + private final int failureReasonColumnIndex; + private final Map> redColumnsByRow; + private final Map redStyleCache = new HashMap<>(); + private final Map noWrapStyleCache = new HashMap<>(); + private final Map columnWidthCache = new HashMap<>(); + + private RawFailureCellStyleHandler(List> rows, Map> redColumnsByRow) { + this.failureReasonColumnIndex = rows.isEmpty() ? 0 : rows.get(0).size() - 1; + this.redColumnsByRow = redColumnsByRow; + } + + @Override + public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, + List> cellDataList, Cell cell, cn.idev.excel.metadata.Head head, + Integer relativeRowIndex, Boolean isHead) { + if (Boolean.TRUE.equals(isHead)) { + adjustColumnWidth(cell); + markNoWrap(cell); + return; + } + adjustColumnWidth(cell); + if (relativeRowIndex == null || relativeRowIndex < 0) { + return; + } + Set redColumns = redColumnsByRow.get(relativeRowIndex); + if (redColumns != null && redColumns.contains(cell.getColumnIndex())) { + markRed(cell); + } + } + + private void markRed(Cell cell) { + CellStyle currentStyle = cell.getCellStyle(); + CellStyle redStyle = redStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> { + Workbook workbook = cell.getSheet().getWorkbook(); + CellStyle newStyle = workbook.createCellStyle(); + newStyle.cloneStyleFrom(currentStyle); + Font font = workbook.createFont(); + font.setColor(IndexedColors.RED.getIndex()); + newStyle.setFont(font); + newStyle.setWrapText(true); + return newStyle; + }); + cell.setCellStyle(redStyle); + } + + private void markNoWrap(Cell cell) { + CellStyle currentStyle = cell.getCellStyle(); + CellStyle noWrapStyle = noWrapStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> { + CellStyle newStyle = cell.getSheet().getWorkbook().createCellStyle(); + newStyle.cloneStyleFrom(currentStyle); + newStyle.setWrapText(false); + return newStyle; + }); + cell.setCellStyle(noWrapStyle); + } + + private void adjustColumnWidth(Cell cell) { + int columnIndex = cell.getColumnIndex(); + int columnWidth = Math.min(Math.max(displayWidth(cell.toString()) + 4, 12), 80) * 256; + Integer currentWidth = columnWidthCache.get(columnIndex); + if (currentWidth == null || columnWidth > currentWidth) { + columnWidthCache.put(columnIndex, columnWidth); + cell.getSheet().setColumnWidth(columnIndex, columnWidth); + } + } + + private int displayWidth(String value) { + int width = 0; + for (int index = 0; index < value.length(); index++) { + width += value.charAt(index) > 255 ? 2 : 1; + } + return width; + } + } + + /** + * 读取阶段异常的一站式兜底:重读原表 → 导出「全量行 + 出错行原因」失败明细。 + *

+ * 控制器用法:在导入的 try 块末尾追加 + *

+	 * } catch (Exception exception) {
+	 *     if (ImportFailureExcelUtil.exportReadFailure(response, fileName, sheetName, file, XxxExcel.class, exception)) {
+	 *         return null;
+	 *     }
+	 *     throw exception;
+	 * }
+	 * 
+ * + * @return true 表示已识别为「单元格转换失败」并已写出明细(调用方直接 return null); + * false 表示不是转换类异常(调用方应继续抛出,保持原有处理) + */ + public static boolean exportReadFailure(HttpServletResponse response, String fileName, String sheetName, + org.springframework.web.multipart.MultipartFile file, + Class excelClass, Throwable exception) { + String failureMessage = describeReadFailure(exception, excelClass); + if (failureMessage == null) { + return false; + } + List> rawRows; + try (java.io.InputStream inputStream = file.getInputStream()) { + rawRows = readRawRows(inputStream, excelClass); + } catch (IOException ioException) { + rawRows = new ArrayList<>(); + } + Map failureMessageByRow = new HashMap<>(); + if (rawRows.isEmpty()) { + // 重读也拿不到内容时,退化为「仅失败原因」一行,至少让用户看到可读原因 + Map single = new java.util.LinkedHashMap<>(); + single.put(FAILURE_REASON, failureMessage); + rawRows.add(single); + failureMessageByRow.put(0, failureMessage); + exportRawFailure(response, fileName, sheetName, rawRows, failureMessageByRow); + return true; + } + // 异常里的数据行下标(0 基)用于把原因挂到对应行 + Integer rowIndex = readFailureRowIndex(exception); + int targetRow = rowIndex == null ? 0 : Math.max(0, Math.min(rowIndex, rawRows.size() - 1)); + failureMessageByRow.put(targetRow, failureMessage); + exportRawFailure(response, fileName, sheetName, rawRows, failureMessageByRow); + return true; + } + + /** + * 取读取阶段异常中「失败数据行」的下标(0 基,只对数据行计数),供挂失败原因使用。 + *

+ * 无论哪种异常,源信息都是绝对 0 基行号(表头占 0,第一个数据行为 1), + * 而这里要的下标是相对数据行的,故统一减 1。 + * 不减的话原因会整体下移一行:错误恰好落在最后一行时会因调用处的 clamping + * 侥幸正确,落在首行/中间行就会标到不该标的行上,误导用户去改正确的那一行。 + */ + private static Integer readFailureRowIndex(Throwable exception) { + Integer excelRowIndex = readFailureExcelRowIndex(exception); + return excelRowIndex == null ? null : Math.max(0, excelRowIndex - 1); + } + + /** + * 取读取阶段异常的绝对 0 基行号(表头 = 0)。 + *

+ * 两条来源统一到这里:fastexcel 自带的 {@code ExcelDataConvertException}, + * 以及模块自行解析时抛出的 {@link ImportCellReadException}。 + */ + private static Integer readFailureExcelRowIndex(Throwable exception) { + cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception, + cn.idev.excel.exception.ExcelDataConvertException.class); + if (convertException != null) { + return convertException.getRowIndex(); + } + ImportCellReadException cellReadException = findCause(exception, ImportCellReadException.class); + return cellReadException == null ? null : cellReadException.getRowIndex(); + } + private static final String FAILURE_REASON = "导入失败原因"; private static final String ERROR_MESSAGE_FIELD = "errorMessage"; private static final String FAILURE_REASON_FIELD = "failureReason"; @@ -247,6 +686,69 @@ public class ImportFailureExcelUtil { .toList(); } + /** + * 按真实表头生成定位关键词(用于原表失败明细标红) + */ + private static List headerKeywords(String header) { + if (header == null || header.isBlank()) { + return List.of(); + } + List keywords = new ArrayList<>(); + keywords.add(header); + keywords.addAll(Arrays.asList(header.replace("*", "").split("[//、()()\\s]+"))); + return keywords.stream() + .map(ImportFailureExcelUtil::normalize) + .filter(keyword -> keyword.length() >= 2) + .distinct() + .toList(); + } + + /** + * 按失败原因命中表头关键词,计算需要标红的列(含失败原因列) + */ + private static Set resolveRawRedColumns(String failureMessage, List> headerKeywords, + int failureReasonColumnIndex) { + Set redColumns = new LinkedHashSet<>(); + redColumns.add(failureReasonColumnIndex); + String normalizedReason = normalize(failureMessage); + if (normalizedReason.isEmpty()) { + return redColumns; + } + List matches = new ArrayList<>(); + for (int column = 0; column < headerKeywords.size(); column++) { + for (String keyword : headerKeywords.get(column)) { + int fromIndex = 0; + while (true) { + int index = normalizedReason.indexOf(keyword, fromIndex); + if (index < 0) { + break; + } + matches.add(new int[]{index, index + keyword.length(), column}); + fromIndex = index + 1; + } + } + } + for (int[] match : matches) { + if (!isRawMatchSubsumed(match, matches)) { + redColumns.add(match[2]); + } + } + return redColumns; + } + + private static boolean isRawMatchSubsumed(int[] match, List matches) { + int matchLength = match[1] - match[0]; + for (int[] other : matches) { + if (other[2] == match[2]) { + continue; + } + if ((other[1] - other[0]) > matchLength && other[0] <= match[0] && other[1] >= match[1]) { + return true; + } + } + return false; + } + private static class ImportFailureCellStyleHandler implements CellWriteHandler { private final int failureReasonColumnIndex; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/ImportFailureException.java b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureException.java similarity index 72% rename from blade-service/blade-system/src/main/java/org/springblade/system/excel/ImportFailureException.java rename to blade-common/src/main/java/org/springblade/common/excel/ImportFailureException.java index 0277ec01..6065ce0f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/ImportFailureException.java +++ b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureException.java @@ -23,7 +23,7 @@ *

* Author: Chill Zhuang (bladejava@qq.com) */ -package org.springblade.system.excel; +package org.springblade.common.excel; import org.springblade.core.log.exception.ServiceException; @@ -33,8 +33,14 @@ import java.util.List; /** * 导入失败异常,携带失败明细用于导出原表并标注错误。 *

- * 批量导入采用「全失败即整批回滚」语义:任一行校验失败都会抛出本异常触发事务回滚, - * 失败明细在抛异常前已收集完毕,因此回滚不影响明细的内容。 + * 批量导入采用「任一行失败即整批回滚」语义:循环内先收集完全部错误明细, + * 再抛出本异常触发事务回滚。明细在抛异常前已收集完毕,因此回滚不影响明细内容。 + *

+ * 注意:仅标注 {@code @Transactional} 并不足够。若循环内把异常吞掉(catch 后 + * 只往 errorList 塞数据并正常 return),方法会正常返回,Spring 会照常提交事务。 + * 必须抛出异常才能让事务真正回滚。 + *

+ * 本类位于 blade-common,供 blade-system 与 blade-transport 等模块共用。 * * @author Chill */ diff --git a/blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java b/blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java index 8add8340..fd2c459c 100644 --- a/blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java +++ b/blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java @@ -34,9 +34,13 @@ import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 自定义Feign日志记录器,只记录出入参信息 @@ -50,6 +54,7 @@ public class CustomFeignLogger extends Logger { private static final String IMAGE_CONTENT_TYPE_PREFIX = "image/"; private static final String AUDIO_CONTENT_TYPE_PREFIX = "audio/"; private static final String VIDEO_CONTENT_TYPE_PREFIX = "video/"; + private static final String MULTIPART_CONTENT_TYPE_PREFIX = "multipart/"; @Override protected void log(String configKey, String format, Object... args) { @@ -63,10 +68,13 @@ public class CustomFeignLogger extends Logger { Map> headers = request.headers(); byte[] body = request.body(); + String contentType = getContentType(headers); String bodyStr; if (body == null || body.length == 0) { bodyStr = "[无]"; - } else if (isBinaryContent(getContentType(headers))) { + } else if (isMultipartContent(contentType)) { + bodyStr = parseMultipartBody(body, contentType); + } else if (isBinaryContent(contentType)) { bodyStr = "[二进制数据,长度=" + body.length + "字节]"; } else { bodyStr = new String(body, StandardCharsets.UTF_8); @@ -160,4 +168,75 @@ public class CustomFeignLogger extends Logger { || lowerContentType.startsWith(AUDIO_CONTENT_TYPE_PREFIX) || lowerContentType.startsWith(VIDEO_CONTENT_TYPE_PREFIX); } + + /** + * 判断是否为multipart内容 + */ + private boolean isMultipartContent(String contentType) { + return contentType != null && contentType.toLowerCase().startsWith(MULTIPART_CONTENT_TYPE_PREFIX); + } + + /** + * 解析multipart请求体,文件部分用占位符替代,文本字段保留打印 + */ + private String parseMultipartBody(byte[] body, String contentType) { + String boundary = extractBoundary(contentType); + if (boundary == null) { + return "[multipart数据,长度=" + body.length + "字节]"; + } + String bodyStr = new String(body, StandardCharsets.UTF_8); + String delimiter = "--" + boundary; + String[] parts = bodyStr.split(delimiter); + + List fields = new ArrayList<>(); + for (String part : parts) { + if (part.isEmpty() || part.startsWith("--") || part.trim().isEmpty()) { + continue; + } + int headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + continue; + } + String headers = part.substring(0, headerEnd); + String value = part.substring(headerEnd + 4); + if (value.endsWith("\r\n")) { + value = value.substring(0, value.length() - 2); + } + + String name = extractParam(headers, "name"); + + if (headers.contains("filename=")) { + String filename = extractParam(headers, "filename"); + fields.add(name + "=[文件: " + filename + "]"); + } else { + fields.add(name + "=" + value); + } + } + return fields.isEmpty() ? "[multipart数据]" : "{" + String.join(", ", fields) + "}"; + } + + /** + * 从Content-Type中提取boundary + */ + private String extractBoundary(String contentType) { + for (String param : contentType.split(";")) { + param = param.trim(); + if (param.toLowerCase().startsWith("boundary=")) { + return param.substring("boundary=".length()).trim(); + } + } + return null; + } + + /** + * 从headers中提取参数值(如name、filename) + */ + private String extractParam(String headers, String paramName) { + Pattern pattern = Pattern.compile(paramName + "=\"([^\"]*)\""); + Matcher matcher = pattern.matcher(headers); + if (matcher.find()) { + return matcher.group(1); + } + return paramName; + } } diff --git a/blade-gateway/src/main/resources/bootstrap.yml b/blade-gateway/src/main/resources/bootstrap.yml index 0f5092a9..e4fa3a81 100644 --- a/blade-gateway/src/main/resources/bootstrap.yml +++ b/blade-gateway/src/main/resources/bootstrap.yml @@ -2,6 +2,9 @@ server: port: 80 spring: + codec: + # 运单批量导入等大 JSON / 失败明细导出场景,默认 256KB 会触发网关 500 + max-in-memory-size: 20MB cloud: gateway: server: diff --git a/blade-service-api/blade-process-api/src/main/java/org/springblade/process/feign/IBusinessProcessClient.java b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/feign/IBusinessProcessClient.java index ecbdf0c6..4cf979bb 100644 --- a/blade-service-api/blade-process-api/src/main/java/org/springblade/process/feign/IBusinessProcessClient.java +++ b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/feign/IBusinessProcessClient.java @@ -7,6 +7,7 @@ import org.springblade.process.pojo.dto.BusinessProcessDeleteDTO; import org.springblade.process.pojo.dto.BusinessProcessSubmitDTO; import org.springblade.process.pojo.dto.BusinessProcessUpdateDTO; import org.springblade.process.pojo.vo.BusinessProcessVO; +import org.springblade.process.pojo.vo.MkFormBindVO; import org.springblade.process.pojo.vo.ProcessApprovedRecordVO; import org.springblade.process.pojo.vo.ProcessTodoVO; import org.springframework.cloud.openfeign.FeignClient; @@ -40,6 +41,8 @@ public interface IBusinessProcessClient { String QUERY_APPROVED_RECORD_LIST = API_PREFIX + "/queryApprovedRecordsNoAttachments"; String GET_CURRENT_NODES = API_PREFIX + "/getCurrentNodes"; String GET_PROCESS_INFO = API_PREFIX + "/getProcessInfo"; + String RESOLVE_BY_FORM_INSTANCE_ID = API_PREFIX + "/resolveByFormInstanceId"; + String MARK_PROCESS_FINISHED = API_PREFIX + "/markProcessFinished"; /** * 提交业务流程 @@ -126,4 +129,24 @@ public interface IBusinessProcessClient { @GetMapping(GET_PROCESS_INFO) FR getProcessInfo(@RequestParam("processInstanceId") String processInstanceId, @RequestParam(value = "loginName", required = false) String loginName); + + /** + * 按表单实例id解析业务绑定 + * + * @param formInstanceId 表单实例id + * @return 绑定信息(含业务主键) + */ + @GetMapping(RESOLVE_BY_FORM_INSTANCE_ID) + FR resolveByFormInstanceId(@RequestParam("formInstanceId") Long formInstanceId); + + /** + * 公开页流程结束时标记业务流程通过/驳回并完结 + * + * @param processInstanceId 流程实例id + * @param approved true=通过 false=驳回 + * @return 是否更新成功 + */ + @PostMapping(MARK_PROCESS_FINISHED) + FR markProcessFinished(@RequestParam("processInstanceId") String processInstanceId, + @RequestParam("approved") boolean approved); } diff --git a/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/entity/BusinessProcess.java b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/entity/BusinessProcess.java index bac2329d..45329958 100644 --- a/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/entity/BusinessProcess.java +++ b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/entity/BusinessProcess.java @@ -65,6 +65,12 @@ public class BusinessProcess implements Serializable { */ @Schema(description = "业务id") private Long bizId; + /** + * MK表单实例id + */ + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "MK表单实例id") + private Long formInstanceId; /** * 流程实例id */ diff --git a/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/entity/MkFormBind.java b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/entity/MkFormBind.java new file mode 100644 index 00000000..03499c83 --- /dev/null +++ b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/entity/MkFormBind.java @@ -0,0 +1,94 @@ +/** + * 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.process.pojo.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springblade.core.tool.utils.DateUtil; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * MK表单实例绑定表 实体类 + * + * @author BladeX + */ +@Data +@TableName("blade_mk_form_bind") +@Schema(description = "MkFormBind对象") +public class MkFormBind implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "主键") + @TableId(value = "id", type = IdType.ASSIGN_ID) + private Long id; + + @Schema(description = "租户ID") + private String tenantId; + + @Schema(description = "业务类型") + private String bizType; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "业务主键") + private Long bizId; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "MK表单实例id") + private Long formInstanceId; + + @Schema(description = "MK流程实例id") + private String processInstanceId; + + @Schema(description = "是否当前生效(1是0否)") + private Integer isCurrent; + + @DateTimeFormat(pattern = DateUtil.PATTERN_DATETIME) + @JsonFormat(pattern = DateUtil.PATTERN_DATETIME) + @Schema(description = "创建时间", hidden = true) + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + @DateTimeFormat(pattern = DateUtil.PATTERN_DATETIME) + @JsonFormat(pattern = DateUtil.PATTERN_DATETIME) + @Schema(description = "更新时间", hidden = true) + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; +} diff --git a/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/vo/MkFormBindVO.java b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/vo/MkFormBindVO.java new file mode 100644 index 00000000..a1623972 --- /dev/null +++ b/blade-service-api/blade-process-api/src/main/java/org/springblade/process/pojo/vo/MkFormBindVO.java @@ -0,0 +1,61 @@ +/** + * 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.process.pojo.vo; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * MK表单实例绑定 VO + * + * @author BladeX + */ +@Data +@Schema(description = "MkFormBindVO") +public class MkFormBindVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "业务主键") + private Long bizId; + + @Schema(description = "业务类型") + private String bizType; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "MK表单实例id") + private Long formInstanceId; + + @Schema(description = "MK流程实例id") + private String processInstanceId; +} 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 932e41a7..15a3e94a 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 @@ -138,6 +138,18 @@ public class Dept extends TenantEntity { @Schema(description = "部门编码") private String deptCode; + /** + * 金蝶部门代码 + */ + @Schema(description = "金蝶部门代码") + private String kingdeeDeptCode; + + /** + * 合同编码前缀(合同编号:前缀(年份)三位流水号) + */ + @Schema(description = "合同编码前缀") + private String contractCodePrefix; + /** * 组织简称 */ @@ -204,4 +216,12 @@ public class Dept extends TenantEntity { @TableField(updateStrategy = FieldStrategy.NOT_NULL) private String oaSupSubComId; + /** + * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 + */ + @JsonIgnore + @Schema(description = "OA上次更新时间", hidden = true) + @TableField(updateStrategy = FieldStrategy.NOT_NULL) + private String oaModified; + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java index 2afb375e..af31a055 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java @@ -25,6 +25,7 @@ */ package org.springblade.system.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; @@ -63,6 +64,7 @@ public class RailwayStation extends BaseEntity { * 电报略码 */ @Schema(description = "电报略码") + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String telegraphCode; /** * 车站名称 diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java index cf21c31b..86e522e3 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java @@ -4,29 +4,34 @@ import lombok.AllArgsConstructor; import lombok.Getter; /** - * 部门类型枚举 + * 组织类型枚举,与业务字典 organization_type 对齐 + * * @author bfhuange * @date 2024/8/26 */ @AllArgsConstructor @Getter public enum DeptCategory { - /** - * 公司 - */ - COMPANY(1, "公司"), - /** - * 部门 - */ - DEPT(2, "部门"), - ; + /** + * 集团 + */ + GROUP(1, "集团"), + /** + * 公司 + */ + COMPANY(2, "公司"), + /** + * 部门 + */ + DEPT(3, "部门"), + ; - /** - * 部门类型编码 - */ - private final Integer code; - /** - * 部门类型名称 - */ - private final String name; + /** + * 组织类型编码 + */ + private final Integer code; + /** + * 组织类型名称 + */ + private final String name; } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java index 3b37571b..05ca3571 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java @@ -35,8 +35,12 @@ public enum UserSex { if (name == null || name.isEmpty()) { return null; } + String sexName = name.trim(); + if ("-1".equals(sexName) || "0".equals(sexName) || "未知".equals(sexName)) { + return null; + } return Stream.of(values()) - .filter(e -> e.getName().equals(name)) + .filter(e -> e.getName().equals(sexName) || String.valueOf(e.getCode()).equals(sexName)) .map(UserSex::getCode) .findFirst().orElse(null); } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/AirportMasterVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/AirportMasterVO.java index 28c7da60..f445c719 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/AirportMasterVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/AirportMasterVO.java @@ -52,6 +52,13 @@ public class AirportMasterVO extends AirportMaster { @Schema(description = "导入错误信息") private String errorMessage; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/FeeItemVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/FeeItemVO.java index b45d4580..7b77ee32 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/FeeItemVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/FeeItemVO.java @@ -49,6 +49,10 @@ public class FeeItemVO extends FeeItem { @Schema(description = "组织") private String createDeptName; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "更新人") private String updateUserName; diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java index 590a8baf..d8390cf5 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java @@ -66,4 +66,7 @@ public class OaOrgSyncPageVO implements Serializable { @Schema(description = "是否已到最后一页") private Boolean finished; + + @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") + private String modifiedSince; } 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 b493bdf3..b57811da 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 @@ -63,4 +63,7 @@ public class OaPersonSyncPageVO implements Serializable { @Schema(description = "是否已到最后一页") private Boolean finished; + + @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") + private String modifiedSince; } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/PortTerminalVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/PortTerminalVO.java index 78474b07..b08a4c8a 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/PortTerminalVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/PortTerminalVO.java @@ -52,6 +52,13 @@ public class PortTerminalVO extends PortTerminal { @Schema(description = "导入错误信息") private String errorMessage; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RailwayStationVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RailwayStationVO.java index c28a15fc..17a50ba7 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RailwayStationVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/RailwayStationVO.java @@ -52,6 +52,13 @@ public class RailwayStationVO extends RailwayStation { @Schema(description = "导入错误信息") private String errorMessage; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java new file mode 100644 index 00000000..9d16ee83 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java @@ -0,0 +1,148 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * LocalDate 宽松反序列化,对齐 OCR / 司机侧日期归一化规则。 + *

+ * 兼容写法: + *

    + *
  • ISO:{@code yyyy-MM-dd}
  • + *
  • 紧凑:{@code yyyyMMdd}
  • + *
  • 点分 / 斜杠 / 年月日:{@code 2018.3.9}、{@code 2026/03/17}、{@code 2030年05月20日}
  • + *
  • 任意非数字分隔:年与月、月与日之间只要不是数字即可
  • + *
  • 区间文本:按字段名取起期(first)或止期(last)
  • + *
+ * + * @author Chill + */ +public class FlexibleLocalDateDeserializer extends JsonDeserializer { + + /** + * 4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(与 OcrFieldSupport 一致) + */ + private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})"); + + private static final Pattern COMPACT_PATTERN = Pattern.compile("(\\d{4})(\\d{2})(\\d{2})"); + + @Override + public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException { + String text = parser.getValueAsString(); + if (text == null) { + return null; + } + String value = text.trim(); + if (value.isEmpty()) { + return null; + } + // 「长期」等非日期文案:按空值处理,避免把无法解析的字符串打成 400 + if (value.contains("长期") || value.contains("永久")) { + return null; + } + boolean preferLast = isEndDateField(parser.getCurrentName()); + LocalDate parsed = parseFlexible(value, preferLast); + if (parsed != null) { + return parsed; + } + return (LocalDate) context.handleWeirdStringValue(LocalDate.class, value, + "期望日期格式 yyyy-MM-dd / yyyyMMdd / yyyy年M月d日 等 OCR 常见写法"); + } + + /** + * 解析宽松日期文本;区间时按 preferLast 取首段或末段。 + * + * @param value 原始文本 + * @param preferLast true 取止期,false 取起期 + * @return 合法 LocalDate;无法识别时返回 null + */ + public static LocalDate parseFlexible(String value, boolean preferLast) { + if (value == null) { + return null; + } + String text = value.trim(); + if (text.isEmpty()) { + return null; + } + List candidates = matchDates(text); + if (!candidates.isEmpty()) { + return preferLast ? candidates.get(candidates.size() - 1) : candidates.get(0); + } + // 去掉所有非数字后恰为 8 位时按 yyyyMMdd 处理(如 20260317、2026-0317 混杂) + String compact = text.replaceAll("\\D", ""); + if (compact.length() == 8) { + return toLocalDate(compact.substring(0, 4), compact.substring(4, 6), compact.substring(6, 8)); + } + Matcher compactMatcher = COMPACT_PATTERN.matcher(text); + if (compactMatcher.find()) { + return toLocalDate(compactMatcher.group(1), compactMatcher.group(2), compactMatcher.group(3)); + } + return null; + } + + private static List matchDates(String text) { + List dates = new ArrayList<>(); + Matcher matcher = DATE_PATTERN.matcher(text); + while (matcher.find()) { + LocalDate date = toLocalDate(matcher.group(1), matcher.group(2), matcher.group(3)); + if (date != null) { + dates.add(date); + } + } + return dates; + } + + private static LocalDate toLocalDate(String yearText, String monthText, String dayText) { + try { + int year = Integer.parseInt(yearText); + int month = Integer.parseInt(monthText); + int day = Integer.parseInt(dayText); + return LocalDate.of(year, month, day); + } catch (NumberFormatException | DateTimeException exception) { + return null; + } + } + + /** + * 止期字段名通常以 End / EndDate 结尾,区间文本应取最后一段日期。 + */ + private static boolean isEndDateField(String fieldName) { + if (fieldName == null || fieldName.isEmpty()) { + return false; + } + return fieldName.endsWith("End") || fieldName.endsWith("EndDate") + || fieldName.contains("EndDate") || fieldName.contains("ValidEnd"); + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java index d6dc67af..ec9e0caf 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java @@ -46,4 +46,7 @@ public class ExceptionDisposalFollowRequest implements Serializable { @Schema(description = "跟进说明") private String followContent; + @Schema(description = "附件JSON,[{\"name\":\"...\",\"url\":\"...\"}]") + private String attachmentsJson; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java new file mode 100644 index 00000000..b1f233f8 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java @@ -0,0 +1,63 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 异常处置编辑请求 + *

+ * 只承载上报四件套。车牌、项目、承运商、配载单号等字段由上报时从运单回填, + * 不在此列,避免编辑后与运单脱钩。 + *

+ * 不含处置状态:状态只能经跟进与完成流转,编辑不得改变它。 + * + * @author Chill + */ +@Data +@Schema(description = "异常处置编辑请求") +public class ExceptionDisposalUpdateRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "异常处置ID") + private Long id; + + @Schema(description = "异常类型") + private String exceptionType; + + @Schema(description = "异常原因") + private String exceptionReason; + + @Schema(description = "上报说明") + private String reportDescription; + + @Schema(description = "现场照片,逗号分隔") + private String scenePhotos; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java index cf5a24e8..4e726481 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java @@ -5,23 +5,22 @@ * 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 + * 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 + * Using this software signifies agreement to the 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) + * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package org.springblade.transport.pojo.dto; @@ -32,12 +31,12 @@ import java.io.Serial; import java.io.Serializable; /** - * 金蝶票池实时统一查询条件(收票登记选票与正式结算单认领共用) + * 金蝶票据池镜像统一查询条件(收票登记选票与正式结算单认领共用,查本地镜像) * * @author Chill */ @Data -@Schema(description = "金蝶票池实时统一查询条件") +@Schema(description = "金蝶票据池镜像统一查询条件") public class KingdeeInvoicePoolQueryDTO implements Serializable { @Serial @@ -73,6 +72,24 @@ public class KingdeeInvoicePoolQueryDTO implements Serializable { @Schema(description = "开票日期止 yyyy-MM-dd") private String invoiceDateEnd; + /** + * 开票单位(销方名称),模糊 + */ + @Schema(description = "开票单位(销方名称,模糊)") + private String salerName; + + /** + * 受票单位(购方名称),模糊 + */ + @Schema(description = "受票单位(购方名称,模糊)") + private String buyerName; + + /** + * 发票类型编码(票易通字典,如 26/27,镜像按 invoice_type_code 精确匹配) + */ + @Schema(description = "发票类型编码(票易通字典,如 26/27)") + private String invoiceType; + /** * 销方税号,精确 */ @@ -86,14 +103,14 @@ public class KingdeeInvoicePoolQueryDTO implements Serializable { private String buyerTaxNo; /** - * 发票类型编码(票易通字典,如 26/27) + * 认领过滤:true 仅已认领,false 仅未认领,null 不过滤(已认领=存在非作废正式结算单发票行) */ - @Schema(description = "发票类型编码(票易通字典,如 26/27)") - private String invoiceType; + @Schema(description = "认领过滤:true已认领/false未认领/null不限") + private Boolean claimed; /** - * 续扫游标:下一页翻页时原样回传上页返回的 nextCursor,空表示按页码锚定重新扫描 + * 登记过滤:true 仅已登记,false 仅未登记,null 不过滤(已登记=存在非作废收票登记) */ - @Schema(description = "续扫游标(下一页请求回传上页 nextCursor)") - private String cursor; + @Schema(description = "登记过滤:true已登记/false未登记/null不限") + private Boolean registered; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java index 6b16994f..0ba2fba4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java @@ -30,7 +30,19 @@ public class NodeSubmitDTO implements Serializable { @Schema(description = "定位信息") private Location location; - @Schema(description = "凭证照片 URL 列表") + /** + * 带类型的打卡照片,类型由提交方显式给出;非空时优先于 photos。 + */ + @Schema(description = "带类型的照片列表,非空时优先于 photos") + private List photoItems; + + /** + * 旧版小程序提交的裸 URL 列表,类型只能按下标回推过程配置的凭证类型。 + *

+ * 与 photoItems 并存是为了不强制升级小程序:新版提交 photoItems,旧版仍走这里; + * 等旧版本覆盖率达标后由后续改造删除。 + */ + @Schema(description = "凭证照片 URL 列表,类型按下标回推") private List photos; @Schema(description = "重量(吨)") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java similarity index 54% rename from blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java rename to blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java index 4e80ad61..0e1aa212 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java @@ -23,44 +23,31 @@ *

* Author: Chill Zhuang (bladejava@qq.com) */ -package org.springblade.transport.pojo.vo; +package org.springblade.transport.pojo.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; -import java.util.List; /** - * 金蝶票池实时查询游标分页返回体(A801-1 无总数:hasMore 驱动上一页/下一页) + * 证件 OCR 识别请求(小程序) * * @author Chill */ @Data -@Schema(description = "金蝶票池游标分页返回体") -public class KingdeeInvoicePoolPageVO implements Serializable { +@Schema(description = "证件OCR识别请求") +public class OcrRecognizeRequest implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** 本页数据(金蝶实时行 + 本地认领/登记装饰标记) */ - @Schema(description = "本页数据") - private List records; + @Schema(description = "已上传图片的地址,必须是百度可访问的HTTP或HTTPS地址") + private String imageUrl; + @Schema(description = "证件类型:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证") + private String type; + @Schema(description = "正副面:front或back,默认front") + private String side; - /** 当前页码,从 1 开始 */ - @Schema(description = "当前页码") - private Long current; - - /** 每页条数 */ - @Schema(description = "每页条数") - private Long size; - - /** 是否还有下一页(服务端有界补页扫描判断) */ - @Schema(description = "是否还有下一页") - private Boolean hasMore; - - /** 下一页续扫游标("远程页码:已消费过滤行数"),null 表示没有更多;翻下一页时原样回传 */ - @Schema(description = "下一页续扫游标,null 表示没有更多") - private String nextCursor; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java new file mode 100644 index 00000000..c33d030f --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java @@ -0,0 +1,98 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDate; + +/** + * 司机端个人认证提交请求 + *

+ * 一次提交承载个人认证的**全部**内容(身份证 + 驾驶证 + 从业资格证), + * 三证一起提交、一起审核,不拆开。 + * + * @author Chill + */ +@Data +@Schema(description = "司机端个人认证提交请求") +public class PersonCertSubmitRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "真实姓名") + private String realName; + @Schema(description = "身份证号") + private String idCardNo; + @Schema(description = "身份证正面照") + private String idCardFrontUrl; + @Schema(description = "身份证反面照") + private String idCardBackUrl; + @Schema(description = "身份证有效期起") + private LocalDate idCardValidStart; + @Schema(description = "身份证有效期止") + private LocalDate idCardValidEnd; + @Schema(description = "身份证长期有效") + private Integer idCardLongTerm; + + @Schema(description = "驾驶证档案编号") + private String driverFileNo; + @Schema(description = "准驾车型") + private String driverClass; + @Schema(description = "驾驶证主页照片") + private String driverLicenseFrontUrl; + @Schema(description = "驾驶证副页照片") + private String driverLicenseBackUrl; + @Schema(description = "驾驶证有效期起") + private LocalDate driverValidStart; + @Schema(description = "驾驶证有效期止") + private LocalDate driverValidEnd; + @Schema(description = "驾驶证长期有效") + private Integer driverLongTerm; + + @Schema(description = "从业资格证类型") + private String qualificationType; + @Schema(description = "从业资格证号") + private String qualificationNo; + @Schema(description = "从业资格证封面页") + private String qualificationFront; + @Schema(description = "从业资格证内容页") + private String qualificationBack; + @Schema(description = "从业资格证有效期起") + private LocalDate qualificationValidStart; + @Schema(description = "从业资格证有效期止") + private LocalDate qualificationValidEnd; + @Schema(description = "从业资格证长期有效") + private Integer qualificationLongTerm; + + @Schema(description = "大头照") + private String headPhoto; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java new file mode 100644 index 00000000..d7c4e20a --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java @@ -0,0 +1,32 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 带类型的打卡照片 + *

+ * 类型取值域是固定的「打卡照片」加该节点过程配置勾选的凭证类型,域外类型一律拒绝: + * 类型是照片分组的唯一依据,放进来路不明的类型会让回显分组错乱。 + */ +@Data +@Schema(description = "带类型的打卡照片") +public class PunchPhotoDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "照片类型") + private String type; + + @Schema(description = "图片URL") + private String url; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceiptClaimPushResult.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceiptClaimPushResult.java new file mode 100644 index 00000000..dd27a8d9 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceiptClaimPushResult.java @@ -0,0 +1,70 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 收款认领推送金蝶结果(A301-3) + *

+ * 推送失败不抛异常跨事务边界(保证失败状态落库不被回滚),以本结果对象向调用方报告成败与原因。 + * + * @author Chill + */ +@Data +@Schema(description = "收款认领推送金蝶结果") +public class ReceiptClaimPushResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "是否推送成功") + private boolean success; + + @Schema(description = "金蝶收款单号(成功时返回,可能为空:金蝶响应未返回单号)") + private String kingdeeBillNo; + + @Schema(description = "失败原因(成功时为空)") + private String message; + + public static ReceiptClaimPushResult success(String kingdeeBillNo) { + ReceiptClaimPushResult result = new ReceiptClaimPushResult(); + result.setSuccess(true); + result.setKingdeeBillNo(kingdeeBillNo); + return result; + } + + public static ReceiptClaimPushResult fail(String message) { + ReceiptClaimPushResult result = new ReceiptClaimPushResult(); + result.setSuccess(false); + result.setMessage(message); + return result; + } +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java new file mode 100644 index 00000000..6cd65d78 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java @@ -0,0 +1,115 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDate; + +/** + * 司机端车辆认证提交请求 + *

+ * 一次提交承载该车辆认证的**全部**内容(行驶证 + 道路运输证),两证一起提交、一起审核。 + * 提交只产生新的待审记录,不覆盖车辆当前已生效的认证内容。 + * + * @author Chill + */ +@Data +@Schema(description = "司机端车辆认证提交请求") +public class VehicleCertSubmitRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "车辆ID:新增时为空,重新认证时必填") + private Long id; + @Schema(description = "车牌号") + private String plateNo; + @Schema(description = "所有人:行驶证登记的单位名称,不是司机") + private String ownerName; + @Schema(description = "车牌颜色") + private String plateColor; + @Schema(description = "车辆类型") + private String vehicleType; + @Schema(description = "车辆识别代号(车架号),选填") + private String vin; + + @Schema(description = "外廓长度,毫米") + private Integer outerLength; + @Schema(description = "外廓宽度,毫米") + private Integer outerWidth; + @Schema(description = "外廓高度,毫米") + private Integer outerHeight; + @Schema(description = "核定载质量,KG") + private Integer approvedLoadKg; + @Schema(description = "准牵引总质量,KG") + private Integer tractionMassKg; + @Schema(description = "能源类型") + private String energyType; + @Schema(description = "业务关系:自有 / 管理挂靠 / 业务合作") + private String businessRelation; + @Schema(description = "强制报废日期") + private LocalDate compulsoryScrapDate; + @Schema(description = "强制报废长期有效:0否 1是") + private Integer compulsoryScrapLongTerm; + + @Schema(description = "行驶证档案编号") + private String drivingLicenseNo; + @Schema(description = "行驶证主页正面照片") + private String drivingLicenseImage; + @Schema(description = "行驶证主页反面照片") + private String drivingLicenseMainBack; + @Schema(description = "行驶证副页正面照片") + private String drivingLicenseViceFront; + @Schema(description = "行驶证副页反面照片") + private String drivingLicenseViceBack; + @Schema(description = "行驶证有效期起") + private LocalDate drivingLicenseStartDate; + @Schema(description = "行驶证有效期止") + private LocalDate drivingLicenseEndDate; + @Schema(description = "行驶证长期有效:0否 1是") + private Integer drivingLicenseLongTerm; + + @Schema(description = "道路运输证号") + private String roadTransportCertNo; + @Schema(description = "道路运输证照片") + private String roadTransportCertImage; + @Schema(description = "道路运输证有效期起") + private LocalDate roadTransportCertStartDate; + @Schema(description = "道路运输证有效期止") + private LocalDate roadTransportCertEndDate; + @Schema(description = "道路运输证长期有效:0否 1是") + private Integer roadTransportCertLongTerm; + + @Schema(description = "机动车登记编号") + private String registrationNo; + @Schema(description = "机动车登记本照片") + private String registrationImage; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java index c346165c..717766fa 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java @@ -32,5 +32,13 @@ public class WaybillImportBatchRequest { private Long planId; private String planName; private String remark; + /** 列表筛选:创建人 */ + private Long createUser; + /** 列表筛选:创建时间起(yyyy-MM-dd HH:mm:ss) */ + private String createTimeStart; + /** 列表筛选:创建时间止(yyyy-MM-dd HH:mm:ss) */ + private String createTimeEnd; private List> rows; + /** 分批确认导入时,是否追加到已有批次(首批为 false,后续批为 true)。 */ + private Boolean append; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java new file mode 100644 index 00000000..763f8f31 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java @@ -0,0 +1,114 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 管理端打卡记录编辑请求 + *

+ * 承载时间轴上单条打卡记录的全部可编辑项,字段按过程配置取舍: + * 打卡定位(地址/经纬度)、上传货量(重量/体积/数量)、上传凭证(照片)。 + *

+ * {@code punched=false} 表示取消打卡,服务端会清除该节点的打卡流水 + * ——时间轴上「未打卡」的判据就是没有流水,故不能只改状态位。 + * + * @author Chill + */ +@Data +@Schema(description = "管理端打卡记录编辑请求") +public class WaybillPunchRecordSaveRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID") + private Long waybillId; + + @Schema(description = "打卡类型:node=过程节点打卡,enroute=在途打卡") + private String type; + + @Schema(description = "过程节点 key") + private String nodeCode; + + @Schema(description = "过程节点名称") + private String nodeName; + + @Schema(description = "打卡状态:true 已打卡,false 未打卡") + private Boolean punched; + + @Schema(description = "打卡时间 yyyy-MM-dd HH:mm:ss,为空时取当前时间") + private String punchTime; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "经度") + private String longitude; + + @Schema(description = "纬度") + private String latitude; + + @Schema(description = "重量(吨)") + private String weight; + + @Schema(description = "体积(方)") + private String volume; + + @Schema(description = "数量(件)") + private String quantity; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "打卡凭证图,按上传顺序回写凭证类型") + private List photos = new ArrayList<>(); + + /** + * 打卡凭证图 + *

+ * 类型随图一起回写:展示层对「逗号分隔的裸 URL」按凭证类型下标回推标签, + * 编辑换了顺序就会串标,所以这里保留原类型。 + */ + @Data + @Schema(description = "打卡凭证图") + public static class Photo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "凭证类型") + private String type; + + @Schema(description = "图片URL") + private String url; + + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java index 782c1b7c..c443b22b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java @@ -61,10 +61,10 @@ public class ContractManage extends TenantEntity { @Schema(description = "所属项目") private String projectName; - @Schema(description = "所属组织ID") + @Schema(description = "合同负责部门ID") private Long organizationId; - @Schema(description = "所属组织") + @Schema(description = "合同负责部门") private String organizationName; @Schema(description = "合同类别") @@ -100,6 +100,9 @@ public class ContractManage extends TenantEntity { @Schema(description = "签订日期") private LocalDate signDate; + @Schema(description = "申请日期(提交正式合同时写入)") + private LocalDate applyDate; + @Schema(description = "结算方式") private String settlementMode; @@ -107,6 +110,7 @@ public class ContractManage extends TenantEntity { private String contractFormat; @Schema(description = "是否需要加盖法人章") + @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS) private Integer legalSealFlag; @Schema(description = "一式份数") @@ -206,7 +210,10 @@ public class ContractManage extends TenantEntity { @Schema(description = "终止原因") private String terminateReason; - @Schema(description = "备注") + @Schema(description = "合同主要内容") + private String mainContent; + + @Schema(description = "其他事项说明") private String remark; @Schema(description = "合规系统单据ID(fdContractId)") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java index 1d13e2ec..fc7c3ce8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java @@ -28,10 +28,15 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; +import org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; @@ -64,6 +69,7 @@ public class Driver extends TenantEntity { * 出生年月 */ @Schema(description = "出生年月") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate birthday; /** * 性别 @@ -111,6 +117,23 @@ public class Driver extends TenantEntity { */ @Schema(description = "身份证反面照") private String idCardBack; + /** + * 身份证有效期起 + */ + @Schema(description = "身份证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) + private LocalDate idCardValidStart; + /** + * 身份证有效期止 + */ + @Schema(description = "身份证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) + private LocalDate idCardValidEnd; + /** + * 身份证长期有效 + */ + @Schema(description = "身份证长期有效") + private Integer idCardLongTerm; /** * 大头照 */ @@ -130,11 +153,13 @@ public class Driver extends TenantEntity { * 驾驶证有效期起 */ @Schema(description = "驾驶证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseStartDate; /** * 驾驶证有效期止 */ @Schema(description = "驾驶证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseEndDate; /** * 驾驶证长期有效 @@ -165,6 +190,7 @@ public class Driver extends TenantEntity { * 从业资格证有效期止 */ @Schema(description = "从业资格证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate qualificationEndDate; /** * 从业资格证长期有效 @@ -192,9 +218,10 @@ public class Driver extends TenantEntity { @Schema(description = "手机号") private String mobile; /** - * 关联系统用户ID + * 关联小程序用户ID(blade_mp_user.id) */ - @Schema(description = "关联系统用户ID") + @Schema(description = "关联小程序用户ID") + @JsonSerialize(using = ToStringSerializer.class) private Long userId; /** * 与联系人关系 @@ -221,5 +248,32 @@ public class Driver extends TenantEntity { */ @Schema(description = "备注") private String remark; + /** + * 最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null 表示从未提交 + *

+ * 仅为列表展示而冗余,不是可用性判据;「是否已认证」一律读生效快照指针。 + */ + @Schema(description = "最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null为从未提交") + private Integer certificationStatus; + /** + * 最新一次驳回原因展示缓存 + */ + @Schema(description = "最新一次驳回原因展示缓存") + private String certificationRejectReason; + /** + * 生效快照指针:指向 blade_transport_cert_record.id + *

+ * 「该司机是否已认证」的唯一判据。为 null 表示从未认证通过; + * 一经设置永不回退——提交、驳回、作废都不得修改它, + * 因此重新提交认证不会让一个已认证的司机失去接单资格。 + *

+ * ⚠️ {@code READ_ONLY}:本字段只能由审核通过时写入。 + * 司机档案的 {@code /submit} 直接以本实体接收请求体,若允许反序列化, + * 调用方就能自行填一个记录 ID 把自己「认证」掉,绕过审核。 + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "生效快照指针") + private Long effectiveCertRecordId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java index 96f5647e..67588e27 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java @@ -51,6 +51,12 @@ public class ExceptionDisposal extends TenantEntity { @Schema(description = "运单号") private String waybillNo; + @Schema(description = "过程节点 key(为空表示未指明节点)") + private String nodeCode; + + @Schema(description = "过程节点名称(上报时复制,此后不随过程配置变化)") + private String nodeName; + @Schema(description = "配载单号/总单号") private String loadingOrMasterNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java index a9921786..e5593217 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java @@ -51,6 +51,9 @@ public class ExceptionDisposalFollowRecord extends TenantEntity { @Schema(description = "跟进说明") private String followContent; + @Schema(description = "附件JSON") + private String attachmentsJson; + @Schema(description = "跟进人") private Long followUser; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java index c29e4c2b..0a03304b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java @@ -144,6 +144,10 @@ public class FormalSettlement extends TenantEntity { * 金蝶单据编号 */ private String kingdeeBillNo; + /** + * 金蝶单据id(星瀚内部主键,A902 附件上传定位用) + */ + private String kingdeeBillId; /** * 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java index 48ae4d6e..60ede7e1 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java @@ -159,6 +159,10 @@ public class InvoiceApplication extends TenantEntity { * 金蝶开票申请单编号(A303-1 返回,A303-5 查询 serialNo) */ private String kingdeeBillNo; + /** + * 金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用) + */ + private String kingdeeBillId; /** * 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败 invoiced-开票完成(终态) */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java index fa875a4f..8e145070 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java @@ -33,6 +33,7 @@ import java.time.LocalDateTime; * 开票申请金蝶发票结果实体类 *

* 定时任务经 A303-5 开票申请发票查询回写的发票明细(发票号唯一键幂等落库), + * 发票文件地址(板式文件/数电PDF/XML/预览图)独立落库供详情页查看下载, * 原文 JSON 整体留档便于联调与追溯 * * @author Chill @@ -61,6 +62,18 @@ public class InvoiceApplicationInvoice extends TenantEntity { @Schema(description = "价税合计") private BigDecimal invoiceAmount; + @Schema(description = "板式文件下载地址(数电票为OFD,航信百望税盘普通电票为PDF,电子专票/UKey电票为OFD)") + private String invoiceFileUrl; + + @Schema(description = "PDF图片预览地址") + private String invoiceImageUrl; + + @Schema(description = "数电PDF下载地址(发票云5.0.025版本返回)") + private String invoicePdfFileUrl; + + @Schema(description = "数电XML下载地址(发票云5.0.025版本返回)") + private String invoiceXmlFileUrl; + @Schema(description = "A303-5 返回原文JSON留档") private String rawJson; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java index 143c27d1..91a33972 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java @@ -152,6 +152,10 @@ public class InvoiceReceipt extends TenantEntity { * 金蝶收票单单据编号(A303-2 批量保存生成/幂等预检命中回填) */ private String kingdeeReceivedInvoiceNo; + /** + * 金蝶收票单id(星瀚内部主键,A902 附件上传定位用) + */ + private String kingdeeReceivedInvoiceId; /** * 收票单推送状态(unsynced 未推送 / pushed 已保存收票单 / assigned 已指定应付 / failed 推送失败) */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java new file mode 100644 index 00000000..c8c0377d --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java @@ -0,0 +1,84 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.time.LocalDateTime; + +/** + * 金蝶附件同步实体类(A902 附件上传到指定单据,单附件行级状态) + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_kingdee_attachment_sync") +@Schema(description = "金蝶附件同步") +public class KingdeeAttachmentSync extends TenantEntity { + @Serial + private static final long serialVersionUID = 1L; + /** + * 业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application) + */ + private String bizType; + /** + * 业务单据id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long bizId; + /** + * 附件幂等键(用户附件=附件url;发票文件=A801-2 的 path) + */ + private String attachmentKey; + /** + * 附件名称(展示/排查用) + */ + private String attachmentName; + /** + * 金蝶侧文件路径(A902 返回 data.path) + */ + private String kingdeePath; + /** + * 同步状态:1 成功 / 0 失败 + */ + private Integer syncStatus; + /** + * 失败原因(超限/下载失败/金蝶报错) + */ + private String failReason; + /** + * 最近上传时间 + */ + private LocalDateTime syncTime; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeBillLock.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeBillLock.java new file mode 100644 index 00000000..1817ce2e --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeBillLock.java @@ -0,0 +1,87 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.time.LocalDateTime; + +/** + * 金蝶单据锁定记录实体类 + *

+ * 一行 = 一张金蝶目标单:推送成功后 lockBusiData 锁定(FAILED→LOCKED),金蝶经 unLockNotifyBusiData + * 回调解锁(→UNLOCKED,DELETE 时废除源单并还原关联数据);同时承担当前锁状态(编辑守卫/回调定位) + * 与锁定补偿扫表(FAILED+try_count)、锁定/解锁留痕 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_kingdee_bill_lock") +@Schema(description = "金蝶单据锁定记录") +public class KingdeeBillLock extends TenantEntity { + @Serial private static final long serialVersionUID = 1L; + /** + * WLTMS 单据类型(SETTLEMENT/INVOICE_APPLY,预留 PAY_APPLY/RECEIVED_INVOICE) + */ + private String sourceBillType; + /** + * WLTMS 业务主键 + */ + private Long sourceBillId; + /** + * WLTMS 单号(正式结算单号/开票申请单号) + */ + private String sourceBillNo; + /** + * 金蝶单据类型(ApFin_other_BT_S/arfin_other_BT_S/cdm_paybill_bank_elect_BT_S 等固定值) + */ + private String targetBillType; + /** + * 金蝶单据编号(即业务单 kingdeeBillNo,开票申请按发票张次逐张一行) + */ + private String targetBillNo; + /** + * 锁状态:failed-锁定失败(补偿重试对象)/locked-金蝶锁定中/unlocked-已解锁(终态) + */ + private String lockStatus; + /** + * 最近动作来源操作类型:SAVE-单据新增,DELETE-单据删除(DELETE 回调触发废除还原) + */ + private String operationType; + /** + * 锁定重试次数(补偿任务用,超上限停试) + */ + private Integer tryCount; + /** + * 锁定时间 + */ + private LocalDateTime lockTime; + /** + * 解锁时间 + */ + private LocalDateTime unlockTime; + /** + * 推单发起人(锁定操作人) + */ + private String lockOperator; + /** + * 回调操作人(金蝶侧操作人) + */ + private String notifyOperator; + /** + * 锁定失败原因 + */ + private String failReason; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java index fbde7a17..79eb12e7 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java @@ -143,4 +143,12 @@ public class KingdeeInvoicePool extends TenantEntity { * 票池数据更新时间(A801-1 billCreateTime,镜像排序字段) */ private LocalDateTime sourceUpdatedTime; + /** + * 剩余可用金额(A801-1 availableAmount,金蝶可用税额口径,选票本地过滤用:>0 才可选) + */ + private BigDecimal availableAmount; + /** + * 金额使用状态(A801-1 amountUseStatus,0 未使用可选 / 2 全部使用;选票本地过滤用) + */ + private String amountUseStatus; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java index 1ac8224d..533394ae 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java @@ -80,16 +80,76 @@ public class KingdeeReceiptFlow extends TenantEntity { * 收款人(A301-2 recpayee) */ private String payeeName; + /** + * 收款通知单号(A301-2 billno) + */ private String receiptNoticeNo; + /** + * 付款人名称(A301-2 recviewpayer) + */ private String payerName; + /** + * 收款金额(A301-2 reamount) + */ private BigDecimal receiptAmount; + /** + * 对方单位名称(A301-2 oppunit) + */ private String counterpartyName; + /** + * 对方银行账号(A301-2 oppbanknumber) + */ private String counterpartyAccount; + /** + * 对方开户行(A301-2 oppbank) + */ private String counterpartyBank; + /** + * 摘要(A301-2 description) + */ private String summary; + /** + * 交易日期(A301-2 tradetime) + */ private LocalDateTime transactionTime; + /** + * 明细流水号(A301-2 detailid,回退 tradedetailno / bankdetailno,幂等键) + */ private String detailSerialNo; + /** + * 已认领金额(本地认领驱动,初始为0) + */ private BigDecimal claimedAmount; + /** + * 认领状态(unclaimed未认领/partial部分认领/claimed认领完成,累计认领与公告未认领金额对表) + */ private String claimStatus; + /** + * 推送状态(none未推送/pushed已推送/failed推送失败;已推送为终态闸门,不允许二次推送) + */ + private String pushStatus; + /** + * 最近一次推送失败原因(成功后清空) + */ + private String pushFailReason; + /** + * 首次推送金蝶成功时间 + */ + private LocalDateTime pushedTime; + /** + * 金蝶推送来源单据编码(分摊结算单号下划线拼接,推送时生成落库;分摊不变则重试编码不变,A301-1 回写匹配键) + */ + private String awrpSrcCode; + /** + * 金蝶收款单号(推送响应回填 / A301-1 回写推进) + */ + private String kingdeeBillNo; + /** + * 金蝶回写状态(none未推送/pushed已推送/approved审核通过/received已收款/failed失败) + */ + private String kingdeeBillStatus; + /** + * 来源更新时间(取交易日期,同步时刷新) + */ private LocalDateTime sourceUpdatedTime; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java index 6393fc0a..e037a882 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java @@ -56,7 +56,7 @@ public class LoadingManage extends TenantEntity { @Schema(description = "货物名称") private String cargoName; - @Schema(description = "车/船/航班/班列") + @Schema(description = "车牌号/船班号/船次号/班次号") private String vehicleNo; @Schema(description = "挂车车牌号") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java index 410884ef..0b6bdffa 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java @@ -70,10 +70,10 @@ public class ProjectApply extends TenantEntity { @Schema(description = "业务部门") private String businessDeptName; - @Schema(description = "承办部门ID") - private Long undertakeDeptId; + @Schema(description = "平台公司ID,多个逗号分隔") + private String undertakeDeptId; - @Schema(description = "承办部门") + @Schema(description = "平台公司,多个顿号分隔") private String undertakeDeptName; @Schema(description = "项目由来") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java index dda3cbca..2294d0a9 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java @@ -66,10 +66,12 @@ public class ReceiptClaim extends TenantEntity { private String remark; private String claimStatus; /** - * 金蝶认领结果推送来源单据编码(A301-3 awrp_src_code,A301-1 回写按此匹配) + * 金蝶收款单号(冗余展示:推送成功/A301-1 回写时由流水级联刷新) */ - private String awrpSrcCode; private String kingdeeBillNo; + /** + * 金蝶回写状态(冗余展示:与流水 kingdee_bill_status 同步刷新) + */ private String kingdeeBillStatus; @JsonSerialize(using = ToStringSerializer.class) private Long voidedBy; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java new file mode 100644 index 00000000..4f1346f4 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java @@ -0,0 +1,158 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.time.LocalDateTime; + +/** + * 认证记录实体类 + *

+ * 认证对象(司机 / 车辆)每一次提交所产生的、不可变的快照。 + * 记录一经创建,其 {@code snapshotJson} 与提交元信息永不修改;被同对象的后续提交取代时 + * 标记为「已作废」,而不是删除。判定某个对象「是否已认证」只看它持有的生效快照指针, + * 与记录表里的任何一条记录的状态都无关。 + *

+ * ⚠️ {@code status} 承载的是「审核状态」,不是 BladeX 的「启用 / 停用」。 + * 该字段继承自 {@link TenantEntity},带来两条必须堵住的框架通道: + * 一是 {@code BaseServiceImpl#resolveEntity} 在插入时会把 {@code null} 的 {@code status} + * 静默填成 {@code 1}(恰好是 {@link #STATUS_APPROVED}); + * 二是继承来的 {@code changeStatus(ids, 1)} 会直接写 {@code status},等于批量通过审核。 + * 二者都已在 {@code TransportCertRecordServiceImpl} 中封堵(前者校验抛错、后者覆写抛错)。 + * 审核必须逐条、带审核人与审核时间落库,不得借道 {@code changeStatus} 批量改状态。 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_transport_cert_record") +@Schema(description = "认证记录") +public class TransportCertRecord extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 认证对象类型:司机 + */ + public static final String BIZ_TYPE_DRIVER = "driver"; + /** + * 认证对象类型:车辆 + */ + public static final String BIZ_TYPE_VEHICLE = "vehicle"; + + /** + * 审核状态:审核中(即该对象的「待审快照」) + */ + public static final Integer STATUS_PENDING = 0; + /** + * 审核状态:已通过(该对象最新一条已通过者即其「生效快照」) + */ + public static final Integer STATUS_APPROVED = 1; + /** + * 审核状态:已驳回(审核的否定结论,需带驳回原因) + */ + public static final Integer STATUS_REJECTED = 2; + /** + * 审核状态:已作废(被同对象的再次提交取代,非驳回) + */ + public static final Integer STATUS_SUPERSEDED = 3; + + /** + * 驳回原因最大长度,与 {@code reject_reason} 列宽保持一致 + */ + public static final int REJECT_REASON_MAX_LENGTH = 200; + + /** + * 认证对象类型:driver-司机,vehicle-车辆 + *

+ * {@code NEVER}:记录一经创建,认证对象的身份不可改。 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @Schema(description = "认证对象类型:driver-司机,vehicle-车辆") + private String bizType; + /** + * 认证对象主键:司机ID 或 车辆ID(车辆按具体车辆,不是司机) + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "认证对象主键") + private Long bizId; + /** + * 展示键:司机姓名 / 车牌号,供审核列表直接展示 + */ + @Schema(description = "展示键:司机姓名 / 车牌号") + private String bizKey; + /** + * 该次提交的全量认证内容(JSON),一经创建不可修改 + *

+ * {@code NEVER}:本字段永不进入 UPDATE 语句,因此不存在「某条链路 + * 忘了保护快照」的可能。改内容只能靠新提交一条记录。 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @Schema(description = "该次提交的全量认证内容JSON") + private String snapshotJson; + /** + * 驳回原因,仅当审核状态为已驳回时有值 + */ + @Schema(description = "驳回原因") + private String rejectReason; + /** + * 提交时间 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @Schema(description = "提交时间") + private LocalDateTime submitTime; + /** + * 提交人 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "提交人") + private Long submitUser; + /** + * 审核时间 + */ + @Schema(description = "审核时间") + private LocalDateTime auditTime; + /** + * 审核人 + */ + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "审核人") + private Long auditUser; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java index 4829cc33..71f5958a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java @@ -26,10 +26,15 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; +import org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; @@ -63,6 +68,11 @@ public class TransportVehicle extends TenantEntity { */ @Schema(description = "车牌号") private String plateNo; + /** + * 所有人:行驶证登记的单位名称,不是司机 + */ + @Schema(description = "所有人(行驶证登记的单位名称,不是司机)") + private String ownerName; /** * 车牌颜色 */ @@ -73,6 +83,11 @@ public class TransportVehicle extends TenantEntity { */ @Schema(description = "车辆类型") private String vehicleType; + /** + * 车辆识别代号(车架号) + */ + @Schema(description = "车辆识别代号(车架号)") + private String vin; /** * 外廓长度,毫米 */ @@ -112,6 +127,7 @@ public class TransportVehicle extends TenantEntity { * 强制报废日期 */ @Schema(description = "强制报废日期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate compulsoryScrapDate; /** * 强制报废长期有效 @@ -132,11 +148,13 @@ public class TransportVehicle extends TenantEntity { * 行驶证有效期起 */ @Schema(description = "行驶证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseStartDate; /** * 行驶证有效期止 */ @Schema(description = "行驶证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseEndDate; /** * 行驶证长期有效 @@ -172,11 +190,13 @@ public class TransportVehicle extends TenantEntity { * 道路运输证有效期起 */ @Schema(description = "道路运输证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate roadTransportCertStartDate; /** * 道路运输证有效期止 */ @Schema(description = "道路运输证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate roadTransportCertEndDate; /** * 道路运输证长期有效 @@ -192,6 +212,7 @@ public class TransportVehicle extends TenantEntity { * 道路运输年审有效期 */ @Schema(description = "道路运输年审有效期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate annualReviewEndDate; /** * 道路运输年审长期有效 @@ -207,6 +228,7 @@ public class TransportVehicle extends TenantEntity { * 机动车登记日期 */ @Schema(description = "机动车登记日期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate registrationDate; /** * 机动车登记本图片 @@ -224,5 +246,21 @@ public class TransportVehicle extends TenantEntity { /** 认证驳回原因 */ @Schema(description = "认证驳回原因") private String certificationRejectReason; + /** + * 生效快照指针:指向 blade_transport_cert_record.id + *

+ * 「该车辆是否已认证」的唯一判据。为 null 表示从未认证通过; + * 一经设置永不回退——重新提交、待审、被驳回都不得修改它, + * 因此重新提交认证不会让一辆已认证的车掉出可用池。 + * {@link #certificationStatus} 是「最新提交状态」的展示缓存,不代表是否已认证。 + *

+ * ⚠️ {@code READ_ONLY}:本字段只能由审核通过时写入。 + * {@code POST /transport-vehicle/submit} 直接以本实体接收请求体, + * 若允许反序列化,调用方就能自行填一个记录 ID 把自己「认证」掉,绕过审核。 + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "生效快照指针") + private Long effectiveCertRecordId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java index 81c481d9..d79f1502 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java @@ -25,6 +25,8 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.NullSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -82,6 +84,7 @@ public class Waybill extends TenantEntity { @Schema(description = "型号") private String model; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "数量") private BigDecimal quantity; @@ -162,7 +165,7 @@ public class Waybill extends TenantEntity { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String driverRejectReason; - @Schema(description = "车/船/航班/班列号") + @Schema(description = "车牌号/船班号/船次号/班次号") private String vehicleNo; @Schema(description = "船长") @@ -183,6 +186,7 @@ public class Waybill extends TenantEntity { @Schema(description = "押运人手机号") private String escortPhone; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "里程") private BigDecimal mileage; @@ -195,12 +199,14 @@ public class Waybill extends TenantEntity { @Schema(description = "预计完成日期") private LocalDate estimatedEndTime; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "单价") private BigDecimal unitPrice; @Schema(description = "计价单位") private String priceUnit; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "其他费用合计") private BigDecimal otherFeeTotal; @@ -234,6 +240,9 @@ public class Waybill extends TenantEntity { @Schema(description = "配载单号") private String loadingNo; + @Schema(description = "配载标识号") + private String loadingIdentifier; + @Schema(description = "运单批次号") private String batchNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AccidentRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AccidentRecordVO.java index e06dc084..427bf362 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AccidentRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AccidentRecordVO.java @@ -80,6 +80,13 @@ public class AccidentRecordVO extends AccidentRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java index d37bd5bd..bb6081fb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java @@ -44,4 +44,7 @@ public class AdminHomeBadgesVO implements Serializable { @Schema(description = "风险记录待办数(disposalStatus=pending)") private long risk; + @Schema(description = "重新派单待办数(司机已拒单且运单未取消未完成)") + private long pendingReassign; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java index 2302825d..25c34e4a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java @@ -49,7 +49,7 @@ public class AdminHomeVO implements Serializable { @Schema(description = "快捷入口角标") private AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); - @Schema(description = "待处理事项(异常处置状态≠已完成)") + @Schema(description = "待处理事项(异常处置状态≠已完成,或司机已拒单待重新派单)") private List feed = new ArrayList<>(); } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AnnualInspectionRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AnnualInspectionRecordVO.java index b76896a3..24606c29 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AnnualInspectionRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AnnualInspectionRecordVO.java @@ -68,6 +68,10 @@ public class AnnualInspectionRecordVO extends AnnualInspectionRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "更新人姓名") private String updateUserName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java index d22af130..8b934612 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java @@ -56,4 +56,14 @@ public class BaiduOcrResultVO implements Serializable { @Schema(description = "百度OCR原始返回结果") private Map result; + /** + * 归一化后的业务字段 + *

+ * 键与提交请求里的字段同名({@code plateNo}、{@code ownerName}…),调用方直接回填即可, + * 不需要认识百度那套中文键名。只包含**识别到**的字段,没识别到的不出现(而不是给空串), + * 调用方据此就能区分「没识别到」与「识别到空值」,不会把用户已填的内容覆盖成空。 + */ + @Schema(description = "归一化后的业务字段,键与提交请求同名") + private Map fields; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java new file mode 100644 index 00000000..577ce62e --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java @@ -0,0 +1,97 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 认证审核详情视图 + *

+ * 审核是「拿新提交的内容和当前生效的内容比」的过程,所以这里**同时**给出两侧: + * {@code pendingSnapshotJson} 是本次待审的全量内容, + * {@code effectiveSnapshotJson} 是该对象当前生效的全量内容。 + * 前端对这两个 JSON 做字段级 diff,即可看出司机这次改了什么。 + *

+ * 两侧的 JSON 结构完全一致(同一套提交结构),因此可以逐键直接比较。 + * + * @author Chill + */ +@Data +@Schema(description = "认证审核详情") +public class CertAuditDetailVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "认证记录主键") + private Long id; + @Schema(description = "认证对象类型:driver-司机,vehicle-车辆") + private String bizType; + @Schema(description = "认证对象主键") + private Long bizId; + @Schema(description = "展示键:司机姓名 / 车牌号") + private String bizKey; + @Schema(description = "审核状态:0审核中 1已通过 2已驳回 3已作废") + private Integer status; + @Schema(description = "状态中文文案") + private String statusText; + @Schema(description = "本次提交时间") + private LocalDateTime submitTime; + @Schema(description = "本次提交人") + private Long submitUser; + @Schema(description = "本次提交人姓名") + private String submitUserName; + @Schema(description = "本次审核人;未审核时为 null") + private Long auditUser; + @Schema(description = "本次审核人姓名;未审核时为空") + private String auditUserName; + @Schema(description = "审核时间(通过或驳回的时间);未审核时为 null") + private LocalDateTime auditTime; + + @Schema(description = "待审快照:本次提交的全量内容") + private String pendingSnapshotJson; + @Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null") + private String effectiveSnapshotJson; + @Schema(description = "生效快照记录ID;null 表示该对象从未认证通过") + private Long effectiveRecordId; + + /** + * 该对象是否已认证 + *

+ * 审核页据此提示「本次驳回不会影响该对象继续使用」—— + * 已认证的对象即便本次被驳回,既得认证依然有效。 + */ + @TableField(exist = false) + @Schema(description = "该对象当前是否已认证") + private Boolean certified; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java index 802ed9ed..5e924aec 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerArchiveVO.java @@ -58,6 +58,10 @@ public class CustomerArchiveVO extends CustomerArchive { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "联系人") private List contacts = new ArrayList<>(); diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java index 4bca6317..691cb573 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java @@ -49,4 +49,7 @@ public class DriverNodePunchVO implements Serializable { @Schema(description = "数量") private String quantity; + @Schema(description = "本次打卡是否使运单自动完成") + private Boolean waybillCompleted; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index f7e88823..03f85510 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -4,6 +4,8 @@ */ package org.springblade.transport.pojo.vo; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -34,9 +36,37 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "是否已打卡(在途=今日已打)") private Boolean done; - @Schema(description = "当前是否可打卡(顺序门禁 + 在途频次时段)") + @Schema(description = "当前是否可打卡(已打卡、被异常阻断、在途频次时段都会置否)") private Boolean actionable; + @Schema(description = "是否被未了结的异常阻断:异常所在节点及其后的节点不可打卡;在途只要有未了结异常即不可打卡") + private Boolean blocked; + + @Schema(description = "是否因前置节点未打卡而被顺序门禁拦住(在途豁免,恒为否)") + private Boolean blockedByOrder; + + @Schema(description = "顺序门禁拦住的原因,如「请先完成装货打卡」") + private String orderLockReason; + + @Schema(description = "是否允许展开:顺序门禁不允许,异常门禁允许(可查看回填内容)") + private Boolean expandable; + + @Schema(description = "该节点自身是否被上报了未了结异常(仅异常所在节点为是,其后被牵连阻断的节点为否)") + private Boolean hasException; + + @Schema(description = "该运单是否存在未了结的整单异常;整单异常不归属任何节点,故每个节点都带此标记") + private Boolean hasWaybillException; + + @Schema(description = "与该节点相关的未了结异常ID,按上报时间升序;相关 = 归属该节点 ∪ 整单异常") + private List relatedExceptionIds = new ArrayList<>(); + + @Schema(description = "阻断该节点的异常处置ID(司机端「异常详情」跳转用);未被阻断或异常已不存在时为空") + @JsonSerialize(using = ToStringSerializer.class) + private Long blockedExceptionId; + + @Schema(description = "不可打卡的原因,供司机端展示") + private String lockReason; + @Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)") private Boolean visible; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java index 856575c6..5b22c7ac 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java @@ -69,4 +69,8 @@ public class DriverVO extends Driver { @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate warningDate; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java index 46bbfc7e..1e8d48ed 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java @@ -44,29 +44,89 @@ public class DriverVehicleCardVO implements Serializable { @Schema(description = "车牌号") private String plateNo; + @Schema(description = "所有人(行驶证登记的单位名称,不是司机)") + private String ownerName; + + @Schema(description = "车牌颜色") + private String plateColor; + @Schema(description = "车辆类型") private String vehicleType; - @Schema(description = "行驶证主页照片") - private String licenseFrontUrl; - - @Schema(description = "行驶证副页照片") - private String licenseBackUrl; - - @Schema(description = "道路运输证号") - private String roadTransportNo; - - @Schema(description = "道路运输证照片") - private String roadTransportUrl; - - @Schema(description = "车架号") + @Schema(description = "车辆识别代号(车架号),选填") private String vin; - @Schema(description = "发动机号") - private String engineNo; + @Schema(description = "外廓长度,毫米") + private Integer outerLength; + + @Schema(description = "外廓宽度,毫米") + private Integer outerWidth; + + @Schema(description = "外廓高度,毫米") + private Integer outerHeight; + + @Schema(description = "核定载质量,KG") + private Integer approvedLoadKg; + + @Schema(description = "准牵引总质量,KG") + private Integer tractionMassKg; + + @Schema(description = "能源类型") + private String energyType; + + @Schema(description = "业务关系:自有 / 管理挂靠 / 业务合作") + private String businessRelation; + + @Schema(description = "强制报废日期") + private String compulsoryScrapDate; + + @Schema(description = "强制报废长期有效:0否 1是") + private Integer compulsoryScrapLongTerm; + + @Schema(description = "行驶证档案编号") + private String drivingLicenseNo; + + @Schema(description = "行驶证主页正面照片") + private String drivingLicenseImage; + + @Schema(description = "行驶证主页反面照片") + private String drivingLicenseMainBack; + + @Schema(description = "行驶证副页正面照片") + private String drivingLicenseViceFront; + + @Schema(description = "行驶证副页反面照片") + private String drivingLicenseViceBack; + + @Schema(description = "行驶证有效期起") + private String drivingLicenseStartDate; @Schema(description = "行驶证有效期止") - private String licenseValidEnd; + private String drivingLicenseEndDate; + + @Schema(description = "行驶证长期有效:0否 1是") + private Integer drivingLicenseLongTerm; + + @Schema(description = "道路运输证号") + private String roadTransportCertNo; + + @Schema(description = "道路运输证照片") + private String roadTransportCertImage; + + @Schema(description = "道路运输证有效期起") + private String roadTransportCertStartDate; + + @Schema(description = "道路运输证有效期止") + private String roadTransportCertEndDate; + + @Schema(description = "道路运输证长期有效:0否 1是") + private Integer roadTransportCertLongTerm; + + @Schema(description = "机动车登记编号") + private String registrationNo; + + @Schema(description = "机动车登记本照片") + private String registrationImage; @Schema(description = "认证状态:0认证中 1认证通过 2认证驳回") private Integer certificationStatus; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EquipmentLedgerVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EquipmentLedgerVO.java index 6df81ad4..72def95e 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EquipmentLedgerVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EquipmentLedgerVO.java @@ -25,5 +25,8 @@ public class EquipmentLedgerVO extends EquipmentLedger { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) private String updateUserName; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EtcRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EtcRecordVO.java index cef2656d..9c636dcd 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EtcRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/EtcRecordVO.java @@ -26,6 +26,10 @@ public class EtcRecordVO extends EtcRecord { @Serial private static final long serialVersionUID = 1L; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "更新人姓名") private String updateUserName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java index 1e507376..5929669a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java @@ -9,6 +9,7 @@ package org.springblade.transport.pojo.vo; import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.transport.pojo.entity.FormalSettlement; @@ -18,6 +19,7 @@ import org.springblade.transport.pojo.entity.FormalSettlementInvoice; import org.springblade.transport.pojo.entity.FormalSettlementSource; import org.springblade.transport.pojo.entity.FormalSettlementPayment; import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee; +import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; import org.springframework.format.annotation.DateTimeFormat; import java.io.Serial; @@ -39,6 +41,15 @@ public class FormalSettlementVO extends FormalSettlement { @TableField(exist = false) private String ids; @TableField(exist = false) private String approvalStatusName; @TableField(exist = false) private String settlementTypeName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; + @TableField(exist = false) @Schema(description = "金蝶锁状态:locked-锁定中/failed-锁定失败;unlocked为终态不返回,null=无锁或已解锁") + private String kingdeeLockStatus; + @TableField(exist = false) @Schema(description = "金蝶锁失败原因(仅锁定失败时有值)") + private String kingdeeLockFailReason; + @TableField(exist = false) @Schema(description = "金蝶锁重试次数(达上限5后不再自动补偿,需重推同步金蝶)") + private Integer kingdeeTryCount; @TableField(exist = false) private String preSettlementNos; @TableField(exist = false) private String preSettlementNo; @TableField(exist = false) private List sources; @@ -46,6 +57,8 @@ public class FormalSettlementVO extends FormalSettlement { @TableField(exist = false) private List summaryFees; @TableField(exist = false) private List payments; @TableField(exist = false) private List invoices; + @TableField(exist = false) @Schema(description = "开票申请经金蝶回写的发票结果(含文件地址,只读展示)") + private List issuedInvoices; @TableField(exist = false) private List paymentApplications; @TableField(exist = false) private List adjustments; @TableField(exist = false) private List changeRecords; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InsuranceRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InsuranceRecordVO.java index 3af9b3b0..0aeeb030 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InsuranceRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InsuranceRecordVO.java @@ -63,6 +63,13 @@ public class InsuranceRecordVO extends InsuranceRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java index 2d497480..c54945aa 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java @@ -49,6 +49,12 @@ public class InvoiceApplicationVO extends InvoiceApplication { @TableField(exist = false) private String settlementNos; @TableField(exist = false) private String approvalStatusName; @TableField(exist = false) private String kingdeeStatusName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; + @TableField(exist = false) private String kingdeeLockStatus; + @TableField(exist = false) private String kingdeeLockFailReason; + @TableField(exist = false) private Integer kingdeeTryCount; @TableField(exist = false) private String createUserName; @TableField(exist = false) private List settlements; @TableField(exist = false) private List sheets; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java index d2380266..2f344ff1 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java @@ -49,6 +49,9 @@ public class InvoiceReceiptVO extends InvoiceReceipt { @TableField(exist = false) private String settlementNos; @TableField(exist = false) private String approvalStatusName; @TableField(exist = false) private String kingdeePushStatusName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; @TableField(exist = false) private String createUserName; @TableField(exist = false) private String updateUserName; @TableField(exist = false) private List settlements; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenancePlanVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenancePlanVO.java index bb4d225d..35306e67 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenancePlanVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenancePlanVO.java @@ -62,6 +62,13 @@ public class MaintenancePlanVO extends MaintenancePlan { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenanceRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenanceRecordVO.java index 260f0946..bfacf9f3 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenanceRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MaintenanceRecordVO.java @@ -62,6 +62,13 @@ public class MaintenanceRecordVO extends MaintenanceRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderVO.java index 490607ab..8ef58199 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MasterOrderVO.java @@ -41,6 +41,11 @@ public class MasterOrderVO extends MasterOrder { private List boundWaybills; @TableField(exist = false) private BigDecimal totalQuantity; + /** + * 各品类已调度量(按货物名称+类型聚合,取各分段已调度量的最大值) + */ + @TableField(exist = false) + private Map dispatchedGoods; @TableField(exist = false) private String createUserName; @TableField(exist = false) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MileageRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MileageRecordVO.java index 4c39754a..963e809c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MileageRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/MileageRecordVO.java @@ -66,6 +66,10 @@ public class MileageRecordVO extends MileageRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "更新人姓名") private String updateUserName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OilElectricRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OilElectricRecordVO.java index 94b35024..d3a08860 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OilElectricRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OilElectricRecordVO.java @@ -76,6 +76,10 @@ public class OilElectricRecordVO extends OilElectricRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "更新人姓名") private String updateUserName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OtherExpenseRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OtherExpenseRecordVO.java index 0c6db1e5..1639fa43 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OtherExpenseRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/OtherExpenseRecordVO.java @@ -49,6 +49,10 @@ public class OtherExpenseRecordVO extends OtherExpenseRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "更新人姓名") private String updateUserName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java index 6e6c183a..d8bc998f 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java @@ -43,6 +43,12 @@ public class PaymentApplicationVO extends PaymentApplication { @TableField(exist = false) private String paymentTypeName; @TableField(exist = false) private String approvalStatusName; @TableField(exist = false) private String kingdeeStatusName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; + @TableField(exist = false) private String kingdeeLockStatus; + @TableField(exist = false) private String kingdeeLockFailReason; + @TableField(exist = false) private Integer kingdeeTryCount; @TableField(exist = false) private List invoices; @TableField(exist = false) private List paymentRecords; @TableField(exist = false) private List settlements; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptClaimRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptClaimRecordVO.java index 9802b661..400af8ae 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptClaimRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptClaimRecordVO.java @@ -76,6 +76,11 @@ public class ReceiptClaimRecordVO extends ReceiptClaim { private String claimStatusName; @TableField(exist = false) private String kingdeeBillStatusName; + /** + * 关联流水推送状态(none未推送/pushed已推送/failed推送失败,查询条件) + */ + @TableField(exist = false) + private String pushStatus; @TableField(exist = false) private List settlements; @TableField(exist = false) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java index b9365180..f32a9491 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java @@ -58,6 +58,10 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow { @TableField(exist = false) private String kingdeeClaimStatusName; @TableField(exist = false) + private String pushStatusName; + @TableField(exist = false) + private String kingdeeBillStatusName; + @TableField(exist = false) private BigDecimal remainingAmount; @TableField(exist = false) private String createUserName; @@ -75,6 +79,16 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow { @TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime transactionEndTime; + /** + * 收款金额区间-最小值(查询条件) + */ + @TableField(exist = false) + private BigDecimal receiptAmountStart; + /** + * 收款金额区间-最大值(查询条件) + */ + @TableField(exist = false) + private BigDecimal receiptAmountEnd; @TableField(exist = false) private List claimRecords = new ArrayList<>(); } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TireReplacementRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TireReplacementRecordVO.java index f9140956..eaf4b1c4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TireReplacementRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TireReplacementRecordVO.java @@ -63,6 +63,13 @@ public class TireReplacementRecordVO extends TireReplacementRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java new file mode 100644 index 00000000..c4bf8a40 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java @@ -0,0 +1,100 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.transport.pojo.entity.TransportCertRecord; + +import java.io.Serial; + +/** + * 认证记录视图实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "认证记录") +public class TransportCertRecordVO extends TransportCertRecord { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** + * 更新人姓名 + */ + @TableField(exist = false) + @Schema(description = "更新人姓名") + private String updateUserName; + /** + * 提交人姓名 + */ + @TableField(exist = false) + @Schema(description = "提交人姓名") + private String submitUserName; + /** + * 审核人姓名 + */ + @TableField(exist = false) + @Schema(description = "审核人姓名") + private String auditUserName; + /** + * 状态中文文案:审核中 / 已通过 / 已驳回 / 已作废 + *

+ * 由服务层统一翻译后下发,避免小程序、Web、接口三处各造一套说法。 + */ + @TableField(exist = false) + @Schema(description = "状态中文文案") + private String statusText; + /** + * 司机姓名(认证对象档案的**当前**姓名,不是提交时的历史值) + *

+ * 司机侧取认证对象本身;车辆侧取提交该记录的司机。 + * 解析不到档案时为空——存量订正记录里没有账号的那些司机补不出人,如实留空。 + */ + @TableField(exist = false) + @Schema(description = "司机姓名") + private String driverName; + /** + * 司机手机号(认证对象档案的**当前**手机号,明文下发) + *

+ * 审核员靠它联系司机核实材料,故不脱敏;认证快照里从来没有手机号, + * 它只有司机档案这一个来源。 + */ + @TableField(exist = false) + @Schema(description = "司机手机号") + private String driverMobile; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportChangeRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportChangeRecordVO.java index 2ae66b51..60f904b6 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportChangeRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportChangeRecordVO.java @@ -57,6 +57,10 @@ public class TransportChangeRecordVO extends TransportChangeRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + @TableField(exist = false) @Schema(description = "更新人姓名") private String updateUserName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportShipVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportShipVO.java index 76fb12af..27e06308 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportShipVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportShipVO.java @@ -62,4 +62,8 @@ public class TransportShipVO extends TransportShip { @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate warningDate; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java index 7536d193..bd4ab362 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java @@ -76,4 +76,8 @@ public class TransportVehicleVO extends TransportVehicle { @Schema(description = "绑定司机") private String boundDriver; + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ViolationRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ViolationRecordVO.java index 98b7569c..0e462908 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ViolationRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ViolationRecordVO.java @@ -63,6 +63,13 @@ public class ViolationRecordVO extends ViolationRecord { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTimeEnd; + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** * 更新人姓名 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java index 1f70c945..6c9ca5a5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java @@ -25,6 +25,7 @@ public class VoucherFolderVO implements Serializable { private String folderName; private Long waybillId; private String waybillNo; + private String loadingNo; private Integer matched; private String processStatus; private String fileName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportRowErrorVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportRowErrorVO.java new file mode 100644 index 00000000..2fa95b19 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportRowErrorVO.java @@ -0,0 +1,26 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** 运单批量导入单行校验错误。 */ +@Data +@Schema(description = "运单批量导入单行校验错误") +public class WaybillImportRowErrorVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "行号(从0开始,相对当前批次)") + private Integer rowIndex; + + @Schema(description = "错误信息") + private String errorMessage; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportValidateResultVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportValidateResultVO.java new file mode 100644 index 00000000..193c5ee7 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportValidateResultVO.java @@ -0,0 +1,28 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** 运单批量导入校验结果。 */ +@Data +@Schema(description = "运单批量导入校验结果") +public class WaybillImportValidateResultVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "是否全部通过") + private Boolean success; + + @Schema(description = "校验错误列表") + private List errors = new ArrayList<>(); +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java index cea37c38..ebba5343 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java @@ -66,6 +66,10 @@ public class WaybillVO extends Waybill { @Schema(description = "业务状态名称") private String businessStatusName; + @TableField(exist = false) + @Schema(description = "运输方式名称") + private String transportTypeName; + @TableField(exist = false) @Schema(description = "是否需要司机确认接单(过程配置接单节点)") private Boolean requireAccept; diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java new file mode 100644 index 00000000..ccdff3cc --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java @@ -0,0 +1,114 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.cache; + +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.cache.utils.CacheUtil; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.SpringUtil; +import org.springblade.core.tool.utils.StringPool; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; + +/** + * 小程序用户缓存 + * + * @author Chill + */ +@Slf4j +public class MpUserCache { + + public static final String MP_USER_CACHE = "blade:mp_user"; + private static final String MP_USER_CACHE_ID = "mp-user:id:"; + + private static IMpUserClient mpUserClient; + + private static IMpUserClient getMpUserClient() { + if (mpUserClient == null) { + mpUserClient = SpringUtil.getBean(IMpUserClient.class); + } + return mpUserClient; + } + + /** + * 按主键获取小程序用户 + */ + public static MpUser getUser(Long userId) { + if (userId == null) { + return null; + } + try { + return CacheUtil.get(MP_USER_CACHE, MP_USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException exception) { + log.warn("小程序用户缓存读取失败,改为直连查询, userId={}", userId, exception); + return loadUserById(userId); + } + } + + /** + * 获取小程序用户真实姓名(优先 realName → name → account → id) + */ + public static String getUserRealName(Long userId) { + if (userId == null) { + return StringPool.EMPTY; + } + try { + MpUser user = getUser(userId); + if (user == null) { + return Func.toStr(userId); + } + if (StringUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StringUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StringUtil.isNotBlank(user.getAccount())) { + return user.getAccount(); + } + return Func.toStr(userId); + } catch (RuntimeException exception) { + log.warn("翻译小程序用户姓名失败,回退为用户ID, userId={}", userId, exception); + return Func.toStr(userId); + } + } + + /** + * 清理小程序用户缓存 + */ + public static void clear() { + CacheUtil.clear(MP_USER_CACHE); + CacheUtil.clear(MP_USER_CACHE, Boolean.FALSE); + } + + private static MpUser loadUserById(Long userId) { + R result = getMpUserClient().userById(userId); + if (result == null || !result.isSuccess()) { + return null; + } + return result.getData(); + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java index 0dbd04fc..b181e1f5 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java @@ -25,6 +25,7 @@ */ package org.springblade.system.cache; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; @@ -42,6 +43,7 @@ import static org.springblade.core.launch.constant.FlowConstant.TASK_USR_PREFIX; * * @author Chill */ +@Slf4j public class UserCache { private static final String USER_CACHE_ID = "user:id:"; private static final String USER_CACHE_ACCOUNT = "user:account:"; @@ -73,10 +75,18 @@ public class UserCache { * @return */ public static User getUser(Long userId) { - return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> { - R result = getUserClient().userInfoById(userId); - return result.getData(); - }); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException exception) { + log.warn("用户缓存读取失败,清理损坏缓存后重试, userId={}", userId, exception); + clearBrokenUserCache(); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException retryException) { + log.warn("用户缓存重试仍失败,改为直连查询, userId={}", userId, retryException); + return loadUserById(userId); + } + } } /** @@ -89,20 +99,25 @@ public class UserCache { if (userId == null) { return StringPool.EMPTY; } - User user = getUser(userId); - if (user == null) { + try { + User user = getUser(userId); + if (user == null) { + return Func.toStr(userId); + } + if (StringUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StringUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StringUtil.isNotBlank(user.getAccount())) { + return user.getAccount(); + } + return Func.toStr(userId); + } catch (RuntimeException exception) { + log.warn("翻译用户姓名失败,回退为用户ID, userId={}", userId, exception); return Func.toStr(userId); } - if (StringUtil.isNotBlank(user.getRealName())) { - return user.getRealName(); - } - if (StringUtil.isNotBlank(user.getName())) { - return user.getName(); - } - if (StringUtil.isNotBlank(user.getAccount())) { - return user.getAccount(); - } - return Func.toStr(userId); } /** @@ -113,10 +128,34 @@ public class UserCache { * @return */ public static User getUser(String tenantId, String account) { - return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, tenantId + StringPool.DASH + account, () -> { - R result = getUserClient().userByAccount(tenantId, account); - return result.getData(); - }); + String accountKey = tenantId + StringPool.DASH + account; + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, accountKey, () -> loadUserByAccount(tenantId, account)); + } catch (RuntimeException exception) { + log.warn("用户缓存读取失败,清理损坏缓存后重试, accountKey={}", accountKey, exception); + clearBrokenUserCache(); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, accountKey, () -> loadUserByAccount(tenantId, account)); + } catch (RuntimeException retryException) { + log.warn("用户缓存重试仍失败,改为直连查询, accountKey={}", accountKey, retryException); + return loadUserByAccount(tenantId, account); + } + } + } + + private static User loadUserById(Long userId) { + R result = getUserClient().userInfoById(userId); + return result.getData(); + } + + private static User loadUserByAccount(String tenantId, String account) { + R result = getUserClient().userByAccount(tenantId, account); + return result.getData(); + } + + private static void clearBrokenUserCache() { + CacheUtil.clear(USER_CACHE); + CacheUtil.clear(USER_CACHE, Boolean.FALSE); } } diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java new file mode 100644 index 00000000..0a63d35c --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java @@ -0,0 +1,74 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.cache; + +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringPool; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.User; + +/** + * 用户展示名解析(系统用户优先,未命中再查小程序用户) + *

+ * 小程序链路写入的审计人/提交人 ID 落在 blade_mp_user,需用本工具翻译姓名。 + * + * @author Chill + */ +public final class UserDisplayCache { + + private UserDisplayCache() { + } + + /** + * 翻译用户姓名:先 blade_user,再 blade_mp_user,都没有则回退 ID + */ + public static String getUserRealName(Long userId) { + if (userId == null) { + return StringPool.EMPTY; + } + User user = UserCache.getUser(userId); + if (user != null && Func.isNotEmpty(user.getId())) { + return resolveName(user.getRealName(), user.getName(), user.getAccount(), userId); + } + MpUser mpUser = MpUserCache.getUser(userId); + if (mpUser != null && Func.isNotEmpty(mpUser.getId())) { + return resolveName(mpUser.getRealName(), mpUser.getName(), mpUser.getAccount(), userId); + } + return Func.toStr(userId); + } + + private static String resolveName(String realName, String name, String account, Long userId) { + if (StringUtil.isNotBlank(realName)) { + return realName; + } + if (StringUtil.isNotBlank(name)) { + return name; + } + if (StringUtil.isNotBlank(account)) { + return account; + } + return Func.toStr(userId); + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java new file mode 100644 index 00000000..0a0a6e4b --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java @@ -0,0 +1,121 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.feign; + +import org.springblade.core.launch.constant.AppConstant; +import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 小程序用户 Feign 接口 + * + * @author Chill + */ +@FeignClient( + value = AppConstant.APPLICATION_SYSTEM_NAME +) +public interface IMpUserClient { + + String API_PREFIX = "/feign/client/mp-user"; + String USER_BY_ID = API_PREFIX + "/user-by-id"; + String USER_BY_PHONE = API_PREFIX + "/user-by-phone"; + String USER_BY_ACCOUNT = API_PREFIX + "/user-by-account"; + String USER_INFO_BY_ID = API_PREFIX + "/info-by-id"; + String USER_INFO_BY_PHONE = API_PREFIX + "/info-by-phone"; + String USER_INFO_BY_ACCOUNT = API_PREFIX + "/info-by-account"; + String SAVE_USER = API_PREFIX + "/save-user"; + String UPDATE_USER = API_PREFIX + "/update-user"; + String BIND_OPENID = API_PREFIX + "/bind-openid"; + String SET_PASSWORD = API_PREFIX + "/set-password"; + + /** + * 按主键查询 + */ + @GetMapping(USER_BY_ID) + R userById(@RequestParam("userId") Long userId); + + /** + * 按租户 + 手机号查询 + */ + @GetMapping(USER_BY_PHONE) + R userByPhone(@RequestParam("tenantId") String tenantId, @RequestParam("phone") String phone); + + /** + * 按租户 + 账号查询 + */ + @GetMapping(USER_BY_ACCOUNT) + R userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + + /** + * 按主键查询登录信息 + */ + @GetMapping(USER_INFO_BY_ID) + R userInfoById(@RequestParam("userId") Long userId); + + /** + * 按租户 + 手机号查询登录信息 + */ + @GetMapping(USER_INFO_BY_PHONE) + R userInfoByPhone(@RequestParam("tenantId") String tenantId, @RequestParam("phone") String phone); + + /** + * 按租户 + 账号查询登录信息 + */ + @GetMapping(USER_INFO_BY_ACCOUNT) + R userInfoByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + + /** + * 新增小程序用户 + */ + @PostMapping(SAVE_USER) + R saveUser(@RequestBody MpUser user); + + /** + * 更新小程序用户 + */ + @PostMapping(UPDATE_USER) + R updateUser(@RequestBody MpUser user); + + /** + * 绑定/更新微信小程序 openid + */ + @PostMapping(BIND_OPENID) + R bindOpenId(@RequestParam("tenantId") String tenantId, + @RequestParam("userId") Long userId, + @RequestParam("openid") String openid); + + /** + * 设置小程序用户密码 + */ + @PostMapping(SET_PASSWORD) + R setPassword(@RequestParam("userId") Long userId, + @RequestParam("password") String password, + @RequestParam("password2") String password2); + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java new file mode 100644 index 00000000..6d613811 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java @@ -0,0 +1,131 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; +import org.springblade.core.tool.jackson.Sensitive; +import org.springblade.core.tool.sensitive.SensitiveType; + +import java.io.Serial; +import java.util.Date; + +/** + * 小程序用户实体(仅用于小程序登录,与 blade_user 分离) + * + * @author Chill + */ +@Data +@TableName("blade_mp_user") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "小程序用户") +public class MpUser extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户编号 + */ + @Schema(description = "用户编号") + private String code; + /** + * 账号 + */ + @Schema(description = "账号") + private String account; + /** + * 密码 + */ + @Schema(description = "密码") + private String password; + /** + * 昵称 + */ + @Schema(description = "昵称") + private String name; + /** + * 真名 + */ + @Schema(description = "真名") + private String realName; + /** + * 头像 + */ + @Schema(description = "头像") + private String avatar; + /** + * 邮箱 + */ + @Sensitive(type = SensitiveType.EMAIL) + @Schema(description = "邮箱") + private String email; + /** + * 手机 + */ + @Sensitive(type = SensitiveType.MOBILE) + @Schema(description = "手机") + private String phone; + /** + * 生日 + */ + @Schema(description = "生日") + private Date birthday; + /** + * 性别 + */ + @Schema(description = "性别") + private Integer sex; + /** + * 微信小程序 openid + */ + @Schema(description = "微信小程序openid") + private String openid; + /** + * 小程序身份,逗号分隔:driver,admin + */ + @Schema(description = "小程序身份") + private String identities; + /** + * 部门id + */ + @Schema(description = "部门id") + private String deptId; + /** + * 岗位id + */ + @Schema(description = "岗位id") + private String postId; + /** + * 备注 + */ + @Schema(description = "备注") + private String remark; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java new file mode 100644 index 00000000..46bfb423 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java @@ -0,0 +1,69 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springblade.core.tool.support.Kv; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 小程序用户登录信息 + * + * @author Chill + */ +@Data +@Schema(description = "小程序用户信息") +public class MpUserInfo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户基础信息 + */ + @Schema(description = "小程序用户") + private MpUser user; + + /** + * 身份编码集合 + */ + @Schema(description = "身份集合") + private List identities; + + /** + * 权限标识集合 + */ + @Schema(description = "权限集合") + private List permissions; + + /** + * 拓展信息 + */ + @Schema(description = "拓展信息") + private Kv detail; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java index 3700a9e0..398ba356 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java @@ -25,6 +25,7 @@ */ package org.springblade.system.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -131,6 +132,11 @@ public class User extends TenantEntity implements IPhone { * 是否是oa的账号 */ private Integer isOa; + /** + * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 + */ + @TableField(updateStrategy = FieldStrategy.NOT_NULL) + private String oaModified; /** * 是否推送mk */ diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java new file mode 100644 index 00000000..12c88b0f --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java @@ -0,0 +1,148 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 小程序用户身份枚举(可叠加,后续可扩展) + * + * @author Chill + */ +@Getter +@AllArgsConstructor +public enum MpUserIdentity { + + /** + * 司机 + */ + DRIVER("driver", "司机"), + + /** + * 管理员(调度端) + */ + ADMIN("admin", "管理员"), + ; + + /** + * 身份编码 + */ + private final String code; + /** + * 身份名称 + */ + private final String label; + + /** + * 管理员身份对应的小程序权限码(兼容原 mp_auth) + */ + public static final String PERMISSION_MP_AUTH = "mp_auth"; + + /** + * detail 中标记小程序用户的 type 值 + */ + public static final String DETAIL_TYPE = "mp"; + + /** + * 解析身份编码 + */ + public static MpUserIdentity of(String code) { + if (Func.isBlank(code)) { + return null; + } + String normalized = code.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(values()) + .filter(item -> item.code.equals(normalized)) + .findFirst() + .orElse(null); + } + + /** + * 拆分身份串为编码列表(去重、保序) + */ + public static List split(String identities) { + if (Func.isBlank(identities)) { + return Collections.emptyList(); + } + Set identitySet = new LinkedHashSet<>(); + for (String part : Func.toStrList(identities)) { + if (Func.isBlank(part)) { + continue; + } + String code = part.trim().toLowerCase(Locale.ROOT); + if (of(code) != null || StringUtil.isNotBlank(code)) { + identitySet.add(code); + } + } + return new ArrayList<>(identitySet); + } + + /** + * 合并身份,确保包含指定身份 + */ + public static String merge(String identities, String... appendCodes) { + Set identitySet = new LinkedHashSet<>(split(identities)); + if (appendCodes != null) { + for (String code : appendCodes) { + if (Func.isNotBlank(code)) { + identitySet.add(code.trim().toLowerCase(Locale.ROOT)); + } + } + } + return identitySet.stream().collect(Collectors.joining(",")); + } + + /** + * 是否包含指定身份 + */ + public static boolean has(String identities, MpUserIdentity identity) { + if (identity == null) { + return false; + } + return split(identities).contains(identity.getCode()); + } + + /** + * 根据身份生成小程序权限码(不再依赖系统角色菜单) + */ + public static List toPermissions(String identities) { + List permissions = new ArrayList<>(); + if (has(identities, ADMIN)) { + permissions.add(PERMISSION_MP_AUTH); + } + return permissions; + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java new file mode 100644 index 00000000..c2ced763 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java @@ -0,0 +1,73 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.system.pojo.entity.MpUser; + +import java.io.Serial; + +/** + * 小程序用户视图实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "小程序用户") +public class MpUserVO extends MpUser { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 身份名称(逗号分隔中文) + */ + @TableField(exist = false) + @Schema(description = "身份名称") + private String identitiesName; + + /** + * 性别名称 + */ + @TableField(exist = false) + @Schema(description = "性别名称") + private String sexName; + + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + + /** + * 更新人姓名 + */ + @TableField(exist = false) + @Schema(description = "更新人姓名") + private String updateUserName; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java new file mode 100644 index 00000000..d2e6482d --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java @@ -0,0 +1,75 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.utils; + +import org.springblade.core.secure.BladeUser; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.support.Kv; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.enums.MpUserIdentity; + +/** + * 小程序登录态识别工具 + * + * @author Chill + */ +public final class MpUserAuth { + + /** + * 严格令牌模式下的占位 ID。 + *

+ * {@code blade.secure.strict-token=true} 时,AuthUtil.userIncomplete 要求 roleId/deptId + * 非空且数值 >= 0;小程序用户不绑定系统角色/部门,用 0 占位避免业务接口 401。 + */ + public static final String PLACEHOLDER_ID = "0"; + + private MpUserAuth() { + } + + /** + * 当前登录用户是否为小程序用户 + */ + public static boolean isMpUser() { + return isMpUser(AuthUtil.getUser()); + } + + /** + * 判断指定登录用户是否为小程序用户 + */ + public static boolean isMpUser(BladeUser bladeUser) { + if (bladeUser == null) { + return false; + } + Kv detail = bladeUser.getDetail(); + if (detail != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(detail.get("type")))) { + return true; + } + // 兼容:无真实系统角色(空或占位 0)且 roleName 为小程序身份 + String roleId = Func.toStr(bladeUser.getRoleId()); + boolean noSystemRole = Func.isBlank(roleId) || PLACEHOLDER_ID.equals(roleId); + return noSystemRole + && Func.isNotBlank(bladeUser.getRoleName()) + && !MpUserIdentity.split(bladeUser.getRoleName()).isEmpty(); + } + +} diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java index 64632b22..07748388 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java @@ -3,6 +3,7 @@ package org.springblade.file.config; import com.aliyun.oss.OSSClientBuilder; import com.obs.services.ObsClient; import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.service.IFileService; @@ -10,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl; import org.springblade.file.service.impl.MinioFileServiceImpl; import org.springblade.file.service.impl.OssFileServiceImpl; import org.springblade.file.service.impl.ObsFileServiceImpl; +import org.springblade.file.util.MinioClientFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,6 +22,7 @@ import org.springframework.core.env.Environment; * @author bfhuange * @date 2024/10/21 */ +@Slf4j @Configuration public class FileConfig { @@ -63,10 +66,22 @@ public class FileConfig { private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) { validateStoreProperties(STORAGE_MINIO, properties); - MinioClient minioClient = MinioClient.builder() - .endpoint(properties.getEndpoint()) - .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) - .build(); + String endpointOrigin = MinioClientFactory.resolveOrigin(properties.getEndpoint()); + String endpointPathPrefix = MinioClientFactory.resolvePathPrefix(properties.getEndpoint()); + if (StringUtil.isNotBlank(endpointPathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址,transform-endpoint 使用外网 HTTPS", + endpointOrigin, endpointPathPrefix); + } + if (StringUtil.isBlank(properties.getTransformEndpoint()) + && properties.getEndpoint().trim().toLowerCase().startsWith("http://")) { + log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传", + properties.getEndpoint()); + } else { + log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}", + endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint()); + } + MinioClient minioClient = MinioClientFactory.create( + properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret()); return new MinioFileServiceImpl(properties, minioClient); } @@ -95,7 +110,16 @@ public class FileConfig { FileStorageProperties.StoreProperties mergedProperties = new FileStorageProperties.StoreProperties(); String fileStoragePrefix = "file.storage." + prefix; mergedProperties.setEndpoint(firstNotBlank(properties.getEndpoint(), environment.getProperty(fileStoragePrefix + ".endpoint"), environment.getProperty(prefix + ".endpoint"))); - mergedProperties.setTransformEndpoint(firstNotBlank(properties.getTransformEndpoint(), environment.getProperty(fileStoragePrefix + ".transform-endpoint"), environment.getProperty(prefix + ".transform-endpoint"))); + // 资源管理页 /resource/oss 的外网地址写在库表,不进 Spring Environment;凭证分片必须单独配 file.storage.minio.transform-endpoint + mergedProperties.setTransformEndpoint(firstNotBlank( + properties.getTransformEndpoint(), + environment.getProperty(fileStoragePrefix + ".transform-endpoint"), + environment.getProperty(fileStoragePrefix + ".transformEndpoint"), + environment.getProperty(prefix + ".transform-endpoint"), + environment.getProperty(prefix + ".transformEndpoint"), + environment.getProperty("oss.transform-endpoint"), + environment.getProperty("oss.transformEndpoint") + )); mergedProperties.setAccessKeyId(firstNotBlank(properties.getAccessKeyId(), environment.getProperty(fileStoragePrefix + ".access-key-id"), environment.getProperty(fileStoragePrefix + ".access-key"), environment.getProperty(prefix + ".access-key-id"), environment.getProperty(prefix + ".access-key"))); mergedProperties.setAccessKeySecret(firstNotBlank(properties.getAccessKeySecret(), environment.getProperty(fileStoragePrefix + ".access-key-secret"), environment.getProperty(fileStoragePrefix + ".secret-key"), environment.getProperty(prefix + ".access-key-secret"), environment.getProperty(prefix + ".secret-key"))); mergedProperties.setBucketName(firstNotBlank(properties.getBucketName(), environment.getProperty(fileStoragePrefix + ".bucket-name"), environment.getProperty(prefix + ".bucket-name"))); @@ -107,7 +131,7 @@ public class FileConfig { private String firstNotBlank(String... values) { for (String value : values) { if (StringUtil.isNotBlank(value)) { - return value; + return value.trim(); } } return null; diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java index 62e4dabf..462de6e0 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java @@ -111,4 +111,26 @@ public class FileTaskController extends BladeController { String uploadUrl = fileTaskService.getUploadUrl(id, partNumber); return R.data(uploadUrl); } + + @ApiLog("上传任务-服务端代传分片") + @PostMapping(value = "/uploadPart", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Parameters({ + @Parameter(name = "id", description = "文件任务id", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "partNumber", description = "分段编号", in = ParameterIn.QUERY, schema = @Schema(type = "int")) + }) + @Operation(summary = "服务端代传分片", description = "HTTPS 页面无法直连 HTTP MinIO 时,由服务端转发分片到对象存储") + public R uploadPart(Long id, Integer partNumber, org.springframework.web.multipart.MultipartFile file) throws java.io.IOException { + if (file == null || file.isEmpty()) { + return R.fail("分片内容不能为空"); + } + java.io.File tempFile = java.io.File.createTempFile("file-part-", ".bin"); + try { + file.transferTo(tempFile); + return R.data(fileTaskService.uploadPart(id, partNumber, tempFile)); + } finally { + if (tempFile.exists() && !tempFile.delete()) { + tempFile.deleteOnExit(); + } + } + } } diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java index ae394521..f47120a4 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java @@ -74,6 +74,16 @@ public interface IFileTaskService extends IService { */ String getUploadUrl(Long id, Integer partNumber); + /** + * 服务端代传分片(HTTPS 页面无法直连 HTTP MinIO 时使用) + * + * @param id 文件任务id + * @param partNumber 分段编号 + * @param partFile 分片临时文件 + * @return ETag + */ + String uploadPart(Long id, Integer partNumber, java.io.File partFile); + /** * 取消文件任务 * @param taskIds diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java index b92fc66e..25a46bab 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java @@ -259,6 +259,33 @@ public class FileTaskServiceImpl extends ServiceImpl i return fileService.getUploadPartUrl(fileTask.getObjectKey(), fileTask.getUploadId(), partNumber, null); } + @Override + public String uploadPart(Long id, Integer partNumber, java.io.File partFile) { + FileTask fileTask = this.getById(id); + Func.requireNotNull(fileTask, "文件任务不存在"); + if (FileTaskStatus.COMPLETED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已完成"); + } + if (FileTaskStatus.PAUSED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已暂停"); + } + if (partFile == null || !partFile.exists()) { + throw new ServiceException("分片文件不存在"); + } + org.springblade.file.pojo.vo.PartEtagVO partEtag = fileService.uploadPart( + fileTask.getObjectKey(), + fileTask.getUploadId(), + partFile, + partNumber, + 0L, + partFile.length() + ); + if (partEtag == null || StringUtil.isBlank(partEtag.getEtag())) { + throw new ServiceException("分片上传未返回 ETag"); + } + return partEtag.getEtag().replace("\"", ""); + } + @Transactional(rollbackFor = Exception.class) @Override public void cancelBatchByIds(List taskIds) { diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index 5214c628..c7d2ed4e 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -25,11 +25,12 @@ */ package org.springblade.file.service.impl; +import io.minio.ComposeObjectArgs; +import io.minio.ComposeSource; import io.minio.GetObjectArgs; import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; -import io.minio.ComposeObjectArgs; -import io.minio.ComposeSource; +import io.minio.ObjectWriteResponse; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; import io.minio.UploadObjectArgs; @@ -39,9 +40,13 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.config.FileStorageProperties; +import org.springblade.file.util.MinioClientFactory; +import org.springblade.file.pojo.vo.PartEtagVO; import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -61,9 +66,11 @@ import java.util.UUID; public class MinioFileServiceImpl extends AbstractFileService { private static final long PART_SIZE = 10 * 1024 * 1024L; + private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION; private final FileStorageProperties.StoreProperties properties; private final MinioClient minioClient; + private volatile MinioClient transformPresignClient; @Override public String initiateMultipartUpload(String objectKey) { @@ -73,15 +80,50 @@ public class MinioFileServiceImpl extends AbstractFileService { @Override public String getUploadPartUrl(String objectKey, String uploadId, Integer partNumber, Long expires) { try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() - .method(Method.PUT).bucket(properties.getBucketName()).object(partObjectKey(objectKey, uploadId, partNumber)) - .expiry(Math.toIntExact(resolveExpires(expires))).build()); + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + .method(Method.PUT) + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .region(DEFAULT_REGION) + .expiry(Math.toIntExact(resolveExpires(expires))) + .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取分片上传链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("获取分片上传链接异常"); } } + @Override + public PartEtagVO uploadPart(String objectKey, String uploadId, File file, int partNumber, Long offset, Long partSize) { + long start = offset == null ? 0L : offset; + long size = partSize == null ? Math.max(0L, file.length() - start) : partSize; + try (FileInputStream inputStream = new FileInputStream(file)) { + long skipped = inputStream.skip(start); + if (skipped < start) { + throw new ServiceException("MinIO分段上传读取文件偏移失败"); + } + ObjectWriteResponse response = minioClient.putObject(PutObjectArgs.builder() + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .stream(inputStream, size, PART_SIZE) + .build()); + String etag = response.etag() == null ? "" : response.etag().replace("\"", ""); + return new PartEtagVO(etag, partNumber); + } catch (ServiceException serviceException) { + throw serviceException; + } catch (Exception exception) { + log.error("MinIO分段上传异常 objectKey:{} partNumber:{}", objectKey, partNumber, exception); + throw new ServiceException("MinIO分段上传异常"); + } + } + + @Override + public void cancelMultipartUpload(String objectKey, String uploadId) { + // MinIO 分片以独立对象模拟,取消时由任务层清理业务状态即可 + log.debug("MinIO取消分段上传 objectKey:{} uploadId:{}", objectKey, uploadId); + } + @Override public void completeMultipartUpload(String objectKey, String uploadId, List partEtags) { try { @@ -143,12 +185,14 @@ public class MinioFileServiceImpl extends AbstractFileService { return null; } try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(properties.getBucketName()) .object(resolveObjectKey(objectKey)) + .region(DEFAULT_REGION) .expiry(Math.toIntExact(resolveExpires(expires))) .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取文件链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("MinIO获取文件链接异常"); @@ -170,6 +214,62 @@ public class MinioFileServiceImpl extends AbstractFileService { } } + /** + * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 + * MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 + */ + private MinioClient resolvePresignClient() { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint)) { + return minioClient; + } + MinioClient cachedClient = transformPresignClient; + if (cachedClient != null) { + return cachedClient; + } + synchronized (this) { + if (transformPresignClient == null) { + transformPresignClient = MinioClientFactory.create( + transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret()); + } + return transformPresignClient; + } + } + + private String applyTransformPathPrefix(String presignedUrl) { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) { + return presignedUrl; + } + String pathPrefix = normalizePathPrefix(URI.create(transformEndpoint.trim()).getPath()); + if (StringUtil.isBlank(pathPrefix)) { + return presignedUrl; + } + URI url = URI.create(presignedUrl); + String path = url.getRawPath() == null ? "" : url.getRawPath(); + if (path.equals(pathPrefix) || path.startsWith(pathPrefix + "/")) { + return presignedUrl; + } + StringBuilder result = new StringBuilder(); + result.append(url.getScheme()).append("://").append(url.getHost()); + if (url.getPort() > 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (StringUtil.isNotBlank(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String resolveObjectKey(String objectKey) { if (StringUtil.isBlank(properties.getRootDirectory())) { return objectKey; diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java new file mode 100644 index 00000000..384dc61e --- /dev/null +++ b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java @@ -0,0 +1,121 @@ +/** + * 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.file.util; + +import io.minio.MinioClient; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * MinIO 客户端构建。 + *

+ * SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则: + *

    + *
  • endpoint 使用 origin
  • + *
  • 通过 OkHttp 拦截器把 /oss 拼回每个 API 请求
  • + *
  • 固定 region,避免 GetBucketLocation 打到业务网关拿到 HTML
  • + *
+ */ +public final class MinioClientFactory { + + public static final String DEFAULT_REGION = "us-east-1"; + + private MinioClientFactory() { + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + EndpointParts parts = parseEndpoint(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(parts.origin()) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(parts.pathPrefix())) { + builder.httpClient(buildHttpClient(parts.pathPrefix()), true); + } + return builder.build(); + } + + public static String resolveOrigin(String endpoint) { + return parseEndpoint(endpoint).origin(); + } + + public static String resolvePathPrefix(String endpoint) { + return parseEndpoint(endpoint).pathPrefix(); + } + + private static EndpointParts parseEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return new EndpointParts(origin.toString(), normalizePathPrefix(uri.getPath())); + } + + private static String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); + } + + private record EndpointParts(String origin, String pathPrefix) { + } +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java b/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java index 6f83dd9f..f65cc45e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java @@ -13,6 +13,7 @@ import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.StringUtil; import org.springblade.process.pojo.dto.ApprovalDTO; import org.springblade.process.pojo.vo.ApprovalVO; import org.springblade.process.pojo.vo.ProcessApprovedRecordVO; @@ -55,15 +56,27 @@ public class BusinessProcessController extends BladeController { @PostMapping("/processSubmit") @ApiOperationSupport(order = 2) - @Operation(summary = "提交MK审核流", description = "调用mk processSubmit,传入templateCode/submitIdentity/formInstanceId") + @Operation(summary = "提交MK审核流", description = "传入bizId/bizType/templateCode,后端生成或复用formInstanceId后调用mk processSubmit") public R processSubmit(@Validated @RequestBody MKProcessCreateDTO param) { return R.data(businessProcessService.processSubmit(param)); } + @GetMapping("/hasMkProcessInstance") + @ApiOperationSupport(order = 8) + @Operation(summary = "查询MK流程实例是否存在", description = "传入业务主键bizId,有数据则无需删除/重新提交") + public R hasMkProcessInstance(@Valid @NotBlank(message = "业务id不能为空") String bizId) { + return R.data(businessProcessService.hasMkProcessInstance(bizId)); + } + @PostMapping("/processDelete") @ApiOperationSupport(order = 7) + @Operation(summary = "删除MK审核流", description = "传入bizId,删除当前业务对应MK流程实例") public R processDelete(@RequestBody MKProcessCreateDTO param) { - return R.data(businessProcessService.processDelete(param == null ? null : param.getFormInstanceId())); + String bizId = null; + if (param != null) { + bizId = StringUtil.isNotBlank(param.getBizId()) ? param.getBizId() : param.getFormInstanceId(); + } + return R.data(businessProcessService.processDelete(bizId)); } @GetMapping("/isEditView") diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/feign/BusinessProcessClient.java b/blade-service/blade-system/src/main/java/org/springblade/process/feign/BusinessProcessClient.java index fa87f975..6319b680 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/feign/BusinessProcessClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/feign/BusinessProcessClient.java @@ -11,6 +11,7 @@ import org.springblade.process.pojo.dto.BusinessProcessDeleteDTO; import org.springblade.process.pojo.dto.BusinessProcessSubmitDTO; import org.springblade.process.pojo.dto.BusinessProcessUpdateDTO; import org.springblade.process.pojo.vo.BusinessProcessVO; +import org.springblade.process.pojo.vo.MkFormBindVO; import org.springblade.process.pojo.vo.ProcessApprovedRecordVO; import org.springblade.process.pojo.vo.ProcessTodoVO; import org.springblade.process.service.IBusinessProcessService; @@ -94,4 +95,19 @@ public class BusinessProcessClient implements IBusinessProcessClient { @RequestParam(value = "loginName", required = false) String loginName) { return FR.data(businessProcessService.getProcessInfo(processInstanceId, loginName)); } + + @PreAuth(AuthConstant.PERMIT_ALL) + @GetMapping(RESOLVE_BY_FORM_INSTANCE_ID) + @Override + public FR resolveByFormInstanceId(@RequestParam("formInstanceId") Long formInstanceId) { + return FR.data(businessProcessService.resolveByFormInstanceId(formInstanceId)); + } + + @PreAuth(AuthConstant.PERMIT_ALL) + @PostMapping(MARK_PROCESS_FINISHED) + @Override + public FR markProcessFinished(@RequestParam("processInstanceId") String processInstanceId, + @RequestParam("approved") boolean approved) { + return FR.data(businessProcessService.markProcessFinished(processInstanceId, approved)); + } } diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/mapper/BusinessProcessMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/process/mapper/BusinessProcessMapper.xml index 47b45b79..4aa5df53 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/mapper/BusinessProcessMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/process/mapper/BusinessProcessMapper.xml @@ -6,6 +6,7 @@ + diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/mapper/MkFormBindMapper.java b/blade-service/blade-system/src/main/java/org/springblade/process/mapper/MkFormBindMapper.java new file mode 100644 index 00000000..23e701f4 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/process/mapper/MkFormBindMapper.java @@ -0,0 +1,38 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.process.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.springblade.process.pojo.entity.MkFormBind; + +/** + * MK表单实例绑定表 Mapper 接口 + * + * @author BladeX + */ +public interface MkFormBindMapper extends BaseMapper { + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/mapper/MkFormBindMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/process/mapper/MkFormBindMapper.xml new file mode 100644 index 00000000..b19c1df2 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/process/mapper/MkFormBindMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java index e3b7834a..36b15ce0 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java @@ -29,18 +29,43 @@ public interface IBusinessProcessService extends IService { /** * 直接调用 MK processSubmit 提交流程 * - * @param param MK 流程创建参数 + * @param param MK 流程创建参数(传 bizId+bizType,后端生成/复用 formInstanceId) * @return 流程实例 id */ String processSubmit(MKProcessCreateDTO param); /** - * 按业务表单实例 id 删除 MK 流程(不删除本地业务流程记录,供驳回后重新提交使用) + * 按业务 id 查询 MK 流程实例是否仍有数据(本地有 processInstanceId 且 MK getProcessInfo 有返回) * - * @param formInstanceId 业务表单实例 id + * @param bizId 业务主键 + * @return true=流程实例仍存在 + */ + boolean hasMkProcessInstance(String bizId); + + /** + * 按业务主键删除 MK 流程(不删除本地业务流程记录与表单绑定,供驳回后重新提交使用) + * + * @param bizId 业务主键 * @return 是否成功 */ - boolean processDelete(String formInstanceId); + boolean processDelete(String bizId); + + /** + * 按表单实例id解析业务绑定 + * + * @param formInstanceId 表单实例id + * @return 绑定信息 + */ + MkFormBindVO resolveByFormInstanceId(Long formInstanceId); + + /** + * 公开页流程结束时标记业务流程通过/驳回并完结 + * + * @param processInstanceId 流程实例id + * @param approved true=通过 false=驳回 + * @return 是否更新成功 + */ + boolean markProcessFinished(String processInstanceId, boolean approved); /** * 获取流程当前节点详情 diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/IMkFormBindService.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/IMkFormBindService.java new file mode 100644 index 00000000..5bd9fe2b --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/IMkFormBindService.java @@ -0,0 +1,81 @@ +/** + * 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.process.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.springblade.process.pojo.entity.MkFormBind; +import org.springblade.process.pojo.vo.MkFormBindVO; + +/** + * MK表单实例绑定表 服务类 + * + * @author BladeX + */ +public interface IMkFormBindService extends IService { + + /** + * 查询业务当前生效的表单实例绑定 + * + * @param bizType 业务类型(可空,空时仅按业务id查) + * @param bizId 业务主键 + * @return 当前绑定,不存在返回 null + */ + MkFormBind getCurrentBind(String bizType, Long bizId); + + /** + * 按表单实例id查询绑定 + * + * @param formInstanceId 表单实例id + * @return 绑定记录 + */ + MkFormBind getByFormInstanceId(Long formInstanceId); + + /** + * 按表单实例id解析业务主键等信息 + * + * @param formInstanceId 表单实例id + * @return 绑定 VO + */ + MkFormBindVO resolveByFormInstanceId(Long formInstanceId); + + /** + * 解析或生成表单实例id:无当前绑定则新建;强制新建/当前流程已通过或已完结则新建;否则复用 + * + * @param bizType 业务类型 + * @param bizId 业务主键 + * @param forceNew 是否强制生成新表单实例(审核通过后再次提交) + * @return 本次应使用的表单实例id + */ + Long resolveOrCreateFormInstanceId(String bizType, Long bizId, boolean forceNew); + + /** + * 回写流程实例id到当前绑定 + * + * @param formInstanceId 表单实例id + * @param processInstanceId 流程实例id + */ + void updateProcessInstanceId(Long formInstanceId, String processInstanceId); +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java index 088c83d5..fed83a70 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java @@ -25,6 +25,7 @@ import org.springblade.process.pojo.entity.BusinessProcess; import org.springblade.process.pojo.enums.TodoStatus; import org.springblade.process.pojo.vo.*; import org.springblade.process.service.IBusinessProcessService; +import org.springblade.process.service.IMkFormBindService; import org.springblade.system.pojo.entity.User; import org.springblade.system.service.IUserService; import org.springblade.thirdparty.mk.config.MKProperties; @@ -72,6 +73,7 @@ public class BusinessProcessServiceImpl extends ServiceImpllambdaQuery() + .eq(BusinessProcess::getBizId, bizId) + .orderByDesc(BusinessProcess::getUpdateTime) + .last("limit 1") + ); + boolean forceNew = Boolean.TRUE.equals(param.getForceNewFormInstance()) + || shouldForceNewFormInstance(businessProcess, loginName); + Long formInstanceId = mkFormBindService.resolveOrCreateFormInstanceId(bizType, bizId, forceNew); + String formInstanceIdText = String.valueOf(formInstanceId); + param.setFormInstanceId(formInstanceIdText); + + // 已有流程实例且 MK 仍能查到详情时,不再删除、不再重新创建提交(强制新建或流程已结束时跳过短路) + if (!forceNew && businessProcess != null && StringUtil.isNotBlank(businessProcess.getProcessInstanceId()) + && Objects.equals(formInstanceId, businessProcess.getFormInstanceId())) { + String existingProcessInstanceId = businessProcess.getProcessInstanceId(); + Object existingProcessInfo = this.getProcessInfo(existingProcessInstanceId, loginName); + if (hasProcessInfoData(existingProcessInfo) && !isMkProcessFinished(existingProcessInfo)) { + log.info("MK流程实例已存在,跳过创建并提交流程 bizId={} formInstanceId={} processInstanceId={}", + bizId, formInstanceIdText, existingProcessInstanceId); + businessProcess.setApproveStatus(ApproveStatusEnum.APPROVING.getValue()); + if (StringUtil.isNotBlank(param.getSubject())) { + businessProcess.setSubject(param.getSubject()); + } + businessProcess.setPromoterLoginName(loginName); + businessProcess.setFormInstanceId(formInstanceId); + this.updateById(businessProcess); + this.getCurrentNodes(existingProcessInstanceId, loginName); + this.syncBizFromProcessInfo(bizType, String.valueOf(bizId), existingProcessInfo); + return existingProcessInstanceId; + } + // 已结束但仍能查到详情:继续走新建提交流程 + forceNew = true; + formInstanceId = mkFormBindService.resolveOrCreateFormInstanceId(bizType, bizId, true); + formInstanceIdText = String.valueOf(formInstanceId); + param.setFormInstanceId(formInstanceIdText); + } + MKProcessCreateDTO mkParam = new MKProcessCreateDTO(); - mkParam.setFormInstanceId(param.getFormInstanceId()); + mkParam.setFormInstanceId(formInstanceIdText); mkParam.setSubject(param.getSubject()); mkParam.setSubmitIdentity(loginName); mkParam.setLoginName(loginName); @@ -149,15 +189,6 @@ public class BusinessProcessServiceImpl extends ServiceImpllambdaQuery() - .eq(BusinessProcess::getBizId, bizId) - ); if (businessProcess == null) { businessProcess = new BusinessProcess(); businessProcess.setBizId(bizId); @@ -177,30 +208,151 @@ public class BusinessProcessServiceImpl extends ServiceImpllambdaQuery() + .eq(BusinessProcess::getBizId, id) + .orderByDesc(BusinessProcess::getUpdateTime) + .last("limit 1") + ); + if (businessProcess == null || StringUtil.isBlank(businessProcess.getProcessInstanceId())) { + return false; + } + // 审批通过 / 已完结:允许再次提交新表单实例 + if (Objects.equals(ApproveStatusEnum.APPROVED.getValue(), businessProcess.getApproveStatus()) + || (businessProcess.getIsCompleted() != null && businessProcess.getIsCompleted() == 1)) { + return false; + } + String loginName = businessProcess.getPromoterLoginName(); + if (StringUtil.isBlank(loginName)) { + loginName = resolveCurrentUserPhone(); + } + if (StringUtil.isBlank(loginName)) { + log.warn("查询MK流程实例跳过,登录名为空 bizId={} processInstanceId={}", + bizId, businessProcess.getProcessInstanceId()); + return false; + } + Object processInfo = this.getProcessInfo(businessProcess.getProcessInstanceId(), loginName); + // MK 流程已结束(fdProcessStatus=30)时视为无有效待办,需重新提交新实例 + if (isMkProcessFinished(processInfo)) { + return false; + } + return hasProcessInfoData(processInfo); + } + + /** + * MK getProcessInfo 是否为已结束流程 + */ + private boolean isMkProcessFinished(Object processInfo) { + if (!(processInfo instanceof Map map)) { + return false; + } + Object status = map.get("fdProcessStatus"); + return status != null && "30".equals(String.valueOf(status)); + } + + /** + * MK getProcessInfo 是否返回有效数据 + */ + private boolean hasProcessInfoData(Object processInfo) { + if (processInfo == null) { + return false; + } + if (processInfo instanceof Map map) { + return !map.isEmpty(); + } + if (processInfo instanceof Collection collection) { + return !collection.isEmpty(); + } + if (processInfo instanceof CharSequence text) { + return StringUtil.isNotBlank(text.toString()); + } + return true; + } + @Transactional(rollbackFor = Exception.class) @Override public boolean processDelete(String formInstanceId) { - AssertUtils.notBlank(formInstanceId, "表单实例id不能为空"); + AssertUtils.notBlank(formInstanceId, "业务id不能为空"); Long bizId; try { bizId = Long.valueOf(formInstanceId); } catch (NumberFormatException e) { - throw new ServiceException("表单实例id格式不正确"); + throw new ServiceException("业务id格式不正确"); } BusinessProcess businessProcess = this.getOne(Wrappers.lambdaQuery() .eq(BusinessProcess::getBizId, bizId) + .orderByDesc(BusinessProcess::getUpdateTime) + .last("limit 1") ); if (businessProcess == null || StringUtil.isBlank(businessProcess.getProcessInstanceId())) { - log.warn("客商驳回后删除流程跳过,未找到流程实例 formInstanceId={}", formInstanceId); + log.warn("驳回后删除流程跳过,未找到流程实例 bizId={}", bizId); return true; } String loginName = businessProcess.getPromoterLoginName(); @@ -208,8 +360,8 @@ public class BusinessProcessServiceImpl extends ServiceImpllambdaQuery() + .eq(BusinessProcess::getBizId, formInstanceId) + .last("limit 1") + ); + if (businessProcess == null) { + businessProcess = this.getOne(Wrappers.lambdaQuery() + .eq(BusinessProcess::getFormInstanceId, formInstanceId) + .last("limit 1") + ); + } + if (businessProcess == null) { + return null; + } + MkFormBindVO fallback = new MkFormBindVO(); + fallback.setBizId(businessProcess.getBizId()); + fallback.setFormInstanceId(businessProcess.getFormInstanceId() != null + ? businessProcess.getFormInstanceId() : businessProcess.getBizId()); + fallback.setProcessInstanceId(businessProcess.getProcessInstanceId()); + return fallback; + } + + @Transactional(rollbackFor = Exception.class) + @Override + public boolean markProcessFinished(String processInstanceId, boolean approved) { + AssertUtils.notBlank(processInstanceId, "流程实例id不能为空"); + BusinessProcess businessProcess = this.getBusinessProcessByProcessInstanceId(processInstanceId); + if (businessProcess == null) { + log.warn("标记流程完结跳过,未找到业务流程 processInstanceId={}", processInstanceId); + return false; + } + String approveStatus = approved + ? ApproveStatusEnum.APPROVED.getValue() + : ApproveStatusEnum.REJECTED.getValue(); + businessProcess.setApproveStatus(approveStatus); + businessProcess.setIsCompleted(1); + businessProcess.setCompleteTime(new Date()); + businessProcess.setCurrentNodeIds(null); + businessProcess.setCurrentNodeNames(null); + businessProcess.setCurrentHandlers(null); + boolean updated = this.updateById(businessProcess); + log.info("标记流程完结 processInstanceId={} approved={} approveStatus={} result={}", + processInstanceId, approved, approveStatus, updated); + return updated; + } + @Override public List getCurrentNodes(String processInstanceId, String loginName) { if (StringUtils.isBlank(processInstanceId)) { @@ -358,17 +563,20 @@ public class BusinessProcessServiceImpl extends ServiceImpllambdaQuery() .eq(BusinessProcess::getBizId, bizId) + .orderByDesc(BusinessProcess::getUpdateTime) .last("limit 1") ); AssertUtils.notNull(businessProcess, "业务流程不存在"); processInstanceId = businessProcess.getProcessInstanceId(); } try { - String mkApprovalUrl = mkService.getMKApprovalUrl(processInstanceId, AuthUtil.getUserAccount()); + String loginName = resolveCurrentUserPhone(); + AssertUtils.notBlank(loginName, "当前用户手机号为空,无法获取审核跳转地址"); + String mkApprovalUrl = mkService.getMKApprovalUrl(processInstanceId, loginName); AssertUtils.notBlank(mkApprovalUrl, "获取mk审批页面链接异常"); return mkApprovalUrl; } catch (MKException e) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/MkFormBindServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/MkFormBindServiceImpl.java new file mode 100644 index 00000000..2e9c4346 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/MkFormBindServiceImpl.java @@ -0,0 +1,177 @@ +/** + * 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.process.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.log.utils.AssertUtils; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.process.mapper.BusinessProcessMapper; +import org.springblade.process.mapper.MkFormBindMapper; +import org.springblade.process.pojo.entity.BusinessProcess; +import org.springblade.process.pojo.entity.MkFormBind; +import org.springblade.process.pojo.enums.ApproveStatusEnum; +import org.springblade.process.pojo.vo.MkFormBindVO; +import org.springblade.process.service.IMkFormBindService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.Objects; + +/** + * MK表单实例绑定表 服务实现类 + * + * @author BladeX + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class MkFormBindServiceImpl extends ServiceImpl implements IMkFormBindService { + + private final BusinessProcessMapper businessProcessMapper; + + @Override + public MkFormBind getCurrentBind(String bizType, Long bizId) { + AssertUtils.notNull(bizId, "业务id不能为空"); + MkFormBind bind = null; + if (StringUtil.isNotBlank(bizType)) { + bind = this.getOne(Wrappers.lambdaQuery() + .eq(MkFormBind::getBizType, bizType) + .eq(MkFormBind::getBizId, bizId) + .eq(MkFormBind::getIsCurrent, 1) + .orderByDesc(MkFormBind::getCreateTime) + .last("limit 1") + ); + } + if (bind == null) { + // 兼容历史迁移数据 biz_type 为空,或未传 bizType + bind = this.getOne(Wrappers.lambdaQuery() + .eq(MkFormBind::getBizId, bizId) + .eq(MkFormBind::getIsCurrent, 1) + .orderByDesc(MkFormBind::getCreateTime) + .last("limit 1") + ); + } + return bind; + } + + @Override + public MkFormBind getByFormInstanceId(Long formInstanceId) { + AssertUtils.notNull(formInstanceId, "表单实例id不能为空"); + return this.getOne(Wrappers.lambdaQuery() + .eq(MkFormBind::getFormInstanceId, formInstanceId) + .last("limit 1") + ); + } + + @Override + public MkFormBindVO resolveByFormInstanceId(Long formInstanceId) { + MkFormBind bind = this.getByFormInstanceId(formInstanceId); + if (bind == null) { + return null; + } + MkFormBindVO vo = new MkFormBindVO(); + vo.setBizId(bind.getBizId()); + vo.setBizType(bind.getBizType()); + vo.setFormInstanceId(bind.getFormInstanceId()); + vo.setProcessInstanceId(bind.getProcessInstanceId()); + return vo; + } + + @Transactional(rollbackFor = Exception.class) + @Override + public Long resolveOrCreateFormInstanceId(String bizType, Long bizId, boolean forceNew) { + AssertUtils.notNull(bizId, "业务id不能为空"); + MkFormBind current = this.getCurrentBind(bizType, bizId); + if (current == null) { + return this.createNewBind(bizType, bizId); + } + BusinessProcess businessProcess = businessProcessMapper.selectOne(Wrappers.lambdaQuery() + .eq(BusinessProcess::getBizId, bizId) + .orderByDesc(BusinessProcess::getUpdateTime) + .last("limit 1") + ); + boolean approvedPass = businessProcess != null + && Objects.equals(ApproveStatusEnum.APPROVED.getValue(), businessProcess.getApproveStatus()); + boolean completed = businessProcess != null + && businessProcess.getIsCompleted() != null + && businessProcess.getIsCompleted() == 1; + if (forceNew || approvedPass || completed) { + log.info("生成新表单实例 forceNew={} approvedPass={} completed={} bizType={} bizId={} oldFormInstanceId={}", + forceNew, approvedPass, completed, bizType, bizId, current.getFormInstanceId()); + this.lambdaUpdate() + .eq(MkFormBind::getBizId, bizId) + .eq(MkFormBind::getIsCurrent, 1) + .set(MkFormBind::getIsCurrent, 0) + .set(MkFormBind::getUpdateTime, new Date()) + .update(); + return this.createNewBind(bizType, bizId); + } + log.info("复用当前表单实例 bizType={} bizId={} formInstanceId={}", + bizType, bizId, current.getFormInstanceId()); + if (StringUtil.isNotBlank(bizType) && StringUtil.isBlank(current.getBizType())) { + current.setBizType(bizType); + current.setUpdateTime(new Date()); + this.updateById(current); + } + return current.getFormInstanceId(); + } + + @Override + public void updateProcessInstanceId(Long formInstanceId, String processInstanceId) { + AssertUtils.notNull(formInstanceId, "表单实例id不能为空"); + this.lambdaUpdate() + .eq(MkFormBind::getFormInstanceId, formInstanceId) + .set(MkFormBind::getProcessInstanceId, processInstanceId) + .set(MkFormBind::getUpdateTime, new Date()) + .update(); + } + + private Long createNewBind(String bizType, Long bizId) { + Long formInstanceId = IdWorker.getId(); + MkFormBind bind = new MkFormBind(); + bind.setId(IdWorker.getId()); + bind.setTenantId(StringUtil.isBlank(AuthUtil.getTenantId()) ? "000000" : AuthUtil.getTenantId()); + bind.setBizType(StringUtil.isBlank(bizType) ? "" : bizType); + bind.setBizId(bizId); + bind.setFormInstanceId(formInstanceId); + bind.setIsCurrent(1); + bind.setCreateTime(new Date()); + bind.setUpdateTime(new Date()); + boolean saved = this.save(bind); + if (!saved) { + throw new ServiceException("保存表单实例绑定失败"); + } + log.info("创建表单实例绑定 bizType={} bizId={} formInstanceId={}", bizType, bizId, formInstanceId); + return formInstanceId; + } +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/AirportMasterController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/AirportMasterController.java index 59950862..355aaee9 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/AirportMasterController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/AirportMasterController.java @@ -157,10 +157,21 @@ public class AirportMasterController extends BladeController { if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { return R.fail("请上传 .xls,.xlsx 标准格式文件"); } - List failureList = airportMasterService.importAirportMaster(ExcelUtil.read(file, AirportMasterExcel.class)); - if (Func.isNotEmpty(failureList)) { - org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "空港机场主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, AirportMasterExcel.class); + try { + airportMasterService.importAirportMaster(ExcelUtil.read(file, AirportMasterExcel.class)); + } catch (org.springblade.common.excel.ImportFailureException exception) { + // 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。 + org.springblade.common.excel.ImportFailureExcelUtil.export(response, "机场主数据导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), AirportMasterExcel.class); return null; + } catch (Exception readException) { + // 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时, + // EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。 + // 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。 + if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure( + response, "机场主数据导入失败明细" + DateUtil.time(), "导入失败明细", file, AirportMasterExcel.class, readException)) { + return null; + } + throw readException; } return R.success("操作成功"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/CargoTypeController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/CargoTypeController.java index 745968b5..cf73d38c 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/CargoTypeController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/CargoTypeController.java @@ -46,9 +46,9 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.common.excel.ImportFailureExcelUtil; +import org.springblade.common.excel.ImportFailureException; import org.springblade.system.excel.CargoTypeExcel; import org.springblade.system.excel.CargoTypeExportExcel; -import org.springblade.system.excel.CargoTypeImportFailureExcel; import org.springblade.system.pojo.entity.CargoType; import org.springblade.system.pojo.vo.CargoTypeVO; import org.springblade.system.service.ICargoTypeService; @@ -156,15 +156,41 @@ public class CargoTypeController extends BladeController { @ApiOperationSupport(order = 7) @Operation(summary = "导入货物类型", description = "传入excel") public R importCargoType(MultipartFile file, HttpServletResponse response) { - List data = ExcelUtil.read(file, CargoTypeExcel.class); - List failureList = cargoTypeService.importCargoType(data); - if (Func.isNotEmpty(failureList)) { - ImportFailureExcelUtil.export(response, "货物类型导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CargoTypeExcel.class); + if (file == null || file.isEmpty()) { + return R.fail("上传文件不能为空"); + } + String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase(); + if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { + return R.fail("请上传 .xls,.xlsx 标准格式文件"); + } + try { + cargoTypeService.importCargoType(ExcelUtil.read(file, CargoTypeExcel.class)); + } catch (ImportFailureException exception) { + // 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。 + exportFailure(response, exception.getFailureList()); return null; + } catch (Exception readException) { + // 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时, + // EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。 + // 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。 + if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure( + response, "货物类型导入失败明细" + DateUtil.time(), "导入失败明细", file, CargoTypeExcel.class, readException)) { + return null; + } + throw readException; } return R.success("导入数据成功"); } + /** + * 导出导入失败明细,内容为原表全部数据并在末尾追加失败原因列。 + *

+ * 失败数据仅标红出错单元格与失败原因列,表头保持默认样式。 + */ + private void exportFailure(HttpServletResponse response, List failureList) { + ImportFailureExcelUtil.exportFailureReasonOnly(response, "货物类型导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CargoTypeExcel.class); + } + /** * 导出货物类型 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/CurrencyController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/CurrencyController.java index 4bc43d95..8b6b6c44 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/CurrencyController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/CurrencyController.java @@ -149,10 +149,28 @@ public class CurrencyController extends BladeController { @ApiOperationSupport(order = 7) @Operation(summary = "导入币种汇率", description = "传入excel") public R importCurrency(MultipartFile file, HttpServletResponse response) { - List failureList = currencyService.importCurrency(ExcelUtil.read(file, CurrencyExcel.class)); - if (Func.isNotEmpty(failureList)) { - org.springblade.common.excel.ImportFailureExcelUtil.export(response, "币种汇率导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CurrencyExcel.class); + if (file == null || file.isEmpty()) { + return R.fail("上传文件不能为空"); + } + String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase(); + if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { + return R.fail("请上传 .xls,.xlsx 标准格式文件"); + } + try { + currencyService.importCurrency(ExcelUtil.read(file, CurrencyExcel.class)); + } catch (org.springblade.common.excel.ImportFailureException exception) { + // 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。 + org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "币种汇率导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), CurrencyExcel.class); return null; + } catch (Exception readException) { + // 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时, + // EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。 + // 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。 + if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure( + response, "币种汇率导入失败明细" + DateUtil.time(), "导入失败明细", file, CurrencyExcel.class, readException)) { + return null; + } + throw readException; } return R.success("操作成功"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java index 180dc550..39d60f72 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java @@ -44,11 +44,10 @@ import org.springblade.core.tenant.annotation.NonDS; import org.springblade.core.tool.api.R; import org.springblade.core.tool.support.Kv; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.DictCache; +import org.springblade.system.cache.DictBizCache; import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.User; -import org.springblade.system.pojo.enums.DictEnum; import org.springblade.system.pojo.vo.DeptVO; import org.springblade.system.pojo.vo.OaOrgSyncPageVO; import org.springblade.system.pojo.vo.UserVO; @@ -94,7 +93,8 @@ public class DeptController extends BladeController { @PreAuth(menu = "dept") @GetMapping("/list") @Parameters({ - @Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 2) @@ -109,7 +109,8 @@ public class DeptController extends BladeController { @PreAuth(menu = "dept") @GetMapping("/lazy-list") @Parameters({ - @Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 3) @@ -156,7 +157,7 @@ public class DeptController extends BladeController { CacheUtil.clear(SYS_CACHE, Boolean.FALSE); // 返回懒加载树更新节点所需字段 Kv kv = Kv.create().set("id", String.valueOf(dept.getId())).set("tenantId", dept.getTenantId()) - .set("deptCategoryName", DictCache.getValue(DictEnum.ORG_CATEGORY, dept.getDeptCategory())); + .set("deptCategoryName", DictBizCache.getValue("organization_type", dept.getDeptCategory())); return R.data(kv); } return R.fail("操作失败"); @@ -196,8 +197,10 @@ public class DeptController extends BladeController { @Operation(summary = "同步OA公司") public R syncOaCompany( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncCompanyPage(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncCompanyPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** @@ -209,8 +212,10 @@ public class DeptController extends BladeController { @Operation(summary = "同步OA部门") public R syncOaDepartment( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncDepartmentPage(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/FeeItemController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/FeeItemController.java index b8eae386..d70330bd 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/FeeItemController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/FeeItemController.java @@ -36,6 +36,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import org.springblade.common.excel.ImportFailureExcelUtil; +import org.springblade.common.excel.ImportFailureException; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.excel.util.ExcelUtil; import org.springblade.core.mp.support.Condition; @@ -47,7 +48,6 @@ import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.excel.FeeItemExcel; import org.springblade.system.excel.FeeItemExportExcel; -import org.springblade.system.excel.FeeItemImportFailureExcel; import org.springblade.system.pojo.entity.FeeItem; import org.springblade.system.pojo.vo.FeeItemVO; import org.springblade.system.service.IFeeItemService; @@ -138,11 +138,28 @@ public class FeeItemController extends BladeController { @ApiOperationSupport(order = 6) @Operation(summary = "导入费用项", description = "传入excel") public R importFeeItem(MultipartFile file, HttpServletResponse response) { - List data = ExcelUtil.read(file, FeeItemExcel.class); - List failureList = feeItemService.importFeeItem(data); - if (Func.isNotEmpty(failureList)) { - ImportFailureExcelUtil.export(response, "费用项导入失败明细" + DateUtil.time(), "导入失败明细", failureList, FeeItemExcel.class); + if (file == null || file.isEmpty()) { + return R.fail("上传文件不能为空"); + } + String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase(); + if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { + return R.fail("请上传 .xls,.xlsx 标准格式文件"); + } + try { + feeItemService.importFeeItem(ExcelUtil.read(file, FeeItemExcel.class)); + } catch (ImportFailureException exception) { + // 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。 + ImportFailureExcelUtil.exportFailureReasonOnly(response, "费用项导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), FeeItemExcel.class); return null; + } catch (Exception readException) { + // 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时, + // EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。 + // 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。 + if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure( + response, "费用项导入失败明细" + DateUtil.time(), "导入失败明细", file, FeeItemExcel.class, readException)) { + return null; + } + throw readException; } return R.success("导入数据成功"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java new file mode 100644 index 00000000..b4c84f8e --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java @@ -0,0 +1,134 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.mp.support.Condition; +import org.springblade.core.mp.support.Query; +import org.springblade.core.secure.annotation.PreAuth; +import org.springblade.core.tenant.annotation.NonDS; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.vo.MpUserVO; +import org.springblade.system.service.IMpUserService; +import org.springblade.system.wrapper.MpUserWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 小程序用户管理 控制器 + * + * @author Chill + */ +@NonDS +@RestController +@AllArgsConstructor +@PreAuth(menu = "mp_user") +@RequestMapping("mp-user") +@Tag(name = "小程序用户", description = "小程序用户管理") +public class MpUserController extends BladeController { + + private final IMpUserService mpUserService; + + /** + * 详情 + */ + @GetMapping("/detail") + @ApiOperationSupport(order = 1) + @Operation(summary = "详情", description = "传入id") + public R detail(MpUser mpUser) { + MpUser detail = mpUserService.getOne(Condition.getQueryWrapper(mpUser)); + if (detail == null) { + return R.fail("小程序用户不存在"); + } + return R.data(MpUserWrapper.build().entityVO(detail)); + } + + /** + * 分页 + */ + @GetMapping("/list") + @ApiOperationSupport(order = 2) + @Operation(summary = "分页", description = "传入查询条件") + public R> list(MpUser mpUser, Query query) { + IPage pages = mpUserService.selectMpUserPage(Condition.getPage(query), mpUser); + return R.data(MpUserWrapper.build().pageVO(pages)); + } + + /** + * 新增或修改 + */ + @PostMapping("/submit") + @ApiOperationSupport(order = 3) + @Operation(summary = "新增或修改", description = "传入MpUser") + public R submit(@Valid @RequestBody MpUser mpUser) { + return R.status(mpUserService.submitOrUpdate(mpUser)); + } + + /** + * 逻辑删除 + */ + @PostMapping("/remove") + @ApiOperationSupport(order = 4) + @Operation(summary = "逻辑删除", description = "传入ids") + public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) { + return R.status(mpUserService.removeMpUser(Func.toLongList(ids))); + } + + /** + * 启用 / 停用 + */ + @PostMapping("/change-status") + @ApiOperationSupport(order = 5) + @Operation(summary = "修改状态", description = "status:1启用,0停用") + public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) { + return R.status(mpUserService.changeStatus(id, status)); + } + + /** + * 重置密码 + */ + @PostMapping("/reset-password") + @ApiOperationSupport(order = 6) + @Operation(summary = "重置密码", description = "传入userId集合,返回账号与明文密码") + public R> resetPassword( + @Parameter(description = "userId集合", required = true) @RequestParam String userIds) { + return R.data(mpUserService.resetPassword(Func.toLongList(userIds))); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java index 0639b52d..8c19eedc 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java @@ -44,7 +44,7 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.common.excel.ImportFailureExcelUtil; -import org.springblade.system.excel.ImportFailureException; +import org.springblade.common.excel.ImportFailureException; import org.springblade.system.excel.PortTerminalExcel; import org.springblade.system.excel.PortTerminalExportExcel; import org.springblade.system.excel.PortTerminalImporter; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RailwayStationController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RailwayStationController.java index 7bc1f8c2..34149e9d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RailwayStationController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RailwayStationController.java @@ -160,10 +160,21 @@ public class RailwayStationController extends BladeController { if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { return R.fail("请上传 .xls,.xlsx 标准格式文件"); } - List failureList = railwayStationService.importRailwayStation(ExcelUtil.read(file, RailwayStationExcel.class)); - if (Func.isNotEmpty(failureList)) { - org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "铁路车站主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, RailwayStationExcel.class); + try { + railwayStationService.importRailwayStation(ExcelUtil.read(file, RailwayStationExcel.class)); + } catch (org.springblade.common.excel.ImportFailureException exception) { + // 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。 + org.springblade.common.excel.ImportFailureExcelUtil.export(response, "铁路站点导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), RailwayStationExcel.class); return null; + } catch (Exception readException) { + // 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时, + // EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。 + // 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。 + if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure( + response, "铁路站点导入失败明细" + DateUtil.time(), "导入失败明细", file, RailwayStationExcel.class, readException)) { + return null; + } + throw readException; } return R.success("操作成功"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java index 0de1e680..9f0ec270 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java @@ -46,6 +46,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tenant.annotation.NonDS; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.utils.Func; import org.springblade.system.pojo.entity.Region; import org.springblade.system.excel.RegionExcel; import org.springblade.system.excel.RegionExportExcel; @@ -191,10 +192,28 @@ public class RegionController extends BladeController { @ApiOperationSupport(order = 10) @Operation(summary = "导入行政区划", description = "传入excel") public R importRegion(MultipartFile file, Integer isCovered, HttpServletResponse response) { - List failureList = regionService.importRegion(ExcelUtil.read(file, RegionExcel.class), Objects.equals(isCovered, 1)); - if (!failureList.isEmpty()) { - org.springblade.common.excel.ImportFailureExcelUtil.export(response, "行政区划导入失败明细" + DateUtil.time(), "导入失败明细", failureList, RegionExcel.class); + if (file == null || file.isEmpty()) { + return R.fail("上传文件不能为空"); + } + String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase(); + if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { + return R.fail("请上传 .xls,.xlsx 标准格式文件"); + } + try { + regionService.importRegion(ExcelUtil.read(file, RegionExcel.class), Objects.equals(isCovered, 1)); + } catch (org.springblade.common.excel.ImportFailureException exception) { + // 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。 + org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "行政区划导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), RegionExcel.class); return null; + } catch (Exception readException) { + // 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时, + // EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。 + // 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。 + if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure( + response, "行政区划导入失败明细" + DateUtil.time(), "导入失败明细", file, RegionExcel.class, readException)) { + return null; + } + throw readException; } return R.success("操作成功"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java index ab5e7820..34a468ad 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java @@ -53,11 +53,14 @@ import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.StringPool; import org.springblade.system.excel.UserExcel; import org.springblade.system.excel.UserImporter; +import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.vo.OaPersonSyncPageVO; import org.springblade.system.pojo.vo.UserVO; import org.springblade.system.service.IOASyncService; +import org.springblade.system.service.IMpUserService; import org.springblade.system.service.IUserService; +import org.springblade.system.utils.MpUserAuth; import org.springblade.system.wrapper.UserWrapper; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -79,6 +82,7 @@ import java.util.Map; public class UserController { private final IUserService userService; + private final IMpUserService mpUserService; private final IOASyncService oaSyncService; /** @@ -101,6 +105,10 @@ public class UserController { @Operation(summary = "查看详情", description = "传入id") @GetMapping("/info") public R info(BladeUser user) { + if (MpUserAuth.isMpUser(user)) { + MpUser mpUser = mpUserService.getById(user.getUserId()); + return R.data(toMpUserVO(mpUser)); + } User detail = userService.getById(user.getUserId()); return R.data(UserWrapper.build().entityVO(detail)); } @@ -112,11 +120,12 @@ public class UserController { @GetMapping("/list") @BladeView(Views.Admin.class) @Parameters({ - @Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 3) - @Operation(summary = "列表", description = "传入account和realName") + @Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索") public R> list(@Parameter(hidden = true) @RequestParam Map user, Query query) { return R.data(userService.selectPage(user, query)); } @@ -128,11 +137,12 @@ public class UserController { @GetMapping("/page") @BladeView(Views.Admin.class) @Parameters({ - @Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 4) - @Operation(summary = "列表", description = "传入account和realName") + @Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索") public R> page(@Parameter(hidden = true) User user, Query query, Long deptId, BladeUser bladeUser) { IPage pages = userService.selectUserPage(Condition.getPage(query), user, deptId, (AuthUtil.isAdministrator() ? StringPool.EMPTY : bladeUser.getTenantId())); return R.data(UserWrapper.build().pageVO(pages)); @@ -169,8 +179,10 @@ public class UserController { @Operation(summary = "同步OA人员") public R syncIamAccounts( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncPersonFromUserList(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncPersonFromUserList(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** @@ -184,6 +196,18 @@ public class UserController { return R.status(userService.updateUser(user)); } + /** + * 启用或停用 + */ + @IsAdmin + @PostMapping("/change-status") + @ApiOperationSupport(order = 6) + @Operation(summary = "启用停用", description = "传入id和status") + public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) { + return R.status(userService.changeUserStatus(id, status)); + } + /** * 删除 */ @@ -236,6 +260,7 @@ public class UserController { * 当前用户设置/重置登录密码(小程序首次设密、短信验证后改密) *

* 对外路径:/blade-system/user/password ;网关别名 /blade-user/password 亦可到达。 + * 小程序用户写入 blade_mp_user,系统用户仍写入 blade_user。 */ @PostMapping("/password") @ApiOperationSupport(order = 10) @@ -243,6 +268,9 @@ public class UserController { public R password(BladeUser user, @Parameter(description = "新密码", required = true) @RequestParam String password, @Parameter(description = "确认密码", required = true) @RequestParam String password2) { + if (MpUserAuth.isMpUser(user)) { + return R.status(mpUserService.setPassword(user.getUserId(), password, password2)); + } return R.status(userService.setPassword(user.getUserId(), password, password2)); } @@ -422,4 +450,34 @@ public class UserController { return R.data(list); } + /** + * 小程序用户转 UserVO(兼容原 /user/info 响应结构) + */ + private UserVO toMpUserVO(MpUser mpUser) { + if (mpUser == null) { + return null; + } + UserVO userVO = new UserVO(); + userVO.setId(mpUser.getId()); + userVO.setTenantId(mpUser.getTenantId()); + userVO.setCode(mpUser.getCode()); + userVO.setAccount(mpUser.getAccount()); + userVO.setName(mpUser.getName()); + userVO.setRealName(mpUser.getRealName()); + userVO.setAvatar(mpUser.getAvatar()); + userVO.setEmail(mpUser.getEmail()); + userVO.setPhone(mpUser.getPhone()); + userVO.setBirthday(mpUser.getBirthday()); + userVO.setSex(mpUser.getSex()); + userVO.setDeptId(mpUser.getDeptId()); + userVO.setPostId(mpUser.getPostId()); + userVO.setRemark(mpUser.getRemark()); + userVO.setStatus(mpUser.getStatus()); + userVO.setCreateTime(mpUser.getCreateTime()); + userVO.setUpdateTime(mpUser.getUpdateTime()); + // 身份放入 roleName,便于前端兼容展示 + userVO.setRoleName(mpUser.getIdentities()); + return userVO; + } + } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java index ecf7b715..cae8c860 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java @@ -43,7 +43,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * 用户手机号变更(小程序「修改手机号」) + * 小程序用户手机号变更(写入 blade_mp_user) *

* 对外路径:/blade-system/user/phone/** ;网关别名 /blade-user/phone/** 亦可到达。 * 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 d46b3c82..ba5bdf43 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 @@ -44,92 +44,79 @@ public interface DeptConvert { @Mapping(source = "subcompanyname", target = "deptName") @Mapping(source = "subcompanydesc", target = "fullName") - // 父公司id转为父部门编码,要拼接前缀 @Mapping(source = "supsubcomid", target = "parentCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - @Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss") + @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); @Mapping(source = "departmentname", target = "deptName") @Mapping(source = "departmentmark", target = "fullName") - // 父部门id转为父部门编码,要拼接前缀 @Mapping(source = "supdepid", target = "parentCode") - // 所属子公司id转为所属公司编码,要拼接前缀 @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - @Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss") + @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OADepartmentResponse department); /** - * oa公司转本系统部门 - * @param company - * @return + * oa公司转本系统部门。组织编码由同步服务按 GWWL+三位数字 统一分配。 */ default Dept company2dept(OACompanyResponse company) { Dept dept = this.baseConvert(company); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.COMPANY_OA_PREFIX + company.getId()); + // 编码稍后按规则生成;此处仅落 OA 主键,供按 oaId 匹配与重建层级 + dept.setDeptCode(null); String parentCode = dept.getParentCode(); if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.COMPANY_OA_PREFIX + parentCode); - // 父部门id后续处理 + // 非顶级:parentCode 暂存上级 OA 主键,分配编码后再替换为正式组织编码 + dept.setParentCode(parentCode); + dept.setParentId(null); } else { - // 父部门编码是0,父id也设置为0 - dept.setParentId(OAConvertConstant.ROOT_PARENT_ID); + dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); } - // 公司所属公司编码就是他自己的部门编码 - dept.setBelongCompanyCode(dept.getDeptCode()); - // OA主键与上级OA主键,分批同步结束后用于重建层级 + dept.setBelongCompanyCode(null); dept.setOaId(company.getId()); dept.setOaSupSubComId(company.getSupsubcomid()); - // 显示顺序处理 dept.setSort(OAUtils.parseInt(company.getShoworder())); - // 部门类型,公司 dept.setDeptCategory(DeptCategory.COMPANY.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } /** - * oa部门转本系统部门 - * @param oaDept - * @param companyDeptMap 所有公司的部门编码与id map - * @return + * oa部门转本系统部门。组织编码由同步服务按「所属公司编码+三位数字」统一分配。 + * + * @param oaDept OA部门 + * @param companyByOaId 已同步公司(oaId → 本地公司) */ - default Dept dept2dept(OADepartmentResponse oaDept, Map companyDeptMap) { + default Dept dept2dept(OADepartmentResponse oaDept, Map companyByOaId) { Dept dept = this.baseConvert(oaDept); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId()); - // OA主键;上级为根部门时记录所属公司OA主键,否则记录上级部门OA主键 + dept.setDeptCode(null); dept.setOaId(oaDept.getId()); if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { dept.setOaSupSubComId(oaDept.getSubcompanyid1()); } else { dept.setOaSupSubComId(oaDept.getSupdepid()); } - // 部门所属公司编码就是他所属公司编码加前缀 - dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode()); - String parentCode = dept.getParentCode(); - if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + parentCode); - // 父部门id后续处理 + Dept belongCompany = companyByOaId == null ? null : companyByOaId.get(oaDept.getSubcompanyid1()); + if (belongCompany != null) { + dept.setBelongCompanyCode(belongCompany.getDeptCode()); + dept.setParentId(null); + if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + } else { + // 上级为部门:parentCode 暂存上级部门 OA 主键,重建层级时再解析 + dept.setParentCode(oaDept.getSupdepid()); + } } else { - // 父部门编码是0,设置为公司编码 - dept.setParentCode(dept.getBelongCompanyCode()); - // 根据父部门编码获取对应的id - dept.setParentId(companyDeptMap.get(dept.getParentCode())); + // 所属公司尚未解析时,暂存 OA 公司主键,后续补齐正式编码 + dept.setBelongCompanyCode(oaDept.getSubcompanyid1()); + dept.setParentCode(oaDept.getSupdepid()); } - - // 显示顺序处理 dept.setSort(OAUtils.parseInt(oaDept.getShoworder())); - // 部门类型,部门 dept.setDeptCategory(DeptCategory.DEPT.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } 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 28f7004b..00b2e576 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -70,6 +70,7 @@ public interface UserConvert { @Mapping(source = "lastname", target = "realName") @Mapping(source = "mobile", target = "phone") @Mapping(source = "email", target = "email") + @Mapping(source = "modified", target = "oaModified") User baseConvert(OAPersonResponse person); @Mapping(source = "subcompanyname", target = "companyName") @@ -87,6 +88,8 @@ public interface UserConvert { default User person2user(OAPersonResponse person, String defaultPassword) { User user = baseConvert(person); user.setAccount(resolveAccount(person)); + // OA 可能返回多号(如 133.../137...),只保留首个号码再入库加密 + user.setPhone(normalizeMobile(user.getPhone())); // 密码 user.setPassword(defaultPassword); // 性别 @@ -102,6 +105,23 @@ public interface UserConvert { return user; } + /** + * 规范化手机号:去空白,多号时取第一个 + */ + default String normalizeMobile(String mobile) { + if (StringUtils.isBlank(mobile)) { + return null; + } + String trimmed = mobile.trim(); + String[] parts = trimmed.split("[/|,;;,\\s]+"); + for (String part : parts) { + if (StringUtils.isNotBlank(part)) { + return part.trim(); + } + } + return null; + } + /** * 解析本系统登录账号:优先 OA loginid,其次工号,最后手机号 * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/AirportMasterExportExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/AirportMasterExportExcel.java index e237be36..d2918d23 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/AirportMasterExportExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/AirportMasterExportExcel.java @@ -102,6 +102,9 @@ public class AirportMasterExportExcel implements Serializable { @ExcelProperty("备注") private String remark; + @ExcelProperty("创建人") + private String createUserName; + @ExcelProperty("更新人") private String updateUserName; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java index b59573d5..998f3aac 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java @@ -76,7 +76,7 @@ public class CurrencyExcel implements Serializable { @ExcelProperty("备注") private String remark; - @ExcelProperty + @ExcelIgnore private String errorMessage; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/FeeItemExportExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/FeeItemExportExcel.java index aedffeb5..593992ad 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/FeeItemExportExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/FeeItemExportExcel.java @@ -50,6 +50,9 @@ public class FeeItemExportExcel implements Serializable { @ExcelProperty("组织") private String createDeptName; + @ExcelProperty("创建人") + private String createUserName; + @ExcelProperty("更新人") private String updateUserName; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/PortTerminalExportExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/PortTerminalExportExcel.java index b1c6471b..5454526d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/PortTerminalExportExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/PortTerminalExportExcel.java @@ -80,6 +80,9 @@ public class PortTerminalExportExcel implements Serializable { @ExcelProperty("备注") private String remark; + @ExcelProperty("创建人") + private String createUserName; + @ExcelProperty("更新人") private String updateUserName; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java index 3da00ca2..a7130e25 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java @@ -54,7 +54,7 @@ public class RailwayStationExcel implements Serializable { @ExcelProperty("TMIS国标编码*") private String tmisCode; - @ExcelProperty("电报略码*") + @ExcelProperty("电报略码") private String telegraphCode; @ExcelProperty("车站名称*") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java index b745a80c..424cc0c9 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java @@ -56,7 +56,7 @@ public class RailwayStationExportExcel implements Serializable { @ExcelProperty("TMIS国标编码*") private String tmisCode; - @ExcelProperty("电报略码*") + @ExcelProperty("电报略码") private String telegraphCode; @ExcelProperty("车站名称*") @@ -92,6 +92,9 @@ public class RailwayStationExportExcel implements Serializable { @ExcelProperty("备注") private String remark; + @ExcelProperty("创建人") + private String createUserName; + @ExcelProperty("更新人") private String updateUserName; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java new file mode 100644 index 00000000..4d8eabfe --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java @@ -0,0 +1,110 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.feign; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.AllArgsConstructor; +import org.springblade.core.tenant.annotation.NonDS; +import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.service.IMpUserService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * 小程序用户 Feign 实现类 + * + * @author Chill + */ +@NonDS +@Hidden +@RestController +@AllArgsConstructor +public class MpUserClient implements IMpUserClient { + + private final IMpUserService mpUserService; + + @Override + @GetMapping(USER_BY_ID) + public R userById(Long userId) { + return R.data(mpUserService.getById(userId)); + } + + @Override + @GetMapping(USER_BY_PHONE) + public R userByPhone(String tenantId, String phone) { + return R.data(mpUserService.getByPhone(tenantId, phone)); + } + + @Override + @GetMapping(USER_BY_ACCOUNT) + public R userByAccount(String tenantId, String account) { + return R.data(mpUserService.getByAccount(tenantId, account)); + } + + @Override + @GetMapping(USER_INFO_BY_ID) + public R userInfoById(Long userId) { + return R.data(mpUserService.userInfo(userId)); + } + + @Override + @GetMapping(USER_INFO_BY_PHONE) + public R userInfoByPhone(String tenantId, String phone) { + return R.data(mpUserService.userInfoByPhone(tenantId, phone)); + } + + @Override + @GetMapping(USER_INFO_BY_ACCOUNT) + public R userInfoByAccount(String tenantId, String account) { + return R.data(mpUserService.userInfoByAccount(tenantId, account)); + } + + @Override + @PostMapping(SAVE_USER) + public R saveUser(@RequestBody MpUser user) { + return R.data(mpUserService.submit(user)); + } + + @Override + @PostMapping(UPDATE_USER) + public R updateUser(@RequestBody MpUser user) { + return R.data(mpUserService.updateUser(user)); + } + + @Override + @PostMapping(BIND_OPENID) + public R bindOpenId(String tenantId, Long userId, String openid) { + return R.data(mpUserService.bindOpenId(tenantId, userId, openid)); + } + + @Override + @PostMapping(SET_PASSWORD) + public R setPassword(Long userId, String password, String password2) { + return R.data(mpUserService.setPassword(userId, password, password2)); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.java index 55661a6a..6176e472 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.java @@ -26,6 +26,7 @@ package org.springblade.system.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.vo.DeptVO; @@ -66,6 +67,18 @@ public interface DeptMapper extends BaseMapper { */ List lazyTree(String tenantId, Long parentId); + /** + * 按组织 ID 集合查询树节点(用于数据权限过滤后的根节点回显) + * + * @param tenantId 租户ID,空表示不限 + * @param ids 需要返回的组织 ID + * @param childIds 计算 hasChildren 时允许计入的子组织 ID;为空则 hasChildren 恒为 false + * @return 树节点列表 + */ + List treeNodesByIds(@Param("tenantId") String tenantId, + @Param("ids") List ids, + @Param("childIds") List childIds); + /** * 获取部门名 * 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 baef84dc..f45626dd 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 @@ -12,6 +12,8 @@ + + @@ -32,6 +34,8 @@ + + @@ -76,6 +80,9 @@ and dept.dept_name like concat(concat('%', #{param3.deptName}),'%') + + and dept.short_name like concat(concat('%', #{param3.shortName}),'%') + and dept.full_name like concat(concat('%', #{param3.fullName}),'%') @@ -115,6 +122,47 @@ ORDER BY dept.sort + + + + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.java index 287c9e27..3d56d687 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.java @@ -40,6 +40,13 @@ import java.util.List; */ public interface UserDeptMapper extends BaseMapper { + /** + * 提高当前会话 group_concat 上限,需与 queryUserDeptIds 在同一事务/连接中调用 + * + * @param maxLen 最大长度 + */ + void setGroupConcatMaxLen(@Param("maxLen") int maxLen); + /** * 根据用户id列表查询对应的部门id列表拼接的字符串 * @param userIds 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 03182d8c..76c059bf 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 @@ -2,6 +2,10 @@ + + SET SESSION group_concat_max_len = #{maxLen} + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InsuranceRecordMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InsuranceRecordMapper.xml index ea739687..074106e7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InsuranceRecordMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InsuranceRecordMapper.xml @@ -44,8 +44,8 @@ policy_no, start_date, end_date, - CASE WHEN insured_amount < 0 THEN 0 ELSE insured_amount END AS insured_amount, - CASE WHEN premium < 0 THEN 0 ELSE premium END AS premium, + CASE WHEN insured_amount = -1 THEN NULL ELSE insured_amount END AS insured_amount, + CASE WHEN premium = -1 THEN NULL ELSE premium END AS premium, invoice_no, invoice_date, ocr_template, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeAttachmentSyncMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeAttachmentSyncMapper.java new file mode 100644 index 00000000..a34b3f36 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeAttachmentSyncMapper.java @@ -0,0 +1,39 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; + +/** + * 金蝶附件同步 Mapper + * + * @author Chill + */ +@Mapper +public interface KingdeeAttachmentSyncMapper extends BaseMapper { +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeBillLockMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeBillLockMapper.java new file mode 100644 index 00000000..89174b12 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeBillLockMapper.java @@ -0,0 +1,18 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.springblade.transport.pojo.entity.KingdeeBillLock; + +/** 金蝶单据锁定记录 Mapper @author Chill */ +@Mapper +public interface KingdeeBillLockMapper extends BaseMapper { +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java index 69663196..29ddf75d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java @@ -26,8 +26,12 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.entity.KingdeeInvoicePool; +import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; /** * 金蝶进项发票票据池 Mapper 接口 @@ -37,4 +41,14 @@ import org.springblade.transport.pojo.entity.KingdeeInvoicePool; @Mapper public interface KingdeeInvoicePoolMapper extends BaseMapper { + /** + * 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出 + * + * @param page 分页参数 + * @param query 统一查询条件(已在上层完成 trim 与日期格式校验) + * @param tenantId 当前租户 + * @return 统一视图分页 + */ + IPage selectInvoicePoolPage(IPage page, + @Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml new file mode 100644 index 00000000..b25b0ddc --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml @@ -0,0 +1,86 @@ + + + + + + + EXISTS (SELECT 1 FROM blade_formal_settlement_invoice c + WHERE c.is_deleted = 0 AND c.tenant_id = p.tenant_id AND c.invoice_no = p.invoice_no + AND EXISTS (SELECT 1 FROM blade_formal_settlement s + WHERE s.id = c.formal_settlement_id AND s.is_deleted = 0 + AND s.approval_status != 'voided')) + + + + + EXISTS (SELECT 1 FROM blade_invoice_receipt r + WHERE r.is_deleted = 0 AND r.tenant_id = p.tenant_id + AND r.approval_status != 'voided' AND r.invoice_no = p.invoice_no) + + + + + p.id, p.invoice_no, p.serial_no, + DATE_FORMAT(p.invoice_date, '%Y-%m-%d') AS invoice_date, + p.invoice_type, p.tax_rate, p.invoice_amount, p.tax_amount, + p.receiver_name, p.buyer_tax_no, p.issuer_name, p.saler_tax_no, + p.bank_name, p.bank_account, p.issuing_bank, p.phone, + p.customer_emails, p.department_emails, + p.kingdee_bill_no, p.kingdee_status, p.check_status, p.attachments_json, + p.invoice_amount AS available_invoice_amount, + p.invoice_amount AS matched_amount, + p.available_amount AS available_tax_amount, + AS claimed, + AS registered + + + + + AND p.available_amount > 0 AND p.amount_use_status = '0' + + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MaintenancePlanMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MaintenancePlanMapper.xml index b67970dc..192e57bc 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MaintenancePlanMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MaintenancePlanMapper.xml @@ -44,7 +44,7 @@ vehicle_no, maintainer, maintenance_time, - CASE WHEN mileage < 0 THEN 0 ELSE mileage END AS mileage, + CASE WHEN mileage = -1 THEN NULL WHEN mileage < 0 THEN 0 ELSE mileage END AS mileage, mileage_unit, maintenance_item, cost, @@ -52,7 +52,7 @@ contact_phone, address, next_maintenance_time, - CASE WHEN next_maintenance_mileage < 0 THEN 0 ELSE next_maintenance_mileage END AS next_maintenance_mileage, + CASE WHEN next_maintenance_mileage = -1 THEN NULL WHEN next_maintenance_mileage < 0 THEN 0 ELSE next_maintenance_mileage END AS next_maintenance_mileage, attachments, remark FROM diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MileageRecordMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MileageRecordMapper.xml index 8e3cbc79..d4dc2165 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MileageRecordMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/MileageRecordMapper.xml @@ -36,10 +36,10 @@ is_deleted, vehicle_type, vehicle_no, - CASE WHEN previous_month_mileage < 0 THEN 0 ELSE previous_month_mileage END AS previous_month_mileage, - CASE WHEN current_month_mileage < 0 THEN 0 ELSE current_month_mileage END AS current_month_mileage, - CASE WHEN monthly_mileage < 0 THEN 0 ELSE monthly_mileage END AS monthly_mileage, - CASE WHEN total_mileage < 0 THEN 0 ELSE total_mileage END AS total_mileage, + CASE WHEN previous_month_mileage = -1 THEN NULL WHEN previous_month_mileage < 0 THEN 0 ELSE previous_month_mileage END AS previous_month_mileage, + CASE WHEN current_month_mileage = -1 THEN NULL WHEN current_month_mileage < 0 THEN 0 ELSE current_month_mileage END AS current_month_mileage, + CASE WHEN monthly_mileage = -1 THEN NULL WHEN monthly_mileage < 0 THEN 0 ELSE monthly_mileage END AS monthly_mileage, + CASE WHEN total_mileage = -1 THEN NULL WHEN total_mileage < 0 THEN 0 ELSE total_mileage END AS total_mileage, mileage_unit, attachments, remark diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/OilElectricRecordMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/OilElectricRecordMapper.xml index 88612747..80f79c14 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/OilElectricRecordMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/OilElectricRecordMapper.xml @@ -48,10 +48,10 @@ vehicle_no, card_holder, data_source, - CASE WHEN quantity < 0 THEN 0 ELSE quantity END AS quantity, - CASE WHEN unit_price < 0 THEN 0 ELSE unit_price END AS unit_price, - CASE WHEN transaction_amount < 0 THEN 0 ELSE transaction_amount END AS transaction_amount, - CASE WHEN balance < 0 THEN 0 ELSE balance END AS balance, + CASE WHEN quantity = -1 THEN NULL WHEN quantity < 0 THEN 0 ELSE quantity END AS quantity, + CASE WHEN unit_price = -1 THEN NULL WHEN unit_price < 0 THEN 0 ELSE unit_price END AS unit_price, + CASE WHEN transaction_amount = -1 THEN NULL WHEN transaction_amount < 0 THEN 0 ELSE transaction_amount END AS transaction_amount, + CASE WHEN balance = -1 THEN NULL WHEN balance < 0 THEN 0 ELSE balance END AS balance, station, attachments, remark diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml index fcbafc57..b1337bbe 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml @@ -22,7 +22,6 @@ c.attachments_json, c.remark, c.claim_status, - c.awrp_src_code, c.kingdee_bill_no, c.kingdee_bill_status, c.voided_by, @@ -37,6 +36,7 @@ f.summary, f.transaction_time, f.detail_serial_no, + f.push_status, (SELECT GROUP_CONCAT(rcs.formal_settlement_no ORDER BY rcs.id SEPARATOR ',') FROM blade_receipt_claim_settlement rcs WHERE rcs.receipt_claim_id = c.id AND rcs.is_deleted = 0) AS associated_settlement_nos @@ -92,6 +92,9 @@ AND c.kingdee_bill_status = #{query.kingdeeBillStatus} + + AND f.push_status = #{query.pushStatus} + + SELECT + r.id, + r.tenant_id, + r.biz_type, + r.biz_id, + r.biz_key, + r.status, + r.reject_reason, + r.submit_time, + r.submit_user, + r.audit_time, + r.audit_user, + r.create_user, + r.create_dept, + r.create_time, + r.update_user, + r.update_time, + r.is_deleted, + d.driver_name AS driver_name, + d.mobile AS driver_mobile + FROM + blade_transport_cert_record r + LEFT JOIN blade_transport_driver d + ON d.is_deleted = 0 + AND d.id = CASE + WHEN r.biz_type = 'driver' THEN r.biz_id + ELSE ( + SELECT dr.id + FROM blade_transport_driver dr + WHERE dr.is_deleted = 0 + AND dr.user_id = r.submit_user + ORDER BY dr.update_time DESC, dr.id DESC + LIMIT 1 + ) + END + WHERE + r.is_deleted = 0 + AND r.biz_type = #{bizType} + AND r.status = #{status} + + AND r.biz_key LIKE CONCAT('%', #{bizKey}, '%') + + + + AND ( + d.driver_name LIKE CONCAT('%', #{driverName}, '%') + OR r.biz_key LIKE CONCAT('%', #{driverName}, '%') + ) + + + AND d.mobile LIKE CONCAT('%', #{driverMobile}, '%') + + ORDER BY + r.submit_time DESC + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml index c7411ab8..0faafb78 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml @@ -48,6 +48,7 @@ + @@ -96,6 +97,7 @@ remark , certification_status , certification_reject_reason + , effective_cert_record_id diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ViolationRecordMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ViolationRecordMapper.xml index 5b90c3f3..9563419c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ViolationRecordMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ViolationRecordMapper.xml @@ -46,8 +46,8 @@ violation_item, violation_time, location, - CASE WHEN fine_amount < 0 THEN 0 ELSE fine_amount END AS fine_amount, - CASE WHEN deduct_points < 0 THEN 0 ELSE deduct_points END AS deduct_points, + CASE WHEN fine_amount = -1 THEN NULL WHEN fine_amount < 0 THEN 0 ELSE fine_amount END AS fine_amount, + CASE WHEN deduct_points = -1 THEN NULL WHEN deduct_points < 0 THEN 0 ELSE deduct_points END AS deduct_points, penalty_unit, process_status, process_description, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java index cfcfe4bf..72f6435f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java @@ -3,15 +3,25 @@ package org.springblade.transport.mk; import lombok.RequiredArgsConstructor; import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.dto.BillPaymentStatusRequest; +import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest; +import org.springblade.transport.pojo.dto.InvoiceApplicationStatusRequest; +import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; +import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; +import org.springblade.transport.pojo.entity.BillPayment; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.InvoiceApplication; +import org.springblade.transport.pojo.entity.InvoiceReceipt; import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.entity.PreSettlement; import org.springblade.transport.pojo.entity.ProjectApply; -import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IBillPaymentService; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.ICustomerArchiveService; import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IInvoiceReceiptService; import org.springblade.transport.service.IPaymentApplicationService; import org.springblade.transport.service.IPreSettlementService; import org.springblade.transport.constant.HcsSyncConstant; @@ -214,57 +224,6 @@ public final class MkProcessBizHandlers { } } - @Component - @TenantIgnore - @RequiredArgsConstructor - public static class WaybillHandler implements IMkProcessBizHandler { - private final IWaybillService service; - - @Override - public String bizType() { - return "waybill-manage"; - } - - @Override - public Object publicDetail(Long id) { - return service.publicDetail(id); - } - - @Override - public boolean syncFromProcessInfo(Long id, Object processInfo) { - Waybill entity = service.getById(id); - if (entity == null) { - return false; - } - String node = MkProcessNodeHelper.currentNode(processInfo); - if (Func.isEmpty(node)) { - return false; - } - entity.setCurrentProcessNode(node); - return service.updateById(entity); - } - - @Override - public boolean approveFromProcess(Long id, String processorName) { - Waybill entity = service.getById(id); - if (entity == null) { - return false; - } - entity.setCurrentProcessNode("审核通过"); - return service.updateById(entity); - } - - @Override - public boolean rejectFromProcess(Long id, String processorName) { - Waybill entity = service.getById(id); - if (entity == null) { - return false; - } - entity.setCurrentProcessNode("审核不通过"); - return service.updateById(entity); - } - } - @Component @TenantIgnore @RequiredArgsConstructor @@ -371,11 +330,18 @@ public final class MkProcessBizHandlers { if (entity == null || "approved".equals(entity.getApprovalStatus())) { return entity != null; } - entity.setApprovalStatus("approved"); - entity.setCurrentNode("审核通过"); - entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); - entity.setApprovedTime(LocalDateTime.now()); - return service.updateById(entity); + // 统一走 service.approve:与手动审批同一入口(补变更记录),审批事务提交后自动触发金蝶推送事件; + // 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送 + FormalSettlementStatusRequest request = new FormalSettlementStatusRequest(); + request.setId(id); + service.approve(request); + FormalSettlement updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; } @Override @@ -434,12 +400,20 @@ public final class MkProcessBizHandlers { if (entity == null || "approved".equals(entity.getApprovalStatus())) { return entity != null; } - entity.setApprovalStatus("approved"); - entity.setCurrentNode("审核通过"); - entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); - // HCS 推送零埋点:MK 审批回调仅置待同步状态,与 /approve 入口收敛于同一权威状态(ADR-0001) - entity.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); - return service.updateById(entity); + // 统一走 service.approve:与手动审批同一入口(补票据额度联动 useBillBalance),审批事务提交后自动触发金蝶推送事件; + // 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送 + PaymentApplicationStatusRequest request = new PaymentApplicationStatusRequest(); + request.setId(id); + service.approve(request); + PaymentApplication updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + // HCS 推送零埋点:MK 审批回调仅置待同步状态,与 /approve 入口收敛于同一权威状态(ADR-0001) + updated.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); + service.updateById(updated); + } + return true; } @Override @@ -454,4 +428,229 @@ public final class MkProcessBizHandlers { return service.updateById(entity); } } + + @Component + @TenantIgnore + @RequiredArgsConstructor + public static class InvoiceApplicationHandler implements IMkProcessBizHandler { + private final IInvoiceApplicationService service; + + @Override + public String bizType() { + return "invoice-application"; + } + + @Override + public Object publicDetail(Long id) { + return service.detail(id); + } + + @Override + public boolean syncFromProcessInfo(Long id, Object processInfo) { + InvoiceApplication entity = service.getById(id); + if (entity == null) { + return false; + } + String node = MkProcessNodeHelper.currentNode(processInfo); + String processor = MkProcessNodeHelper.currentProcessor(processInfo); + if (Func.isEmpty(node) && Func.isEmpty(processor)) { + return false; + } + if (Func.isNotEmpty(node)) { + entity.setCurrentNode(node); + } + if (Func.isNotEmpty(processor)) { + entity.setCurrentProcessor(processor); + } + entity.setApprovalStatus("reviewing"); + return service.updateById(entity); + } + + @Override + public boolean approveFromProcess(Long id, String processorName) { + InvoiceApplication entity = service.getById(id); + if (entity == null || "approved".equals(entity.getApprovalStatus())) { + return entity != null; + } + InvoiceApplicationStatusRequest request = new InvoiceApplicationStatusRequest(); + request.setId(id); + service.approve(request); + InvoiceApplication updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; + } + + @Override + public boolean rejectFromProcess(Long id, String processorName) { + InvoiceApplication entity = service.getById(id); + if (entity == null || "returned".equals(entity.getApprovalStatus())) { + return entity != null; + } + InvoiceApplicationStatusRequest request = new InvoiceApplicationStatusRequest(); + request.setId(id); + request.setReason("MK流程驳回"); + service.returnBill(request); + InvoiceApplication updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核不通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; + } + } + + @Component + @TenantIgnore + @RequiredArgsConstructor + public static class InvoiceReceiptHandler implements IMkProcessBizHandler { + private final IInvoiceReceiptService service; + + @Override + public String bizType() { + return "invoice-receipt"; + } + + @Override + public Object publicDetail(Long id) { + return service.detail(id); + } + + @Override + public boolean syncFromProcessInfo(Long id, Object processInfo) { + InvoiceReceipt entity = service.getById(id); + if (entity == null) { + return false; + } + String node = MkProcessNodeHelper.currentNode(processInfo); + String processor = MkProcessNodeHelper.currentProcessor(processInfo); + if (Func.isEmpty(node) && Func.isEmpty(processor)) { + return false; + } + if (Func.isNotEmpty(node)) { + entity.setCurrentNode(node); + } + if (Func.isNotEmpty(processor)) { + entity.setCurrentProcessor(processor); + } + entity.setApprovalStatus("reviewing"); + return service.updateById(entity); + } + + @Override + public boolean approveFromProcess(Long id, String processorName) { + InvoiceReceipt entity = service.getById(id); + if (entity == null || "approved".equals(entity.getApprovalStatus())) { + return entity != null; + } + InvoiceReceiptStatusRequest request = new InvoiceReceiptStatusRequest(); + request.setId(id); + service.approve(request); + InvoiceReceipt updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; + } + + @Override + public boolean rejectFromProcess(Long id, String processorName) { + InvoiceReceipt entity = service.getById(id); + if (entity == null || "returned".equals(entity.getApprovalStatus())) { + return entity != null; + } + InvoiceReceiptStatusRequest request = new InvoiceReceiptStatusRequest(); + request.setId(id); + request.setReason("MK流程驳回"); + service.returnBill(request); + InvoiceReceipt updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核不通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; + } + } + + @Component + @TenantIgnore + @RequiredArgsConstructor + public static class BillPaymentHandler implements IMkProcessBizHandler { + private final IBillPaymentService service; + + @Override + public String bizType() { + return "bill-payment"; + } + + @Override + public Object publicDetail(Long id) { + return service.detail(id); + } + + @Override + public boolean syncFromProcessInfo(Long id, Object processInfo) { + BillPayment entity = service.getById(id); + if (entity == null) { + return false; + } + String node = MkProcessNodeHelper.currentNode(processInfo); + String processor = MkProcessNodeHelper.currentProcessor(processInfo); + if (Func.isEmpty(node) && Func.isEmpty(processor)) { + return false; + } + if (Func.isNotEmpty(node)) { + entity.setCurrentNode(node); + } + if (Func.isNotEmpty(processor)) { + entity.setCurrentProcessor(processor); + } + entity.setApprovalStatus("reviewing"); + return service.updateById(entity); + } + + @Override + public boolean approveFromProcess(Long id, String processorName) { + BillPayment entity = service.getById(id); + if (entity == null || "approved".equals(entity.getApprovalStatus())) { + return entity != null; + } + BillPaymentStatusRequest request = new BillPaymentStatusRequest(); + request.setId(id); + service.approve(request); + BillPayment updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; + } + + @Override + public boolean rejectFromProcess(Long id, String processorName) { + BillPayment entity = service.getById(id); + if (entity == null || "returned".equals(entity.getApprovalStatus())) { + return entity != null; + } + BillPaymentStatusRequest request = new BillPaymentStatusRequest(); + request.setId(id); + request.setReason("MK流程驳回"); + service.returnBill(request); + BillPayment updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核不通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; + } + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java index 7ca1ad3d..2e23f078 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java @@ -10,7 +10,6 @@ import org.springblade.core.tool.utils.StringUtil; import org.springblade.process.feign.IBusinessProcessClient; import org.springframework.stereotype.Service; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -106,7 +105,7 @@ public class MkProcessMessageService { bizType, processId, loginName, JSON.toJSONString(processInfo)); if (isProcessFinished(processInfo)) { applyProcessResult(handler, bizType, bizIdText, processId, formValues, true); - } else if (isProcessRejected(processInfo)) { + } else if (MkProcessNodeHelper.isProcessRejected(processInfo)) { applyProcessResult(handler, bizType, bizIdText, processId, formValues, false); } else if (StringUtil.isNotBlank(bizIdText)) { try { @@ -124,21 +123,6 @@ public class MkProcessMessageService { return "30".equals(firstText(asMap(processInfo), "fdProcessStatus")); } - private static boolean isProcessRejected(Object processInfo) { - Map info = asMap(processInfo); - if (!"20".equals(firstText(info, "fdProcessStatus"))) { - return false; - } - if (hasItems(info.get("currentHandlers"))) { - return false; - } - return !hasItems(asMap(info.get("fdTaskInfo")).get("handlerInfos")); - } - - private static boolean hasItems(Object value) { - return value instanceof Collection collection && !collection.isEmpty(); - } - private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; @@ -154,8 +138,18 @@ public class MkProcessMessageService { } else { handler.rejectFromProcess(bizId, processorName); } + try { + FR markResult = businessProcessClient.markProcessFinished(processId, approved); + log.info("公开页标记业务流程完结 bizType={} processId={} approved={} result={}", + bizType, processId, approved, JSON.toJSONString(markResult)); + } catch (Exception markException) { + log.error("公开页标记业务流程完结失败 bizType={} processId={} approved={}", + bizType, processId, approved, markException); + } } catch (NumberFormatException e) { log.warn("流程{}但业务id格式不正确 bizType={} id={}", action, bizType, bizIdText); + } catch (Exception e) { + log.error("流程{}同步业务状态失败 bizType={} id={} processId={}", action, bizType, bizIdText, processId, e); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java index c665e58f..6004bbb0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java @@ -11,20 +11,47 @@ import java.util.Map; import java.util.stream.Collectors; /** - * 解析 MK 流程实例详情中的当前节点、当前处理人 + * 解析 MK 流程实例详情中的当前节点、当前处理人,以及流程是否驳回到起草节点。 */ public final class MkProcessNodeHelper { + /** + * 起草节点编号。审批节点编号随流程模板变化,不能用来判断驳回。 + */ + private static final String DRAFT_NODE_NUMBER = "N2"; + + private static final List CURRENT_NODE_KEYS = List.of("handlerInfos", "drafterInfos", "adminInfos"); + private MkProcessNodeHelper() { } + /** + * 流程未结束,且当前待办已经回到起草节点时,才视为审核驳回。 + * 中间审批节点通过后流程状态仍是 20,不能把历史节点编号当成驳回。 + */ + public static boolean isProcessRejected(Object processInfo) { + Map info = asMap(processInfo); + String processStatus = text(info.get("fdProcessStatus")); + if (!"11".equals(processStatus) && !"20".equals(processStatus)) { + return false; + } + return hasActiveDraftNode(asMap(info.get("fdTaskInfo"))); + } + public static String currentProcessor(Object processInfo) { return joinDistinct(extractTexts(asMap(processInfo).get("currentHandlers"), "fdName", "name"), "、"); } public static String currentNode(Object processInfo) { Map taskInfo = asMap(asMap(processInfo).get("fdTaskInfo")); - return joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、"); + List nodeNames = new ArrayList<>(); + for (String key : CURRENT_NODE_KEYS) { + nodeNames.addAll(extractActiveNodeNames(taskInfo.get(key))); + } + if (nodeNames.isEmpty()) { + nodeNames.addAll(extractTexts(taskInfo.get("handlerInfos"), "nodeName")); + } + return joinDistinct(nodeNames, "、"); } public static String text(String value, String fallback) { @@ -52,6 +79,59 @@ public final class MkProcessNodeHelper { } } + private static boolean hasActiveDraftNode(Map taskInfo) { + for (String key : CURRENT_NODE_KEYS) { + Object nodes = taskInfo.get(key); + if (!(nodes instanceof Collection collection)) { + continue; + } + for (Object item : collection) { + if (isActiveDraftNode(asMap(item))) { + return true; + } + } + } + return false; + } + + private static boolean isActiveDraftNode(Map node) { + if (!"20".equals(text(node.get("status")))) { + return false; + } + String nodeNumber = text(node.get("nodeNumber")); + if (Func.isEmpty(nodeNumber)) { + nodeNumber = text(node.get("nodeId")); + } + if (DRAFT_NODE_NUMBER.equals(nodeNumber)) { + return true; + } + String activityType = text(node.get("activityType")); + return "draft".equalsIgnoreCase(activityType) || "draftTask".equalsIgnoreCase(activityType); + } + + private static List extractActiveNodeNames(Object listObj) { + List result = new ArrayList<>(); + if (!(listObj instanceof Collection collection)) { + return result; + } + for (Object item : collection) { + Map node = asMap(item); + if (!"20".equals(text(node.get("status")))) { + continue; + } + String nodeName = text(node.get("nodeName")); + if (Func.isNotEmpty(nodeName)) { + result.add(nodeName); + } + } + return result; + } + + private static String text(Object value) { + String text = Func.toStr(value, "").trim(); + return "null".equalsIgnoreCase(text) ? "" : text; + } + private static List extractTexts(Object listObj, String... keys) { List result = new ArrayList<>(); if (!(listObj instanceof Collection collection) || keys == null) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java index 3a606276..2ddbde55 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java @@ -36,6 +36,7 @@ import org.springblade.transport.ocr.config.BaiduOcrProperties; import org.springblade.transport.ocr.constant.BaiduOcrType; import org.springblade.transport.ocr.service.IBaiduOcrService; import org.springblade.transport.pojo.vo.BaiduOcrResultVO; +import org.springblade.transport.support.OcrFieldSupport; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; @@ -143,6 +144,9 @@ public class BaiduOcrServiceImpl implements IBaiduOcrService { response.setType(type.name()); response.setSide(normalizedSide); response.setResult(objectMapper.convertValue(result, RESULT_TYPE)); + // 同时下发归一化字段:调用方不该去认识百度那套中文键名, + // 且键名对不上时前端只会「静默什么都不填」,是最难查的一类问题 + response.setFields(OcrFieldSupport.extract(type, normalizedSide, result)); return response; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java new file mode 100644 index 00000000..89af4d43 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java @@ -0,0 +1,83 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.CertAuditDetailVO; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +/** + * 认证审核服务(管理端) + *

+ * 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。 + * 通过使该记录成为生效快照;驳回只终结该记录,生效快照保持不变。 + * + * @author Chill + */ +public interface ICertAuditService { + + /** + * 认证记录分页(按认证对象类型与审核状态) + *

+ * 审核页按「待审核 / 已通过 / 已驳回」分页签展示,故状态可传; + * 不传时按待审核处理,兼容只想要待审队列的调用方。 + *

+ * 列表同时下发该记录的司机姓名与手机号,取的是司机档案的**当前**值 + * (认证记录里的姓名是提交时的历史值,手机号则从来不在快照里)。 + * 解析不到司机档案的记录,两个字段为空,不回落成提交时的值—— + * 那会让「同一个人的多条记录显示成不同的人」。 + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空 + * @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空 + * @param driverMobile 司机手机号模糊匹配,可为空 + * @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中) + * @param page 分页参数(由 Controller 用 Condition.getPage 构造) + * @return 认证记录分页 + */ + IPage pendingPage(String bizType, String bizKey, String driverName, String driverMobile, + Integer status, IPage page); + + /** + * 审核详情:同时返回待审快照与当前生效值,供前端做 diff + * + * @param recordId 认证记录主键 + * @return 审核详情 + */ + CertAuditDetailVO detail(Long recordId); + + /** + * 审核一条认证记录 + * + * @param recordId 认证记录主键 + * @param pass true 通过,false 驳回 + * @param rejectReason 驳回原因;驳回时必填,≤200 字 + * @return 是否成功 + */ + boolean audit(Long recordId, boolean pass, String rejectReason); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java index 71fef30c..e43ebcc7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java @@ -64,4 +64,12 @@ public interface IContractManageService extends BaseService { Map expireStats(ContractManageVO contractManage); List exportContractManage(ContractManageVO contractManage, String ids); + /** + * 预览下一合同编号(按合同负责部门前缀 + 年份 + 三位流水) + * + * @param organizationId 合同负责部门ID + * @return 预览编号 + */ + String nextContractNo(Long organizationId); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java index 5f357a44..d7c7d34a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java @@ -22,6 +22,7 @@ */ package org.springblade.transport.service; +import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; import org.springblade.transport.pojo.vo.DriverVO; @@ -44,4 +45,15 @@ public interface IDriverAppService { */ List myVehicles(); + /** + * 当前登录用户对应的司机**实体** + *

+ * 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle}, + * 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配, + * 匹配不到再按 {@code user_id} 兜底。 + * + * @return 司机实体;未登录或查不到时返回 null + */ + Driver currentDriverEntity(); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverCertService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverCertService.java new file mode 100644 index 00000000..7a76e5ff --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverCertService.java @@ -0,0 +1,111 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +import java.util.List; + +/** + * 司机端认证服务(小程序) + *

+ * 认证对象是「一事一议」的:司机的个人认证与每一辆车的车辆认证各自独立成立、 + * 独立审核、独立留痕。本服务只负责司机端能做什么,管理端审核在审核服务里。 + * + * @author Chill + */ +public interface IDriverCertService { + + /** + * 提交个人认证(全量:身份证 + 驾驶证 + 从业资格证,三证一起审) + *

+ * 生成一条待审记录,并把本人前一条待审记录置为已作废。 + * **不修改生效快照**:已认证的司机在本次审核通过前仍按旧生效值认定。 + * + * @param request 个人认证内容 + * @return 新生成的认证记录主键 + */ + Long submitPersonCert(PersonCertSubmitRequest request); + + /** + * 读取本人当前生效的个人认证内容(供个人认证卡片展示) + * + * @return 生效快照内容;从未认证通过时返回 null + */ + PersonCertSubmitRequest getEffectivePersonCert(); + + /** + * 提交车辆认证(新增或重新认证) + *

+ * 生成一条待审记录,并把该车前一条待审记录置为已作废。 + * **不修改生效快照**:已认证的车辆在本次审核通过前仍按旧生效值认定, + * 因此提交审核不会影响车辆正常接单。 + * + * @param request 车辆认证内容 + * @return 新生成的认证记录主键 + */ + Long submitVehicleCert(VehicleCertSubmitRequest request); + + /** + * 删除车辆认证(按逻辑删除处理) + *

+ * 只把车辆标记为已删除,**认证记录一律保留**——记录是不可变快照, + * 也是「曾通过即永久可用」这一判定的依据,物理删除会让历史无从追溯。 + * 只能删除本人绑定的车辆,越权拒绝。 + * + * @param vehicleId 车辆主键 + * @return 是否成功 + */ + boolean deleteVehicleCert(Long vehicleId); + + /** + * 读取某辆车当前生效的认证内容 + * + * @param vehicleId 车辆主键 + * @return 生效快照内容;从未认证通过时返回 null + */ + VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId); + + /** + * 查询某认证对象的认证记录列表(按提交时间倒序) + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizId 认证对象主键 + * @return 认证记录列表 + */ + List listRecords(String bizType, Long bizId); + + /** + * 查询单条认证记录详情 + * + * @param recordId 认证记录主键 + * @return 认证记录;不存在或无权查看时抛业务异常 + */ + TransportCertRecordVO getRecord(Long recordId); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index be434257..0d9d3f15 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -30,6 +30,9 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; + +import java.util.Map; /** * 司机端运单服务(小程序首页 / 列表) @@ -68,6 +71,19 @@ public interface IDriverWaybillService { */ DriverWaybillCardVO detail(Long id); + /** + * 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。 + *

+ * 起止日期优先级: + *

    + *
  1. 有打卡且多于 1 条:首条打卡日 → 末条打卡日
  2. + *
  3. 有打卡且仅 1 条:首条打卡日 → 今天
  4. + *
  5. 无打卡有计划开始日:计划开始日 → 今天
  6. + *
  7. 无打卡无计划:3 天前 → 今天
  8. + *
+ */ + WaybillTrackVO track(Long id); + /** * 按运单ID组装详情打卡数据(punchNodes / enrouteRecords),不校验当前登录人是否为该司机。 * 供调度端 manage/detail 复用。 @@ -99,4 +115,26 @@ public interface IDriverWaybillService { */ boolean complete(Long id); + /** + * 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。 + *

+ * 运单优先用项目启用中的过程配置,不可用时回退运单自身快照。 + */ + Map findPunchNode(Long waybillId, String nodeCode); + + /** + * 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。 + *

+ * 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。 + */ + boolean isNodePunched(Long waybillId, String nodeCode); + + /** + * 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。 + *

+ * 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案, + * 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。 + */ + void assertCurrentDriverOwnsWaybill(Long waybillId); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index b04ee264..d23ca19c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -25,6 +25,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -44,6 +45,11 @@ public interface IExceptionDisposalService extends BaseService void returnBill(FormalSettlementStatusRequest request); void voidBill(FormalSettlementStatusRequest request); String syncKingdee(Long id); + + /** + * 金蝶删单回调废除(unLockNotifyBusiData DELETE 分发):作废结算单、同步信息还原推送前状态 + * (kingdeeSyncStatus→unsynced,清单据编号/星瀚内码/同步时间),操作人取金蝶回调报文 + * + * @param id 正式结算单id + * @param operator 金蝶侧操作人 + */ + void voidByKingdee(Long id, String operator); + + /** + * 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用): + * A102-2/A302-2 保存并回写结算单单据编号与星瀚内码(kingdeeBillId) + * + * @param id 正式结算单id + * @return 金蝶单据编号 + */ + String doSyncKingdee(Long id); + + /** + * 独立事务落"生成失败"状态并记失败原因(在推送事务回滚、行锁释放后执行,与付款/收票口径统一); + * 仅更新审批通过且未同步的单据,未审批/已同步/已删除单据自动跳过,保持原状态 + * + * @param id 正式结算单id + * @param reason 失败原因(写变更记录留档) + */ + void markKingdeeSyncFailed(Long id, String reason); + + /** + * 重推金蝶生成失败(kingdeeSyncStatus=failed 且审批通过)的正式结算单:逐单独立成败,失败不中断。 + * 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮 + * + * @return 执行摘要 + */ + String retryKingdeeFailed(); + String syncKingdeeArResult(); List detailFees(Long detailId); void adjustDetail(PreSettlementDetailAdjustRequest request); void refreshPaymentSummary(Long settlementId); + + /** + * 批量刷新收款汇总:固定数次查询完成 N 张结算单的重算,避免逐单刷新的 N+1 + * + * @param settlementIds 结算单id集合 + */ + void refreshPaymentSummaries(Collection settlementIds); + void refreshPaymentSummariesForPreSettlement(Long preSettlementId); String applyPayment(FormalSettlementPaymentRequest request); List applyPayments(FormalSettlementBatchPaymentRequest request); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java index b126e786..aa9faf18 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java @@ -55,5 +55,41 @@ public interface IInvoiceApplicationService extends BaseService { InvoiceReceiptVO detail(Long id); /** - * 金蝶票池实时统一查询(A801-1 游标分页 + 本地认领/登记装饰标记),收票登记选票与正式结算单认领共用 - *

A801-1 不返回总数,改用「多取 1 条探测」得出 hasMore 驱动上一页/下一页; - * claimed/registered 仅本地展示装饰,不下推金蝶过滤。 + * 金蝶票据池镜像统一查询(本地真分页,收票登记选票与正式结算单认领共用)。 + *

仅出可用票(剩余可用金额>0 且金额使用状态未用),认领/登记三态过滤在 SQL 内直出; + * 票号精确查本地未命中时窄查一次 A801-1 回源入库后重查,兜底增量窗口缝隙内的当日新票。 * * @param query 统一查询条件 - * @return 实时游标分页 + * @return 镜像统一视图分页 */ - KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query); + IPage invoicePoolPage(KingdeeInvoicePoolQueryDTO query); + + /** + * 金蝶全票池定时同步(瘦身版):滚动增量窗口分页拉取 A801-1 + 镜像批量全列 upsert。 + *

无绑定回填/漏绑补偿/白名单(保存即写存根绑定,登记侧无需定时绑定); + * single-flight 锁防多副本并发,手动触发与定时任务共用。 + * + * @return 执行摘要 + */ + String syncKingdeeInvoicePool(); + + /** + * 结算单认领确认闸门:按票号集合逐张重查 A801-1 并按选票同口径复核可用性, + * 选中到确认之间票被认领/使用/红冲在此拦截(与收票保存重查同闸门) + * + * @param invoiceNos 待认领发票号码集合 + */ + void assertInvoicesClaimable(Collection invoiceNos); + + /** + * 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。 + *

按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证), + * 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器, + * 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。 + * + * @param invoiceNo 发票号码 + * @param response 响应对象(文件流写出口) + */ + void downloadPoolAttachment(String invoiceNo, HttpServletResponse response); List> settlementCandidates(String keyword, Long receiptId); @@ -85,6 +115,24 @@ public interface IInvoiceReceiptService extends BaseService { */ String pushKingdeeReceivedInvoice(List ids); + /** + * 自动推送路径批量推送金蝶收票单(审批通过事件 / 定时重推专用): + * 前置拦截(未绑定镜像/查验未通过/前置应付单未生成等)一并置 failed + 原因,纳入定时重推循环 + * + * @param ids 收票登记id列表 + * @param autoSync 自动推送标记(前置拦截置 failed) + * @return 执行摘要(逐单失败原因) + */ + String pushKingdeeReceivedInvoice(List ids, boolean autoSync); + + /** + * 重推金蝶推送失败(kingdeePushStatus=failed 且审批通过)的收票登记:逐单独立成败,失败不中断。 + * 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮 + * + * @return 执行摘要 + */ + String retryKingdeeFailed(); + /** * 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记); * pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeAttachmentSyncService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeAttachmentSyncService.java new file mode 100644 index 00000000..758f42ba --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeAttachmentSyncService.java @@ -0,0 +1,37 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.core.mp.base.BaseService; +import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; + +/** + * 金蝶附件同步服务 + * + * @author Chill + */ +public interface IKingdeeAttachmentSyncService extends BaseService { +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java new file mode 100644 index 00000000..7af9adfc --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java @@ -0,0 +1,78 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.transport.pojo.entity.KingdeeBillLock; + +import java.util.List; +import java.util.Map; + +/** + * 金蝶单据锁定记录服务 + * + * @author Chill + */ +public interface IKingdeeBillLockService extends IService { + + /** + * 推送金蝶成功后锁单(由各推送链路在主单事务提交后调用): + * 每张金蝶目标单一行锁记录(先落 failed 再调 lockBusiData,成功翻 locked); + * 锁单接口 1对1:逐张调用(单次一个源单号对一个金蝶单号),单张成败互不影响; + * 已 locked 幂等跳过,failed 原地重锁并重置补偿计数; + * 异常独立捕获,不影响主单同步状态 + * + * @param sourceBillType WLTMS 单据类型(SETTLEMENT/INVOICE_APPLY) + * @param sourceBillId WLTMS 业务主键 + * @param sourceBillNo WLTMS 单号 + * @param targetBillType 金蝶目标单据类型 + * @param targetBillNos 金蝶单据编号集合(开票申请为多张) + * @return 锁定失败的金蝶单号摘要(逗号拼接),全部锁定成功返回 null;调用方可拼入页面提示 + */ + String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List targetBillNos); + + /** + * 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction, + * targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录; + * operationType=DELETE 时按单据类型分发废除源单并还原关联数据 + * + * @param param 金蝶回调参数 + * @return 处理单据数(应答 updateCount) + */ + int onUnlockNotify(BfiBillLockDTO param); + + /** + * 编辑守卫:源单存在 locked 锁记录时抛 ServiceException(金蝶锁定中不可修改) + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillId WLTMS 业务主键 + */ + void checkEditable(String sourceBillType, Long sourceBillId); + + /** + * 批量查源单当前锁状态(供列表/详情页填充展示标识): + * 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁) + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillIds 源单主键集合 + * @return sourceBillId -> 最新锁记录,无活跃锁的源单不在 Map 中 + */ + Map mapActiveLocks(String sourceBillType, List sourceBillIds); + + /** + * 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData + * (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发); + * 成功翻 locked,失败 try_count+1 并记失败原因; + * 计数烧满上限后停止自动补偿(人工重推「同步金蝶」会重置计数重新接管)。锁记录落库均为批量更新 + * + * @return 补偿摘要(待补偿/成功张数),供调用方记日志或页面提示 + */ + String retryFailedLocks(); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java index fddbe20c..6d4807af 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java @@ -73,6 +73,26 @@ public interface IPaymentApplicationService extends BaseService { void updateAttachments(ReceiptClaimAttachmentsRequest request); - String voidClaim(Long id); + void voidClaim(Long id); /** * A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进金蝶单据状态 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java index 0f7c5686..89586f79 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java @@ -27,6 +27,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; +import org.springblade.transport.pojo.dto.ReceiptClaimPushResult; import org.springblade.transport.pojo.dto.ReceiptClaimRequest; import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; import org.springblade.transport.pojo.vo.ReceiptFlowVO; @@ -57,4 +58,20 @@ public interface IReceiptFlowService extends BaseService { * @return 本次同步条数 */ int sync(); + + /** + * 手动推送金蝶(A301-3 收款认领结果推送):认领完成且未推送过的流水才可推送, + * 推送前以当前用户姓名重查 A301-2 最新公告对账。与定时任务共用同一推送实现。 + * + * @param flowId 收款流水id + * @return 推送结果(失败不抛异常,原因随结果返回) + */ + ReceiptClaimPushResult pushClaimToKingdee(Long flowId); + + /** + * 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。 + * + * @return 执行摘要 + */ + String pushClaimByTask(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportCertRecordService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportCertRecordService.java new file mode 100644 index 00000000..9dda48a2 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportCertRecordService.java @@ -0,0 +1,62 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.core.mp.base.BaseService; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +import java.util.List; + +/** + * 认证记录 服务类 + *

+ * 只承载认证记录本身的读写与数据模型约束的守护;提交、审核、可用性门槛等业务链路 + * 由各自的业务服务实现。 + * + * @author Chill + */ +public interface ITransportCertRecordService extends BaseService { + + /** + * 某认证对象的认证记录列表,按提交时间倒序 + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizId 认证对象主键 + * @return 认证记录列表 + */ + List selectRecordList(String bizType, Long bizId); + + /** + * 取某认证对象当前的待审快照(至多一条) + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizId 认证对象主键 + * @return 待审快照,无则返回 null + */ + TransportCertRecord selectPendingRecord(String bizType, Long bizId); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java index 92c253a5..b8a16346 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java @@ -19,6 +19,7 @@ public interface IVoucherManageService extends BaseService { VoucherManageVO detail(Long id); IPage folderPage(IPage page, Long voucherId, String plateNo, Integer matched); VoucherFolderVO folderDetail(Long voucherId, String plateNo); + List listWaybillFolders(Long waybillId); void replaceFolder(Long voucherId, String plateNo, MultipartFile file); void replaceFolderByObject(Long voucherId, String plateNo, String objectKey, String fileName, Long size, String contentType); void removeFolder(Long voucherId, String plateNo); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java index a06dcd40..58c60733 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java @@ -7,12 +7,14 @@ import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.entity.WaybillImportBatch; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.WaybillImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; /** 运单批次服务。 */ public interface IWaybillImportBatchService extends BaseService { WaybillImportBatch saveDraft(WaybillImportBatchRequest request); - void validate(WaybillImportBatchRequest request, HttpServletResponse response); - void confirm(WaybillImportBatchRequest request, HttpServletResponse response); + WaybillImportValidateResultVO validate(WaybillImportBatchRequest request); + void exportFailures(WaybillImportBatchRequest request, HttpServletResponse response); + WaybillImportBatchVO confirm(WaybillImportBatchRequest request); IPage page(IPage page, WaybillImportBatchRequest request); BusinessRemoveResultVO removeBatches(String ids); String nextBatchNo(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java index 7f3af7dc..8a5abe66 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java @@ -33,6 +33,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.dto.WaybillMileageRequest; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import java.util.List; @@ -59,6 +60,16 @@ public interface IWaybillService extends BaseService { */ WaybillPunchRecordsVO listPunchRecords(Long waybillId); + /** + * 管理端:编辑时间轴上的单条打卡记录(含打卡状态) + *

+ * punched=false 时清除该节点(在途节点为该运单全部在途流水)的打卡流水。 + * + * @param request 打卡记录编辑请求 + * @return 是否成功 + */ + boolean savePunchRecord(WaybillPunchRecordSaveRequest request); + /** * 运单车辆实时定位(按运单绑定车牌调用 LBS) * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java index e8b95e15..aeac5967 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java @@ -40,16 +40,17 @@ import java.util.Map; * * @author Chill */ -public record KingdeeSyncContext(Map measureUnitCodeByName, Map contractCache, +public record KingdeeSyncContext(Map contractCache, Map customerCodeCache, Map projectCache, Map apBillNoCache, Map> settlementDetailCache, Map settlementCache, - Map receiptAccountCache) { + Map receiptAccountCache, + Map feeCategoryByName) { /** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */ public static KingdeeSyncContext empty() { - return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), - new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>()); + return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(), + new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/AccidentRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/AccidentRecordServiceImpl.java index 70b04017..81155e0b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/AccidentRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/AccidentRecordServiceImpl.java @@ -61,6 +61,8 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl ACCIDENT_NATURES = Set.of("重大事故", "一般事故", "轻微事故", "其他"); @@ -69,7 +71,12 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl selectAccidentRecordPage(IPage page, AccidentRecordVO accidentRecord) { List records = baseMapper.selectAccidentRecordPage(page, accidentRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setDirectEconomicLoss(normalizeEmptyAmount(record.getDirectEconomicLoss())); + record.setInsuranceClaimAmount(normalizeEmptyAmount(record.getInsuranceClaimAmount())); + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -86,7 +93,7 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl importAccidentRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List accidentRecordList = new ArrayList<>(); @@ -111,8 +118,9 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl exportAccidentRecord(Wrapper queryWrapper) { return list(queryWrapper).stream().map(accidentRecord -> { AccidentRecordExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(accidentRecord, AccidentRecordExportExcel.class)); - excel.setDirectEconomicLoss(nonNegative(accidentRecord.getDirectEconomicLoss())); - excel.setInsuranceClaimAmount(nonNegative(accidentRecord.getInsuranceClaimAmount())); + excel.setDirectEconomicLoss(normalizeEmptyAmount(accidentRecord.getDirectEconomicLoss())); + excel.setInsuranceClaimAmount(normalizeEmptyAmount(accidentRecord.getInsuranceClaimAmount())); + excel.setCreateUserName(UserCache.getUserRealName(accidentRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(accidentRecord.getUpdateUser())); return excel; }).toList(); @@ -168,6 +177,9 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyAmount(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_AMOUNT_SENTINEL) == 0 ? null : value; } private String normalizeVehicleType(String vehicleType) { @@ -255,7 +264,7 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl PASSENGER_TYPE_LEVELS = Set.of("一级", "二级", "其他"); @@ -70,7 +72,11 @@ public class AnnualInspectionRecordServiceImpl extends BaseServiceImpl selectAnnualInspectionRecordPage(IPage page, AnnualInspectionRecordVO annualInspectionRecord) { List records = baseMapper.selectAnnualInspectionRecordPage(page, annualInspectionRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setFee(normalizeEmptyAmount(record.getFee())); + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -105,7 +111,7 @@ public class AnnualInspectionRecordServiceImpl extends BaseServiceImpl importAnnualInspectionRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List annualInspectionRecordList = new ArrayList<>(); @@ -131,8 +137,9 @@ public class AnnualInspectionRecordServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyAmount(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_AMOUNT_SENTINEL) == 0 ? null : value; } private String normalizeVehicleType(String vehicleType) { @@ -285,7 +292,7 @@ public class AnnualInspectionRecordServiceImpl extends BaseServiceImpl + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.alibaba.fastjson.JSON; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.CertAuditDetailVO; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.ICertAuditService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.CertificationRuleSupport; +import org.springblade.transport.wrapper.TransportCertRecordWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +/** + * 认证审核服务实现 + *

+ * 审核作用于一条记录,两条路径的差别在于是否切换生效快照: + *

    + *
  • 通过:记录转已通过,并把该对象的生效快照指针切到它;
  • + *
  • 驳回:记录转已驳回并留原因,生效快照指针一个字都不动。
  • + *
+ * 因此「已认证的车被驳回」不会让它掉出可用池,只是这次提交没被采纳。 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class CertAuditServiceImpl implements ICertAuditService { + + private final ITransportCertRecordService certRecordService; + private final TransportCertRecordMapper certRecordMapper; + private final ITransportVehicleService transportVehicleService; + private final IDriverService driverService; + + @Override + public IPage pendingPage(String bizType, String bizKey, String driverName, String driverMobile, + Integer status, IPage page) { + if (!CertificationRuleSupport.isValidBizType(bizType)) { + throw new ServiceException("认证对象类型不正确"); + } + // 未传状态时按「审核中」处理:审核页默认停在待审页签, + // 只关心待审队列的调用方也不必额外传参 + Integer queryStatus = status == null ? TransportCertRecord.STATUS_PENDING : status; + + // 司机姓名与手机号、以及按它们的筛选,都要在**分页之前**由 SQL 完成: + // 在 Java 层补字段再过滤会变成「先分页再过滤」,条数与总数都不准。 + List records = certRecordMapper.selectAuditPage(page, bizType, queryStatus, + blankToNull(bizKey), blankToNull(driverName), blankToNull(driverMobile)); + TransportCertRecordWrapper wrapper = TransportCertRecordWrapper.build(); + records.forEach(wrapper::fillDisplayFields); + return page.setRecords(records); + } + + /** + * 搜索项的空串与纯空白一律当作「没填」 + *

+ * 否则 {@code " "} 会变成 {@code LIKE '% %'},把「没输入」当成一个真实筛选条件。 + * + * @param value 原始搜索值 + * @return 去空白后的值;为空时返回 null + */ + private String blankToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @Override + public CertAuditDetailVO detail(Long recordId) { + TransportCertRecord record = requireRecord(recordId); + Long effectiveId = effectiveRecordIdOf(record.getBizType(), record.getBizId()); + + CertAuditDetailVO detail = new CertAuditDetailVO(); + detail.setId(record.getId()); + detail.setBizType(record.getBizType()); + detail.setBizId(record.getBizId()); + detail.setBizKey(record.getBizKey()); + detail.setStatus(record.getStatus()); + detail.setStatusText(CertificationRuleSupport.statusText(record.getStatus())); + detail.setSubmitTime(record.getSubmitTime()); + detail.setSubmitUser(record.getSubmitUser()); + // 提交人来自小程序用户;审核人来自后台系统用户 + detail.setSubmitUserName(UserDisplayCache.getUserRealName(record.getSubmitUser())); + detail.setAuditUser(record.getAuditUser()); + detail.setAuditTime(record.getAuditTime()); + detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser())); + detail.setPendingSnapshotJson(record.getSnapshotJson()); + + // 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理 + detail.setEffectiveRecordId(effectiveId); + detail.setCertified(CertificationRuleSupport.isCertified(effectiveId)); + if (Func.isNotEmpty(effectiveId)) { + TransportCertRecord effective = certRecordService.getById(effectiveId); + detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson()); + } + return detail; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean audit(Long recordId, boolean pass, String rejectReason) { + TransportCertRecord record = requireRecord(recordId); + + // 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」, + // 生效值就凭空消失了——这正是必须挡住的原因。 + if (!CertificationRuleSupport.isAuditable(record)) { + throw new ServiceException("该认证记录已是" + + CertificationRuleSupport.statusText(record.getStatus()) + "状态,不可再次审核"); + } + if (!CertificationRuleSupport.isRejectReasonValid(pass, rejectReason)) { + throw new ServiceException("请输入认证驳回原因(不超过" + + TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)"); + } + + Long auditor = AuthUtil.getUserId(); + LocalDateTime auditTime = LocalDateTime.now(); + + // 1) 记录本身:状态 + 审核留痕(审核人、审核时间写在这条记录上,不依赖会被覆盖的审计字段) + TransportCertRecord patch = new TransportCertRecord(); + patch.setId(record.getId()); + patch.setStatus(pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED); + patch.setRejectReason(pass + ? CertificationRuleSupport.reasonForApprove() + : CertificationRuleSupport.reasonForReject(rejectReason)); + patch.setAuditUser(auditor); + patch.setAuditTime(auditTime); + certRecordService.updateById(patch); + + // 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE, + // 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。 + updateCertTarget(record, pass, rejectReason); + return true; + } + + /** + * 更新认证对象的展示缓存,并在通过时把生效快照指针切到这条记录 + *

+ * 只发**一次** UPDATE: + *

    + *
  • 通过 —— 状态=已通过、清空驳回原因、指针指向本条(切换而非追加,故仍至多一条生效快照);
  • + *
  • 驳回 —— 状态=已驳回、写入原因,**不带指针字段**,生效快照一个字都不动。
  • + *
+ * 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。 + * + * @param record 被审核的记录 + * @param pass 是否通过 + * @param rejectReason 驳回原因 + */ + private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) { + boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType()); + Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED; + // 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列 + Long newEffectiveId = pass ? record.getId() : null; + + if (vehicleTarget) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(record.getBizId()); + vehicle.setCertificationStatus(status); + if (pass) { + vehicle.setEffectiveCertRecordId(newEffectiveId); + // 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。 + // 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值—— + // 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。 + // 与指针合并成同一次 UPDATE。 + applySnapshotToVehicle(vehicle, record.getSnapshotJson()); + } else { + vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); + } + requireTargetUpdated(transportVehicleService.updateById(vehicle), "车辆", record.getBizId()); + if (pass) { + clearVehicleRejectReason(record.getBizId()); + } + return; + } + Driver driver = new Driver(); + driver.setId(record.getBizId()); + driver.setCertificationStatus(status); + // 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS, + // 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL, + // 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。 + copyDrivingVehicle(record.getBizId(), driver); + if (pass) { + driver.setEffectiveCertRecordId(newEffectiveId); + // 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。 + // 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空), + // 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。 + // 与指针合并成同一次 UPDATE。 + applySnapshotToDriver(driver, record.getSnapshotJson()); + } else { + driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); + } + requireTargetUpdated(driverService.updateById(driver), "司机", record.getBizId()); + if (pass) { + clearDriverRejectReason(record.getBizId()); + } + } + + /** + * 清空车辆档案上的驳回原因展示缓存 + *

+ * 该列没有驳回原因时必须是 NULL(建表即 DEFAULT NULL)。但框架的 not_null 更新策略 + * 会跳过 patch 里的 null 字段,交给 updateById 的 null 到不了 SQL, + * 「已通过」的车辆会一直挂着上一次的驳回文案,故单独发一条只改该列的语句。 + * 它不碰生效快照指针。 + * + * @param vehicleId 车辆主键 + */ + private void clearVehicleRejectReason(Long vehicleId) { + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } + + /** + * 清空司机档案上的驳回原因展示缓存 + *

+ * 与车辆侧同理:null 交给 updateById 到不了 SQL,只能单独发一条只改该列的语句。 + * 它不碰生效快照指针,也不碰车辆绑定。 + * + * @param driverId 司机主键 + */ + private void clearDriverRejectReason(Long driverId) { + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); + } + + /** + * 目标对象的写入必须命中一行,否则整体失败 + *

+ * {@code updateById} 命中 0 行时**不抛异常、只返回 false**。原先丢弃这个返回值, + * 于是「审核记录已通过、司机/车辆档案一个字没变」能静默成立:生效快照指针没写进去, + * 对象下次接单仍被判未认证,而审核页显示的是已通过。此处让事务整体回滚, + * 宁可报错也不留下状态与档案互相矛盾的数据。 + * + * @param updated 更新结果 + * @param target 对象名称,用于提示 + * @param id 对象主键 + */ + private void requireTargetUpdated(boolean updated, String target, Long id) { + if (!updated) { + throw new ServiceException("未能更新" + target + "档案(ID:" + id + + "),本次审核已回滚、未生效;请核对该对象是否存在、未被删除,且与认证记录属于同一租户"); + } + } + + /** + * 把司机当前的车辆绑定原值搬进 patch,避免被 ALWAYS 更新策略清空 + *

+ * {@code driving_vehicle} 上标了 {@code FieldStrategy.ALWAYS},任何一次 + * {@code updateById} 都会把该列写成 patch 里的值(null 也照写)。 + * 而司机端的车辆列表是按这一列的车牌匹配出来的,一旦被清空, + * 页面就变成「暂无绑定车辆」,且无法从界面上恢复。 + * + * @param driverId 司机主键 + * @param patch 待更新的司机 patch(就地写入原值) + */ + private void copyDrivingVehicle(Long driverId, Driver patch) { + Driver current = driverService.getById(driverId); + if (current != null) { + patch.setDrivingVehicle(current.getDrivingVehicle()); + } + } + + /** + * 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象) + *

+ * 搬**司机自己申报的全部实名内容**,姓名与身份证号也在其中:审核通过即认可这次申报, + * 档案必须与生效值一致;否则司机端卡片显示档案里的名字、「重新认证」表单回填快照里的名字, + * 同一个页面上出现两个名字。 + *

+ * 仍不碰 {@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}: + * 内部管理归属与派工关系由管理端维护,不该被司机的提交内容覆盖。 + *

+ * 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), + * 因此不会把管理端填过、司机端没填的字段清空。 + *

+ * 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**: + * 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败; + * 何况历史快照本来就存在,阻断会让这些对象再也审不过去。 + * 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号, + * 必须阻断。 + * + * @param driver 待更新的司机 patch + * @param snapshotJson 该条记录的全量提交内容 + */ + private void applySnapshotToDriver(Driver driver, String snapshotJson) { + if (Func.isEmpty(snapshotJson)) { + return; + } + PersonCertSubmitRequest snapshot; + try { + snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class); + } catch (Exception exception) { + return; + } + if (snapshot == null) { + return; + } + // 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况), + // 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。 + // 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。 + if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) { + return; + } + // 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引, + // 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效 + requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId()); + driver.setDriverName(snapshot.getRealName()); + driver.setIdCardNo(snapshot.getIdCardNo()); + driver.setIdCardFront(snapshot.getIdCardFrontUrl()); + driver.setIdCardBack(snapshot.getIdCardBackUrl()); + driver.setIdCardValidStart(snapshot.getIdCardValidStart()); + driver.setIdCardValidEnd(snapshot.getIdCardValidEnd()); + driver.setIdCardLongTerm(snapshot.getIdCardLongTerm()); + driver.setHeadPhoto(snapshot.getHeadPhoto()); + driver.setDrivingType(snapshot.getDriverClass()); + driver.setDrivingLicenseNo(snapshot.getDriverFileNo()); + driver.setDrivingLicenseStartDate(snapshot.getDriverValidStart()); + driver.setDrivingLicenseEndDate(snapshot.getDriverValidEnd()); + driver.setDrivingLicenseLongTerm(snapshot.getDriverLongTerm()); + driver.setDrivingLicenseFront(snapshot.getDriverLicenseFrontUrl()); + driver.setDrivingLicenseBack(snapshot.getDriverLicenseBackUrl()); + driver.setQualificationType(snapshot.getQualificationType()); + driver.setQualificationNo(snapshot.getQualificationNo()); + driver.setQualificationFront(qualificationFrontOf(snapshot, snapshotJson)); + driver.setQualificationBack(snapshot.getQualificationBack()); + driver.setQualificationEndDate(snapshot.getQualificationValidEnd()); + driver.setQualificationLongTerm(snapshot.getQualificationLongTerm()); + } + + /** + * 身份证号不得落在另一名司机名下 + *

+ * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据, + * 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号 + * 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、 + * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。 + *

+ * 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断; + * 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。 + * + * @param idCardNo 快照里的身份证号 + * @param driverId 本次要写入的司机主键 + */ + private void requireIdCardNotTaken(String idCardNo, Long driverId) { + if (Func.isEmpty(idCardNo)) { + return; + } + Driver owner = driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getIdCardNo, idCardNo) + .last("LIMIT 1")); + if (owner != null && !Objects.equals(owner.getId(), driverId)) { + throw new ServiceException("身份证号 " + idCardNo + " 已属于另一名司机档案(ID:" + owner.getId() + + "),本次审核已回滚、未生效;请先处理档案归属"); + } + } + + /** + * 取从业资格证封面页地址,兼容改名前的旧键 + *

+ * 该字段原叫 {@code qualificationUrl},与司机档案列 {@code qualification_front} 不一致, + * 已统一为 {@code qualificationFront}。改名之前提交的记录里存的仍是旧键, + * 直接按新键取值会得到 null,审核通过后反而把已填的封面页丢掉,故这里回退读一次旧键。 + * + * @param snapshot 已解析的快照(可能因旧键而为 null) + * @param snapshotJson 原始快照 JSON,用于回退读取旧键 + * @return 封面页地址;新旧键都没有时为 null + */ + private String qualificationFrontOf(PersonCertSubmitRequest snapshot, String snapshotJson) { + if (Func.isNotEmpty(snapshot.getQualificationFront())) { + return snapshot.getQualificationFront(); + } + try { + return JSON.parseObject(snapshotJson).getString("qualificationUrl"); + } catch (Exception exception) { + // 旧键缺失或 JSON 结构异常都不该阻断写回,没有封面页就不写这一列 + return null; + } + } + + /** + * 把生效快照里的车辆证件字段搬进车辆档案(就地写入同一个 patch 对象) + *

+ * 只搬**证件内容**,不碰两类字段: + *

    + *
  • {@code plateNo}:车牌是这辆车的身份,不是本次提交可以改的内容;
  • + *
  • {@code organizationName} / {@code useDepartment}:内部管理归属, + * 由管理端维护,不该被司机的提交内容覆盖。
  • + *
+ * 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), + * 因此不会把管理端填过、司机端没填的字段清空。 + * + * @param vehicle 待更新的车辆 patch + * @param snapshotJson 该条记录的全量提交内容 + */ + private void applySnapshotToVehicle(TransportVehicle vehicle, String snapshotJson) { + if (Func.isEmpty(snapshotJson)) { + throw new ServiceException("该认证记录没有提交内容,无法写入车辆档案"); + } + VehicleCertSubmitRequest snapshot; + try { + snapshot = JSON.parseObject(snapshotJson, VehicleCertSubmitRequest.class); + } catch (Exception exception) { + throw new ServiceException("该认证记录的提交内容无法解析,未写入车辆档案"); + } + // 字段名对不上时 JSON 会解析成「全是 null」而不报错(早期格式的历史快照就是这种情况), + // 那样写回等于什么也没写。故用证件内容里的关键字段判定「这份快照是否是当前格式」。 + if (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg())) { + throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照)," + + "未写入车辆档案;请重新提交一次认证"); + } + vehicle.setOwnerName(snapshot.getOwnerName()); + vehicle.setPlateColor(snapshot.getPlateColor()); + vehicle.setVehicleType(snapshot.getVehicleType()); + vehicle.setVin(snapshot.getVin()); + vehicle.setOuterLength(snapshot.getOuterLength()); + vehicle.setOuterWidth(snapshot.getOuterWidth()); + vehicle.setOuterHeight(snapshot.getOuterHeight()); + vehicle.setApprovedLoadKg(snapshot.getApprovedLoadKg()); + vehicle.setTractionMassKg(snapshot.getTractionMassKg()); + vehicle.setEnergyType(snapshot.getEnergyType()); + vehicle.setBusinessRelation(snapshot.getBusinessRelation()); + vehicle.setCompulsoryScrapDate(snapshot.getCompulsoryScrapDate()); + vehicle.setCompulsoryScrapLongTerm(snapshot.getCompulsoryScrapLongTerm()); + vehicle.setDrivingLicenseNo(snapshot.getDrivingLicenseNo()); + vehicle.setDrivingLicenseImage(snapshot.getDrivingLicenseImage()); + vehicle.setDrivingLicenseMainBack(snapshot.getDrivingLicenseMainBack()); + vehicle.setDrivingLicenseViceFront(snapshot.getDrivingLicenseViceFront()); + vehicle.setDrivingLicenseViceBack(snapshot.getDrivingLicenseViceBack()); + vehicle.setDrivingLicenseStartDate(snapshot.getDrivingLicenseStartDate()); + vehicle.setDrivingLicenseEndDate(snapshot.getDrivingLicenseEndDate()); + vehicle.setDrivingLicenseLongTerm(snapshot.getDrivingLicenseLongTerm()); + vehicle.setRoadTransportCertNo(snapshot.getRoadTransportCertNo()); + vehicle.setRoadTransportCertImage(snapshot.getRoadTransportCertImage()); + vehicle.setRoadTransportCertStartDate(snapshot.getRoadTransportCertStartDate()); + vehicle.setRoadTransportCertEndDate(snapshot.getRoadTransportCertEndDate()); + vehicle.setRoadTransportCertLongTerm(snapshot.getRoadTransportCertLongTerm()); + vehicle.setRegistrationNo(snapshot.getRegistrationNo()); + vehicle.setRegistrationImage(snapshot.getRegistrationImage()); + } + + /** + * 取该对象当前的生效快照指针 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + * @return 指针值;对象不存在或从未认证通过时返回 null + */ + private Long effectiveRecordIdOf(String bizType, Long bizId) { + if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType)) { + TransportVehicle vehicle = transportVehicleService.getById(bizId); + return vehicle == null ? null : vehicle.getEffectiveCertRecordId(); + } + Driver driver = driverService.getById(bizId); + return driver == null ? null : driver.getEffectiveCertRecordId(); + } + + private TransportCertRecord requireRecord(Long recordId) { + if (Func.isEmpty(recordId)) { + throw new ServiceException("认证记录主键不能为空"); + } + TransportCertRecord record = certRecordService.getById(recordId); + if (record == null) { + throw new ServiceException("认证记录不存在"); + } + return record; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java index c20948a7..4dfe0d51 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonAddressServiceImpl.java @@ -38,6 +38,7 @@ import org.springblade.transport.pojo.entity.CommonAddress; import org.springblade.transport.pojo.vo.CommonAddressRemoveResultVO; import org.springblade.transport.pojo.vo.CommonAddressVO; import org.springblade.transport.service.ICommonAddressService; +import org.springblade.transport.support.TransportBusinessSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -160,18 +161,14 @@ public class CommonAddressServiceImpl extends BaseServiceImpl { CommonAddressExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(address, CommonAddressExportExcel.class)); excel.setSiteCodeDisplay(TYPE_NORMAL.equals(address.getAddressType()) ? NORMAL_SITE_CODE : address.getSiteCode()); + excel.setCreateUserName(UserCache.getUserRealName(address.getCreateUser())); return excel; }).toList(); } private void prepareQuery(CommonAddressVO address) { - if (Objects.equals(address.getAllDept(), 1)) { - if (!AuthUtil.isAdministrator()) { - throw new ServiceException("无权查看全部组织常用地址"); - } - } else { - address.setDeptId(currentDeptId()); - } + List deptIdList = TransportBusinessSupport.resolveDeptFilterIds(address.getAllDept(), address.getDeptId(), "常用地址"); + address.setDeptId(Func.isEmpty(deptIdList) ? null : deptIdList.get(0)); address.setAddressName(trimToNull(address.getAddressName())); address.setAddressType(trimToNull(address.getAddressType())); address.setDetailAddress(trimToNull(address.getDetailAddress())); @@ -300,14 +297,14 @@ public class CommonAddressServiceImpl extends BaseServiceImpl importCommonCargo(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } + // 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。 + // 注意:本方法虽已标注 @Transactional,但循环内把异常吞掉后正常 return, + // Spring 会认为一切成功并提交事务——注解救不了被吞掉的异常。 + // 必须把错误明细收集完后抛异常,回滚才会真正发生。 List failureList = new ArrayList<>(); for (int index = 0; index < data.size(); index++) { CommonCargoExcel excel = data.get(index); @@ -168,14 +171,23 @@ public class CommonCargoServiceImpl extends BaseServiceImpl buildQuery(CommonCargoVO commonCargo) { - TransportBusinessSupport.validateAllDept(commonCargo.getAllDept(), "常用货物"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(CommonCargo::getIsDeleted, 0); - if (!Objects.equals(commonCargo.getAllDept(), 1)) { - queryWrapper.eq(CommonCargo::getDeptId, TransportBusinessSupport.currentDeptId("常用货物")); - } else if (Func.isNotEmpty(commonCargo.getDeptId())) { - queryWrapper.eq(CommonCargo::getDeptId, commonCargo.getDeptId()); + List deptIdList = TransportBusinessSupport.resolveDeptFilterIds(commonCargo.getAllDept(), commonCargo.getDeptId(), "常用货物"); + if (Func.isNotEmpty(deptIdList)) { + if (deptIdList.size() == 1) { + queryWrapper.eq(CommonCargo::getDeptId, deptIdList.get(0)); + } else { + queryWrapper.in(CommonCargo::getDeptId, deptIdList); + } } if (Func.isNotEmpty(commonCargo.getFirstCargoTypeId())) { queryWrapper.eq(CommonCargo::getFirstCargoTypeId, commonCargo.getFirstCargoTypeId()); @@ -301,14 +315,15 @@ public class CommonCargoServiceImpl extends BaseServiceImpl importCommonRoute(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } + // 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。 + // 注意:本方法虽已标注 @Transactional,但循环内把异常吞掉后正常 return, + // Spring 会认为一切成功并提交事务——注解救不了被吞掉的异常。 + // 必须把错误明细收集完后抛异常,回滚才会真正发生。 List failureList = new ArrayList<>(); for (int index = 0; index < data.size(); index++) { CommonRouteImportExcel excel = data.get(index); @@ -142,16 +147,19 @@ public class CommonRouteServiceImpl extends BaseServiceImpl buildQuery(CommonRouteVO commonRoute) { - TransportBusinessSupport.validateAllDept(commonRoute.getAllDept(), "常用线路"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(CommonRoute::getIsDeleted, 0); - if (!Objects.equals(commonRoute.getAllDept(), 1)) { - queryWrapper.eq(CommonRoute::getDeptId, TransportBusinessSupport.currentDeptId("常用线路")); - } else if (Func.isNotEmpty(commonRoute.getDeptId())) { - queryWrapper.eq(CommonRoute::getDeptId, commonRoute.getDeptId()); + List deptIdList = TransportBusinessSupport.resolveDeptFilterIds(commonRoute.getAllDept(), commonRoute.getDeptId(), "常用线路"); + if (Func.isNotEmpty(deptIdList)) { + queryWrapper.eq(CommonRoute::getDeptId, deptIdList.get(0)); } if (Func.isNotEmpty(commonRoute.getRouteCode())) { queryWrapper.eq(CommonRoute::getRouteCode, commonRoute.getRouteCode()); 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 85ee86fc..2ef52bdb 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 @@ -34,7 +34,9 @@ import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; +import org.springblade.system.pojo.entity.Dept; import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.excel.ContractManageExcel; import org.springblade.transport.mapper.ContractManageMapper; @@ -63,6 +65,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 合同管理 服务实现类 @@ -211,6 +215,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl buildQuery(ContractManageVO contractManage) { TransportBusinessSupport.validateAllDept(contractManage.getAllDept(), "合同管理"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(ContractManage::getIsDeleted, 0); - if (!Objects.equals(contractManage.getAllDept(), 1)) { + if (TransportBusinessSupport.isAdministrator()) { + if (Objects.equals(contractManage.getAllDept(), 1) && Func.isNotEmpty(contractManage.getOrganizationId())) { + queryWrapper.eq(ContractManage::getOrganizationId, contractManage.getOrganizationId()); + } + } else if (!Objects.equals(contractManage.getAllDept(), 1)) { Long currentDeptId = TransportBusinessSupport.currentDeptId("合同管理"); Long currentUserId = AuthUtil.getUserId(); queryWrapper.and(wrapper -> wrapper.eq(ContractManage::getOrganizationId, currentDeptId) @@ -522,13 +534,12 @@ public class ContractManageServiceImpl extends BaseServiceImpl latestList = list(Wrappers.lambdaQuery() + .select(ContractManage::getContractNo) + .eq(ContractManage::getIsDeleted, 0) + .eq(ContractManage::getOrganizationId, organizationId) + .likeRight(ContractManage::getContractNo, head) + .orderByDesc(ContractManage::getContractNo) + .last("LIMIT 50")); + Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$"); + int next = 1; + for (ContractManage item : latestList) { + String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo()); + if (Func.isEmpty(contractNo)) { + continue; + } + Matcher matcher = pattern.matcher(contractNo); + if (!matcher.matches()) { + continue; + } + next = Integer.parseInt(matcher.group(1)) + 1; + break; + } + if (next > 999) { + throw new ServiceException("当前部门本年合同流水号已达上限999"); + } + return head + String.format("%03d", next) + "号"; + } + private void refreshArchiveStatusOnApprove(ContractManage contractManage) { if (hasSealArchiveFile(contractManage.getContractFileJson())) { contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED); @@ -624,7 +677,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl> entry : originalApproved.entrySet()) { if (!requestKeys.contains(entry.getKey())) { - throw new ServiceException("已审核通过的合同文件不允许删除"); + throw new ServiceException("已审核通过的用印材料不允许删除"); } } return merged.isEmpty() ? null : JsonUtil.toJson(merged); @@ -699,8 +752,9 @@ public class ContractManageServiceImpl extends BaseServiceImpllambdaQuery() - .eq(ContractManage::getIsDeleted, 0) - .eq(ContractManage::getContractName, contractManage.getContractName()) - .ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId())); - if (count > 0) { - throw new ServiceException("合同名称已存在"); - } - } - private void validateContractPartyCombinationUnique(ContractManage contractManage) { if (Func.isEmpty(contractManage.getContractCategory()) || Func.isEmpty(contractManage.getPartyA()) @@ -851,9 +895,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl importCreditScoreQuantification(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } Map> dataMap = data.stream() .filter(row -> Func.isNotEmpty(trimToNull(row.getTableName()))) @@ -214,6 +216,13 @@ public class CreditScoreQuantificationServiceImpl extends BaseServiceImpl info = asMap(processInfo); - if (!"20".equals(firstText(info, "fdProcessStatus"))) { - return false; - } - if (hasItems(info.get("currentHandlers"))) { - return false; - } - return !hasItems(asMap(info.get("fdTaskInfo")).get("handlerInfos")); - } - - private static boolean hasItems(Object value) { - return value instanceof Collection collection && !collection.isEmpty(); - } - private void applyProcessResult(String customerIdText, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; @@ -145,8 +130,17 @@ public class CustomerArchivePublicProcessService { } else { customerArchiveService.rejectFromProcess(customerId, processorName); } + try { + FR markResult = businessProcessClient.markProcessFinished(processId, approved); + log.info("客商公开页标记业务流程完结 processId={} approved={} result={}", + processId, approved, JSON.toJSONString(markResult)); + } catch (Exception markException) { + log.error("客商公开页标记业务流程完结失败 processId={} approved={}", processId, approved, markException); + } } catch (NumberFormatException e) { log.warn("流程{}但客商id格式不正确:{}", action, customerIdText); + } catch (Exception e) { + log.error("流程{}同步客商状态失败 id={} processId={}", action, customerIdText, processId, e); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index ae13b4b8..f3545757 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -34,6 +34,8 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mk.MkProcessNodeHelper; +import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; @@ -44,11 +46,15 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultErrorVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.system.cache.SysCache; +import org.springblade.system.cache.UserCache; import org.springblade.transport.excel.CustomerArchiveExcel; +import org.springblade.transport.config.BfiProperties; import org.springblade.transport.mapper.CreditRatingStandardMapper; import org.springblade.transport.mapper.CreditScoreCategoryMapper; import org.springblade.transport.mapper.CreditScoreItemMapper; @@ -88,6 +94,7 @@ import org.springblade.transport.pojo.vo.CustomerInvoiceContactVO; import org.springblade.transport.pojo.vo.CustomerInvoiceInfoVO; import org.springblade.transport.pojo.vo.CustomerReceiptAccountVO; import org.springblade.transport.service.ICustomerArchiveService; +import org.springblade.transport.support.KingdeeSyncLockSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -158,6 +165,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl selectCustomerArchivePage(IPage page, CustomerArchiveVO customer) { - List records = baseMapper.selectCustomerArchivePage(page, customer, AuthUtil.getUserId()); + List records = baseMapper.selectCustomerArchivePage(page, customer, currentScopeUserId()); + records.forEach(record -> record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser()))); fillFundUseRisk(records); return page.setRecords(records); } @@ -287,6 +302,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl taskInfo = asStringObjectMap(info.get("fdTaskInfo")); - String currentNode = joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、"); + String currentProcessor = MkProcessNodeHelper.currentProcessor(processInfo); + String currentNode = MkProcessNodeHelper.currentNode(processInfo); if (Func.isEmpty(currentNode) && Func.isEmpty(currentProcessor)) { log.warn("同步客商当前节点跳过,未解析到节点名称或处理人 id={}", id); return false; @@ -396,7 +411,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl approvedList = list(Wrappers.lambdaQuery() .eq(CustomerArchive::getApprovalStatus, APPROVAL_APPROVED) - .eq(CustomerArchive::getCustomerCode, "KS000067") .orderByAsc(CustomerArchive::getId)); // ① 待推送客商:未推送/推送失败,单条失败不中断批次 List pendingList = approvedList.stream() @@ -897,15 +922,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl deptIdList = Func.toLongList(customer.getDeptIds()); - if (Func.isEmpty(deptIdList)) { - throw new ServiceException("所属组织不正确"); - } - customer.setDeptId(deptIdList.get(0)); - } - customer.setDeptName(trimToEmpty(customer.getDeptName())); + applyFixedOwnerDept(customer); customer.setAccessType(Func.isEmpty(customer.getAccessType()) ? ACCESS_TEMPORARY : customer.getAccessType()); customer.setApprovalStatus(Func.isEmpty(customer.getApprovalStatus()) ? APPROVAL_DRAFT : customer.getApprovalStatus()); customer.setKingdeeStatus(Func.isEmpty(customer.getKingdeeStatus()) ? KINGDEE_STATUS_NONE : customer.getKingdeeStatus()); @@ -1015,6 +1032,47 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl invoices = customer.getInvoices(); + if (Func.isEmpty(invoices)) { + return; + } + List taxNos = invoices.stream() + .map(CustomerInvoiceInfoVO::getTaxNo) + .filter(Func::isNotEmpty) + .map(taxNo -> taxNo.trim().toUpperCase()) + .distinct() + .toList(); + if (Func.isEmpty(taxNos)) { + return; + } + List blacklist = bfiService.queryBlacklist("1", taxNos); + BfiBlacklistVO hit = blacklist.stream() + .filter(item -> Func.isNotEmpty(item.getNumber()) && taxNos.contains(item.getNumber().trim().toUpperCase())) + .findFirst() + .orElse(null); + if (hit == null) { + return; + } + String category = Func.isEmpty(hit.getAwrpCombofieldTitle()) ? hit.getAwrpCombofield() : hit.getAwrpCombofieldTitle(); + boolean forbidden = "A".equals(hit.getAwrpCombofield()); + boolean riskWarning = "B".equals(hit.getAwrpCombofield()); + if (forbidden || (riskWarning && bfiProperties.getBlacklist().isBlockRiskWarning())) { + throw new ServiceException(String.format("客商【%s】在国资委黑名单中(失信类别:%s,列入事由:%s),不允许提交准入审批", + customer.getFullName(), category, hit.getAwrpTextareafield())); + } + log.warn("客商命中金蝶黑名单风险警示类,放行,fullName={},taxNos={},失信类别={},列入事由={}", + customer.getFullName(), taxNos, category, hit.getAwrpTextareafield()); + } + private void validateUnique(CustomerArchiveVO customer) { LambdaQueryWrapper unifiedCreditCodeQuery = Wrappers.lambdaQuery() .eq(CustomerArchive::getUnifiedCreditCode, customer.getUnifiedCreditCode()) @@ -1635,6 +1693,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl deptIdList = Func.toLongList(deptIds); + if (Func.isEmpty(deptIdList) || deptIdList.get(0) == null) { + throw new ServiceException("所属组织「" + FIXED_OWNER_DEPT_NAME + "」不正确"); + } + Long deptId = deptIdList.get(0); + customer.setDeptId(deptId); + customer.setDeptIds(String.valueOf(deptId)); + } + private String trimToEmpty(String value) { return value == null ? "" : value.trim(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerBfiConverter.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerBfiConverter.java index 39ec4c15..d4ec9544 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerBfiConverter.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerBfiConverter.java @@ -29,6 +29,8 @@ import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.DictBizCache; +import org.springblade.system.cache.SysCache; +import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.DictBiz; import org.springblade.thirdparty.bfi.constant.BfiConstant; import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerBankEntryDTO; @@ -72,6 +74,10 @@ public class CustomerBfiConverter { param.setName(customer.getFullName()); // 【必填】统一社会信用代码(候选键):个人为身份证号或手机号 param.setSocietycreditcode(customer.getUnifiedCreditCode()); + + // 【必填】创建组织:客商所属组织编码对应的金蝶组织编码 + // param.setCreateorgNumber(this.resolveOrgNumber(customer)); + // 纳税人识别号:与统一社会信用代码一致时推送 param.setTxRegisterNo(customer.getUnifiedCreditCode()); // 法人代表:企业法人 @@ -106,6 +112,24 @@ public class CustomerBfiConverter { return param; } + /** + * 金蝶创建组织编码:客商所属组织部门编码,与金蝶组织编码一致; + * 所属组织不存在或未维护部门编码时抛出异常,阻断推送 + * + * @param customer 客商档案 + * @return 金蝶创建组织编码 + */ + private String resolveOrgNumber(CustomerArchiveVO customer) { + Dept dept = customer.getDeptId() == null ? null : SysCache.getDept(customer.getDeptId()); + if (dept == null) { + throw new ServiceException("推送金蝶失败,客商所属组织不存在,请检查客商所属组织配置"); + } + if (Func.isBlank(dept.getDeptCode())) { + throw new ServiceException("推送金蝶失败,客商所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护部门编码"); + } + return dept.getDeptCode(); + } + /** * 解析分类标准分录客户分组编码:内部组织 1101,其余(外部客商)1102 * @@ -138,7 +162,9 @@ public class CustomerBfiConverter { // 账户名称:传开户行 entry.setAccountname(account.getBankName()); // 开户银行.编码(行号) todo - entry.setBankNumber("001110012009"); + String cnapsCode = account.getCnapsCode(); + // cnapsCode = "001110012009"; + entry.setBankNumber(cnapsCode); // 币种:人民币(CNY) entry.setCurrencyNumber(BfiConstant.CURRENCY_CNY); // 是否默认银行 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java index 551479d3..148ce41e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java @@ -22,14 +22,15 @@ */ package org.springblade.transport.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; -import org.springblade.system.feign.IUserClient; -import org.springblade.system.pojo.entity.User; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; @@ -37,16 +38,14 @@ import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverAppService; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springblade.transport.wrapper.DriverWrapper; import org.springframework.stereotype.Service; -import java.util.ArrayList; +import java.time.LocalDate; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; /** @@ -58,7 +57,7 @@ public class DriverAppServiceImpl implements IDriverAppService { private final IDriverService driverService; private final ITransportVehicleService transportVehicleService; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; @Override public DriverVO currentByPhone(String mobile) { @@ -72,22 +71,15 @@ public class DriverAppServiceImpl implements IDriverAppService { if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { return List.of(); } - List plates = splitPlates(driver.getDrivingVehicle()); + List plates = VehiclePlateSupport.splitPlates(driver.getDrivingVehicle()); if (plates.isEmpty()) { return List.of(); } - // 精确匹配 + 规范化匹配(兼容库中带间隔符/横线的车牌) - List vehicles = transportVehicleService.list(Wrappers.lambdaQuery() - .and(w -> { - w.in(TransportVehicle::getPlateNo, plates); - for (String plate : plates) { - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - } - }) - .orderByDesc(TransportVehicle::getUpdateTime)); + // 精确 + 规范化双条件匹配,统一走共享实现(避免各调用点各自实现而漏掉写法差异) + LambdaQueryWrapper plateQuery = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(plateQuery, plates); + List vehicles = transportVehicleService.list( + plateQuery.orderByDesc(TransportVehicle::getUpdateTime)); // 去重(精确与规范化可能命中同一条) Map uniq = new LinkedHashMap<>(); for (TransportVehicle vehicle : vehicles) { @@ -98,6 +90,11 @@ public class DriverAppServiceImpl implements IDriverAppService { return uniq.values().stream().map(this::toCard).collect(Collectors.toList()); } + @Override + public Driver currentDriverEntity() { + return currentDriver(null); + } + private Driver currentDriver(String mobileHint) { Long userId = AuthUtil.getUserId(); if (userId == null || userId <= 0) { @@ -119,57 +116,66 @@ public class DriverAppServiceImpl implements IDriverAppService { return driver; } - private List splitPlates(String drivingVehicle) { - String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",") - .replace(";", ",").replace("/", ",").replace("|", ","); - Set plates = new LinkedHashSet<>(); - for (String part : Func.toStrList(",", normalized)) { - if (Func.isEmpty(part)) { - continue; - } - String plate = normalizePlate(part); - if (Func.isNotEmpty(plate)) { - plates.add(plate); - } - } - return new ArrayList<>(plates); - } - - /** 车牌规范化:去空格/横线/间隔符并转大写,便于与车辆表关联 */ - private String normalizePlate(String plateNo) { - if (Func.isEmpty(plateNo)) { - return ""; - } - return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT); - } - private DriverVehicleCardVO toCard(TransportVehicle vehicle) { DriverVehicleCardVO card = new DriverVehicleCardVO(); card.setId(vehicle.getId()); card.setPlateNo(vehicle.getPlateNo()); + card.setOwnerName(Func.toStr(vehicle.getOwnerName(), "")); + card.setPlateColor(Func.toStr(vehicle.getPlateColor(), "")); card.setVehicleType(vehicle.getVehicleType()); - card.setLicenseFrontUrl(Func.toStr(vehicle.getDrivingLicenseImage(), "")); - card.setLicenseBackUrl(firstNotEmpty(vehicle.getDrivingLicenseViceFront(), vehicle.getDrivingLicenseMainBack())); - card.setRoadTransportNo(Func.toStr(vehicle.getRoadTransportCertNo(), "")); - card.setRoadTransportUrl(Func.toStr(vehicle.getRoadTransportCertImage(), "")); - card.setVin(""); - card.setEngineNo(""); - if (vehicle.getDrivingLicenseEndDate() != null) { - card.setLicenseValidEnd(vehicle.getDrivingLicenseEndDate().toString()); - } else if (Integer.valueOf(1).equals(vehicle.getDrivingLicenseLongTerm())) { - card.setLicenseValidEnd("长期"); - } else { - card.setLicenseValidEnd(""); - } + card.setVin(Func.toStr(vehicle.getVin(), "")); + card.setOuterLength(vehicle.getOuterLength()); + card.setOuterWidth(vehicle.getOuterWidth()); + card.setOuterHeight(vehicle.getOuterHeight()); + card.setApprovedLoadKg(vehicle.getApprovedLoadKg()); + card.setTractionMassKg(vehicle.getTractionMassKg()); + card.setEnergyType(Func.toStr(vehicle.getEnergyType(), "")); + card.setBusinessRelation(Func.toStr(vehicle.getBusinessRelation(), "")); + card.setCompulsoryScrapDate(dateText(vehicle.getCompulsoryScrapDate(), vehicle.getCompulsoryScrapLongTerm())); + card.setCompulsoryScrapLongTerm(vehicle.getCompulsoryScrapLongTerm()); + card.setDrivingLicenseNo(Func.toStr(vehicle.getDrivingLicenseNo(), "")); + // 行驶证四张图各自独立,不再把「主页反面」与「副页正面」挤在同一个字段里: + // 它们本来就是证件上不同的四面,混在一起会让「重新认证」回填出错的图。 + card.setDrivingLicenseImage(Func.toStr(vehicle.getDrivingLicenseImage(), "")); + card.setDrivingLicenseMainBack(Func.toStr(vehicle.getDrivingLicenseMainBack(), "")); + card.setDrivingLicenseViceFront(Func.toStr(vehicle.getDrivingLicenseViceFront(), "")); + card.setDrivingLicenseViceBack(Func.toStr(vehicle.getDrivingLicenseViceBack(), "")); + card.setDrivingLicenseStartDate(dateText(vehicle.getDrivingLicenseStartDate(), null)); + card.setDrivingLicenseEndDate(dateText(vehicle.getDrivingLicenseEndDate(), vehicle.getDrivingLicenseLongTerm())); + card.setDrivingLicenseLongTerm(vehicle.getDrivingLicenseLongTerm()); + card.setRoadTransportCertNo(Func.toStr(vehicle.getRoadTransportCertNo(), "")); + card.setRoadTransportCertImage(Func.toStr(vehicle.getRoadTransportCertImage(), "")); + card.setRoadTransportCertStartDate(dateText(vehicle.getRoadTransportCertStartDate(), null)); + card.setRoadTransportCertEndDate(dateText(vehicle.getRoadTransportCertEndDate(), vehicle.getRoadTransportCertLongTerm())); + card.setRoadTransportCertLongTerm(vehicle.getRoadTransportCertLongTerm()); + card.setRegistrationNo(Func.toStr(vehicle.getRegistrationNo(), "")); + card.setRegistrationImage(Func.toStr(vehicle.getRegistrationImage(), "")); card.setCertificationStatus(vehicle.getCertificationStatus()); return card; } - private String firstNotEmpty(String first, String second) { - if (Func.isNotEmpty(first)) { - return first; + /** + * 日期字段的下发值:只下发 {@code yyyy-MM-dd} 或空串,**从不下发「长期」这类文字** + *

+ * 卡片是「重新认证」表单的回填来源,值会直接进日期框,所以两条约束必须守住: + *

    + *
  • 长期有效时下发**空串**:长期与否由同名字段 {@code xxxLongTerm} 表达, + * 界面上显示「长期」是前端的事。把「长期」当日期下发,用户关掉长期有效开关后 + * 日期框里会留着一个字面量「长期」,提交时后端按 {@code LocalDate} 反序列化直接报错。
  • + *
  • 长期有效时**不下发止期**:止期在「长期」语义下已作废(管理端勾长期有效时也会清掉它), + * 下发旧值会被表单原样回传、审核通过后又写回车辆行,形成「长期有效 + 一个过期止期」 + * 的脏数据——车辆行上一次长期有效的提交正是这样留下 2040-08-14 的。
  • + *
+ * + * @param date 日期 + * @param longTerm 长期有效标志:1 表示长期 + * @return {@code yyyy-MM-dd} 或空串 + */ + private String dateText(LocalDate date, Integer longTerm) { + if (Integer.valueOf(1).equals(longTerm)) { + return ""; } - return Func.toStr(second, ""); + return date == null ? "" : date.toString(); } /** @@ -180,7 +186,13 @@ public class DriverAppServiceImpl implements IDriverAppService { String selfPhone = resolveSelfPhone(userId); String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim(); if (Func.isNotEmpty(hint)) { - if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) { + if (Func.isEmpty(selfPhone)) { + // 解析不出本人号码时不能拿前端传的 mobile 去查: + // currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。 + // 这里返回空让它退回按 user_id 查,宁可查不到也不越权。 + return null; + } + if (!selfPhone.equals(hint)) { throw new ServiceException("只能查询本人司机档案"); } return hint; @@ -190,21 +202,29 @@ public class DriverAppServiceImpl implements IDriverAppService { private String resolveSelfPhone(Long userId) { String account = AuthUtil.getUserAccount(); - if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + if (isMobileLike(account)) { return account.trim(); } - R result = userClient.userInfoById(userId); - if (result == null || !R.isSuccess(result) || result.getData() == null) { - return Func.isEmpty(account) ? null : account.trim(); - } - User user = result.getData(); - if (Func.isNotEmpty(user.getPhone())) { - return user.getPhone().trim(); - } - if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) { - return user.getAccount().trim(); + // 小程序用户 phone 带 @Sensitive(MOBILE),经 Feign 回来是脱敏串, + // 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。 + R result = mpUserClient.userById(userId); + if (result != null && R.isSuccess(result) && result.getData() != null) { + String userAccount = result.getData().getAccount(); + if (isMobileLike(userAccount)) { + return userAccount.trim(); + } } return Func.isEmpty(account) ? null : account.trim(); } + /** + * 是否是「可作为司机档案手机号使用」的号码串。 + *

+ * 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号 + * (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。 + */ + private boolean isMobileLike(String value) { + return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java new file mode 100644 index 00000000..ca7ef54e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -0,0 +1,574 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.constant.BladeConstant; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.IDriverAppService; +import org.springblade.transport.service.IDriverCertService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.CertificationRuleSupport; +import org.springblade.transport.support.VehiclePlateSupport; +import org.springblade.transport.wrapper.TransportCertRecordWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 司机端认证服务实现 + *

+ * 提交链路只做三件事:把本次提交的内容**原样**存成一条新的待审记录、 + * 把该对象前一条待审记录置为已作废、刷新展示缓存。 + *

+ * ⚠️ 提交**绝不**修改 {@code effective_cert_record_id},也**不覆盖**证件字段, + * 这正是「提交审核不影响车辆正常使用」的实现方式:待审内容只存在于记录表里, + * 在审核通过之前,该对象对外呈现的仍然是旧的生效值。 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class DriverCertServiceImpl implements IDriverCertService { + + /** + * 证件文本字段的长度上限:与车辆表列宽及管理端校验保持一致 + */ + private static final int SHORT_TEXT_MAX_LENGTH = 50; + + /** + * 所有人(行驶证登记的单位名称)长度上限 + */ + private static final int OWNER_NAME_MAX_LENGTH = 100; + + private final IDriverAppService driverAppService; + private final IDriverService driverService; + private final ITransportVehicleService transportVehicleService; + private final ITransportCertRecordService transportCertRecordService; + + /** + * 司机端提交的车辆认证内容校验 + *

+ * 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过。 + * 校验发生在**落快照之前**——待审内容虽然要等审核通过才写进车辆档案,但它已经是一条 + * 不可变记录、并会在通过时按字段写回车辆行({@code applySnapshotToVehicle}), + * 校验只做在「新建车辆」那一条路上,重新认证就成了绕过校验的后门。 + * + * @param request 提交内容 + */ + private void validateCertContent(VehicleCertSubmitRequest request) { + requireText(request.getPlateColor(), "车牌颜色不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getVehicleType(), "车辆类型不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getOwnerName(), "所有人不能为空", OWNER_NAME_MAX_LENGTH); + requireText(request.getEnergyType(), "能源类型不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getBusinessRelation(), "业务关系不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getDrivingLicenseNo(), "行驶证档案编号不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getRoadTransportCertNo(), "道路运输证号不能为空", SHORT_TEXT_MAX_LENGTH); + Optional.ofNullable(request.getVin()) + .filter(Func::isNotEmpty) + .ifPresent(vin -> requireText(vin, "车架号格式不正确", 17)); + requireNonNegative(request.getApprovedLoadKg(), "核定载质量不能为空", "核定载质量不能小于0"); + requireNonNegative(request.getTractionMassKg(), "准牵引总质量不能为空", "准牵引总质量不能小于0"); + requireNonNegative(request.getOuterLength(), "外廓长度不能为空", "外廓长度不能小于0"); + requireNonNegative(request.getOuterWidth(), "外廓宽度不能为空", "外廓宽度不能小于0"); + requireNonNegative(request.getOuterHeight(), "外廓高度不能为空", "外廓高度不能小于0"); + requireDateRange(request.getDrivingLicenseStartDate(), request.getDrivingLicenseEndDate(), + request.getDrivingLicenseLongTerm(), "请选择行驶证有效期", "行驶证有效期止不能早于有效期起"); + requireDateRange(request.getRoadTransportCertStartDate(), request.getRoadTransportCertEndDate(), + request.getRoadTransportCertLongTerm(), "请选择道路运输证有效期", "道路运输证有效期止不能早于有效期起"); + if (Func.isEmpty(request.getCompulsoryScrapDate()) + && !Integer.valueOf(1).equals(request.getCompulsoryScrapLongTerm())) { + throw new ServiceException("请选择强制报废日期"); + } + } + + /** + * 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT + * 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。 + *

+ * 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属), + * 拿它拦住重新认证只会让司机无从下手。 + * + * @param driver 当前司机 + */ + private void requireOrganizationForNewVehicle(Driver driver) { + if (Func.isEmpty(driver.getOrganizationName())) { + throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交"); + } + } + + /** + * 文本必填 + 长度校验 + * + * @param value 待校验值 + * @param emptyText 为空时的提示 + * @param maxLength 长度上限 + */ + private void requireText(String value, String emptyText, int maxLength) { + if (Func.isEmpty(value)) { + throw new ServiceException(emptyText); + } + if (value.trim().length() > maxLength) { + throw new ServiceException(emptyText.replace("不能为空", "") + "不能超过" + maxLength + "字"); + } + } + + /** + * 数值必填 + 非负校验 + * + * @param value 待校验值 + * @param emptyText 为空时的提示 + * @param negativeText 为负数时的提示 + */ + private void requireNonNegative(Integer value, String emptyText, String negativeText) { + if (value == null) { + throw new ServiceException(emptyText); + } + if (value < 0) { + throw new ServiceException(negativeText); + } + } + + /** + * 有效期校验:未勾「长期有效」时**止期**必填;勾了长期有效则止期也不要求 + *

+ * 起期不再是必填:司机端表单不采集它(管理端车辆表单同样只填止期), + * 存量车辆档案里起期列全是空的,强制必填会让每一次重新认证都卡在提交前。 + * 起止先后只在**两端都有值**时才比较——只有止期时没有可比的对象。 + * + * @param start 有效期起,可为空 + * @param end 有效期止 + * @param longTerm 长期有效标志:1 表示长期 + * @param emptyText 缺止期时的提示 + * @param rangeText 区间颠倒时的提示 + */ + private void requireDateRange(LocalDate start, LocalDate end, Integer longTerm, + String emptyText, String rangeText) { + if (Integer.valueOf(1).equals(longTerm)) { + return; + } + if (end == null) { + throw new ServiceException(emptyText); + } + if (start != null && end.isBefore(start)) { + throw new ServiceException(rangeText); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long submitPersonCert(PersonCertSubmitRequest request) { + if (Func.isEmpty(request)) { + throw new ServiceException("认证内容不能为空"); + } + Driver driver = requireCurrentDriver(); + String snapshotJson = JSON.toJSONString(request); + + // 先作废本人上一条待审,再落新记录:任何时刻同一对象至多一条待审。 + // 作废只动「待审」,已通过/已驳回都是终态,尤其不得碰生效快照指着的那条。 + supersedePending(TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId()); + + TransportCertRecord record = newRecord( + TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId(), + Func.toStr(driver.getDriverName(), ""), snapshotJson, driver.getUserId()); + transportCertRecordService.save(record); + + // 展示缓存:让小程序卡片显示「审核中」。可用性判定不看这个字段。 + updateDriverDisplayCache(driver.getId(), TransportCertRecord.STATUS_PENDING, null); + return record.getId(); + } + + @Override + public PersonCertSubmitRequest getEffectivePersonCert() { + Driver driver = requireCurrentDriver(); + // 当前司机实体已加载过,直接读它身上的指针,不再回查一次 + TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId()); + return effective == null + ? null + : JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long submitVehicleCert(VehicleCertSubmitRequest request) { + if (Func.isEmpty(request)) { + throw new ServiceException("认证内容不能为空"); + } + Driver driver = requireCurrentDriver(); + // 校验在 resolveBoundVehicle 之前:它既覆盖「新建车辆」,也覆盖「重新认证」—— + // 后者原先一点校验都不做,内容全靠审核环节兜底 + validateCertContent(request); + TransportVehicle vehicle = resolveBoundVehicle(driver, request); + String snapshotJson = JSON.toJSONString(request); + + supersedePending(TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId()); + + TransportCertRecord record = newRecord( + TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId(), + Func.toStr(vehicle.getPlateNo(), ""), snapshotJson, driver.getUserId()); + transportCertRecordService.save(record); + + updateVehicleDisplayCache(vehicle.getId(), TransportCertRecord.STATUS_PENDING, null); + return record.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteVehicleCert(Long vehicleId) { + if (Func.isEmpty(vehicleId)) { + throw new ServiceException("车辆主键不能为空"); + } + Driver driver = requireCurrentDriver(); + TransportVehicle vehicle = transportVehicleService.getById(vehicleId); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + // 越权校验:与提交链路同一套绑定判定 + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权删除该车辆的认证"); + } + // 逻辑删除车辆;认证记录**不删**——它是不可变快照,且是「曾通过即永久可用」的依据 + return transportVehicleService.deleteLogic(Collections.singletonList(vehicleId)); + } + + @Override + public VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId) { + if (Func.isEmpty(vehicleId)) { + throw new ServiceException("车辆主键不能为空"); + } + TransportVehicle vehicle = transportVehicleService.getById(vehicleId); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + if (!VehiclePlateSupport.isBound(requireCurrentDriver().getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权查看该车辆的认证信息"); + } + TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId()); + return effective == null + ? null + : JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class); + } + + @Override + public List listRecords(String bizType, Long bizId) { + requireValidBizType(bizType); + requireReadableTarget(bizType, bizId); + return transportCertRecordService.selectRecordList(bizType, bizId); + } + + @Override + public TransportCertRecordVO getRecord(Long recordId) { + TransportCertRecord record = transportCertRecordService.getById(recordId); + if (record == null) { + throw new ServiceException("认证记录不存在"); + } + requireReadableTarget(record.getBizType(), record.getBizId()); + return TransportCertRecordWrapper.build().entityVO(record); + } + + /** + * 把该认证对象当前那条待审记录置为已作废 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + */ + private void supersedePending(String bizType, Long bizId) { + TransportCertRecord pending = transportCertRecordService.selectPendingRecord(bizType, bizId); + TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(pending); + if (superseded != null) { + transportCertRecordService.updateById(superseded); + } + } + + /** + * 构造一条待审记录 + *

+ * 状态必须显式设置为待审:框架在插入时会把空的 {@code status} 静默填成 1, + * 而 1 在本表是「已通过」,那会让一次提交凭空变成审核通过。 + * + * @param submitUser 提交人**用户ID**(blade_mp_user.id)——审核页要靠它翻译成姓名。 + * 不要传司机档案ID:两者都是雪花ID、类型也相同,传错不会报任何错, + * 只会让「提交人」一栏显示成一串数字。 + */ + private TransportCertRecord newRecord(String bizType, Long bizId, String bizKey, + String snapshotJson, Long submitUser) { + TransportCertRecord record = new TransportCertRecord(); + record.setBizType(bizType); + record.setBizId(bizId); + record.setBizKey(bizKey); + record.setSnapshotJson(snapshotJson); + record.setSubmitTime(LocalDateTime.now()); + record.setSubmitUser(submitUser); + CertificationRuleSupport.prepareForSubmit(record); + return record; + } + + /** + * 按生效快照指针取那条记录 + *

+ * 只按指针查,不看记录表里任何一条的「最新状态」——这正是生效值与待审值分离的意义。 + * + * @param effectiveRecordId 生效快照指针 + * @return 生效快照记录;指针为空时返回 null + */ + private TransportCertRecord findEffectiveRecord(Long effectiveRecordId) { + return Func.isEmpty(effectiveRecordId) + ? null + : transportCertRecordService.getById(effectiveRecordId); + } + + /** + * 刷新司机侧的「最新提交状态」展示缓存 + *

+ * 只写展示缓存,**不碰生效快照指针**。 + */ + private void updateDriverDisplayCache(Long driverId, Integer status, String rejectReason) { + Driver patch = new Driver(); + patch.setId(driverId); + patch.setCertificationStatus(status); + patch.setCertificationRejectReason(rejectReason); + // 必须带上 driving_vehicle 原值:该列标了 FieldStrategy.ALWAYS, + // 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL, + // 而司机端的车辆列表正是按这一列的车牌匹配出来的(清空即「暂无绑定车辆」)。 + Driver current = driverService.getById(driverId); + if (current != null) { + patch.setDrivingVehicle(current.getDrivingVehicle()); + } + driverService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 重新提交时驳回原因应为空,但框架的 not_null 更新策略会跳过 patch 里的 null, + // 只写 null 到不了 SQL,旧驳回文案会一直挂到审核通过,故单独清一次 + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); + } + } + + /** + * 刷新车辆侧的「最新提交状态」展示缓存 + *

+ * 只写展示缓存,**不碰生效快照指针**,也不覆盖车辆当前的证件字段。 + */ + private void updateVehicleDisplayCache(Long vehicleId, Integer status, String rejectReason) { + TransportVehicle patch = new TransportVehicle(); + patch.setId(vehicleId); + patch.setCertificationStatus(status); + patch.setCertificationRejectReason(rejectReason); + transportVehicleService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 与司机侧同理:null 交给 updateById 到不了 SQL,旧驳回文案会一直挂着 + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } + } + + /** + * 解析本次提交指向的车辆,并校验它确实绑定在当前司机名下 + * + * @param driver 当前司机 + * @param request 提交内容 + * @return 目标车辆 + */ + private TransportVehicle resolveBoundVehicle(Driver driver, VehicleCertSubmitRequest request) { + // 按 id 定位:只能操作本人绑定的车,否则越权 + if (Func.isNotEmpty(request.getId())) { + TransportVehicle vehicle = transportVehicleService.getById(request.getId()); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权操作该车辆的认证"); + } + return vehicle; + } + + String normalized = VehiclePlateSupport.normalize(request.getPlateNo()); + if (Func.isEmpty(normalized)) { + throw new ServiceException("车牌号不能为空"); + } + + // 按车牌定位:已登记的车必须已绑定到本人;未登记的车视为「新增」,就地建档并绑定。 + // 小程序「+ 新增车辆」允许司机直接填车牌,若这里只认已有绑定, + // 新车的提交必然失败——而司机名下还没有这辆车,也就永远无从绑定。 + TransportVehicle existing = findVehicleByAnyPlateForm(normalized); + if (existing != null) { + if (existing.getIsDeleted() != null && existing.getIsDeleted() != 0) { + throw new ServiceException("该车牌已被删除,请联系管理员"); + } + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), existing.getPlateNo())) { + // 已登记但不在本人名下,可能属于其他司机 —— 拒绝,不能顺手改归属 + throw new ServiceException("该车牌已归属其他司机,请联系管理员"); + } + return existing; + } + return createVehicleForDriver(driver, request, normalized); + } + + /** + * 按车牌(容忍写法差异)查已登记的车辆 + * + * @param normalizedPlate 已规范化的车牌 + * @return 车辆;未登记返回 null + */ + private TransportVehicle findVehicleByAnyPlateForm(String normalizedPlate) { + LambdaQueryWrapper plateQuery = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(plateQuery, List.of(normalizedPlate)); + return transportVehicleService.list(plateQuery).stream() + .filter(item -> normalizedPlate.equals(VehiclePlateSupport.normalize(item.getPlateNo()))) + .findFirst() + .orElse(null); + } + + /** + * 为司机新增一辆车并绑定到其名下 + *

+ * 只写入车辆的基本识别信息与本次提交的证件内容;车辆是否「已认证」仍由 + * 生效快照指针决定,故此处建档**不会**让一辆未审核通过的车变得可用。 + * + * @param driver 当前司机 + * @param request 提交内容 + * @param normalizedPlate 已规范化的车牌 + * @return 新建的车辆 + */ + private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request, + String normalizedPlate) { + // 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件 + requireOrganizationForNewVehicle(driver); + + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setPlateNo(normalizedPlate); + // 所属组织取司机档案:车辆表的该列 NOT NULL 且无默认值,不填这条 INSERT 直接报 + // 「Field 'organization_name' doesn't have a default value」(严格模式下必错)。 + // 车辆归到司机所属组织名下,管理端按组织查看时才看得到这台车。 + vehicle.setOrganizationName(driver.getOrganizationName()); + vehicle.setOwnerName(request.getOwnerName()); + vehicle.setPlateColor(request.getPlateColor()); + vehicle.setVehicleType(request.getVehicleType()); + vehicle.setVin(request.getVin()); + vehicle.setOuterLength(request.getOuterLength()); + vehicle.setOuterWidth(request.getOuterWidth()); + vehicle.setOuterHeight(request.getOuterHeight()); + vehicle.setApprovedLoadKg(request.getApprovedLoadKg()); + vehicle.setTractionMassKg(request.getTractionMassKg()); + vehicle.setEnergyType(request.getEnergyType()); + vehicle.setBusinessRelation(request.getBusinessRelation()); + vehicle.setCompulsoryScrapDate(request.getCompulsoryScrapDate()); + vehicle.setCompulsoryScrapLongTerm(request.getCompulsoryScrapLongTerm()); + // 行驶证四张图与车辆表四列一一对应,不再把「主页反面」塞进语义含糊的「副页」字段 + vehicle.setDrivingLicenseNo(request.getDrivingLicenseNo()); + vehicle.setDrivingLicenseImage(request.getDrivingLicenseImage()); + vehicle.setDrivingLicenseMainBack(request.getDrivingLicenseMainBack()); + vehicle.setDrivingLicenseViceFront(request.getDrivingLicenseViceFront()); + vehicle.setDrivingLicenseViceBack(request.getDrivingLicenseViceBack()); + vehicle.setDrivingLicenseStartDate(request.getDrivingLicenseStartDate()); + vehicle.setDrivingLicenseEndDate(request.getDrivingLicenseEndDate()); + vehicle.setDrivingLicenseLongTerm(request.getDrivingLicenseLongTerm()); + vehicle.setRoadTransportCertNo(request.getRoadTransportCertNo()); + vehicle.setRoadTransportCertStartDate(request.getRoadTransportCertStartDate()); + vehicle.setRoadTransportCertEndDate(request.getRoadTransportCertEndDate()); + vehicle.setRoadTransportCertLongTerm(request.getRoadTransportCertLongTerm()); + vehicle.setRoadTransportCertImage(request.getRoadTransportCertImage()); + // 机动车登记编号该列同样 NOT NULL:司机端是选填,缺值写空串而不是 null + vehicle.setRegistrationNo(Func.toStr(request.getRegistrationNo(), "")); + vehicle.setRegistrationImage(request.getRegistrationImage()); + vehicle.setStatus(BladeConstant.DB_STATUS_NORMAL); + transportVehicleService.save(vehicle); + + // 绑定到司机名下,否则本人后续仍无法操作这辆车 + String drivingVehicle = Func.isEmpty(driver.getDrivingVehicle()) + ? normalizedPlate + : driver.getDrivingVehicle() + "," + normalizedPlate; + // 「最多 5 辆 + 车牌格式」在写入前校验:字段只有 varchar(100), + // 超限内容一旦写进去要么报错、要么被静默截断成半个车牌(还会被当成一辆车) + VehiclePlateSupport.validateBoundVehicles(drivingVehicle); + Driver patch = new Driver(); + patch.setId(driver.getId()); + patch.setDrivingVehicle(drivingVehicle); + driverService.updateById(patch); + driver.setDrivingVehicle(drivingVehicle); + return vehicle; + } + + /** + * 校验当前登录用户能否查看该认证对象的记录 + *

+ * 司机只能查自己的;车辆必须是本人绑定的。越权一律拒绝,不返回空列表 + * ——空列表会让调用方以为「没有记录」,而不是「无权查看」。 + */ + private void requireReadableTarget(String bizType, Long bizId) { + if (Func.isEmpty(bizId)) { + throw new ServiceException("认证对象主键不能为空"); + } + Driver driver = requireCurrentDriver(); + if (TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)) { + if (!bizId.equals(driver.getId())) { + throw new ServiceException("无权查看其他司机的认证记录"); + } + return; + } + TransportVehicle vehicle = transportVehicleService.getById(bizId); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权查看该车辆的认证记录"); + } + } + + private void requireValidBizType(String bizType) { + if (!CertificationRuleSupport.isValidBizType(bizType)) { + throw new ServiceException("认证对象类型不正确"); + } + } + + private Driver requireCurrentDriver() { + Driver driver = driverAppService.currentDriverEntity(); + if (driver == null) { + throw new ServiceException("未找到当前登录用户对应的司机档案"); + } + return driver; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index ee887b7c..97e7186b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -34,21 +34,21 @@ import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.system.cache.UserCache; import org.springblade.core.tool.utils.Func; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.ISysClient; -import org.springblade.system.feign.IUserClient; -import org.springblade.system.pojo.entity.User; -import org.springblade.system.pojo.entity.UserInfo; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springblade.system.pojo.enums.UserSex; -import org.springblade.system.pojo.enums.UserType; import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.mapper.DriverMapper; -import org.springblade.transport.mapper.TransportVehicleMapper; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,21 +68,22 @@ public class DriverServiceImpl extends BaseServiceImpl imp private static final int NAME_MAX_LENGTH = 10; private static final int ID_CARD_MAX_LENGTH = 18; private static final int MOBILE_MAX_LENGTH = 20; - private static final int VEHICLE_NO_MAX_LENGTH = 30; + private static final int VEHICLE_NO_MAX_LENGTH = 100; private static final int SHORT_TEXT_MAX_LENGTH = 50; private static final int ADDRESS_MAX_LENGTH = 200; private static final int REMARK_MAX_LENGTH = 200; private static final int DEFAULT_ENABLED_STATUS = 1; private static final int DEFAULT_FALSE = 0; - private final TransportVehicleMapper transportVehicleMapper; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; private final ISysClient sysClient; @Override public IPage selectDriverPage(IPage page, DriverVO driver) { prepareQuery(driver); - return page.setRecords(baseMapper.selectDriverPage(page, driver)); + List records = baseMapper.selectDriverPage(page, driver); + records.forEach(record -> record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser()))); + return page.setRecords(records); } @Override @@ -133,6 +134,8 @@ public class DriverServiceImpl extends BaseServiceImpl imp public List exportDriver(Wrapper queryWrapper) { return list(queryWrapper).stream().map(driver -> { DriverExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(driver, DriverExcel.class)); + excel.setCreateUserName(UserCache.getUserRealName(driver.getCreateUser())); + excel.setCreateTime(driver.getCreateTime()); excel.setAddress((driver.getAddressRegion() == null ? "" : driver.getAddressRegion()) + (driver.getAddress() == null ? "" : driver.getAddress())); return excel; @@ -250,15 +253,11 @@ public class DriverServiceImpl extends BaseServiceImpl imp validateLength(driver.getIdCardNo(), ID_CARD_MAX_LENGTH, "身份证号不能超过18字"); validateLength(driver.getMobile(), MOBILE_MAX_LENGTH, "手机号不能超过20字"); validateLength(driver.getEmergencyContactMobile(), MOBILE_MAX_LENGTH, "紧急联系人手机号不能超过20字"); - validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过30字"); - if (Func.isNotEmpty(driver.getDrivingVehicle())) { - Long vehicleCount = transportVehicleMapper.selectCount(Wrappers.lambdaQuery() - .eq(TransportVehicle::getIsDeleted, 0) - .eq(TransportVehicle::getPlateNo, driver.getDrivingVehicle())); - if (vehicleCount == null || vehicleCount <= 0) { - throw new ServiceException("驾驶车辆不存在,请重新选择"); - } - } + validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过100字"); + // 「驾驶车辆」是**逗号并列的多辆车**(小程序每绑一辆就追加一个车牌), + // 不是一个车牌。此前这里拿整串去车牌表精确查,绑了两辆车的司机 + // 在司机管理页一保存就会撞上「驾驶车辆不存在」——校验口径必须与写入方一致。 + VehiclePlateSupport.validateBoundVehicles(driver.getDrivingVehicle()); validateLength(driver.getDrivingLicenseNo(), SHORT_TEXT_MAX_LENGTH, "驾驶证档案编号不能超过50字"); validateLength(driver.getQualificationNo(), SHORT_TEXT_MAX_LENGTH, "资格证号不能超过50字"); validateLength(driver.getOrganizationName(), SHORT_TEXT_MAX_LENGTH, "所属组织不能超过50字"); @@ -310,13 +309,13 @@ public class DriverServiceImpl extends BaseServiceImpl imp tenantId = driver.getTenantId(); } if (Func.isEmpty(tenantId)) { - throw new ServiceException("租户信息为空,无法同步系统用户"); + throw new ServiceException("租户信息为空,无法同步小程序用户"); } String mobile = driver.getMobile(); if (Func.isEmpty(mobile) || mobile.length() < 6) { - throw new ServiceException("手机号长度不足,无法同步系统用户"); + throw new ServiceException("手机号长度不足,无法同步小程序用户"); } - // 编辑时前端可能不传 userId,从库中补齐已关联用户 + // 编辑时前端可能不传 userId,从库中补齐已关联小程序用户 if (Func.isEmpty(driver.getUserId()) && Func.isNotEmpty(driver.getId())) { Driver dbDriver = getById(driver.getId()); if (dbDriver != null && Func.isNotEmpty(dbDriver.getUserId())) { @@ -324,95 +323,83 @@ public class DriverServiceImpl extends BaseServiceImpl imp } } - R roleResult = sysClient.getRoleIdByAlias(tenantId, "driver"); - if (roleResult == null || !R.isSuccess(roleResult) || Func.isEmpty(roleResult.getData())) { - throw new ServiceException("未配置角色别名为driver的角色,请先在系统角色中维护"); - } - String roleId = roleResult.getData(); - + // 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步 + String deptId = null; R deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName()); - if (deptResult == null || !R.isSuccess(deptResult) || Func.isEmpty(deptResult.getData())) { - throw new ServiceException("所属组织未匹配到系统部门,无法同步用户"); + if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) { + deptId = deptResult.getData(); } - String deptId = deptResult.getData(); - User existing = findExistingUser(driver, tenantId, mobile); + MpUser existing = findExistingMpUser(driver, tenantId, mobile); if (existing == null || Func.isEmpty(existing.getId())) { - // 司机未关联有效用户时新建;密码为手机号后6位 - createAndLinkDriverUser(driver, tenantId, mobile, roleId, deptId); + createAndLinkDriverMpUser(driver, tenantId, mobile, deptId); } else { - updateAndLinkDriverUser(driver, existing, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existing, tenantId, mobile, deptId); } } - private User findExistingUser(Driver driver, String tenantId, String mobile) { + private MpUser findExistingMpUser(Driver driver, String tenantId, String mobile) { if (Func.isNotEmpty(driver.getUserId())) { - R byId = userClient.userInfoById(driver.getUserId()); + R byId = mpUserClient.userById(driver.getUserId()); if (byId != null && R.isSuccess(byId) && byId.getData() != null && Func.isNotEmpty(byId.getData().getId())) { return byId.getData(); } - // 关联的用户已不存在,清空后按手机号重建 + // 关联的小程序用户已不存在,清空后按手机号重建 driver.setUserId(null); } - return findExistingUserByAccountOrPhone(tenantId, mobile); + return findExistingMpUserByPhone(tenantId, mobile); } - private User findExistingUserByAccountOrPhone(String tenantId, String mobile) { - R byAccount = userClient.userByAccount(tenantId, mobile); - if (byAccount != null && R.isSuccess(byAccount) && byAccount.getData() != null - && Func.isNotEmpty(byAccount.getData().getId())) { - return byAccount.getData(); - } - R byPhone = userClient.userInfoByPhone(tenantId, mobile, UserType.WEB.getName()); + private MpUser findExistingMpUserByPhone(String tenantId, String mobile) { + R byPhone = mpUserClient.userByPhone(tenantId, mobile); if (byPhone != null && R.isSuccess(byPhone) && byPhone.getData() != null - && byPhone.getData().getUser() != null && Func.isNotEmpty(byPhone.getData().getUser().getId())) { - return byPhone.getData().getUser(); + && Func.isNotEmpty(byPhone.getData().getId())) { + return byPhone.getData(); } return null; } - private void createAndLinkDriverUser(Driver driver, String tenantId, String mobile, String roleId, String deptId) { - User user = buildSyncUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId); - user.setPassword(mobile.substring(mobile.length() - 6)); - user.setUserType(UserType.WEB.getCategory()); + private void createAndLinkDriverMpUser(Driver driver, String tenantId, String mobile, String deptId) { + MpUser user = buildSyncMpUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(), + deptId, MpUserIdentity.DRIVER.getCode()); - R saveResult = null; + R saveResult; try { - saveResult = userClient.saveUser(user); + saveResult = mpUserClient.saveUser(user); } catch (Exception ex) { - User existed = findExistingUserByAccountOrPhone(tenantId, mobile); + MpUser existed = findExistingMpUserByPhone(tenantId, mobile); if (existed != null) { - updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId); return; } - throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步系统用户失败"); + throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步小程序用户失败"); } if (!isFeignSuccess(saveResult)) { - User existed = findExistingUserByAccountOrPhone(tenantId, mobile); + MpUser existed = findExistingMpUserByPhone(tenantId, mobile); if (existed != null) { - updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId); return; } - throw new ServiceException(resolveFeignError(saveResult, "同步系统用户失败")); + throw new ServiceException(resolveFeignError(saveResult, "同步小程序用户失败")); } - R created = userClient.userByAccount(tenantId, mobile); - User createdUser = (created != null && R.isSuccess(created)) ? created.getData() : null; + MpUser createdUser = findExistingMpUserByPhone(tenantId, mobile); if (createdUser == null || Func.isEmpty(createdUser.getId())) { - createdUser = findExistingUserByAccountOrPhone(tenantId, mobile); - } - if (createdUser == null || Func.isEmpty(createdUser.getId())) { - throw new ServiceException("系统用户创建成功但无法回查用户ID"); + throw new ServiceException("小程序用户创建成功但无法回查用户ID"); } linkDriverUserId(driver, createdUser.getId()); } - private void updateAndLinkDriverUser(Driver driver, User existing, String tenantId, String mobile, String roleId, String deptId) { - User user = buildSyncUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId); - R updateResult = userClient.updateUser(user); - // updateById 无字段变化时可能返回 false,但业务上仍视为成功,以接口 code 为准 + private void updateAndLinkDriverMpUser(Driver driver, MpUser existing, String tenantId, String mobile, String deptId) { + // 保留已有管理员等身份,确保包含司机身份 + String identities = MpUserIdentity.merge(existing.getIdentities(), MpUserIdentity.DRIVER.getCode()); + MpUser user = buildSyncMpUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(), + deptId, identities); + // 更新时不覆盖 openid + user.setOpenid(null); + R updateResult = mpUserClient.updateUser(user); if (!isFeignSuccess(updateResult)) { - throw new ServiceException(resolveFeignError(updateResult, "同步更新系统用户失败")); + throw new ServiceException(resolveFeignError(updateResult, "同步更新小程序用户失败")); } linkDriverUserId(driver, existing.getId()); } @@ -433,9 +420,9 @@ public class DriverServiceImpl extends BaseServiceImpl imp return msg; } - private User buildSyncUser(Long userId, String tenantId, String mobile, String driverName, String gender, - String roleId, String deptId) { - User user = new User(); + private MpUser buildSyncMpUser(Long userId, String tenantId, String mobile, String driverName, String gender, + String deptId, String identities) { + MpUser user = new MpUser(); user.setId(userId); user.setTenantId(tenantId); user.setAccount(mobile); @@ -443,8 +430,8 @@ public class DriverServiceImpl extends BaseServiceImpl imp user.setName(driverName); user.setRealName(driverName); user.setSex(UserSex.getCodeByName(gender)); - user.setRoleId(roleId); user.setDeptId(deptId); + user.setIdentities(identities); return user; } 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 2181abcf..9158ce0d 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 @@ -31,12 +31,23 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.constant.BladeConstant; +import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.CertificationGate; +import org.springblade.transport.support.VehiclePlateSupport; import org.springblade.transport.pojo.entity.ProcessConfig; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; @@ -48,6 +59,7 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IProcessConfigService; @@ -67,8 +79,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -107,11 +119,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final DateTimeFormatter DATE_DOT_SHORT = DateTimeFormatter.ofPattern("MM.dd"); private static final DateTimeFormatter TIME_HM = DateTimeFormatter.ofPattern("HH:mm"); + /** 被异常阻断时的不可打卡原因(司机端展示) */ + private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡"; + private final IWaybillService waybillService; private final IDriverService driverService; + private final IMpUserClient mpUserClient; private final IProcessConfigService processConfigService; + private final ITransportVehicleService transportVehicleService; + private final CertificationGate certificationGate; private final WaybillEnroutePunchMapper enroutePunchMapper; private final WaybillNodePunchMapper nodePunchMapper; + private final ExceptionDisposalMapper exceptionDisposalMapper; @Override public DriverWaybillCardVO currentTask() { @@ -225,6 +244,52 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return toCard(normalizeAcceptStatus(waybill), true); } + @Override + public WaybillTrackVO track(Long id) { + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + LocalDate[] range = resolveTrackDateRange(waybill); + return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString()); + } + + /** + * 按打卡记录 / 计划时间推导轨迹查询起止日期 + */ + private LocalDate[] resolveTrackDateRange(Waybill waybill) { + LocalDate today = LocalDate.now(); + List punchDates = new ArrayList<>(); + nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybill.getId()) + .isNotNull(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getPunchTime)).forEach(punch -> { + if (punch.getPunchTime() != null) { + punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + } + }); + enroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybill.getId()) + .isNotNull(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getPunchTime)).forEach(punch -> { + if (punch.getPunchTime() != null) { + punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + } + }); + punchDates.sort(LocalDate::compareTo); + if (punchDates.size() > 1) { + return new LocalDate[]{punchDates.get(0), punchDates.get(punchDates.size() - 1)}; + } + if (punchDates.size() == 1) { + return new LocalDate[]{punchDates.get(0), today}; + } + if (waybill.getEstimatedStartTime() != null) { + return new LocalDate[]{waybill.getEstimatedStartTime(), today}; + } + if (waybill.getStartDate() != null) { + return new LocalDate[]{waybill.getStartDate(), today}; + } + return new LocalDate[]{today.minusDays(3), today}; + } + @Override public DriverWaybillCardVO detailPunchSnapshot(Long id) { if (id == null) { @@ -247,16 +312,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); + String processJson = resolveProcessJson(waybill); Date lastPunchAt = findLastPunchTime(waybill.getId()); WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin( - resolveProcessJson(waybill), waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); + processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); if (!decision.punchEnabled()) { throw new ServiceException("该运单未启用在途打卡"); } if (decision.doneToday()) { throw new ServiceException("今日已完成在途打卡,不可重复打卡"); } + // 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束 + assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.transitNodeKey(processJson)); // 不做频次/时段门禁;定位按前端是否传参落库 + assertPunchPhoto(dto.getPhoto() == null ? null : List.of(dto.getPhoto())); Date now = new Date(); WaybillEnroutePunch punch = new WaybillEnroutePunch(); @@ -292,7 +361,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); - Map nodeCfg = findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); + String processJson = resolveProcessJson(waybill); + Map nodeCfg = findPunchNodeConfig(processJson, nodeCode); if (nodeCfg == null) { throw new ServiceException("该节点未启用打卡或不存在"); } @@ -303,6 +373,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (existed != null) { throw new ServiceException("该节点已打卡,不可重复打卡"); } + assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg)); + assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg); + // photoItems 是新版小程序带的类型化照片;photos 是旧版仍在提交的裸 URL 列表, + // 类型只能按下标回推过程配置的凭证类型。两者并存是为了不强制升级小程序。 + boolean typedPhotoItems = dto.getPhotoItems() != null && !dto.getPhotoItems().isEmpty(); + List> photoItems = typedPhotoItems + ? typedNodePunchPhotos(dto.getPhotoItems(), WaybillProcessSupport.nodePhotoTypes(nodeCfg)) + : legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes")); + assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems); Date now = new Date(); WaybillNodePunch punch = new WaybillNodePunch(); @@ -321,24 +400,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim()); } - if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) { - List urls = dto.getPhotos().stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(Func::isNotEmpty) - .collect(Collectors.toList()); - if (!urls.isEmpty()) { - List types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"); - List> photoItems = new ArrayList<>(); - for (int i = 0; i < urls.size(); i++) { - Map item = new LinkedHashMap<>(); - String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1)); - item.put("type", type); - item.put("url", urls.get(i)); - photoItems.add(item); - } - punch.setPhotos(JsonUtil.toJson(photoItems)); - } + if (!photoItems.isEmpty()) { + punch.setPhotos(JsonUtil.toJson(photoItems)); } punch.setWeight(trimOrNull(dto.getWeight())); punch.setVolume(trimOrNull(dto.getVolume())); @@ -355,6 +418,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Transactional(rollbackFor = Exception.class) public boolean accept(Long id) { Driver driver = requireCurrentDriver(); + // 认证门槛:司机本人须已认证,且承运车辆须已认证。 + // 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。 + assertCertified(driver); Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); waybillService.syncDriverAcceptState(waybill); assertAcceptable(waybill); @@ -368,6 +434,40 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return waybillService.updateById(waybill); } + /** + * 接单前的认证门槛校验 + *

+ * 司机的生效快照由其档案持有;车辆按司机绑定的车牌取。 + * 任一不满足即拒绝接单,并给出可读原因。 + * + * @param driver 当前司机 + */ + private void assertCertified(Driver driver) { + if (!certificationGate.isDriverAvailable(driver.getEffectiveCertRecordId())) { + throw new ServiceException(certificationGate.driverUnavailableReason()); + } + if (currentBoundPlates(driver).isEmpty()) { + return; + } + // 用共享的车牌匹配条件:只做精确 IN 会漏掉库中带间隔符/大小写差异的车牌, + // 那会把已认证的司机误判为未认证而拒绝接单 + LambdaQueryWrapper plateQuery = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(plateQuery, currentBoundPlates(driver)); + List boundVehicles = transportVehicleService.list(plateQuery); + // 逐车取不可用原因:停用与未认证要分开报,否则「车已认证但被停用」 + // 会被报成「认证没通过」,把人引去做无用的重新认证 + String unavailableReason = boundVehicles.stream() + .map(vehicle -> certificationGate.vehicleUnavailableReason( + vehicle.getStatus() != null && vehicle.getStatus() == BladeConstant.DB_STATUS_NORMAL, + vehicle.getEffectiveCertRecordId())) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + if (unavailableReason != null) { + throw new ServiceException(unavailableReason); + } + } + @Override @Transactional(rollbackFor = Exception.class) public boolean reject(Long id, String reason) { @@ -404,6 +504,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); } + // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 + assertNoUnresolvedException(waybill.getId()); // 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单 return waybillService.completeWithoutDeptCheck(waybill.getId()); } @@ -539,7 +641,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 当前登录司机:优先 userId,其次 JWT 账号(手机号)匹配 driver.mobile + * 当前登录司机:优先 userId,其次手机号(JWT 账号 / 小程序用户 account)匹配 driver.mobile */ private Driver currentDriver() { Long userId = AuthUtil.getUserId(); @@ -552,37 +654,38 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (driver != null) { return driver; } - String account = AuthUtil.getUserAccount(); - if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + String phone = resolveSelfPhone(userId); + if (Func.isNotEmpty(phone)) { return driverService.getOne(Wrappers.lambdaQuery() - .eq(Driver::getMobile, account.trim()) + .eq(Driver::getMobile, phone) .orderByDesc(Driver::getUpdateTime) .last("LIMIT 1")); } return null; } - private List splitPlates(String drivingVehicle) { - String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",") - .replace(";", ",").replace("/", ",").replace("|", ","); - Set plates = new LinkedHashSet<>(); - for (String part : Func.toStrList(",", normalized)) { - if (Func.isEmpty(part)) { - continue; - } - String plate = normalizePlate(part); - if (Func.isNotEmpty(plate)) { - plates.add(plate); - } + /** + * 解析当前小程序用户可用于匹配司机档案的手机号 + */ + private String resolveSelfPhone(Long userId) { + String account = AuthUtil.getUserAccount(); + if (isMobileLike(account)) { + return account.trim(); } - return new ArrayList<>(plates); + R result = mpUserClient.userById(userId); + if (result != null && R.isSuccess(result) && result.getData() != null + && isMobileLike(result.getData().getAccount())) { + return result.getData().getAccount().trim(); + } + return Func.isEmpty(account) ? null : account.trim(); } - private String normalizePlate(String plateNo) { - if (Func.isEmpty(plateNo)) { - return ""; - } - return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT); + private boolean isMobileLike(String value) { + return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); + } + + private List splitPlates(String drivingVehicle) { + return VehiclePlateSupport.splitPlates(drivingVehicle); } private int normalizePreviewSize(Integer size) { @@ -746,11 +849,266 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .anyMatch(projectId::equals); } + /** + * 该运单未了结(未达终态)的异常处置所属节点 key。 + *

+ * 含空串表示存在未指明节点的异常(从全局入口上报),此类异常阻断该运单全部尚未打卡的节点。 + */ + @Override + public Map findPunchNode(Long waybillId, String nodeCode) { + if (waybillId == null || Func.isEmpty(nodeCode)) { + return null; + } + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + return null; + } + return findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); + } + + @Override + public boolean isNodePunched(Long waybillId, String nodeCode) { + if (waybillId == null || Func.isEmpty(nodeCode)) { + return false; + } + // 不复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置 key 比不出来 + return nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)) + .stream() + .anyMatch(punch -> WaybillProcessSupport.sameNodeCode(nodeCode, punch.getNodeCode())); + } + + @Override + public void assertCurrentDriverOwnsWaybill(Long waybillId) { + // 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定 + Driver driver = requireCurrentDriver(); + loadDriverWaybill(waybillId, currentBoundPlates(driver)); + } + + /** + * 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。 + *

+ * 打卡门禁与司机端的「异常」标签共用此口径,故两者永远一致: + * 只要标签还在,该节点就是被拦的;异常结案后标签与阻断同时消失。 + *

+ * 不含空串——未指明节点的异常没有对应节点,无从挂标签; + * 它是否阻断全单由 {@link #blockingException} 另行判定。 + */ + private Set unresolvedExceptionNodeCodes(Long waybillId) { + if (waybillId == null) { + return Collections.emptySet(); + } + return unresolvedExceptions(waybillId).stream() + .map(item -> Func.toStr(item.getNodeCode(), "").trim()) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + } + + /** + * 该运单未处理完成异常的节点 key 列表,**保留空串**(表示存在未指明节点的异常)。 + *

+ * 门禁判定需要知道「有没有未指明节点的异常」,故不能用上面那个已过滤空串的集合。 + */ + private List unresolvedExceptionNodeCodeList(Long waybillId) { + return unresolvedExceptions(waybillId).stream() + .map(item -> Func.toStr(item.getNodeCode(), "").trim()) + .collect(Collectors.toList()); + } + + /** 该运单未了结(未达终态)的异常处置,按上报时间升序 */ + private List unresolvedExceptions(Long waybillId) { + if (waybillId == null) { + return Collections.emptyList(); + } + return exceptionDisposalMapper.selectList(Wrappers.lambdaQuery() + .eq(ExceptionDisposal::getWaybillId, waybillId) + .eq(ExceptionDisposal::getIsDeleted, 0) + .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES) + .orderByAsc(ExceptionDisposal::getReportTime) + .orderByAsc(ExceptionDisposal::getId)); + } + + /** + * 取「正在阻断该运单打卡」的那条异常处置。 + *

+ * 多条未了结异常时取阻断区间最大的那条(最早节点优先、未指明节点最优先), + * 与 {@link WaybillProcessSupport#blockedNodeKeys} 的判定口径一致。 + */ + private ExceptionDisposal blockingException(Waybill waybill, String processJson) { + String earliestCode = WaybillProcessSupport.earliestBlockingNodeCode( + processJson, unresolvedExceptionNodeCodeList(waybill.getId())); + if (earliestCode == null) { + return null; + } + return unresolvedExceptions(waybill.getId()).stream() + .filter(item -> earliestCode.equals(Func.toStr(item.getNodeCode(), "").trim())) + .findFirst() + .orElse(null); + } + + /** + * 断言指定节点未被异常门禁阻断。 + *

+ * 普通节点:阻断范围是未了结异常所在节点及其后的节点。 + * 在途节点:该运单存在任何未了结异常即被阻断,与异常落在哪个节点无关。 + * 前端禁用只是体验层,这里才是强制点。 + */ + private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) { + Set blocked = WaybillProcessSupport.blockedNodeKeys( + processJson, unresolvedExceptionNodeCodeList(waybillId)); + if (Func.isNotEmpty(nodeCode) && blocked.contains(nodeCode)) { + throw new ServiceException("该运单存在未处理异常,处理完成后才能继续打卡"); + } + } + + /** + * 断言该节点的前置打卡节点都已完成(顺序门禁)。 + *

+ * 与异常门禁并列的第二道约束:两者解除条件不同,故分别校验、各自报错, + * 司机才知道该等一等(顺序)还是该找人处理(异常)。 + */ + private void assertPrerequisitePunched(String processJson, Long waybillId, Map nodeCfg) { + // ⚠️ 不能复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置的 key 比不出来 + Set punched = nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)) + .stream() + .map(punch -> Func.toStr(punch.getNodeCode(), "").trim()) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg)); + if (!unfinished.isEmpty()) { + throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + } + } + + /** + * 断言打卡照片已上传(在途链路)。 + *

+ * 在途只有一张照片、不带类型,故只判有没有;此处拦的是绕过客户端直接调接口的情况 + * ——前端的必填提示不构成约束。 + */ + private void assertPunchPhoto(List photos) { + boolean hasPhoto = photos != null && photos.stream() + .filter(Objects::nonNull) + .map(String::trim) + .anyMatch(Func::isNotEmpty); + if (!hasPhoto) { + throw new ServiceException("请上传打卡照片"); + } + } + + /** + * 节点打卡的显式类型照片:类型由提交方给出,超出该节点取值域的直接拒绝。 + *

+ * 取值域是固定的「打卡照片」加过程配置勾选的凭证类型;类型是照片分组的唯一依据, + * 放进来路不明的类型会让回显分组错乱。 + */ + private List> typedNodePunchPhotos(List items, List allowedTypes) { + List> out = new ArrayList<>(); + for (PunchPhotoDTO item : items) { + if (item == null) { + continue; + } + String url = Func.toStr(item.getUrl(), "").trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = Func.toStr(item.getType(), "").trim(); + if (Func.isEmpty(type)) { + throw new ServiceException("照片类型不能为空"); + } + if (!allowedTypes.contains(type)) { + throw new ServiceException("照片类型「" + type + "」不属于该节点"); + } + out.add(nodePunchPhotoItem(type, url)); + } + return out; + } + + /** + * 节点打卡的旧版照片:裸 URL 列表,类型按下标取该节点的凭证类型,超出补「凭证N」。 + */ + private List> legacyNodePunchPhotos(List photos, List voucherTypes) { + List> out = new ArrayList<>(); + if (photos == null || photos.isEmpty()) { + return out; + } + List urls = photos.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(Func::isNotEmpty) + .collect(Collectors.toList()); + for (int i = 0; i < urls.size(); i++) { + String type = i < voucherTypes.size() ? voucherTypes.get(i) : ("凭证" + (i + 1)); + out.add(nodePunchPhotoItem(type, urls.get(i))); + } + return out; + } + + private Map nodePunchPhotoItem(String type, String url) { + Map item = new LinkedHashMap<>(); + item.put("type", type); + item.put("url", url); + return item; + } + + /** + * 断言节点打卡照片满足类型口径。 + *

+ * 每个类型至多 9 张,按类型各自计数,不是节点合计。 + * 新版提交(photoItems)还需每个类型至少 1 张,含固定的「打卡照片」; + * 旧版只提交裸 URL 列表,类型按下标回推、表达不出「打卡照片」, + * 对它套用「每个类型至少 1 张」会让所有未升级的小程序提交失败,故旧版仍只要求至少有 1 张。 + */ + private void assertNodePunchPhotos(List> photoItems, Map nodeCfg, + boolean typedPhotoItems) { + Map countByType = countPunchPhotoByType(photoItems); + for (Map.Entry entry : countByType.entrySet()) { + if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) { + throw new ServiceException("「" + entry.getKey() + "」最多上传 " + + WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片"); + } + } + if (!typedPhotoItems) { + if (countByType.isEmpty()) { + throw new ServiceException("请上传打卡照片"); + } + return; + } + for (String type : WaybillProcessSupport.nodePhotoTypes(nodeCfg)) { + if (countByType.getOrDefault(type, 0) < 1) { + throw new ServiceException("「" + type + "」至少上传 1 张照片"); + } + } + } + + private Map countPunchPhotoByType(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + return countByType; + } + + /** + * 断言该运单没有未了结的异常。 + *

+ * 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从 + * 「完成运单」绕过打卡门禁。 + */ + private void assertNoUnresolvedException(Long waybillId) { + if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) { + throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单"); + } + } + /** * 组装过程配置 punch=是 的打卡节点列表。 *

- * 未打卡节点均可打,不做顺序/时段门禁; - * 默认展开:第一个未完成的可见节点。 + * 两道跨节点门禁:异常门禁(未了结异常所在节点及其后节点不可打卡,在途另按运单级—— + * 该运单有任何未了结异常就不可打卡)、顺序门禁(前置打卡节点未完成则不可打卡,在途豁免)。 + * 默认展开:第一个未完成、且未被异常阻断的可见节点。 * 非在途节点「已打卡」以节点打卡表为准。 * 节点字段(定位/货量/凭证)取自动态过程配置。 */ @@ -837,8 +1195,87 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodes.add(vo); } + // 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡; + // 在途另按运单级——该运单只要还有未了结异常,在途就不可打卡。 + // 已打卡节点不受影响,仍可展开查看回填内容。 + ExceptionDisposal blocker = blockingException(waybill, processJson); + Set blockedKeys = WaybillProcessSupport.blockedNodeKeys( + processJson, unresolvedExceptionNodeCodeList(waybill.getId())); + if (!blockedKeys.isEmpty()) { + for (DriverPunchNodeVO vo : nodes) { + if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) { + continue; + } + vo.setBlocked(true); + vo.setActionable(false); + if (blocker != null) { + vo.setBlockedExceptionId(blocker.getId()); + } + vo.setLockReason(LOCK_REASON_EXCEPTION); + } + } + + // 顺序门禁:前面还有打卡节点没打,本节点就不能打(在途豁免)。 + // 与异常门禁独立:两者可同时成立,也可只成立一个。 + Set punchedCodes = new HashSet<>(); for (DriverPunchNodeVO vo : nodes) { - if (!Boolean.TRUE.equals(vo.getDone())) { + if (Boolean.TRUE.equals(vo.getDone())) { + punchedCodes.add(vo.getKey()); + } + } + for (DriverPunchNodeVO vo : nodes) { + if (Boolean.TRUE.equals(vo.getDone())) { + continue; + } + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + processJson, punchedCodes, vo.getKey()); + if (unfinished.isEmpty()) { + continue; + } + vo.setBlockedByOrder(true); + vo.setActionable(false); + vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + } + + // 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用); + // 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。 + for (DriverPunchNodeVO vo : nodes) { + boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder()); + vo.setExpandable(!blockedByOrder); + } + + // 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。 + // 被牵连阻断的后续节点没有异常上报,不挂标签——否则司机以为每个节点都出了事。 + Set exceptionNodeCodes = unresolvedExceptionNodeCodes(waybill.getId()); + if (!exceptionNodeCodes.isEmpty()) { + for (DriverPunchNodeVO vo : nodes) { + if (exceptionNodeCodes.contains(vo.getKey())) { + vo.setHasException(true); + } + } + } + + // 「整单异常」标签与相关异常 id:整单异常不归属任何节点(节点编码为空), + // 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。 + // 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点 + // 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。 + // 未了结异常已按上报时间升序取出,过滤后仍保持该顺序;id 转字符串是因为主键是 int64, + // 前端直收数字会丢精度。 + List unresolved = unresolvedExceptions(waybill.getId()); + boolean hasWaybillException = unresolved.stream() + .anyMatch(item -> WaybillProcessSupport.isWaybillLevelException(item.getNodeCode())); + for (DriverPunchNodeVO vo : nodes) { + vo.setHasWaybillException(hasWaybillException); + vo.setRelatedExceptionIds(unresolved.stream() + .filter(item -> WaybillProcessSupport.relatesToNode(vo.getKey(), item.getNodeCode())) + .map(item -> String.valueOf(item.getId())) + .collect(Collectors.toList())); + } + + for (DriverPunchNodeVO vo : nodes) { + if (!Boolean.TRUE.equals(vo.getDone()) + && !Boolean.TRUE.equals(vo.getBlocked()) + && !Boolean.TRUE.equals(vo.getBlockedByOrder())) { vo.setDefaultExpanded(true); break; } @@ -846,6 +1283,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return nodes; } + /** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */ + private String prerequisiteNames(String processJson, Set nodeKeys) { + List names = new ArrayList<>(); + for (Map node : WaybillProcessSupport.listDriverPunchNodes(processJson)) { + String key = WaybillProcessSupport.nodeKey(node); + if (nodeKeys.contains(key)) { + names.add(WaybillProcessSupport.nodeName(node)); + } + } + return names.isEmpty() ? "" : String.join("、", names); + } + private Map findPunchNodeConfig(String processJson, String nodeCode) { List> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); for (Map cfg : punchConfigs) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EquipmentLedgerServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EquipmentLedgerServiceImpl.java index cd1eccb5..cf601dc8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EquipmentLedgerServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EquipmentLedgerServiceImpl.java @@ -59,7 +59,10 @@ public class EquipmentLedgerServiceImpl extends BaseServiceImpl selectEquipmentLedgerPage(IPage page, EquipmentLedgerVO equipmentLedger) { List records = baseMapper.selectEquipmentLedgerPage(page, equipmentLedger); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -79,7 +82,7 @@ public class EquipmentLedgerServiceImpl extends BaseServiceImpl importEquipmentLedger(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List equipmentLedgerList = new ArrayList<>(); @@ -114,8 +117,9 @@ public class EquipmentLedgerServiceImpl extends BaseServiceImpl exportEquipmentLedger(Wrapper queryWrapper) { return list(queryWrapper).stream() - .map(item -> Objects.requireNonNull(BeanUtil.copyProperties(item, EquipmentLedgerExcel.class))) + .map(item -> { + EquipmentLedgerExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(item, EquipmentLedgerExcel.class)); + excel.setCreateUserName(UserCache.getUserRealName(item.getCreateUser())); + excel.setCreateTime(item.getCreateTime()); + return excel; + }) .toList(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EtcRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EtcRecordServiceImpl.java index 3a015869..7da83099 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EtcRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/EtcRecordServiceImpl.java @@ -45,7 +45,10 @@ public class EtcRecordServiceImpl extends BaseServiceImpl selectEtcRecordPage(IPage page, EtcRecordVO etcRecord) { List records = baseMapper.selectEtcRecordPage(page, etcRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -61,7 +64,7 @@ public class EtcRecordServiceImpl extends BaseServiceImpl importEtcRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List etcRecordList = new ArrayList<>(); @@ -85,8 +88,9 @@ public class EtcRecordServiceImpl extends BaseServiceImpl { EtcRecordExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(etcRecord, EtcRecordExportExcel.class)); excel.setTransactionAmount(nonNegative(etcRecord.getTransactionAmount())); + excel.setCreateUserName(UserCache.getUserRealName(etcRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(etcRecord.getUpdateUser())); return excel; }).toList(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 008e827e..d86e008c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -31,25 +31,30 @@ import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IExceptionDisposalService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -68,13 +73,19 @@ public class ExceptionDisposalServiceImpl private static final String STATUS_PROCESSING = "processing"; private static final String STATUS_COMPLETED = "completed"; + /** latest_follow_content 与 follow_content 两列均为 varchar(500) */ + private static final int FOLLOW_CONTENT_MAX_LENGTH = 500; + private final ExceptionDisposalFollowRecordMapper followRecordMapper; private final IWaybillService waybillService; + private final IDriverWaybillService driverWaybillService; public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper, - IWaybillService waybillService) { + IWaybillService waybillService, + IDriverWaybillService driverWaybillService) { this.followRecordMapper = followRecordMapper; this.waybillService = waybillService; + this.driverWaybillService = driverWaybillService; } @Override @@ -120,7 +131,7 @@ public class ExceptionDisposalServiceImpl disposal.setDisposalStatus(STATUS_PENDING); disposal.setReportTime(LocalDateTime.now()); disposal.setReporterId(AuthUtil.getUserId()); - disposal.setReporterName(UserCache.getUserRealName(AuthUtil.getUserId())); + disposal.setReporterName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); fillFromWaybill(disposal, request.getWaybillId(), request.getWaybillNo()); @@ -128,12 +139,58 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请关联运单后再上报"); } + // 上报人只可能是该运单的承运司机本人,取登录态;请求体里的上报人一律忽略。 + // 归属按司机绑定车牌判定,与司机打卡、司机接单同一口径。 + // 整单异常同样受这条约束:没有节点不等于没有上报人。 + driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId()); + + // 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节, + // 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。 + // 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。 + String nodeCode = Func.toStr(request.getNodeCode(), "").trim(); + if (Func.isNotEmpty(nodeCode)) { + Map nodeCfg = driverWaybillService.findPunchNode(disposal.getWaybillId(), nodeCode); + if (nodeCfg == null) { + throw new ServiceException("异常节点不存在"); + } + String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + // 上报门禁之一:已打卡说明这个环节已经过去,事后才发现的问题走整单异常。 + // 在途例外——它可重复打卡、没有完成含义,打卡与否不说明环节是否已经过去。 + if (!WaybillProcessSupport.isTransitNodePublic(nodeCfg) + && driverWaybillService.isNodePunched(disposal.getWaybillId(), nodeKey)) { + throw new ServiceException("该节点已打卡,不能上报异常"); + } + // 上报门禁之二:同一节点同时至多一条未了结异常,处理中也占位。 + // 约束是同时性的:异常了结后该节点重新可以上报。 + if (hasUnresolvedNodeException(disposal.getWaybillId(), nodeKey)) { + throw new ServiceException("该节点已有未处理异常,处理完成后才能再次上报"); + } + disposal.setNodeCode(nodeKey); + disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg)); + } + if (!save(disposal)) { throw new ServiceException("异常上报失败"); } return toVO(disposal); } + /** + * 该运单的指定节点是否已有未了结(未达终态)的异常。 + *

+ * 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算, + * 「处理中」同样占位——它只是有人跟进过,问题可能仍在。 + * 节点编码忽略大小写与首尾空白:历史数据里的写法不一定与过程配置的 key 完全一致。 + */ + private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) { + Long count = baseMapper.selectCount(Wrappers.lambdaQuery() + .eq(ExceptionDisposal::getWaybillId, waybillId) + .eq(ExceptionDisposal::getIsDeleted, 0) + .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES) + .apply("LOWER(TRIM(node_code)) = {0}", nodeCode.trim().toLowerCase(Locale.ROOT))); + return count != null && count > 0; + } + /** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */ private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) { Waybill waybill = null; @@ -165,31 +222,129 @@ public class ExceptionDisposalServiceImpl disposal.setLoadingOrMasterNo(loadingOrMaster); } + @Override + @Transactional(rollbackFor = Exception.class) + public void update(ExceptionDisposalUpdateRequest request) { + if (request == null || request.getId() == null) { + throw new ServiceException("请选择需要编辑的异常"); + } + if (Func.isBlank(request.getExceptionType())) { + throw new ServiceException("请选择异常类型"); + } + if (Func.isBlank(request.getReportDescription())) { + throw new ServiceException("请填写上报说明"); + } + if (request.getReportDescription().length() > 500) { + throw new ServiceException("上报说明不能超过500字"); + } + if (Func.isNotBlank(request.getExceptionReason()) && request.getExceptionReason().length() > 200) { + throw new ServiceException("异常原因不能超过200字"); + } + ExceptionDisposal disposal = getExisting(request.getId()); + + String exceptionType = request.getExceptionType().trim(); + String exceptionReason = Func.isBlank(request.getExceptionReason()) + ? null + : request.getExceptionReason().trim(); + String reportDescription = request.getReportDescription().trim(); + String scenePhotos = Func.isBlank(request.getScenePhotos()) + ? null + : request.getScenePhotos().trim(); + + String changeLog = buildChangeLog(disposal, exceptionType, exceptionReason, reportDescription, scenePhotos); + if (Func.isBlank(changeLog)) { + throw new ServiceException("未检测到任何修改"); + } + + disposal.setExceptionType(exceptionType); + disposal.setExceptionReason(exceptionReason); + disposal.setReportDescription(reportDescription); + disposal.setScenePhotos(scenePhotos); + disposal.setLatestFollowContent(changeLog); + updateById(disposal); + + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposal.getId()); + record.setFollowContent(changeLog); + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + } + + /** + * 逐字段比对生成变更说明,用于写入跟进记录。 + * 未发生变更时返回 null,调用方据此拒绝这次编辑。 + *

+ * 结果长度受 latest_follow_content / follow_content 的 varchar(500) 限制, + * 而变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超限,故须截断。 + */ + private String buildChangeLog(ExceptionDisposal before, String exceptionType, String exceptionReason, + String reportDescription, String scenePhotos) { + List changes = new ArrayList<>(); + appendChange(changes, "异常类型", before.getExceptionType(), exceptionType); + appendChange(changes, "异常原因", before.getExceptionReason(), exceptionReason); + appendChange(changes, "上报说明", before.getReportDescription(), reportDescription); + if (!Objects.equals(before.getScenePhotos(), scenePhotos)) { + changes.add("现场照片由 " + photoCount(before.getScenePhotos()) + " 张改为 " + photoCount(scenePhotos) + " 张"); + } + if (changes.isEmpty()) { + return null; + } + return truncate("编辑:" + String.join(";", changes)); + } + + private void appendChange(List changes, String label, String before, String after) { + String oldValue = Func.isBlank(before) ? "空" : before; + String newValue = Func.isBlank(after) ? "空" : after; + if (!Objects.equals(before, after)) { + changes.add(label + "由 " + oldValue + " 改为 " + newValue); + } + } + + /** 截断到字段上限内,留出省略号位置 */ + private String truncate(String text) { + return text.length() <= FOLLOW_CONTENT_MAX_LENGTH + ? text + : text.substring(0, FOLLOW_CONTENT_MAX_LENGTH - 1) + "…"; + } + + private int photoCount(String scenePhotos) { + return splitPhotos(scenePhotos).size(); + } + @Override @Transactional(rollbackFor = Exception.class) public void follow(ExceptionDisposalFollowRequest request) { if (request == null || request.getId() == null) { throw new ServiceException("请选择需要跟进的异常"); } - if (Func.isBlank(request.getFollowContent())) { - throw new ServiceException("请填写跟进说明"); + boolean hasAttachments = Func.isNotBlank(request.getAttachmentsJson()); + if (Func.isBlank(request.getFollowContent()) && !hasAttachments) { + throw new ServiceException("请填写跟进说明或上传附件"); } - if (request.getFollowContent().length() > 500) { + if (Func.isNotBlank(request.getFollowContent()) && request.getFollowContent().length() > 500) { throw new ServiceException("跟进说明不能超过500字"); } ExceptionDisposal disposal = getExisting(request.getId()); if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) { throw new ServiceException("已完成的异常不允许继续跟进"); } + // 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示 + String followContent = Func.isBlank(request.getFollowContent()) + ? null + : request.getFollowContent(); disposal.setDisposalStatus(STATUS_PROCESSING); - disposal.setLatestFollowContent(request.getFollowContent()); + disposal.setLatestFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent); updateById(disposal); ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); record.setDisposalId(disposal.getId()); - record.setFollowContent(request.getFollowContent()); + // follow_content 列为 NOT NULL + record.setFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent); + record.setAttachmentsJson(hasAttachments ? request.getAttachmentsJson() : null); record.setFollowUser(AuthUtil.getUserId()); - record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); record.setFollowTime(LocalDateTime.now()); followRecordMapper.insert(record); } @@ -231,6 +386,9 @@ public class ExceptionDisposalServiceImpl private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ExceptionDisposal::getIsDeleted, 0) + // 按运单精确过滤,供司机端「本运单的异常」列表使用。 + // 不用运单号:它是模糊匹配,YD001 会命中 YD0011。 + .eq(query.getWaybillId() != null, ExceptionDisposal::getWaybillId, query.getWaybillId()) .like(Func.isNotEmpty(query.getWaybillNo()), ExceptionDisposal::getWaybillNo, query.getWaybillNo()) .like(Func.isNotEmpty(query.getVehicleNo()), ExceptionDisposal::getVehicleNo, query.getVehicleNo()) .like(Func.isNotEmpty(query.getReporterName()), ExceptionDisposal::getReporterName, query.getReporterName()) @@ -259,8 +417,8 @@ public class ExceptionDisposalServiceImpl if (vo == null) { return null; } - vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); - vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); + vo.setCreateUserName(UserDisplayCache.getUserRealName(entity.getCreateUser())); + vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser())); vo.setDisposalStatusName(statusName(entity.getDisposalStatus())); vo.setScenePhotoList(splitPhotos(entity.getScenePhotos())); fillRouteAndCargo(vo, entity.getWaybillId()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 9c8d0f86..d3f7a922 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -28,6 +28,9 @@ import org.springblade.transport.mapper.FormalSettlementSummaryFeeMapper; import org.springblade.transport.mapper.FormalSettlementPaymentMapper; import org.springblade.transport.mapper.InvoiceReceiptMapper; import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper; +import org.springblade.transport.mapper.InvoiceApplicationInvoiceMapper; +import org.springblade.transport.mapper.InvoiceApplicationMapper; +import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper; import org.springblade.transport.mapper.PreSettlementDetailMapper; import org.springblade.transport.mapper.PreSettlementDetailFeeMapper; import org.springblade.transport.mapper.PreSettlementMapper; @@ -36,7 +39,11 @@ import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper; import org.springblade.transport.mapper.PaymentApplicationSettlementMapper; import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; +import org.springblade.transport.constant.KingdeeAttachmentConstant; +import org.springblade.transport.config.BfiProperties; import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.constant.KingdeeLockConstant; +import org.springblade.transport.pojo.entity.KingdeeBillLock; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.ReceivablePayableDetailMapper; import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper; @@ -57,6 +64,9 @@ import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee; import org.springblade.transport.pojo.entity.FormalSettlementPayment; import org.springblade.transport.pojo.entity.InvoiceReceipt; import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement; +import org.springblade.transport.pojo.entity.InvoiceApplication; +import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; +import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.PreSettlement; import org.springblade.transport.pojo.entity.PreSettlementDetail; @@ -74,17 +84,24 @@ import org.springblade.transport.pojo.vo.FormalSettlementVO; import org.springblade.transport.pojo.vo.PreSettlementVO; import org.springblade.transport.pojo.vo.SettlementAdjustmentVO; import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springblade.transport.service.IKingdeeBillLockService; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.IPreSettlementService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.support.KingdeeApBillSyncSupport; import org.springblade.transport.support.KingdeeArBillSyncSupport; +import org.springblade.transport.support.KingdeeSyncLockSupport; +import org.springblade.transport.support.KingdeeAttachmentSyncSupport; import org.springblade.transport.wrapper.PreSettlementWrapper; import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.transport.event.KingdeeBillApprovedEvent; import org.springblade.transport.wrapper.FormalSettlementWrapper; import org.springblade.transport.wrapper.PaymentApplicationWrapper; import org.springblade.system.cache.UserCache; +import org.springblade.core.tool.utils.SpringUtil; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -94,6 +111,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -114,12 +133,27 @@ import java.util.stream.Collectors; public class FormalSettlementServiceImpl extends BaseServiceImpl implements IFormalSettlementService { + /** 金蝶票池认领闸门(收票服务承载 A801-1 重查与可用性口径) */ + private final IInvoiceReceiptService invoiceReceiptService; + + /** 金蝶单据锁定记录服务(lockBusiData 锁定/补偿/解锁守卫) */ + private final IKingdeeBillLockService kingdeeBillLockService; + + /** 金蝶应收单勾稽 single-flight 锁键前缀(多副本定时任务与手动触发共用) */ + private static final String AR_RESULT_SYNC_LOCK_PREFIX = "transport:formal-settlement:kingdee-ar-result-sync:"; + + /** 金蝶应收单勾稽结算明细分批查询单次条数上限(in 条件分批,避免单次过滤条件过长) */ + private static final int AR_RESULT_DETAIL_QUERY_BATCH = 500; + private final FormalSettlementSourceMapper sourceMapper; private final FormalSettlementSummaryFeeMapper summaryFeeMapper; private final FormalSettlementPaymentMapper paymentMapper; private final FormalSettlementInvoiceMapper invoiceMapper; private final InvoiceReceiptMapper invoiceReceiptMapper; private final InvoiceReceiptSettlementMapper invoiceReceiptSettlementMapper; + private final InvoiceApplicationMapper invoiceApplicationMapper; + private final InvoiceApplicationInvoiceMapper invoiceApplicationInvoiceMapper; + private final InvoiceApplicationSettlementMapper invoiceApplicationSettlementMapper; private final FormalSettlementChangeRecordMapper changeRecordMapper; private final FormalSettlementDetailMapper detailMapper; private final FormalSettlementDetailFeeMapper detailFeeMapper; @@ -139,6 +173,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl selectPage(IPage page, FormalSettlementVO query) { @@ -167,7 +205,60 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime)); - return result.convert(this::toVO); + IPage voPage = result.convert(this::toVO); + fillAttachmentSyncStatus(voPage.getRecords()); + fillKingdeeLockStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回): + * 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废, + * failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶) + * + * @param rows 本页 VO 列表 + */ + private void fillKingdeeLockStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_SETTLEMENT, + rows.stream().map(FormalSettlementVO::getId).toList()); + for (FormalSettlementVO vo : rows) { + KingdeeBillLock lockRecord = lockMap.get(vo.getId()); + if (lockRecord == null) { + continue; + } + vo.setKingdeeLockStatus(lockRecord.getLockStatus()); + vo.setKingdeeLockFailReason(lockRecord.getFailReason()); + vo.setKingdeeTryCount(lockRecord.getTryCount()); + } + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):按结算类型分业务类型一次 GROUP BY 查询,避免 N+1 + * + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map apSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP, rows.stream() + .filter(row -> !"receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList()); + Map arSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR, rows.stream() + .filter(row -> "receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList()); + for (FormalSettlementVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + Map successCounts = "receivable".equals(vo.getSettlementType()) ? arSuccessCounts : apSuccessCounts; + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } } @Override @@ -212,6 +303,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl sources = sourceMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementSource::getFormalSettlementId, id).orderByAsc(FormalSettlementSource::getCreateTime)); vo.setSources(sources); @@ -223,6 +315,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementPayment::getFormalSettlementId, id).orderByDesc(FormalSettlementPayment::getCreateTime))); vo.setInvoices(listInvoices(settlement)); + vo.setIssuedInvoices(listIssuedInvoices(id)); Set relationApplicationIds = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .select(PaymentApplicationSettlement::getPaymentApplicationId) @@ -340,6 +433,31 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl listIssuedInvoices(Long settlementId) { + List applicationIds = invoiceApplicationSettlementMapper.selectList( + Wrappers.lambdaQuery() + .select(InvoiceApplicationSettlement::getInvoiceApplicationId) + .eq(InvoiceApplicationSettlement::getFormalSettlementId, settlementId) + ).stream().map(InvoiceApplicationSettlement::getInvoiceApplicationId) + .filter(Objects::nonNull).distinct().toList(); + if (applicationIds.isEmpty()) return List.of(); + Set activeApplicationIds = invoiceApplicationMapper.selectBatchIds(applicationIds).stream() + .filter(application -> !TransportBusiConstant.VOIDED.equals(application.getApprovalStatus()) + && !Objects.equals(application.getIsDeleted(), 1)) + .map(InvoiceApplication::getId).collect(Collectors.toSet()); + if (activeApplicationIds.isEmpty()) return List.of(); + return invoiceApplicationInvoiceMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceApplicationInvoice::getInvoiceApplicationId, activeApplicationIds) + .orderByAsc(InvoiceApplicationInvoice::getInvoiceApplicationId) + .orderByAsc(InvoiceApplicationInvoice::getCreateTime)); + } + @Override @Transactional(rollbackFor = Exception.class) public Long saveDraft(FormalSettlementSaveRequest request) { @@ -451,6 +569,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(FormalSettlement::getId, id) + .set(FormalSettlement::getApprovalStatus, TransportBusiConstant.VOIDED) + .set(FormalSettlement::getCurrentNode, "已作废") + .set(FormalSettlement::getCurrentProcessor, operator) + .set(FormalSettlement::getVoidReason, "金蝶侧删单回调自动废除") + .set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED) + .set(FormalSettlement::getKingdeeBillNo, null) + .set(FormalSettlement::getKingdeeBillId, null) + .set(FormalSettlement::getSyncedTime, null)); + // 变更流水:回调无登录态,操作人取金蝶报文(兜底 system,与 saveChange 同款) + FormalSettlementChangeRecord record = new FormalSettlementChangeRecord(); + record.setFormalSettlementId(id); + record.setChangeType("结算单基本信息"); + record.setOperationType("调整"); + record.setChangeContent("金蝶删单回调自动废除并还原同步信息(金蝶单据已删除,可还原重推)"); + record.setChangeReason("金蝶操作人:" + Func.toStr(operator)); + record.setOperatorName(Func.isEmpty(operator) ? "system" : operator); + record.setChangeTime(LocalDateTime.now()); + changeRecordMapper.insert(record); + } + + @Override public String syncKingdee(Long id) { + String billNo; + try { + // 单据级事务经自代理调用:单条失败只回滚自身 + billNo = SpringUtil.getBean(IFormalSettlementService.class).doSyncKingdee(id); + } catch (Exception exception) { + log.error("正式结算单[{}]推送金蝶异常", id, exception); + // 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态(与付款/收票口径统一,失败可被重推任务续推) + self().markKingdeeSyncFailed(id, exception.getMessage()); + throw exception; + } + // 主单事务提交后锁定金蝶单据(lockBusiData):失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态; + // 锁定失败摘要拼入返回值,页面直接可见 + String lockFailSummary = lockKingdeeBillAfterPush(id, billNo); + // 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传 + syncAttachmentsAfterPush(id); + return Func.isBlank(lockFailSummary) ? billNo + : billNo + ";注意:金蝶单据锁定未成功(" + lockFailSummary + "),系统将自动补偿重锁,重推同步金蝶可立即补锁"; + } + + /** + * 金蝶单据锁定(lockBusiData):A102-2/A302-2 推送成功事务提交后调用,目标单据类型按结算类型路由; + * 锁定异常独立捕获,不影响主单同步结果 + * + * @param id 正式结算单id + * @param billNo 金蝶单据编号 + * @return 锁定失败的金蝶单号摘要,全部锁定成功返回 null + */ + private String lockKingdeeBillAfterPush(Long id, String billNo) { + try { + if (Func.isBlank(billNo)) { + return null; + } + FormalSettlement settlement = getById(id); + if (settlement == null) { + return null; + } + return kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_SETTLEMENT, id, settlement.getFormalSettlementNo(), + "receivable".equals(settlement.getSettlementType()) + ? KingdeeLockConstant.TARGET_AR_BILL : KingdeeLockConstant.TARGET_AP_BILL, + Func.toStrList(billNo)); + } catch (Exception exception) { + log.error("正式结算单[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception); + return "锁定动作异常,已留档待补偿"; + } + } + + /** 自代理:跨方法调用确保事务注解生效(失败状态独立事务) */ + private IFormalSettlementService self() { + return SpringUtil.getBean(IFormalSettlementService.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markKingdeeSyncFailed(Long id, String reason) { + if (id == null) return; + // 条件更新:仅审批通过且未同步的单据落"生成失败"状态,未审批/已同步/已删除单据自动跳过保持原状态 + int updated = baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(FormalSettlement::getId, id) + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .ne(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED) + .set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + if (updated > 0) { + // 失败原因写变更记录留档(推送事务已回滚,此处独立事务);原因过长截断留档,不抛错 + String failReason = Func.toStrWithEmpty(reason, "未知异常"); + saveChange(id, "结算单基本信息", null, "调整", + "同步金蝶失败:" + (failReason.length() > 200 ? failReason.substring(0, 200) : failReason), null); + } + } + + @Override + public String retryKingdeeFailed() { + List failedIds = list(Wrappers.lambdaQuery() + .eq(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(FormalSettlement::getCreateTime)) + .stream().map(FormalSettlement::getId).toList(); + return executeKingdeeRetry(failedIds); + } + + /** + * 逐单重推失败单据:单条失败独立 try/catch 不中断整批,返回执行摘要。 + * 推送前预检(A102-4/A302-4 幂等回填)保证重推不重复生成金蝶单据。 + * + * @param failedIds 失败单据id集合 + * @return 执行摘要 + */ + private String executeKingdeeRetry(List failedIds) { + if (Func.isEmpty(failedIds)) { + return "无生成失败单据"; + } + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + syncKingdee(id); + success++; + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } + } + String summary = "重推生成失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + + /** + * 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,以本地 kingdeeBillId 定位金蝶单据; + * 本地缺失(存量数据/幂等跳过路径未带回内码)时经 A102-4/A302-4 按防重键反查回填; + * 附件上传异常独立捕获,绝不影响主单同步结果 + * + * @param id 正式结算单id + */ + private void syncAttachmentsAfterPush(Long id) { + try { + FormalSettlement settlement = getById(id); + if (settlement == null || !"synced".equals(settlement.getKingdeeSyncStatus())) { + return; + } + String billPkId = settlement.getKingdeeBillId(); + if (Func.isBlank(billPkId)) { + billPkId = resolveKingdeeBillId(settlement); + if (Func.isNotBlank(billPkId)) { + log.info("正式结算单[{}]经 A102-4/A302-4 反查回填星瀚单据内码:{}", settlement.getFormalSettlementNo(), billPkId); + FormalSettlement patch = new FormalSettlement(); + patch.setId(settlement.getId()); + patch.setKingdeeBillId(billPkId); + updateById(patch); + } + } + if (Func.isBlank(billPkId)) { + log.warn("正式结算单[{}]金蝶附件联带上传跳过:星瀚单据内码未落库且反查未命中,待重推补传", + settlement.getFormalSettlementNo()); + return; + } + String bizType = "receivable".equals(settlement.getSettlementType()) + ? KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR : KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP; + kingdeeAttachmentSyncSupport.syncUserAttachments(bizType, id, billPkId, settlement.getAttachmentsJson()); + } catch (Exception exception) { + log.error("正式结算单[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception); + } + } + + /** + * 反查星瀚单据内码:按结算类型组装防重键(与 A102-2/A302-2 传参一致)后调用对应查询接口 + * + * @param settlement 正式结算单 + * @return 星瀚单据内码,未查得返回 null + */ + private String resolveKingdeeBillId(FormalSettlement settlement) { + List details = detailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + if (Func.isEmpty(details)) { + return null; + } + if ("receivable".equals(settlement.getSettlementType())) { + return kingdeeArBillSyncSupport.queryArBillExistId(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details)); + } + return kingdeeApBillSyncSupport.queryApBillExistId(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String doSyncKingdee(Long id) { FormalSettlement settlement = existing(id); if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶"); - // 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据) + // 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据);星瀚内码由附件补传分支反查回填 if ("synced".equals(settlement.getKingdeeSyncStatus())) return settlement.getKingdeeBillNo(); // 流程拆分:正式结算单「同步金蝶」负责推送财务应付单(A102-2)并回写结算单单据编号; // 付款申请「同步金蝶」仅负责付款申请单(A103-1 应付下推),推送前经 A102-4 预检本单应付单已生成 @@ -493,6 +808,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl分批与事务说明: + * ① 结算明细按 {@link #AR_RESULT_DETAIL_QUERY_BATCH} 分批 in 查询后内存分组,消除逐单查询 N+1; + * ② 回写聚合后经 {@code updateBatchById} 批量落库——本方法为 this 自调用,MP 自带的 + * {@code @Transactional} 不生效,但其 executeBatch 会独立开启 BATCH SqlSession 并在结束时统一提交、 + * 异常整体回滚,等效单次原子写;③ 整轮任务不包外层事务(远程星瀚查询不占库连接,勾稽按防重键 + * 幂等可重跑),private + lambda 自调用上补加 {@code @Transactional} 会静默失效,禁止添加。 + * + * @return 执行摘要 + */ + private String executeArResultSync() { + long startMillis = System.currentTimeMillis(); // 待勾稽应收结算单:审批通过 + 结算类型应收 + 金蝶状态为 已同步/生成失败(已同步一并轮询为补齐单号,生成失败纳入勾稽纠偏: // 金蝶侧已生成说明上次推送实际成功,查到即回填纠正为已同步,避免人工重新推送产生重复单据) List settlements = list(Wrappers.lambdaQuery() @@ -532,23 +872,36 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(item.getKingdeeSyncStatus())).count(); + log.info("金蝶应收单勾稽开始:待勾稽应收结算单{}笔(已同步{}笔,生成失败{}笔)", + settlements.size(), syncedCount, settlements.size() - syncedCount); + // 结算明细分批一次查全(消除逐单查询 N+1),按结算单 id 分组 + List settlementIds = settlements.stream().map(FormalSettlement::getId).toList(); + Map> detailsBySettlement = new HashMap<>(); + kingdeeApBillSyncSupport.batchForeach(settlementIds, AR_RESULT_DETAIL_QUERY_BATCH, batch -> + detailsBySettlement.putAll(detailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, batch) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)))); // 组装防重键(与 A302-2 保存传参一致),同一防重键对应一张财务应收单 Map settlementBySrcCode = new LinkedHashMap<>(); for (FormalSettlement settlement : settlements) { - List details = detailMapper.selectList(Wrappers.lambdaQuery() - .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId()) - .eq(FormalSettlementDetail::getIsDeleted, 0) - .orderByAsc(FormalSettlementDetail::getLineNo)); - String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details); + String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, + detailsBySettlement.getOrDefault(settlement.getId(), List.of())); if (Func.isNotBlank(srcCode)) { settlementBySrcCode.putIfAbsent(srcCode, settlement); } } // A302-4 批量查询:查到 → 回填金蝶单据编号(缺失时)、生成失败纠偏为已同步;查不到 → 状态保持不变 Map existBillNos = kingdeeArBillSyncSupport.queryArBillExistNos(new ArrayList<>(settlementBySrcCode.keySet())); - int updated = 0; + // 勾稽回写聚合后经 updateBatchById 批量更新,避免逐单 update + // (this 自调用下 MP 事务注解不生效,executeBatch 自开 BATCH SqlSession 统一提交、异常整体回滚,等效原子写) + List toUpdateSettlements = new ArrayList<>(); int corrected = 0; for (Map.Entry entry : settlementBySrcCode.entrySet()) { String billNo = existBillNos.get(entry.getKey()); @@ -568,11 +921,20 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 0) { throw new ServiceException("发票匹配结算单金额合计不能超过结算金额"); } @@ -1053,15 +1417,104 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl settlementIds) { + List ids = settlementIds == null ? List.of() : settlementIds.stream() + .filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return; + } + // 结算单一次锁行查询替代逐单 selectOne(id 升序加锁,与逐单版加锁顺序一致防死锁) + List settlements = baseMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlement::getId, ids) + .orderByAsc(FormalSettlement::getId) + .last("FOR UPDATE")); + if (settlements.isEmpty()) { + return; + } + List settledIds = settlements.stream().map(FormalSettlement::getId).toList(); + boolean hasPayable = settlements.stream().anyMatch(item -> "payable".equals(item.getSettlementType())); + boolean hasReceivable = settlements.stream().anyMatch(item -> "receivable".equals(item.getSettlementType())); + + // 结算-付款申请关系(全量) + Map> relationsBySettlement = paymentApplicationSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(PaymentApplicationSettlement::getFormalSettlementId, settledIds) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)).stream() + .collect(Collectors.groupingBy(PaymentApplicationSettlement::getFormalSettlementId)); + // 付款申请(按结算单直挂,payment_type=结算) + Map> applicationsBySettlement = paymentApplicationMapper.selectList( + Wrappers.lambdaQuery() + .in(PaymentApplication::getSettlementId, settledIds) + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) + .eq(PaymentApplication::getIsDeleted, 0)).stream() + .collect(Collectors.groupingBy(PaymentApplication::getSettlementId)); + // 关系表引用的付款申请状态(全量一次) + Set relationApplicationIds = relationsBySettlement.values().stream() + .flatMap(List::stream).map(PaymentApplicationSettlement::getPaymentApplicationId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map relationStatuses = relationApplicationIds.isEmpty() ? Map.of() + : paymentApplicationMapper.selectList(Wrappers.lambdaQuery() + .select(PaymentApplication::getId, PaymentApplication::getApprovalStatus) + .in(PaymentApplication::getId, relationApplicationIds) + .eq(PaymentApplication::getIsDeleted, 0)).stream() + .collect(Collectors.toMap(PaymentApplication::getId, PaymentApplication::getApprovalStatus, + (first, second) -> first)); + // 预收来源(仅应付结算单需要) + Map> sourcesBySettlement = hasPayable + ? sourceMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementSource::getFormalSettlementId, settledIds) + .eq(FormalSettlementSource::getIsDeleted, 0)).stream() + .collect(Collectors.groupingBy(FormalSettlementSource::getFormalSettlementId)) + : Map.of(); + // 收款认领分摊聚合(仅应收结算单需要) + Map receiptClaimPaidBySettlement = hasReceivable + ? receiptClaimSettlementMapper.selectList(Wrappers.lambdaQuery() + .in(ReceiptClaimSettlement::getFormalSettlementId, settledIds) + .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, + Collectors.reducing(BigDecimal.ZERO, + item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))) + : Map.of(); + + for (FormalSettlement settlement : settlements) { + List relations = relationsBySettlement + .getOrDefault(settlement.getId(), List.of()); + // 直挂付款申请需排除该结算单关系表已引用的申请(与单笔版 notIn 语义一致) + Set ownRelationApplicationIds = relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + List directApplications = applicationsBySettlement + .getOrDefault(settlement.getId(), List.of()).stream() + .filter(item -> !ownRelationApplicationIds.contains(item.getId())) + .toList(); + PaymentSummary summary = calculatePaymentSummary(settlement, new PaymentSummaryData( + relations, directApplications, relationStatuses, + sourcesBySettlement.getOrDefault(settlement.getId(), List.of()), + receiptClaimPaidBySettlement.getOrDefault(settlement.getId(), BigDecimal.ZERO))); + settlement.setAppliedPaymentAmount(summary.appliedAmount()); + settlement.setPaidAmount(summary.paidAmount()); + settlement.setRemainingPayableAmount(money(settlement.getSettlementAmount()) + .subtract(summary.paidAmount()).max(BigDecimal.ZERO)); + settlement.setPaymentStatus(paymentStatus(summary.paidAmount(), settlement.getSettlementAmount())); + } + updateBatchById(settlements); + } + @Override @Transactional(rollbackFor = Exception.class) public void refreshPaymentSummariesForPreSettlement(Long preSettlementId) { if (preSettlementId == null) return; - sourceMapper.selectList(Wrappers.lambdaQuery() - .eq(FormalSettlementSource::getPreSettlementId, preSettlementId) - .eq(FormalSettlementSource::getIsDeleted, 0)).stream() - .map(FormalSettlementSource::getFormalSettlementId).distinct() - .forEach(this::refreshPaymentSummary); + List settlementIds = sourceMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementSource::getPreSettlementId, preSettlementId) + .eq(FormalSettlementSource::getIsDeleted, 0)).stream() + .map(FormalSettlementSource::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + refreshPaymentSummaries(settlementIds); } private List listSummaryFees(Long settlementId) { @@ -1264,54 +1717,65 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl query = Wrappers.lambdaQuery() - .eq(PaymentApplication::getSettlementId, settlement.getId()) - .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) - .eq(PaymentApplication::getIsDeleted, 0); List relations = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId()) .eq(PaymentApplicationSettlement::getIsDeleted, 0)); Set relationApplicationIds = relations.stream().map(PaymentApplicationSettlement::getPaymentApplicationId) .filter(Objects::nonNull).collect(Collectors.toSet()); + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .eq(PaymentApplication::getSettlementId, settlement.getId()) + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) + .eq(PaymentApplication::getIsDeleted, 0); if (!relationApplicationIds.isEmpty()) query.notIn(PaymentApplication::getId, relationApplicationIds); List applications = paymentApplicationMapper.selectList(query); - BigDecimal appliedAmount = applications.stream() - .filter(item -> TransportBusiConstant.REVIEWING.equals(item.getApprovalStatus())) - .map(PaymentApplication::getAppliedAmount).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal paidAmount = applications.stream() - .filter(item -> TransportBusiConstant.APPROVED.equals(item.getApprovalStatus())) - .map(PaymentApplication::getPaidAmount).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); Map relationStatuses = relationApplicationIds.isEmpty() ? Map.of() : paymentApplicationMapper.selectList( Wrappers.lambdaQuery().select(PaymentApplication::getId, PaymentApplication::getApprovalStatus) .in(PaymentApplication::getId, relationApplicationIds).eq(PaymentApplication::getIsDeleted, 0)) .stream().collect(Collectors.toMap(PaymentApplication::getId, PaymentApplication::getApprovalStatus)); - appliedAmount = appliedAmount.add(relations.stream() - .filter(item -> TransportBusiConstant.REVIEWING.equals(relationStatuses.get(item.getPaymentApplicationId()))) + return calculatePaymentSummary(settlement, new PaymentSummaryData(relations, applications, + relationStatuses, null, null)); + } + + /** + * 收款汇总纯计算:查询数据由调用方供给(单笔版自行查询;批量版由 refreshPaymentSummaries 一次供给多单)。 + * sources / receiptClaimPaid 为 null 时按需自行查询(仅 payable/receivable 分支使用)。 + */ + private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, PaymentSummaryData data) { + BigDecimal appliedAmount = data.directApplications().stream() + .filter(item -> TransportBusiConstant.REVIEWING.equals(item.getApprovalStatus())) + .map(PaymentApplication::getAppliedAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal paidAmount = data.directApplications().stream() + .filter(item -> TransportBusiConstant.APPROVED.equals(item.getApprovalStatus())) + .map(PaymentApplication::getPaidAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + appliedAmount = appliedAmount.add(data.relations().stream() + .filter(item -> TransportBusiConstant.REVIEWING.equals(data.relationStatuses().get(item.getPaymentApplicationId()))) .map(PaymentApplicationSettlement::getAppliedAmount).map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add)); - paidAmount = paidAmount.add(relations.stream() - .filter(item -> TransportBusiConstant.APPROVED.equals(relationStatuses.get(item.getPaymentApplicationId()))) + paidAmount = paidAmount.add(data.relations().stream() + .filter(item -> TransportBusiConstant.APPROVED.equals(data.relationStatuses().get(item.getPaymentApplicationId()))) .map(PaymentApplicationSettlement::getPaidAmount).map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add)); if ("payable".equals(settlement.getSettlementType())) { - List sources = sourceMapper.selectList(Wrappers.lambdaQuery() - .eq(FormalSettlementSource::getFormalSettlementId, settlement.getId()) - .eq(FormalSettlementSource::getIsDeleted, 0)); + List sources = data.sources() != null ? data.sources() + : sourceMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementSource::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementSource::getIsDeleted, 0)); appliedAmount = appliedAmount.add(sources.stream().map(FormalSettlementSource::getAdvanceAppliedAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); paidAmount = paidAmount.add(sources.stream().map(FormalSettlementSource::getAdvancePaidAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); } if ("receivable".equals(settlement.getSettlementType())) { - paidAmount = receiptClaimSettlementMapper.selectList( - Wrappers.lambdaQuery() - .eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId()) - .eq(ReceiptClaimSettlement::getStatus, 1)) - .stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); + paidAmount = data.receiptClaimPaid() != null ? data.receiptClaimPaid() + : receiptClaimSettlementMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId()) + .eq(ReceiptClaimSettlement::getStatus, 1)) + .stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); } return new PaymentSummary(money(appliedAmount), money(paidAmount)); } @@ -1330,6 +1794,20 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relations, + List directApplications, Map relationStatuses, + List sources, BigDecimal receiptClaimPaid) { + } + private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity, String kingdeeBillNo) { SettlementAdjustmentVO vo = Objects.requireNonNull( BeanUtil.copyProperties(entity, SettlementAdjustmentVO.class)); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java index 6337e586..62bca461 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InsuranceRecordServiceImpl.java @@ -82,6 +82,7 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl INSURANCE_TYPES = Set.of("交强险", "商业险", "承运人责任险", "货运险", "船舶险"); private static final Set SUPPORT_FILE_TYPES = Set.of("jpg", "jpeg", "png", "bmp"); private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})[-/.年](\\d{1,2})[-/.月](\\d{1,2})日?"); @@ -93,7 +94,12 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl selectInsuranceRecordPage(IPage page, InsuranceRecordVO insuranceRecord) { List records = baseMapper.selectInsuranceRecordPage(page, insuranceRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setInsuredAmount(normalizeEmptyAmount(record.getInsuredAmount())); + record.setPremium(normalizeEmptyAmount(record.getPremium())); + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -110,7 +116,7 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl importInsuranceRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List insuranceRecordList = new ArrayList<>(); @@ -142,8 +148,9 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl exportInsuranceRecord(Wrapper queryWrapper) { return list(queryWrapper).stream().map(insuranceRecord -> { InsuranceRecordExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(insuranceRecord, InsuranceRecordExportExcel.class)); - excel.setInsuredAmount(nonNegative(insuranceRecord.getInsuredAmount())); - excel.setPremium(nonNegative(insuranceRecord.getPremium())); + excel.setInsuredAmount(normalizeEmptyAmount(insuranceRecord.getInsuredAmount())); + excel.setPremium(normalizeEmptyAmount(insuranceRecord.getPremium())); + excel.setCreateUserName(UserCache.getUserRealName(insuranceRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(insuranceRecord.getUpdateUser())); return excel; }).toList(); @@ -336,6 +344,9 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyAmount(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_AMOUNT_SENTINEL) == 0 ? null : value; } private String normalizeVehicleType(String vehicleType) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index baf49500..2bd7d3ac 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -44,6 +44,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveFreightDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiInvoiceVO; @@ -52,9 +53,13 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeAttachmentConstant; import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.constant.KingdeeLockConstant; +import org.springblade.transport.pojo.entity.KingdeeBillLock; import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.event.KingdeeBillApprovedEvent; import org.springblade.transport.mapper.ContractManageMapper; import org.springblade.transport.mapper.CustomerArchiveMapper; import org.springblade.transport.mapper.CustomerContactMapper; @@ -92,9 +97,15 @@ import org.springblade.transport.pojo.vo.CustomerInvoiceInfoVO; import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO; import org.springblade.transport.pojo.vo.InvoiceApplicationVO; import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IKingdeeBillLockService; import org.springblade.transport.support.KingdeeApBillSyncSupport; import org.springblade.transport.support.KingdeeArBillSyncSupport; +import org.springblade.transport.support.KingdeeSyncLockSupport; +import org.springblade.transport.support.KingdeeAttachmentSyncSupport; import org.springblade.transport.wrapper.InvoiceApplicationWrapper; +import org.springblade.transport.wrapper.WaybillWrapper; +import org.springblade.core.tool.utils.SpringUtil; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -128,6 +139,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl selectPage(IPage page, InvoiceApplicationVO query) { @@ -156,13 +175,62 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl voPage = page(page, wrapper).convert(this::toListVO); + fillAttachmentSyncStatus(voPage.getRecords()); + fillKingdeeLockStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回): + * 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废, + * failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶) + * + * @param rows 本页 VO 列表 + */ + private void fillKingdeeLockStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_INVOICE_APPLY, + rows.stream().map(InvoiceApplicationVO::getId).toList()); + for (InvoiceApplicationVO vo : rows) { + KingdeeBillLock lockRecord = lockMap.get(vo.getId()); + if (lockRecord == null) { + continue; + } + vo.setKingdeeLockStatus(lockRecord.getLockStatus()); + vo.setKingdeeLockFailReason(lockRecord.getFailReason()); + vo.setKingdeeTryCount(lockRecord.getTryCount()); + } + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1 + * + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map successCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, rows.stream().map(InvoiceApplicationVO::getId).toList()); + for (InvoiceApplicationVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } } @Override public InvoiceApplicationVO detail(Long id) { InvoiceApplication entity = existing(id); InvoiceApplicationVO vo = toListVO(entity); + fillKingdeeLockStatus(List.of(vo)); vo.setSettlements(settlementRelationMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) .orderByAsc(InvoiceApplicationSettlement::getCreateTime))); @@ -444,6 +512,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(InvoiceApplication::getId, id) + .set(InvoiceApplication::getApprovalStatus, TransportBusiConstant.VOIDED) + .set(InvoiceApplication::getCurrentNode, "已作废") + .set(InvoiceApplication::getCurrentProcessor, operator) + .set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED) + .set(InvoiceApplication::getKingdeeBillNo, null) + .set(InvoiceApplication::getSyncedTime, null)); + // 操作流水:回调无登录态,操作人取金蝶报文(兜底 system,与 record 同款) + InvoiceApplicationRecord voidRecord = new InvoiceApplicationRecord(); + voidRecord.setInvoiceApplicationId(id); + voidRecord.setActionType("kingdee_delete"); + voidRecord.setActionName("金蝶删单回调作废"); + voidRecord.setFromStatus(TransportBusiConstant.APPROVED); + voidRecord.setToStatus(TransportBusiConstant.VOIDED); + voidRecord.setOperatorName(Func.isEmpty(operator) ? "system" : operator); + voidRecord.setReason("金蝶侧删单回调自动作废并还原同步信息(金蝶开票申请单已删除,可还原重推)"); + recordMapper.insert(voidRecord); + // 关联数据还原:关联正式结算单开票状态刷新 + relationSettlementIds(id).forEach(this::refreshSettlementInvoiceStatus); + } + + @Override public String syncKingdee(Long id) { + String billNo; + try { + // 单据级事务经自代理调用:单条失败只回滚自身 + billNo = SpringUtil.getBean(IInvoiceApplicationService.class).doSyncKingdee(id); + } catch (Exception exception) { + log.error("开票申请[{}]推送金蝶异常", id, exception); + // 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态(与付款/收票口径统一,失败可被重推任务续推) + self().markKingdeeSyncFailed(id, exception.getMessage()); + throw exception; + } + // 主单事务提交后锁定金蝶开票单(lockBusiData):每张发票张次一张金蝶单,接口1对1逐张锁定; + // 失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态;锁定失败摘要拼入返回值,页面直接可见 + String lockFailSummary = lockKingdeeBillAfterPush(id, billNo); + // 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传 + syncAttachmentsAfterPush(id); + return Func.isBlank(lockFailSummary) ? billNo + : billNo + ";注意:金蝶单据锁定未成功(" + lockFailSummary + "),系统将自动补偿重锁,重推同步金蝶可立即补锁"; + } + + /** + * 金蝶开票单锁定(lockBusiData):A303-1 推送成功事务提交后调用,接口1对1逐张锁定; + * 锁定异常独立捕获,不影响主单同步结果 + * + * @param id 开票申请id + * @param billNo 金蝶开票单编号(多张逗号拼接) + * @return 锁定失败的金蝶单号摘要,全部锁定成功返回 null + */ + private String lockKingdeeBillAfterPush(Long id, String billNo) { + try { + if (Func.isBlank(billNo)) { + return null; + } + InvoiceApplication entity = getById(id); + if (entity == null) { + return null; + } + return kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_INVOICE_APPLY, id, entity.getApplicationNo(), + KingdeeLockConstant.TARGET_INVOICE_BILL, Func.toStrList(billNo)); + } catch (Exception exception) { + log.error("开票申请[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception); + return "锁定动作异常,已留档待补偿"; + } + } + + /** 自代理:跨方法调用确保事务注解生效(失败状态独立事务) */ + private IInvoiceApplicationService self() { + return SpringUtil.getBean(IInvoiceApplicationService.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markKingdeeSyncFailed(Long id, String reason) { + if (id == null) return; + // 条件更新:仅审批通过且未同步/未开票的单据落"生成失败"状态,其余自动跳过保持原状态 + int updated = baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(InvoiceApplication::getId, id) + .eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .notIn(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, + KingdeeBfiConstant.KINGDEE_STATUS_INVOICED) + .set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + if (updated > 0) { + // 失败原因写操作流水留档(推送事务已回滚,此处独立事务) + record(id, "sync", "同步金蝶失败", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, + truncateReason(reason), null); + } + } + + /** 失败原因截断留档(流水 reason 字段兜底 500 长度) */ + private String truncateReason(String reason) { + String value = Func.toStrWithEmpty(reason, "未知异常"); + return value.length() > 500 ? value.substring(0, 500) : value; + } + + @Override + public String retryKingdeeFailed() { + List failedIds = list(Wrappers.lambdaQuery() + .eq(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(InvoiceApplication::getCreateTime)) + .stream().map(InvoiceApplication::getId).toList(); + if (Func.isEmpty(failedIds)) { + return "无生成失败单据"; + } + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + syncKingdee(id); + success++; + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } + } + String summary = "重推生成失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + + /** + * 附件联带上传(A902):主单推送成功(含 A303-4 勾稽回填)后调用,以本地 kingdeeBillId 定位金蝶单据; + * 本地缺失(存量数据)跳过待重推补传;附件上传异常独立捕获,绝不影响主单同步结果 + * + * @param id 开票申请id + */ + private void syncAttachmentsAfterPush(Long id) { + try { + InvoiceApplication entity = getById(id); + if (entity == null || Func.isBlank(entity.getKingdeeBillId())) { + log.warn("开票申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", entity == null ? id : entity.getApplicationNo()); + return; + } + KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncUserAttachments( + KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, id, entity.getKingdeeBillId(), entity.getAttachmentsJson()); + if (summary.total() > 0) { + // 附件同步操作流水(Q5 决策:开票申请有 actionType 流水表,追加 attachment_upload 记录) + record(id, "attachment_upload", "附件上传金蝶", entity.getApprovalStatus(), entity.getApprovalStatus(), + summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons()) + : "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)", + entity.getKingdeeBillNo()); + } + } catch (Exception exception) { + log.error("开票申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String doSyncKingdee(Long id) { InvoiceApplication entity = existing(id); if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶"); - // 已同步/已开票幂等返回(开票完成为终态) + // 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填 if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus()) || KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())) { return entity.getKingdeeBillNo(); @@ -484,7 +713,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl arSrcCodes = new ArrayList<>(); - String firstArBillNo = null; Map settlementNoBySrcCode = new LinkedHashMap<>(); for (InvoiceApplicationSettlement relation : relations) { FormalSettlement settlement = formalSettlementMapper.selectById(relation.getFormalSettlementId()); @@ -499,9 +727,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl existArNos = kingdeeArBillSyncSupport.queryArBillExistNos(arSrcCodes); for (Map.Entry entry : settlementNoBySrcCode.entrySet()) { @@ -510,6 +735,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl sheets = sheetMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo)); @@ -534,6 +762,16 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl items) { + String firstArSrcCode, BigDecimal sheetAmount, List items) { BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ==================== - // 【必填】组织.编码:申请部门 deptId → 部门编码 - param.setOrgidNumber(dept.getDeptCode()); + // 【必填】组织.编码:申请部门 deptId → 金蝶组织编码 + param.setOrgidNumber(dept.getKingdeeDeptCode()); // 【必填】申请部门.编码:同上 - param.setAwrpOrgfieldNumber(dept.getDeptCode()); + param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode()); // 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号) param.setBillno(billNo); // 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss @@ -693,8 +933,20 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl freights = this.buildFreights(entity); + if (Func.isNotEmpty(freights)) { + param.setFreights(freights); + } else { + log.warn("金蝶开票申请推送:开票申请[{}]按货物运输特殊票种(E04)推送但无结算明细快照,货物运输单据体未填报", + entity.getApplicationNo()); + } + } // 【必填】客户.编码:受票方客商档案客商编码 param.setCustomnameidNumber(customer.getCustomerCode()); // 【必填】项目.项目编码(联调期临时传金蝶测试档案值 999) @@ -706,10 +958,12 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl items) { + boolean allFreight = items.stream() + .allMatch(item -> this.isFreightGoods(item.getGoodsname(), item.getGoodssimplename())); + return allFreight ? KingdeeBfiConstant.BFI_INVOICE_SPECIAL_TYPE_FREIGHT : KingdeeBfiConstant.BFI_INVOICE_SPECIAL_TYPE_NONE; + } + + /** + * 商品行是否货物运输类:商品名称或商品和服务分类简称含关键字(运费/货物运输)即视为命中; + * 其他费用及非货物运输的运输类(如按简称“运输”维护的开票项目)不命中,随全张送非特殊票种 00 + * + * @param goodsname 商品名称(开票项目简称,如 运费) + * @param goodssimplename 商品和服务分类简称(开票项目分类名) + * @return true 表示货物运输类 + */ + private boolean isFreightGoods(String goodsname, String goodssimplename) { + String goodsText = Func.toStr(goodsname).trim() + Func.toStr(goodssimplename).trim(); + return KingdeeBfiConstant.BFI_INVOICE_FREIGHT_KEYWORDS.stream().anyMatch(goodsText::contains); + } + + /** + * 组装 A303-1 货物运输单据体(特殊票种 E04 随单报送)。 + *

+ * 金蝶数电货运发票要求随单报送运输工具信息,取数与组装规则: + *

    + *
  • 数据来源:开票申请结算明细快照(blade_invoice_application_detail,制单时自正式结算明细快照,含车牌/线路/货物运输信息)
  • + *
  • 字段映射:vehicleNo→licenseplate(运输工具牌号)、departureAddress→startplace(起运地)、 + * arrivalAddress→endplace(到达地)、cargoName→transportgoods(运输货物名称)、 + * transportType→transporttype(运输工具种类,字典键翻译后映射,见 {@link #kingdeeTransportTypeOf(String)})
  • + *
  • 行归并:结算明细按车次成行,同一「车牌×起运地×到达地×货物×运输工具种类」的多笔明细只报送一条
  • + *
  • 空行剔除:牌号/起运地/到达地/货物名称均为空的明细行不报送(快照缺运输信息),避免金蝶侧空行报错
  • + *
+ * + * @param entity 开票申请 + * @return 货物运输单据体分录(无可用快照数据时返回空列表,由调用方决定是否告警) + */ + private List buildFreights(InvoiceApplication entity) { + List details = applicationDetailMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationDetail::getInvoiceApplicationId, entity.getId())); + // 按运程归并键去重:结算明细按车次成行,同一运程多笔明细金蝶侧只需一条(LinkedHashMap 保持明细行序) + Map freightByKey = new LinkedHashMap<>(); + int emptyRowSkipCount = 0; + for (InvoiceApplicationDetail detail : details) { + BfiInvoiceApplySaveFreightDTO freight = new BfiInvoiceApplySaveFreightDTO(); + // 运输工具种类:字典 transport_type 键翻译后映射合法枚举,未命中兜底其它运输工具 + freight.setTransporttype(this.kingdeeTransportTypeOf(detail.getTransportType())); + // 运输工具牌号/运输货物名称/起运地/到达地:结算明细快照直传 + freight.setLicenseplate(detail.getVehicleNo()); + freight.setTransportgoods(detail.getCargoName()); + freight.setStartplace(detail.getDepartureAddress()); + freight.setEndplace(detail.getArrivalAddress()); + // 全空明细行(快照缺运输信息)不报送,避免金蝶侧空行报错 + if (Func.isBlank(freight.getLicenseplate()) && Func.isBlank(freight.getStartplace()) + && Func.isBlank(freight.getEndplace()) && Func.isBlank(freight.getTransportgoods())) { + emptyRowSkipCount++; + continue; + } + String dedupeKey = Func.toStr(freight.getLicenseplate()) + "|" + Func.toStr(freight.getStartplace()) + + "|" + Func.toStr(freight.getEndplace()) + "|" + Func.toStr(freight.getTransportgoods()) + + "|" + Func.toStr(freight.getTransporttype()); + freightByKey.putIfAbsent(dedupeKey, freight); + } + List freights = new ArrayList<>(freightByKey.values()); + log.info("金蝶开票申请货物运输单据体组装:开票申请[{}],结算明细快照{}条,剔除空行{}条,去重归并后分录{}条", + entity.getApplicationNo(), details.size(), emptyRowSkipCount, freights.size()); + freights.forEach(freight -> log.info("金蝶开票申请货物运输分录:开票申请[{}],运输工具种类[{}],牌号[{}],货物[{}],起运地[{}],到达地[{}]", + entity.getApplicationNo(), freight.getTransporttype(), freight.getLicenseplate(), + freight.getTransportgoods(), freight.getStartplace(), freight.getEndplace())); + return freights; + } + + /** + * 运输工具种类映射:结算明细快照存字典 transport_type 键,按业务/系统字典翻译为名称 + * (复用运单展示口径 {@link WaybillWrapper#transportTypeName(String)}), + * 翻译名称命中 A303-1 合法枚举(铁路/公路/水路/航空/管道/其它运输工具)直送,否则兜底其它运输工具 + * + * @param transportType 结算明细快照运输方式(字典键) + * @return 金蝶运输工具种类 + */ + private String kingdeeTransportTypeOf(String transportType) { + String name = WaybillWrapper.transportTypeName(transportType); + return KingdeeBfiConstant.BFI_FREIGHT_TRANSPORT_TYPES.contains(name) + ? name : KingdeeBfiConstant.BFI_FREIGHT_TRANSPORT_TYPE_OTHER; + } + @Override public String syncKingdeeInvoiceResult() { + return kingdeeSyncLockSupport.runSingleFlight(INVOICE_RESULT_SYNC_LOCK_PREFIX, bfiProperties.getInvoiceResult().getLockLeaseSeconds(), + "金蝶开票结果回写", this::executeInvoiceResultSync); + } + + /** + * 金蝶开票结果回写主体:A303-5 分批查询开票申请发票结果落库并推进开票完成, + * 由 {@link #syncKingdeeInvoiceResult()} 在分布式锁保护下调用 + * + * @return 执行摘要 + */ + private String executeInvoiceResultSync() { // 待回写开票申请:审批通过 + 已同步(invoiced 为终态不再轮询;failed 无真实金蝶单号不参与查询,纠偏走重推预检) List applications = list(Wrappers.lambdaQuery() .eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED) @@ -846,11 +1204,12 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl existingInvoiceNos = batchQueryExistingInvoiceNos(batch); - log.debug("金蝶开票结果回写:批次已存在发票号码数={}", existingInvoiceNos.size()); - // 收集待插入的发票记录和待更新的开票申请 + // 批量查询当前批次已落库的发票记录(按发票号索引,用于幂等去重与缺文件地址补齐) + Map existingInvoiceMap = batchQueryExistingInvoices(batch); + log.debug("金蝶开票结果回写:批次已存在发票记录数={}", existingInvoiceMap.size()); + // 收集待插入的发票记录、待补文件地址的已有发票记录和待更新的开票申请 List toInsertInvoices = new ArrayList<>(); + List toPatchInvoices = new ArrayList<>(); List toUpdateApplications = new ArrayList<>(); for (BfiInvoiceApplyOpenApiBillVO bill : result.getData()) { String billNo = bill.getBillNo(); @@ -864,24 +1223,32 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl 0) { invoiceWritten += inserted; @@ -899,6 +1266,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl batchQueryExistingInvoiceNos(List serialNos) { + private Map batchQueryExistingInvoices(List serialNos) { List existingList = invoiceApplicationInvoiceMapper.selectList( Wrappers.lambdaQuery() - .in(InvoiceApplicationInvoice::getSerialNo, serialNos) - .select(InvoiceApplicationInvoice::getInvoiceNo)); + .in(InvoiceApplicationInvoice::getSerialNo, serialNos)); return existingList.stream() - .map(InvoiceApplicationInvoice::getInvoiceNo) - .filter(Func::isNotBlank) - .collect(Collectors.toSet()); + .filter(record -> Func.isNotBlank(record.getInvoiceNo())) + .collect(Collectors.toMap(InvoiceApplicationInvoice::getInvoiceNo, Function.identity(), (left, right) -> left)); + } + + /** + * 将 A303-5 返回的发票文件地址补齐到已落库记录(仅补空字段,不覆盖已有值) + * + * @param record 已落库发票记录 + * @param invoice A303-5 返回发票明细 + * @return 发生补齐时返回 true + */ + private boolean fillMissingInvoiceFileUrls(InvoiceApplicationInvoice record, BfiInvoiceApplyOpenApiInvoiceVO invoice) { + boolean patched = false; + if (Func.isBlank(record.getInvoiceFileUrl()) && Func.isNotBlank(invoice.getInvoiceFileUrl())) { + record.setInvoiceFileUrl(invoice.getInvoiceFileUrl()); + patched = true; + } + if (Func.isBlank(record.getInvoiceImageUrl()) && Func.isNotBlank(invoice.getInvoiceImageUrl())) { + record.setInvoiceImageUrl(invoice.getInvoiceImageUrl()); + patched = true; + } + if (Func.isBlank(record.getInvoicePdfFileUrl()) && Func.isNotBlank(invoice.getInvoicePdfFileUrl())) { + record.setInvoicePdfFileUrl(invoice.getInvoicePdfFileUrl()); + patched = true; + } + if (Func.isBlank(record.getInvoiceXmlFileUrl()) && Func.isNotBlank(invoice.getInvoiceXmlFileUrl())) { + record.setInvoiceXmlFileUrl(invoice.getInvoiceXmlFileUrl()); + patched = true; + } + return patched; } /** 从票易通原文 Map 中按候选键顺序取首个非空文本值 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index b80dba04..30837ee2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -28,14 +28,20 @@ package org.springblade.transport.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.toolkit.Db; +import feign.Response; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; +import org.springblade.core.tool.utils.WebUtil; import org.springblade.thirdparty.bfi.constant.BfiConstant; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO; @@ -45,8 +51,11 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.transport.constant.HcsSyncConstant; +import org.springblade.transport.constant.KingdeeAttachmentConstant; +import org.springblade.transport.config.BfiProperties; import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.event.KingdeeBillApprovedEvent; import org.springblade.transport.mapper.CustomerArchiveMapper; import org.springblade.transport.mapper.CustomerContactMapper; import org.springblade.transport.mapper.FormalSettlementDetailMapper; @@ -64,30 +73,37 @@ import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.CustomerContact; import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.FormalSettlementDetail; -import org.springblade.transport.pojo.entity.FormalSettlementInvoice; import org.springblade.transport.pojo.entity.InvoiceReceipt; import org.springblade.transport.pojo.entity.InvoiceReceiptRecord; import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement; import org.springblade.transport.pojo.entity.KingdeeInvoicePool; import org.springblade.transport.pojo.vo.InvoiceReceiptVO; -import org.springblade.transport.pojo.vo.KingdeeInvoicePoolPageVO; import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; import org.springblade.transport.service.IInvoiceReceiptService; import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.support.KingdeeApBillSyncSupport; +import org.springblade.transport.support.KingdeeAttachmentSyncSupport; import org.springblade.transport.support.KingdeeReceivedInvoiceSyncSupport; +import org.springblade.transport.support.KingdeeSyncLockSupport; import org.springblade.transport.wrapper.InvoiceReceiptWrapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.math.BigDecimal; import java.math.RoundingMode; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -113,6 +129,18 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl selectPage(IPage page, InvoiceReceiptVO query) { @@ -146,7 +173,30 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl voPage = page(page, wrapper).convert(this::toListVO); + fillAttachmentSyncStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1 + * + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map successCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, rows.stream().map(InvoiceReceiptVO::getId).toList()); + for (InvoiceReceiptVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } } @Override @@ -172,82 +222,61 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl invoicePoolPage(KingdeeInvoicePoolQueryDTO query) { long current = query.getCurrent() == null ? 1L : Math.max(query.getCurrent(), 1L); long size = query.getSize() == null ? 10L : Math.min(Math.max(query.getSize(), 1L), 100L); - // 查询票号(已 trim 归一,方法级可见):用于对远程模糊命中做全等防御 - String queryInvoiceNo = trimOrNull(query.getInvoiceNo()); - // WHY: 金额可用过滤发生在拉取之后,下一页必须按游标续扫才能跨请求对齐过滤进度,按页码锚定会漏行/重行 - PoolScanCursor cursor = PoolScanCursor.parse(query.getCursor()); - List window = new ArrayList<>(); - // scanPageNo 指向待拉取的远程页;游标续扫固定按 500 行窗口取数,保证"页码:过滤偏移"语义稳定 - long scanPageNo = cursor == null ? current : cursor.pageNo(); - int skip = cursor == null ? 0 : cursor.skip(); - boolean exhausted = false; - // 末次拉取前的过滤累计行数与末次拉取页码:未消费的过滤行只可能落在末次拉取页内 - int filteredBeforeLastFetch = 0; - long lastFetchPageNo = scanPageNo; - int remoteCalls = 0; - // 有界循环补页:单请求最多 POOL_MAX_REMOTE_CALLS 次远程调用凑满一页,仍不足则返回稀疏页并给出游标 - for (int call = 0; call < KingdeeBfiConstant.POOL_MAX_REMOTE_CALLS && window.size() - skip < size; call++) { - // 首拉按 size+1 轻量探测,补拉按契约上限 500 行以减少调用次数 - int fetchSize = call == 0 && cursor == null ? (int) size + 1 : POOL_PAGE_SIZE; - List rawRows = this.queryPoolPage(scanPageNo, fetchSize, query); - remoteCalls++; - List usableRows = rawRows.stream() - .filter(this::isUsablePoolRow) - // 按票号查询时全等防御:远程为模糊匹配,命中"包含"关系的其他票号不得混入选票结果 - .filter(row -> queryInvoiceNo == null || queryInvoiceNo.equals(row.getInvoiceNo().trim())) - .toList(); - filteredBeforeLastFetch = window.size(); - window.addAll(usableRows); - lastFetchPageNo = scanPageNo; - // WHY: A801-1 无总数,远程短页是唯一耗尽信号 - if (rawRows.size() < fetchSize) { - exhausted = true; - break; - } - scanPageNo++; + query.setCurrent(current); + query.setSize(size); + query.setInvoiceNo(trimOrNull(query.getInvoiceNo())); + query.setSalerName(trimOrNull(query.getSalerName())); + query.setBuyerName(trimOrNull(query.getBuyerName())); + query.setInvoiceType(trimOrNull(query.getInvoiceType())); + query.setSalerTaxNo(trimOrNull(query.getSalerTaxNo())); + query.setBuyerTaxNo(trimOrNull(query.getBuyerTaxNo())); + query.setInvoiceDateStart(normalizeQueryDate(query.getInvoiceDateStart())); + query.setInvoiceDateEnd(normalizeQueryDate(query.getInvoiceDateEnd())); + IPage page = this.selectLocalPoolPage(query, current, size); + // 票号精确查本地未命中(且无认领/登记过滤干扰)→ 窄查一次 A801-1 回源入库再本地重查: + // 兜底增量窗口缝隙内的当日新票;名称模糊本地为空不回源(远程不支持名称条件,回源救不了) + if (page.getRecords().isEmpty() && query.getInvoiceNo() != null + && query.getClaimed() == null && query.getRegistered() == null + && this.fetchInvoicePoolFromRemote(query.getInvoiceNo())) { + page = this.selectLocalPoolPage(query, current, size); } - // 按游标偏移切页:skip 为游标窗口内已消费的过滤行数 - int fromIndex = Math.min(skip, window.size()); - int end = Math.min(fromIndex + (int) size, window.size()); - List pageRows = window.subList(fromIndex, end); - // 末次拉取页内已消费的过滤行数:翻页游标锚定在该页,页内剩余行下页续用 - int consumedInLastPage = Math.max(0, end - Math.max(fromIndex, filteredBeforeLastFetch)); - boolean hasMore = !exhausted || end < window.size(); - List records = pageRows.stream().map(this::toPoolViewVO).toList(); - this.decorateUseFlags(records); - KingdeeInvoicePoolPageVO result = new KingdeeInvoicePoolPageVO(); - result.setCurrent(current); - result.setSize(size); - result.setHasMore(hasMore); - result.setRecords(records); - result.setNextCursor(hasMore ? PoolScanCursor.encode(lastFetchPageNo, consumedInLastPage) : null); - log.info("金蝶票池实时查询 第{}页 {}条/页 返回{}条 hasMore:{} 远程调用{}次 游标:{}", current, size, - records.size(), hasMore, remoteCalls, result.getNextCursor()); - return result; + log.info("金蝶票池镜像查询 第{}页 {}条/页 返回{}条 total:{}", current, size, page.getRecords().size(), page.getTotal()); + return page; } - /** A801-1 按页拉取原始行:查询条件清洗与选票口径集中在此,循环补页多次复用 */ - private List queryPoolPage(long pageNo, int pageSize, KingdeeInvoicePoolQueryDTO query) { - BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO(); - data.setPageNo((int) pageNo); - data.setPageSize(pageSize); - // 固定只认正常状态票,作废/异常票不进选票结果 - data.setInvoiceStatus(KingdeeBfiConstant.POOL_INVOICE_STATUS_NORMAL); - data.setInvoiceNo(trimOrNull(query.getInvoiceNo())); - data.setSalerTaxNo(trimOrNull(query.getSalerTaxNo())); - data.setBuyerTaxNo(trimOrNull(query.getBuyerTaxNo())); - data.setInvoiceType(trimOrNull(query.getInvoiceType())); - // 单据使用状态固定查未用,不走前端条件 - data.setExpenseStatus(KingdeeBfiConstant.POOL_EXPENSE_STATUS_UNUSED); - data.setInvoiceDateStart(normalizeQueryDate(query.getInvoiceDateStart())); - data.setInvoiceDateEnd(normalizeQueryDate(query.getInvoiceDateEnd())); - BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); - param.setQueryParam(data); - BfiPageVO poolPage = bfiService.queryInvoicePool(param); - return Optional.ofNullable(poolPage.getRows()).orElse(List.of()); + /** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出) */ + private IPage selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) { + return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId()); + } + + /** + * 按发票号码窄查 A801-1 回源入库(票号全等防御 + 选票同口径可用过滤,不可用票不入镜像)。 + * + * @param invoiceNo 已 trim 的发票号码 + * @return 是否有可用票入库(false:远程未命中或不可用,调用方维持本地空页) + */ + private boolean fetchInvoicePoolFromRemote(String invoiceNo) { + List poolRows = queryPoolByInvoiceNo(invoiceNo); + BfiInvoicePoolVO poolRow = poolRows.stream() + .filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()) + && invoiceNo.equals(row.getInvoiceNo().trim())) + .findFirst() + .orElse(null); + if (poolRow == null) { + return false; + } + if (!this.isUsablePoolRow(poolRow)) { + log.info("金蝶票池回源命中但不可用,不入镜像,发票号码={}", invoiceNo); + return false; + } + KingdeeInvoicePool existing = invoicePoolMapper.selectOne(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo)); + upsertInvoicePool(existing, poolRow); + log.info("金蝶票池回源入库完成,发票号码={}", invoiceNo); + return true; } /** @@ -265,39 +294,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl 0; } - /** - * 票池扫描游标("远程页码:已消费过滤行数"):过滤发生在拉取之后,翻页按游标续扫才能跨请求对齐过滤进度; - * 游标页码统一按 500 行窗口定位,解析失败降级为按页码锚定(仅存在翻页错位风险,不阻断查询) - */ - private record PoolScanCursor(long pageNo, int skip) { - - static PoolScanCursor parse(String text) { - if (StringUtil.isBlank(text)) { - return null; - } - String[] parts = text.trim().split(":"); - if (parts.length != 2) { - log.warn("金蝶票池续扫游标格式非法,忽略后按页码锚定,游标={}", text); - return null; - } - try { - long pageNo = Long.parseLong(parts[0].trim()); - int skip = Integer.parseInt(parts[1].trim()); - if (pageNo < 1 || skip < 0) { - throw new NumberFormatException("游标数值越界"); - } - return new PoolScanCursor(pageNo, skip); - } catch (NumberFormatException exception) { - log.warn("金蝶票池续扫游标解析失败,忽略后按页码锚定,游标={}", text); - return null; - } - } - - static String encode(long pageNo, int skip) { - return pageNo + ":" + skip; - } - } - /** 空白串归一为 null,便于远程查询条件与 MyBatis-Plus 判空 */ private String trimOrNull(String text) { return StringUtil.isBlank(text) ? null : text.trim(); @@ -315,69 +311,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl 统一视图:镜像未生成故 id 为空;金额口径价税合计三值同源; - * 银行/邮箱/电话远端无来源保持空,与镜像存根同形 - */ - private KingdeeInvoicePoolVO toPoolViewVO(BfiInvoicePoolVO row) { - KingdeeInvoicePoolVO vo = new KingdeeInvoicePoolVO(); - vo.setInvoiceNo(row.getInvoiceNo().trim()); - vo.setSerialNo(row.getSerialNo()); - vo.setInvoiceDate(row.getInvoiceDate()); - vo.setInvoiceType(BfiConstant.INVOICE_TYPE_NAMES.getOrDefault(row.getInvoiceType(), row.getInvoiceType())); - vo.setTaxRate(resolveTaxRate(row)); - vo.setTaxAmount(parseAmount(row.getInvoiceNo(), row.getTaxAmount())); - BigDecimal totalAmount = parseAmount(row.getInvoiceNo(), row.getTotalAmount()); - vo.setInvoiceAmount(totalAmount); - vo.setAvailableInvoiceAmount(totalAmount); - vo.setMatchedAmount(totalAmount); - // 剩余可用金额为金蝶可用税额口径,仅用于选票展示,与可认领金额的价税合计口径不同 - vo.setAvailableTaxAmount(parseAmount(row.getInvoiceNo(), row.getAvailableAmount())); - vo.setReceiverName(row.getBuyerName()); - vo.setBuyerTaxNo(row.getBuyerTaxNo()); - vo.setIssuerName(row.getSalerName()); - vo.setSalerTaxNo(row.getSalerTaxNo()); - vo.setKingdeeBillNo(row.getSerialNo()); - vo.setCheckStatus(row.getCheckStatus()); - vo.setAttachmentsJson(buildAttachmentJson(row)); - return vo; - } - - /** - * claimed/registered 只读装饰:本页票号三次批量本地查询内存打标,金蝶不感知、不作过滤下推; - * claimed=票关联的结算单中存在未作废主单(作废结算单不占坑) - */ - private void decorateUseFlags(List records) { - if (records.isEmpty()) { - return; - } - List invoiceNos = records.stream().map(KingdeeInvoicePoolVO::getInvoiceNo).distinct().toList(); - Set registeredNos = list(Wrappers.lambdaQuery() - .select(InvoiceReceipt::getInvoiceNo) - .in(InvoiceReceipt::getInvoiceNo, invoiceNos) - .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED) - .eq(InvoiceReceipt::getIsDeleted, 0)).stream() - .map(InvoiceReceipt::getInvoiceNo).collect(Collectors.toSet()); - Map> claimIdsByInvoice = formalSettlementInvoiceMapper.selectList( - Wrappers.lambdaQuery() - .select(FormalSettlementInvoice::getInvoiceNo, FormalSettlementInvoice::getFormalSettlementId) - .in(FormalSettlementInvoice::getInvoiceNo, invoiceNos)).stream() - .collect(Collectors.groupingBy(FormalSettlementInvoice::getInvoiceNo, - Collectors.mapping(FormalSettlementInvoice::getFormalSettlementId, Collectors.toSet()))); - Set allClaimIds = claimIdsByInvoice.values().stream().flatMap(Set::stream).collect(Collectors.toSet()); - Set aliveSettlementIds = allClaimIds.isEmpty() ? Set.of() - : formalSettlementMapper.selectList(Wrappers.lambdaQuery() - .select(FormalSettlement::getId) - .in(FormalSettlement::getId, allClaimIds) - .ne(FormalSettlement::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() - .map(FormalSettlement::getId).collect(Collectors.toSet()); - records.forEach(record -> { - record.setRegistered(registeredNos.contains(record.getInvoiceNo())); - record.setClaimed(claimIdsByInvoice.getOrDefault(record.getInvoiceNo(), Set.of()).stream() - .anyMatch(aliveSettlementIds::contains)); - }); - } - @Override public List> settlementCandidates(String keyword, Long receiptId) { List settlements = formalSettlementMapper.selectList( @@ -543,6 +476,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl ids) { + // 手动批量推送:前置拦截保持现状(blockedReason 不动推送状态,逐单报因) + return pushKingdeeReceivedInvoice(ids, false); + } + + /** + * 批量推送收票登记至金蝶(图05 环节②③:A303-2 收票单批量保存 + A302-6 指定应付)。 + *

+ * 编排流程:参数去重校验 → 批量组装推送计划(分摊/镜像/结算单/结算明细一次取回防 N+1)→ + * A102-4 应付单已生成预检 → A801-3 幂等预检(金蝶已有收票单则跳过保存续推指定)→ + * 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回 + * + * @param ids 收票登记主键集合 + * @param autoSync 自动推送路径(审批通过事件/定时重推):前置拦截置 failed + 原因纳入重推循环; + * 手动批量路径保持现状(拦截不改状态,逐单报因) + * @return 推送结果汇总(本批笔数、成功/失败笔数及失败原因明细) + */ + @Override + public String pushKingdeeReceivedInvoice(List ids, boolean autoSync) { if (Func.isEmpty(ids)) { throw new ServiceException("请选择需要推送金蝶的收票登记"); } @@ -655,7 +608,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl failures = new ArrayList<>(); for (InvoiceReceipt receipt : receipts) { try { - pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context); + pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync); success++; } catch (RuntimeException pushException) { failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) @@ -671,33 +624,84 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl failedIds = list(Wrappers.lambdaQuery() + .eq(InvoiceReceipt::getKingdeePushStatus, KingdeeBfiConstant.RECEIVED_PUSH_FAILED) + .eq(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(InvoiceReceipt::getCreateTime)) + .stream().map(InvoiceReceipt::getId).toList(); + if (Func.isEmpty(failedIds)) { + return "无推送失败单据"; + } + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + // 批量推送方法逐单吞异常返回摘要,成败以推送落库状态为准(pushed/assigned 视为成功) + pushKingdeeReceivedInvoice(List.of(id), true); + InvoiceReceipt pushed = getById(id); + String pushStatus = pushed == null ? null : pushed.getKingdeePushStatus(); + if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(pushStatus) + || KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) { + success++; + } else { + failures.add(id + ":" + (pushed == null ? "单据不存在" : Func.toStrWithEmpty(pushed.getKingdeePushMsg(), "推送未完成"))); + } + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } + } + String summary = "重推推送失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + /** * 单张收票登记推送编排:前置校验 → A102-4 预检 → A801-3 幂等预检 → A303-2 保存 → pushed 落库 → - * A302-6 指定应付 → assigned;保存/指定阶段失败置 failed(预检拦截不改状态),断点可凭防重键续推 + * A302-6 指定应付 → assigned;保存/指定阶段失败置 failed,断点可凭防重键续推。 + * 自动推送路径(autoSync=true)前置拦截一并置 failed + 原因,纳入定时重推循环; + * 手动批量路径前置拦截保持不动状态(逐单报因),避免人工误触把可自助修复的单据打成失败 * * @param plan 预检组装的推送计划 * @param kingdeeRows A801-3 幂等预检命中的金蝶收票单(发票号码 -> 行) * @param apBillNoBySrcCode A102-4 已生成的财务应付单(防重键 -> 单据编号) * @param context 金蝶同步上下文(合同/客商编码缓存) + * @param autoSync 自动推送路径标记(前置拦截置 failed) * @return 推送结果描述 */ private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map kingdeeRows, - Map apBillNoBySrcCode, KingdeeSyncContext context) { + Map apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) { InvoiceReceipt receipt = plan.receipt(); log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(), receipt.getKingdeePushStatus(), plan.apBillSrcCode()); if (Func.isNotBlank(plan.blockedReason())) { log.warn("收票登记[{}]推送金蝶收票单拦截:{}", receipt.getInvoiceNo(), plan.blockedReason()); + // 自动推送路径:前置不满足置 failed + 原因纳入重推循环("已指定应付"的良性跳过不改状态) + if (autoSync && !KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals( + Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED))) { + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, truncatePushMsg(plan.blockedReason()), true); + } throw new ServiceException(plan.blockedReason()); } String apBillSrcCode = plan.apBillSrcCode(); if (!apBillNoBySrcCode.containsKey(apBillSrcCode)) { log.warn("收票登记[{}]推送金蝶收票单拦截:结算单[{}]未生成财务应付单(防重键={})", receipt.getInvoiceNo(), plan.settlement().getFormalSettlementNo(), apBillSrcCode); + // 自动推送路径:前置应付单未生成置 failed + 原因,上游结算单就绪后由重推任务自动续推 + if (autoSync) { + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo() + "未生成财务应付单"), true); + } throw new ServiceException("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo() + "未生成财务应付单"); } String billNo; + String billPkId; BfiReceivedInvoiceVO kingdeeRow = kingdeeRows.get(String.valueOf(receipt.getId())); if (kingdeeRow != null) { if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(kingdeeRow.getInvoicestatus())) { @@ -706,11 +710,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl entryGroups = kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context); - billNo = kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context); - log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}]", receipt.getInvoiceNo(), billNo); + KingdeeReceivedInvoiceSyncSupport.ReceivedInvoicePushOutcome pushOutcome = + kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context); + billNo = pushOutcome.billNo(); + billPkId = pushOutcome.billId(); + log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}],星瀚内码={}", receipt.getInvoiceNo(), billNo, billPkId); } catch (RuntimeException pushException) { log.warn("收票登记[{}]A303-2 推送失败:{}", receipt.getInvoiceNo(), pushException.getMessage()); - applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED, truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true); throw pushException; } } - // 保存成功先落单号+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付 - applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false); + // 保存成功先落单号+星瀚内码+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付 + applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false); + // A303-2 成功即联带上传附件(登记附件 + 镜像发票文件经 A801-2 下载回传): + // 失败只记附件同步状态,不阻塞 A302-6 指定应付,也不阻塞同批其他登记,重推自动补传 + syncAttachmentsAfterPush(receipt, plan, billPkId); try { log.info("收票登记[{}]开始 A302-6 指定应付:应付单防重键={}", receipt.getInvoiceNo(), apBillSrcCode); kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode); } catch (RuntimeException assignException) { log.warn("收票登记[{}]A302-6 指定应付失败:{}", receipt.getInvoiceNo(), assignException.getMessage()); - applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_FAILED, truncatePushMsg("金蝶收票单指定应付失败:" + assignException.getMessage()), true); throw assignException; } - applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true); + applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true); log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已指定应付单", receipt.getInvoiceNo(), billNo); return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单"; } + /** + * 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件; + * billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度 + * + * @param receipt 收票登记 + * @param plan 推送计划(镜像附件取数来源) + * @param billPkId 星瀚收票单内码 + */ + private void syncAttachmentsAfterPush(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan, String billPkId) { + try { + if (Func.isBlank(billPkId)) { + log.warn("收票登记[{}]金蝶附件联带上传跳过:星瀚收票单内码未落库,待重推补传", receipt.getInvoiceNo()); + return; + } + List items = new ArrayList<>(); + items.addAll(kingdeeAttachmentSyncSupport.parseUserAttachments(receipt.getAttachmentsJson())); + // 镜像发票文件:幂等键=path,经 A801-2 下载后 A902 回传收票单(path 变化视为新文件自动重传) + items.addAll(kingdeeAttachmentSyncSupport.parseInvoicePoolFile(plan.mirror().getAttachmentsJson())); + KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncAttachments( + KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, receipt.getId(), billPkId, items); + if (summary.total() > 0 || summary.skipCount() > 0) { + // 附件同步操作流水(Q5 决策:收票登记有 actionType 流水表,追加 attachment_upload 记录) + record(receipt.getId(), "attachment_upload", "附件上传金蝶", receipt.getApprovalStatus(), receipt.getApprovalStatus(), + summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons()) + : "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)", + receipt.getKingdeeReceivedInvoiceNo()); + } + } catch (Exception exception) { + log.error("收票登记[{}]金蝶附件联带上传异常(不影响推送状态,重推自动补传)", receipt.getInvoiceNo(), exception); + } + } + /** * 推送前置组装:状态机、绑定、查验、一票一应付、应付单防重键;不满足项写入 blockedReason(不拦截整批,逐单报因) */ @@ -806,7 +852,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl rows = new LinkedHashMap<>(); LocalDateTime windowEnd = LocalDateTime.now(); - LocalDateTime windowStart = windowEnd.minusDays(Math.max(receivedQueryWindowDays, 1)); + LocalDateTime windowStart = windowEnd.minusDays(Math.max(bfiProperties.getReceivedInvoice().getQueryWindowDays(), 1)); for (Map.Entry> entry : invoiceNosByBuyer.entrySet()) { if (StringUtil.isBlank(entry.getKey())) { continue; @@ -818,11 +864,14 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl匹配与容错说明: + * ① 匹配键为收票登记 id,与 A303-2 落库的 awrp_src_code 同源,按发票号匹配将永远查不到自推单据; + * ② 查询窗口为近 {@code thirdparty.bfi.receivedInvoice.resultWindowDays} 配置天数(至少 1 天),窗口内查不到不判为金蝶侧已删除 + * (金蝶修改时间延迟/滑窗),保持现状由人工重推兜底; + * ③ 单笔回写异常仅记入失败明细不中断整轮,结束时汇总进执行摘要; + * ④ 由 {@link #syncKingdeeReceivedInvoiceResult()} 在分布式锁保护下调用,防多实例并发重跑。 + * + * @return 执行摘要 + */ + private String executeReceivedInvoiceResultSync() { + long startMillis = System.currentTimeMillis(); List pendingReceipts = list(Wrappers.lambdaQuery() .in(InvoiceReceipt::getKingdeePushStatus, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED) @@ -879,8 +949,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())).count(); + log.info("金蝶收票单结果回写开始:待回写收票登记{}笔(pushed待指定补偿 {} 笔,assigned待单号补登 {} 笔)", + pendingReceipts.size(), pushedCount, pendingReceipts.size() - pushedCount); List mirrorIds = pendingReceipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId) .filter(Objects::nonNull).distinct().toList(); Map mirrorById = invoicePoolMapper.selectBatchIds(mirrorIds).stream() @@ -901,18 +976,19 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl new ArrayList<>()).add(receipt); } LocalDateTime windowEnd = LocalDateTime.now(); - LocalDateTime windowStart = windowEnd.minusDays(Math.max(receivedResultWindowDays, 1)); + LocalDateTime windowStart = windowEnd.minusDays(Math.max(bfiProperties.getReceivedInvoice().getResultWindowDays(), 1)); int recovered = 0; int reassigned = 0; int filled = 0; List failures = new ArrayList<>(); for (Map.Entry> entry : receiptsByBuyer.entrySet()) { - Set invoiceNos = entry.getValue().stream().map(InvoiceReceipt::getInvoiceNo) + // 匹配键须与 A303-2 落库的 awrp_src_code 同源(收票登记id),按发票号匹配将永远查不到自推单据 + Set srcCodes = entry.getValue().stream().map(receipt -> String.valueOf(receipt.getId())) .collect(Collectors.toCollection(LinkedHashSet::new)); Map matched = kingdeeReceivedInvoiceSyncSupport - .queryReceivedInvoiceBySrcCodes(entry.getKey(), invoiceNos, windowStart, windowEnd); + .queryReceivedInvoiceBySrcCodes(entry.getKey(), srcCodes, windowStart, windowEnd); for (InvoiceReceipt receipt : entry.getValue()) { - BfiReceivedInvoiceVO row = matched.get(receipt.getInvoiceNo()); + BfiReceivedInvoiceVO row = matched.get(String.valueOf(receipt.getId())); // 窗口内查不到不判为删除(金蝶修改时间延迟/滑窗),保持现状由人工重推兜底 if (row == null) { continue; @@ -920,7 +996,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl attachment = new LinkedHashMap<>(); attachment.put("source", "piaozone"); - attachment.put("path", row.getDownloadUrl()); - attachment.put("name", row.getInvoiceNo() + ".pdf"); + attachment.put("path", path); + attachment.put("name", row.getInvoiceNo() + (ofdOnly ? ".ofd" : ".pdf")); return JsonUtil.toJson(attachment); } + // ==================== 金蝶全票池定时同步(瘦身版:窗口拉取 + 全列 upsert,无绑定/补偿/白名单) ==================== + + @Override + public String syncKingdeeInvoicePool() { + return kingdeeSyncLockSupport.runSingleFlight(POOL_SYNC_LOCK_PREFIX, bfiProperties.getInvoiceReceipt().getLockLeaseSeconds(), + "金蝶全票池同步", this::executePoolSync); + } + + /** + * 同步主体(39ffac1 绑定回填口径瘦身版):滚动增量窗口按 updateTime **切片续扫** + 镜像批量 upsert(有变化才写) + * + 未绑定存量登记顺带补绑(同页发票号码批量定位登记,页级事务批量落库)。 + *

切片续扫(ponytail: 单查询窗口有 MAX_POOL_PAGES×500=2.5 万硬上限,窗口数据超限尾部永久漏同步; + * 切片让每片各自独立受翻页保护,总覆盖不受限):把大窗口切成小片独立翻页,某片触顶说明该时间片内 + * 数据 >2.5 万,二分为 [start, mid) [mid, end) 两片递归处理;updateTime 单调,切片边界不重不漏。 + *

发票种类白名单**下推远端**(invoiceType 为单值参数,按白名单编码逐轮查询,每轮独立切片额度), + * 白名单为空退化为单轮全量——只同步数电普票/专票时不把他票种的额度消耗进来。 + *

拉取只认正常票(invoiceStatus=0),不推单据使用状态——在用/已用票随 updateTime 进窗口刷新 + * 金额使用状态,选票可用过滤在本地 SQL 执行;被删除/红冲票不再出现在拉取结果,由保存/认领重查闸门兜底。 + * + * @return 执行摘要 + */ + private String executePoolSync() { + long startMillis = System.currentTimeMillis(); + LocalDateTime windowStart = LocalDateTime.now().minusDays(bfiProperties.getInvoiceReceipt().getIncrementDays()); + LocalDateTime windowEnd = LocalDateTime.now(); + List invoiceTypes = this.poolInvoiceTypeCodes(); + SyncStats stats = new SyncStats(); + log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)", + windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER), + invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES); + if (invoiceTypes.isEmpty()) { + this.syncWindowSlice(windowStart, windowEnd, null, stats); + } else { + // 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度 + for (String invoiceType : invoiceTypes) { + this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats); + } + } + String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取" + + stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化" + + stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged + + ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms"; + log.info("金蝶全票池同步结束,{}", summary); + return summary; + } + + /** 白名单编码集:逗号分隔配置解析 trim,空配置返回空集(不限制) */ + private List poolInvoiceTypeCodes() { + if (StringUtil.isBlank(bfiProperties.getInvoiceReceipt().getInvoiceTypes())) { + return List.of(); + } + return java.util.Arrays.stream(bfiProperties.getInvoiceReceipt().getInvoiceTypes().split(",")) + .map(String::trim) + .filter(StringUtil::isNotBlank) + .distinct() + .toList(); + } + + /** 整轮同步累计统计(各切片共享累加,结束进摘要) */ + private static final class SyncStats { + /** A801-1 原始拉取张数 */ + int pulledCount; + /** 镜像新增条数(票池有、本地无) */ + int created; + /** 镜像有变化并重写条数 */ + int updated; + /** 镜像无变化跳过条数 */ + int mirrorUnchanged; + /** 登记补绑条数(罕见:存量手工数据未绑定时顺带补绑) */ + int bound; + /** 登记无变化跳过条数 */ + int unchanged; + /** 页级批量落库失败条数(整页跳过,下轮幂等重试) */ + int failed; + /** 实际处理的翻页总页数 */ + int pageCount; + /** 实际处理的切片数 */ + int sliceCount; + } + + /** + * 同步单个时间片 [sliceStart, sliceEnd):独立翻页拉取(最多 MAX_POOL_PAGES 页), + * 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛, + * 单片 500 条以内 1 页即短页终止) + * + * @param sliceStart 切片起点(含) + * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) + * @param stats 整轮统计累加器 + */ + private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) { + // 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏 + if (!sliceStart.isBefore(sliceEnd)) { + log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd); + return; + } + boolean truncated = false; + for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) { + List rows = this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo); + if (rows.isEmpty()) { + break; + } + stats.pageCount++; + stats.pulledCount += rows.size(); + BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows); + stats.created += upsertResult.created(); + stats.updated += upsertResult.updated(); + stats.mirrorUnchanged += upsertResult.unchanged(); + // 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库 + if (!upsertResult.mirrors().isEmpty()) { + RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors()); + stats.bound += refreshResult.bound(); + stats.unchanged += refreshResult.unchanged(); + stats.failed += refreshResult.failed(); + } + // 远程短页:该片到底,正常终止 + if (rows.size() < POOL_PAGE_SIZE) { + stats.sliceCount++; + return; + } + truncated = true; + } + // 触顶:该切片数据超过单查询窗口上限,从中点二分续扫(updateTime 落在中点的行归左片 [start, mid), + // 右片 [mid, end) 不含 mid,同 updateTime 行整体归左片,不重不漏) + if (truncated) { + LocalDateTime mid = sliceStart.plus(java.time.Duration.between(sliceStart, sliceEnd).dividedBy(2)); + if (!mid.isAfter(sliceStart)) { + // 切片已细到不足 1 秒仍满页:理论不可能,防御死递归 + log.error("金蝶全票池同步切片 {} ~ {} 已达最小粒度仍满页(>{} 条),该时间片数据异常,跳过", + sliceStart, sliceEnd, MAX_POOL_PAGES * POOL_PAGE_SIZE); + return; + } + log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})", + sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd); + this.syncWindowSlice(sliceStart, mid, invoiceType, stats); + this.syncWindowSlice(mid, sliceEnd, invoiceType, stats); + return; + } + stats.sliceCount++; + } + + /** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */ + private record RefreshResult(int bound, int unchanged, int failed) { + } + + /** + * 同页发票号码批量定位收票登记,绑定关系变更页级事务批量落库(39ffac1 口径瘦身版): + * 新架构下登记绑定在保存时完成且登记无金蝶流水号/状态字段(70d5d34 已删), + * 已绑定登记零写入,仅未绑定存量登记(罕见,人工数据)顺带补绑;批量失败整页跳过待下轮幂等重试 + * + * @param pageNo 当前页码(日志定位) + * @param mirrorMap 本页 invoiceNo -> 刚 upsert 的镜像 + * @return 刷新结果统计 + */ + private RefreshResult refreshBoundReceipts(int pageNo, Map mirrorMap) { + Map> receiptMap = list(Wrappers.lambdaQuery() + .in(InvoiceReceipt::getInvoiceNo, mirrorMap.keySet()) + .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() + .filter(receipt -> !Objects.equals(receipt.getIsDeleted(), 1)) + .collect(Collectors.groupingBy(InvoiceReceipt::getInvoiceNo)); + // 镜像有了却没人登记:本页不产生任何刷新,属正常(票池范围大于我方登记范围) + if (receiptMap.isEmpty()) { + return new RefreshResult(0, 0, 0); + } + List changedReceipts = new ArrayList<>(); + List syncRecords = new ArrayList<>(); + int bound = 0; + int unchanged = 0; + for (Map.Entry> entry : receiptMap.entrySet()) { + KingdeeInvoicePool mirror = mirrorMap.get(entry.getKey()); + for (InvoiceReceipt receipt : entry.getValue()) { + // 新架构登记无 kingdeeBillNo/kingdeeStatus 字段(70d5d34 已删),仅绑定关系可变: + // 已绑定且指向同一镜像的登记零写入;未绑定存量登记(罕见,人工数据)顺带补绑 + boolean unbound = receipt.getKingdeeInvoicePoolId() == null + || !Objects.equals(receipt.getKingdeeInvoicePoolId(), mirror.getId()); + if (!unbound) { + unchanged++; + continue; + } + receipt.setKingdeeInvoicePoolId(mirror.getId()); + changedReceipts.add(receipt); + syncRecords.add(this.buildPoolSyncRecord(receipt, "定时绑定金蝶票池发票")); + bound++; + } + } + if (changedReceipts.isEmpty()) { + return new RefreshResult(0, unchanged, 0); + } + try { + // 页级事务批量落库:绑定/状态批量更新 + 同步流水批量写入(JDBC batch,整页原子生效) + Db.updateBatchById(changedReceipts); + if (!syncRecords.isEmpty()) { + Db.saveBatch(syncRecords); + } + log.info("金蝶全票池同步 第{}页落库完成,补绑 {} 条,同步流水 {} 条", + pageNo, changedReceipts.size(), syncRecords.size()); + return new RefreshResult(bound, unchanged, 0); + } catch (Exception batchException) { + // 批量落库失败:整页跳过计入失败,下轮任务幂等重试,不做逐单降级(保持全批量链路) + log.error("金蝶全票池同步登记刷新批量落库异常,第{}页 {} 条跳过待下轮重试", pageNo, changedReceipts.size(), batchException); + return new RefreshResult(0, unchanged, changedReceipts.size()); + } + } + + /** 构建金蝶票池同步流水(操作人以「定时任务」名义记录) */ + private InvoiceReceiptRecord buildPoolSyncRecord(InvoiceReceipt receipt, String reason) { + InvoiceReceiptRecord syncRecord = new InvoiceReceiptRecord(); + syncRecord.setInvoiceReceiptId(receipt.getId()); + syncRecord.setActionType("sync"); + syncRecord.setActionName(reason); + syncRecord.setFromStatus(receipt.getApprovalStatus()); + syncRecord.setToStatus(receipt.getApprovalStatus()); + syncRecord.setOperatorName(AuthUtil.getUserName() == null ? "定时任务" : AuthUtil.getUserName()); + return syncRecord; + } + + /** A801-1 按票池更新时间窗口分页拉取(只认正常票,不推单据使用状态;invoiceType 单值下推,null 不限) */ + private List pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd, + String invoiceType, int pageNo) { + BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO(); + data.setPageNo(pageNo); + data.setPageSize(POOL_PAGE_SIZE); + data.setUpdateTime(windowStart.format(POOL_TIME_FORMATTER)); + data.setUpdateTimeEnd(windowEnd.format(POOL_TIME_FORMATTER)); + data.setInvoiceStatus(KingdeeBfiConstant.POOL_INVOICE_STATUS_NORMAL); + data.setInvoiceType(invoiceType); + BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); + param.setQueryParam(data); + BfiPageVO poolPage = bfiService.queryInvoicePool(param); + log.info("金蝶全票池同步 第{}页拉取 {} 条", pageNo, poolPage.getRows() == null ? 0 : poolPage.getRows().size()); + return poolPage.getRows() == null ? List.of() : poolPage.getRows(); + } + + /** + * 票池镜像批量 upsert:一次查询同页既有镜像,全列覆盖批量写库(新增批量插入、既有批量更新), + * 避免逐条查改 N+1;MyBatis-Plus 非空字段更新策略下源侧空字段维持库中原值,与手动同步路径同口径 + * + * @param rows A801-1 窗口拉取的票池发票 + * @return 批量 upsert 结果 + */ + private BatchUpsertResult batchUpsertInvoicePool(List rows) { + Map mirrorMap = new LinkedHashMap<>(); + if (rows.isEmpty()) { + return new BatchUpsertResult(mirrorMap, 0, 0, 0); + } + List invoiceNos = rows.stream() + .map(row -> row.getInvoiceNo().trim()) + .distinct() + .toList(); + // 同页发票号码一次批量查询既有镜像 + Map existingMap = invoicePoolMapper.selectList( + Wrappers.lambdaQuery() + .in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first)); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + int unchangedCount = 0; + for (BfiInvoicePoolVO row : rows) { + if (Func.isEmpty(row.getInvoiceNo())) { + continue; + } + KingdeeInvoicePool existing = existingMap.get(row.getInvoiceNo().trim()); + KingdeeInvoicePool mirror = fillInvoicePool(existing, row); + mirrorMap.put(mirror.getInvoiceNo(), mirror); + if (existing == null) { + toInsert.add(mirror); + } else if (poolMirrorChanged(existing, mirror)) { + toUpdate.add(mirror); + } else { + // 票面无变化跳过重写:整窗重复拉取时消掉每轮 2.5 万行无效写 + unchangedCount++; + } + } + // 批量写库:失败中断本轮,由任务层记录异常、下轮重试(upsert 幂等) + if (!toInsert.isEmpty()) { + Db.saveBatch(toInsert); + } + if (!toUpdate.isEmpty()) { + Db.updateBatchById(toUpdate); + } + log.info("金蝶全票池镜像批量写入完成,新增 {} 条,更新 {} 条,无变化 {} 条", toInsert.size(), toUpdate.size(), unchangedCount); + return new BatchUpsertResult(mirrorMap, toInsert.size(), toUpdate.size(), unchangedCount); + } + + /** + * 镜像票面是否有变化(轻量比对,ponytail: 只比对"会变且影响业务"的字段, + * 不搞人工字段清单——清单漏新增列即静默不同步是旧版指纹的坑): + * 流水号(金蝶侧标识)、价税合计、税额、剩余可用金额、金额使用状态。 + * BigDecimal 比对用 compareTo(13.00 与 13.0 等价);空值视为变化跳过重写(与 MP 非空字段更新策略一致) + * + * @param existing 库中既有镜像 + * @param filled 填充后的镜像 + * @return true 表示有变化需重写 + */ + private boolean poolMirrorChanged(KingdeeInvoicePool existing, KingdeeInvoicePool filled) { + if (!Objects.equals(existing.getSerialNo(), filled.getSerialNo()) + || !Objects.equals(existing.getAmountUseStatus(), filled.getAmountUseStatus())) { + return true; + } + return amountChanged(existing.getInvoiceAmount(), filled.getInvoiceAmount()) + || amountChanged(existing.getTaxAmount(), filled.getTaxAmount()) + || amountChanged(existing.getAvailableAmount(), filled.getAvailableAmount()); + } + + /** 金额变化比对:双方均空视为无变化;一方空一方有视为有变化;否则按数值比较 */ + private boolean amountChanged(BigDecimal before, BigDecimal after) { + if (before == null && after == null) { + return false; + } + if (before == null || after == null) { + return true; + } + return before.compareTo(after) != 0; + } + + /** 票池镜像批量 upsert 结果(invoiceNo -> 镜像,新增条数、更新条数、无变化条数) */ + private record BatchUpsertResult(Map mirrors, int created, int updated, int unchanged) { + } + + // ==================== 结算单认领确认重查闸门 ==================== + + @Override + public void assertInvoicesClaimable(Collection invoiceNos) { + List distinctNos = invoiceNos == null ? List.of() : invoiceNos.stream() + .filter(Func::isNotEmpty) + .map(String::trim) + .distinct() + .toList(); + for (String invoiceNo : distinctNos) { + // 窄查 + 票号全等防御 + 选票同口径可用复核:选中到确认之间票被认领/使用/红冲在此拦截 + BfiInvoicePoolVO poolRow = queryPoolByInvoiceNo(invoiceNo).stream() + .filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()) + && invoiceNo.equals(row.getInvoiceNo().trim())) + .findFirst() + .orElse(null); + if (poolRow == null) { + throw new ServiceException("金蝶票池未查询到发票 " + invoiceNo + ",不能认领"); + } + if (!this.isUsablePoolRow(poolRow)) { + throw new ServiceException("发票 " + invoiceNo + " 剩余可用金额为0或金额使用状态已变更,不能认领"); + } + } + } + + // ==================== 金蝶票池发票文件下载(A801-2 前端代理,见 2026-09-27 设计文档) ==================== + + @Override + public void downloadPoolAttachment(String invoiceNo, HttpServletResponse response) { + String trimmedInvoiceNo = trimOrNull(invoiceNo); + if (StringUtil.isBlank(trimmedInvoiceNo)) { + WebUtil.renderJson(response, R.fail("发票号不能为空")); + return; + } + try { + doDownloadPoolAttachment(trimmedInvoiceNo, response); + } catch (ServiceException exception) { + // 写流前的业务失败统一以 JSON 返回:前端按响应 Content-Type 识别提示,浏览器不落损坏文件 + log.warn("金蝶票池发票文件下载失败:发票[{}]:{}", trimmedInvoiceNo, exception.getMessage()); + WebUtil.renderJson(response, R.fail(exception.getMessage())); + } catch (IOException exception) { + // 传输中断类失败(客户端断开等):响应通道已不可用,仅记录日志 + log.error("金蝶票池发票文件下载传输异常:发票[{}]", trimmedInvoiceNo, exception); + } + } + + /** + * 下载主流程:按发票号定位金蝶侧文件 path(镜像优先、A801-1 实时回源兜底)→ A801-2 文件流 + * → 先校验后写头(失败在前端可识别阶段暴露)→ 8KB 流式透传,不落盘不进堆 + */ + private void doDownloadPoolAttachment(String invoiceNo, HttpServletResponse response) throws IOException { + KingdeeAttachmentSyncSupport.KingdeeAttachmentItem item = resolvePoolAttachment(invoiceNo); + if (item == null || Func.isBlank(item.key())) { + throw new ServiceException("该发票没有金蝶侧文件"); + } + long start = System.currentTimeMillis(); + Response bfiResponse = bfiService.downloadInvoice(item.key()); + try { + String contentType = firstHeader(bfiResponse, "Content-Type"); + // A801-2 失败以 JSON 体返回(如 {"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}),先校验再写流 + if (contentType != null && contentType.contains("application/json")) { + throw new ServiceException("调用BFI文件下载失败:" + readBfiJsonMessage(bfiResponse)); + } + String fileName = Func.isBlank(item.name()) ? invoiceNo + ".pdf" : item.name(); + // BFI 文件流统一回 octet-stream:PDF 版式须显式声明 MIME,否则浏览器 blob 不渲染、查看退化为静默下载 + if (fileName.endsWith(".pdf") && (Func.isBlank(contentType) || contentType.contains("octet-stream"))) { + contentType = "application/pdf"; + } + // 文件名按 RFC 5987 编码(+ 号转 %20),中文/特殊字符不乱码 + String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"); + response.setContentType(Func.isBlank(contentType) ? "application/pdf" : contentType); + String contentLength = firstHeader(bfiResponse, "Content-Length"); + if (contentLength != null) { + try { + response.setContentLengthLong(Long.parseLong(contentLength)); + } catch (NumberFormatException ignored) { + // 非法长度头按无长度处理,走分块传输 + } + } + response.setHeader("Content-Disposition", + "attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName); + int totalBytes = 0; + try (InputStream inputStream = bfiResponse.body().asInputStream(); + OutputStream outputStream = response.getOutputStream()) { + byte[] buffer = new byte[DOWNLOAD_BUFFER_SIZE]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + totalBytes += read; + } + outputStream.flush(); + } + log.info("金蝶票池发票文件下载完成:发票[{}],文件[{}],{}字节,耗时{}ms", + invoiceNo, fileName, totalBytes, System.currentTimeMillis() - start); + } finally { + bfiResponse.close(); + } + } + + /** + * 定位金蝶侧发票文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证), + * 镜像未落库(新进票池尚未同步)或镜像无文件时实时回源 A801-1 按票号取 downloadUrl; + * 远程票号查询为模糊匹配,命中行需与目标票号全等防御 + */ + private KingdeeAttachmentSyncSupport.KingdeeAttachmentItem resolvePoolAttachment(String invoiceNo) { + List mirrors = invoicePoolMapper.selectList(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo)); + if (Func.isNotEmpty(mirrors)) { + Optional mirrorItem = kingdeeAttachmentSyncSupport + .parseInvoicePoolFile(mirrors.get(0).getAttachmentsJson()).stream().findFirst(); + if (mirrorItem.isPresent()) { + return mirrorItem.get(); + } + } + List rows = queryPoolByInvoiceNo(invoiceNo); + for (BfiInvoicePoolVO row : rows) { + if (row.getInvoiceNo() != null && invoiceNo.equals(row.getInvoiceNo().trim())) { + return kingdeeAttachmentSyncSupport.parseInvoicePoolFile(buildAttachmentJson(row)) + .stream().findFirst().orElse(null); + } + } + return null; + } + + /** 读取 A801-2 JSON 错误体提示信息(优先 message,兜底 errorCode) */ + private String readBfiJsonMessage(Response response) { + try (InputStream inputStream = response.body().asInputStream()) { + Object parsed = JsonUtil.parse(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8), Object.class); + if (parsed instanceof Map map) { + String message = firstTextValue(map, "message"); + if (Func.isNotBlank(message)) { + return message; + } + String errorCode = firstTextValue(map, "errorCode"); + if (Func.isNotBlank(errorCode)) { + return errorCode; + } + } + } catch (Exception exception) { + log.warn("解析BFI文件下载错误响应失败", exception); + } + return "响应体为JSON错误"; + } + + /** 大小写不敏感取第一个响应头值 */ + private String firstHeader(Response response, String name) { + for (Map.Entry> entry : response.headers().entrySet()) { + if (name.equalsIgnoreCase(entry.getKey()) && Func.isNotEmpty(entry.getValue())) { + return entry.getValue().iterator().next(); + } + } + return null; + } + + private String firstTextValue(Map map, String key) { + Object value = map.get(key); + return value == null ? null : String.valueOf(value); + } + /** * 税率优先取顶层小数换算为百分数(0.13 -> 13),顶层为空回退首条明细,均无效时置 0(镜像列非空约束) */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeAttachmentSyncServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeAttachmentSyncServiceImpl.java new file mode 100644 index 00000000..607224f0 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeAttachmentSyncServiceImpl.java @@ -0,0 +1,44 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper; +import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; +import org.springblade.transport.service.IKingdeeAttachmentSyncService; +import org.springframework.stereotype.Service; + +/** + * 金蝶附件同步服务实现 + * + * @author Chill + */ +@Slf4j +@Service +public class KingdeeAttachmentSyncServiceImpl extends BaseServiceImpl + implements IKingdeeAttachmentSyncService { +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java new file mode 100644 index 00000000..97ee28db --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java @@ -0,0 +1,375 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.SpringUtil; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeLockConstant; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.mapper.FormalSettlementMapper; +import org.springblade.transport.mapper.InvoiceApplicationMapper; +import org.springblade.transport.mapper.KingdeeBillLockMapper; +import org.springblade.transport.mapper.PaymentApplicationMapper; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.InvoiceApplication; +import org.springblade.transport.pojo.entity.KingdeeBillLock; +import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IKingdeeBillLockService; +import org.springblade.transport.service.IPaymentApplicationService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 金蝶单据锁定记录服务实现 + *

+ * 锁定:推送链路事务提交后逐张 LOCK(接口 1对1:单次一个源单号对一个金蝶单号,每张先落 failed 行占位再调 + * lockBusiData,成功翻 locked;单张成败互不影响),失败由补偿定时任务重试;落库统一批量更新。 + * 解锁:金蝶 unLockNotifyBusiData 回调(支持逗号拼接批量), + * DELETE 时分发废除源单并还原关联数据。 + * 与结算单/开票申请服务互相依赖,为避免构造器循环依赖,单据服务经 {@code SpringUtil.getBean} 运行时获取 + * (与本工程 doSyncKingdee 自代理同一惯用法) + * + * @author Chill + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KingdeeBillLockServiceImpl extends ServiceImpl implements IKingdeeBillLockService { + + /** 金蝶各接口统一日期文本格式(与 A102-2/A102-4/A103-1 等一致) */ + private static final DateTimeFormatter BFI_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final IBfiService bfiService; + private final BfiProperties bfiProperties; + private final FormalSettlementMapper formalSettlementMapper; + private final InvoiceApplicationMapper invoiceApplicationMapper; + private final PaymentApplicationMapper paymentApplicationMapper; + + @Override + public String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List targetBillNos) { + // 总开关关闭:不落锁记录、不调 lockBusiData,返回 null 不影响推送主流程 + if (!bfiProperties.getBillLock().isEnabled()) { + log.info("金蝶单据锁定总开关关闭,跳过锁定:源单[{}:{}]", sourceBillType, sourceBillNo); + return null; + } + if (Func.isEmpty(targetBillNos)) { + return null; + } + String operator = operatorName(); + // 待锁目标单:已 locked 幂等跳过(人工重推「同步金蝶」幂等命中路径不会重复锁); + // 其余(新目标单/failed 重锁/同单重推)先落 failed 行占位,保证补偿扫表无遗漏 + List pendingRecords = new ArrayList<>(); + for (String targetBillNo : targetBillNos.stream().filter(Func::isNotBlank).distinct().toList()) { + KingdeeBillLock lockRecord = latestRecord(sourceBillType, sourceBillId, targetBillNo); + if (lockRecord != null && KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) { + continue; + } + if (lockRecord == null) { + lockRecord = new KingdeeBillLock(); + lockRecord.setSourceBillType(sourceBillType); + lockRecord.setSourceBillId(sourceBillId); + lockRecord.setSourceBillNo(sourceBillNo); + lockRecord.setTargetBillType(targetBillType); + lockRecord.setTargetBillNo(targetBillNo); + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_FAILED); + lockRecord.setOperationType(KingdeeLockConstant.OPERATION_SAVE); + lockRecord.setTryCount(0); + lockRecord.setLockOperator(operator); + this.save(lockRecord); + } else { + // 人工重推触发的补锁:重置补偿计数重新起算(定时补偿烧完上限后,人工动作可重新接管自动补偿) + KingdeeBillLock resetRecord = new KingdeeBillLock(); + resetRecord.setId(lockRecord.getId()); + resetRecord.setTryCount(0); + this.updateById(resetRecord); + lockRecord.setTryCount(0); + } + pendingRecords.add(lockRecord); + } + if (pendingRecords.isEmpty()) { + return null; + } + // 锁单接口 1对1:单次仅一个源单号对一个金蝶单号,逐张调用,单张成败互不影响;成败落库批量更新 + LocalDateTime now = LocalDateTime.now(); + List lockedRecords = new ArrayList<>(); + List failedRecords = new ArrayList<>(); + for (KingdeeBillLock lockRecord : pendingRecords) { + try { + bfiService.lockBusiData(this.buildLockParam(sourceBillType, sourceBillNo, targetBillType, lockRecord.getTargetBillNo(), operator)); + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_LOCKED); + lockRecord.setLockTime(now); + lockRecord.setFailReason(null); + lockedRecords.add(lockRecord); + log.info("金蝶单据锁定成功:源单[{}:{}],金蝶单[{}]", sourceBillType, sourceBillNo, lockRecord.getTargetBillNo()); + } catch (Exception exception) { + String failReason = limitText(exception.getMessage(), 500); + lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1); + lockRecord.setFailReason(failReason); + failedRecords.add(lockRecord); + log.error("金蝶单据锁定失败(待补偿重锁):源单[{}:{}],金蝶单[{}],原因:{}", sourceBillType, sourceBillNo, lockRecord.getTargetBillNo(), failReason, exception); + } + } + this.updateBatchById(lockedRecords); + this.updateBatchById(failedRecords); + if (failedRecords.isEmpty()) { + log.info("金蝶单据锁定全部成功:源单[{}:{}],金蝶单{}张[{}]", sourceBillType, sourceBillNo, lockedRecords.size(), + lockedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(","))); + return null; + } + String failedNos = failedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(",")); + log.warn("金蝶单据锁定汇总:源单[{}:{}],成功{}张,失败{}张[{}]", sourceBillType, sourceBillNo, lockedRecords.size(), failedRecords.size(), failedNos); + return failedNos; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int onUnlockNotify(BfiBillLockDTO param) { + // 总开关关闭:只应答不处理(返回 0 与「未匹配」同构,金蝶侧不会触发重试) + if (!bfiProperties.getBillLock().isEnabled()) { + log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},targetBillNo={}", + param == null ? null : param.getOperationType(), param == null ? null : param.getTargetBillNo()); + return 0; + } + if (param == null || !KingdeeLockConstant.SYSTEM_XH.equals(param.getSourceSystem()) + || !KingdeeLockConstant.SYSTEM_WLTMS.equals(param.getTargetSystem()) + || !KingdeeLockConstant.ACTION_UNLOCK.equals(param.getLockAction())) { + throw new ServiceException("金蝶解锁回调参数非法:sourceSystem须为XH、targetSystem须为WLTMS、lockAction须为UNLOCK"); + } + if (Func.isBlank(param.getTargetBillNo())) { + throw new ServiceException("金蝶解锁回调参数非法:targetBillNo不能为空"); + } + // 批量回调:targetBillNo 逗号拼接,逐张处理;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴) + int processed = 0; + for (String targetBillNo : Func.toStrList(param.getTargetBillNo())) { + KingdeeBillLock lockRecord = this.getOne(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType()) + .eq(KingdeeBillLock::getTargetBillNo, targetBillNo) + .orderByDesc(KingdeeBillLock::getId) + .last("limit 1"), false); + if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) { + log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}", + param.getTargetBillType(), targetBillNo); + continue; + } + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED); + lockRecord.setOperationType(param.getOperationType()); + lockRecord.setUnlockTime(LocalDateTime.now()); + lockRecord.setNotifyOperator(param.getOperator()); + this.updateById(lockRecord); + processed++; + log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}", + lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator()); + if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) { + this.restoreOnKingdeeDelete(lockRecord, param.getOperator()); + } + } + return processed; + } + + /** + * 金蝶删单(DELETE 回调)分发:废除源单并还原关联数据(还原口径详见各 voidByKingdee 实现) + * + * @param lockRecord 锁记录(携带源单类型与源单主键) + * @param operator 金蝶侧操作人 + */ + private void restoreOnKingdeeDelete(KingdeeBillLock lockRecord, String operator) { + String sourceType = KingdeeLockConstant.TARGET_TO_SOURCE_MAP.get(lockRecord.getTargetBillType()); + switch (sourceType == null ? "" : sourceType) { + case KingdeeLockConstant.SOURCE_SETTLEMENT -> { + FormalSettlement settlement = formalSettlementMapper.selectById(lockRecord.getSourceBillId()); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { + log.warn("金蝶删单回调源结算单不存在:id={}", lockRecord.getSourceBillId()); + return; + } + if (TransportBusiConstant.VOIDED.equals(settlement.getApprovalStatus())) { + log.info("金蝶删单回调源结算单[{}]已作废(幂等跳过)", settlement.getFormalSettlementNo()); + return; + } + SpringUtil.getBean(IFormalSettlementService.class).voidByKingdee(settlement.getId(), operator); + } + case KingdeeLockConstant.SOURCE_INVOICE_APPLY -> { + InvoiceApplication application = invoiceApplicationMapper.selectById(lockRecord.getSourceBillId()); + if (application == null || Objects.equals(application.getIsDeleted(), 1)) { + log.warn("金蝶删单回调源开票申请不存在:id={}", lockRecord.getSourceBillId()); + return; + } + if (TransportBusiConstant.VOIDED.equals(application.getApprovalStatus())) { + log.info("金蝶删单回调源开票申请[{}]已作废(幂等跳过)", application.getApplicationNo()); + return; + } + SpringUtil.getBean(IInvoiceApplicationService.class).voidByKingdee(application.getId(), operator); + } + case KingdeeLockConstant.SOURCE_PAY_APPLY -> { + PaymentApplication application = paymentApplicationMapper.selectById(lockRecord.getSourceBillId()); + if (application == null || Objects.equals(application.getIsDeleted(), 1)) { + log.warn("金蝶删单回调源付款申请不存在:id={}", lockRecord.getSourceBillId()); + return; + } + if (TransportBusiConstant.VOIDED.equals(application.getApprovalStatus())) { + log.info("金蝶删单回调源付款申请[{}]已作废(幂等跳过)", application.getPaymentNo()); + return; + } + // 复用 ADR-0005 解锁口径:不作废源单,kingdeeStatus→生成失败、清编号内码(原值留档), + // 不自动重推,人工核实后重新推送(A103-2 勾稽预检防重复推单) + SpringUtil.getBean(IPaymentApplicationService.class).unlockBill(application.getId(), "金蝶删单回调"); + } + default -> log.warn("金蝶删单回调暂不支持的单据类型:targetBillType={}(映射预留)", lockRecord.getTargetBillType()); + } + } + + @Override + public void checkEditable(String sourceBillType, Long sourceBillId) { + long lockedCount = this.count(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .eq(KingdeeBillLock::getSourceBillId, sourceBillId) + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED)); + if (lockedCount > 0) { + throw new ServiceException("该单据已被金蝶锁定,不可修改"); + } + } + + @Override + public Map mapActiveLocks(String sourceBillType, List sourceBillIds) { + if (Func.isEmpty(sourceBillIds)) { + return Map.of(); + } + List lockRecords = this.list(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .in(KingdeeBillLock::getSourceBillId, sourceBillIds) + .in(KingdeeBillLock::getLockStatus, List.of(KingdeeLockConstant.STATUS_LOCKED, KingdeeLockConstant.STATUS_FAILED)) + .orderByAsc(KingdeeBillLock::getId)); + // 升序取回后写覆盖:每源单保留最新一条;unlocked 终态不参与查询 + return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> second)); + } + + /** + * 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData + * (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。 + *

成功翻 locked(补锁时间、清失败原因);失败 try_count+1 并记失败原因,计数烧满上限后不再自动重试 + * (人工重推「同步金蝶」重置计数重新接管);成败落库均为批量更新(updateBatchById),避免逐张 update + */ + @Override + public String retryFailedLocks() { + // 总开关关闭:定时任务与手动补偿一并跳过 + if (!bfiProperties.getBillLock().isEnabled()) { + return "金蝶单据锁定总开关关闭,跳过补偿"; + } + List failedList = this.list(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_FAILED) + .lt(KingdeeBillLock::getTryCount, KingdeeLockConstant.RETRY_MAX_COUNT)); + if (Func.isEmpty(failedList)) { + return "无待补偿锁记录"; + } + log.info("金蝶单据锁定补偿开始:待补偿{}张", failedList.size()); + LocalDateTime now = LocalDateTime.now(); + List lockedRecords = new ArrayList<>(); + List failedRecords = new ArrayList<>(); + for (KingdeeBillLock lockRecord : failedList) { + String operator = Func.isBlank(lockRecord.getLockOperator()) ? "system" : lockRecord.getLockOperator(); + try { + bfiService.lockBusiData(this.buildLockParam(lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), + lockRecord.getTargetBillType(), lockRecord.getTargetBillNo(), operator)); + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_LOCKED); + lockRecord.setLockTime(now); + lockRecord.setFailReason(null); + lockedRecords.add(lockRecord); + log.info("金蝶单据锁定补偿成功:源单[{}:{}],金蝶单[{}]", + lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), lockRecord.getTargetBillNo()); + } catch (Exception exception) { + String failReason = limitText(exception.getMessage(), 500); + lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1); + lockRecord.setFailReason(failReason); + failedRecords.add(lockRecord); + log.error("金蝶单据锁定补偿失败(try_count已+1,待下次补偿):源单[{}:{}],金蝶单[{}],原因:{}", + lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), lockRecord.getTargetBillNo(), failReason, exception); + } + } + this.updateBatchById(lockedRecords); + this.updateBatchById(failedRecords); + log.info("金蝶单据锁定补偿结束:待补偿{}张,成功{}张,仍失败{}张[{}]", failedList.size(), lockedRecords.size(), failedRecords.size(), + failedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(","))); + return "待补偿" + failedList.size() + "张,成功" + lockedRecords.size() + "张"; + } + + /** + * 组装 lockBusiData 参数:WLTMS 调金蝶,源系统取配置(默认 WLTMS),目标系统 XH, + * 操作类型固定 LOCK/SAVE;接口 1对1:单次仅一个源单号对一个金蝶单号 + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillNo WLTMS 单号 + * @param targetBillType 金蝶目标单据类型 + * @param targetBillNo 金蝶单据编号(单张) + * @param operator 操作人 + * @return 锁单参数 + */ + private BfiBillLockDTO buildLockParam(String sourceBillType, String sourceBillNo, String targetBillType, String targetBillNo, String operator) { + BfiBillLockDTO param = new BfiBillLockDTO(); + param.setLockAction(KingdeeLockConstant.ACTION_LOCK); + param.setSourceSystem(Func.isBlank(bfiProperties.getSrcSys()) ? KingdeeLockConstant.SYSTEM_WLTMS : bfiProperties.getSrcSys()); + param.setTargetSystem(KingdeeLockConstant.SYSTEM_XH); + param.setOperationType(KingdeeLockConstant.OPERATION_SAVE); + param.setSourceBillType(sourceBillType); + param.setSourceBillNo(sourceBillNo); + param.setTargetBillType(targetBillType); + param.setTargetBillNo(targetBillNo); + param.setTime(LocalDateTime.now().format(BFI_DATE_TIME)); + param.setOperator(operator); + return param; + } + + /** + * 查同源单同目标单最新一条锁记录 + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillId WLTMS 业务主键 + * @param targetBillNo 金蝶单据编号 + * @return 最新锁记录,无则 null + */ + private KingdeeBillLock latestRecord(String sourceBillType, Long sourceBillId, String targetBillNo) { + return this.getOne(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .eq(KingdeeBillLock::getSourceBillId, sourceBillId) + .eq(KingdeeBillLock::getTargetBillNo, targetBillNo) + .orderByDesc(KingdeeBillLock::getId) + .last("limit 1"), false); + } + + /** 当前操作人:无登录态(定时任务/回调)时兜底 system(与 saveChange 同款兜底) */ + private String operatorName() { + return Func.isEmpty(AuthUtil.getUserName()) ? "system" : AuthUtil.getUserName(); + } + + /** 失败原因文本截断(fail_reason 列宽 500) */ + private String limitText(String text, int maxLength) { + if (Func.isBlank(text)) { + return null; + } + return text.length() <= maxLength ? text : text.substring(0, maxLength); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index df6a10f3..7393aa55 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -14,6 +14,7 @@ import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.UserCache; import org.springblade.system.cache.DictCache; import org.springblade.system.pojo.entity.Dept; import org.springblade.transport.excel.LoadingManageExcel; @@ -42,6 +43,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -176,7 +178,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpllambdaUpdate() @@ -229,6 +235,27 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills, String loadingNo) { + Set waybillIds = new LinkedHashSet<>(waybillIds(existing.getWaybillIdsJson())); + waybills.stream().map(Waybill::getId).filter(Objects::nonNull).forEach(waybillIds::add); + List mergedWaybillIds = new ArrayList<>(waybillIds); + List linkedWaybills = waybillMapper.selectList(Wrappers.lambdaQuery() + .in(Waybill::getId, mergedWaybillIds) + .eq(Waybill::getIsDeleted, 0)); + existing.setWaybillIdsJson(JsonUtil.toJson(mergedWaybillIds)); + existing.setLoadingSubNos(linkedWaybills.stream() + .map(Waybill::getWaybillNo) + .filter(Func::isNotEmpty) + .collect(Collectors.joining(","))); + updateById(existing); + waybillMapper.update(null, Wrappers.lambdaUpdate() + .in(Waybill::getId, mergedWaybillIds) + .set(Waybill::getLoadingNo, loadingNo)); + } + /** * 根据导入运单构建配载单的途经地: * 组内运单按导入顺序串联路线,上一票的到货地址与下一票的发货地址相同(中途卸货点)时只保留一个点, @@ -300,6 +327,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl selectMaintenancePlanPage(IPage page, MaintenancePlanVO maintenancePlan) { List records = baseMapper.selectMaintenancePlanPage(page, maintenancePlan); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setMileage(normalizeEmptyMileage(record.getMileage())); + record.setNextMaintenanceMileage(normalizeEmptyMileage(record.getNextMaintenanceMileage())); + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -80,7 +87,7 @@ public class MaintenancePlanServiceImpl extends BaseServiceImpl importMaintenancePlan(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List maintenancePlanList = new ArrayList<>(); @@ -103,8 +110,9 @@ public class MaintenancePlanServiceImpl extends BaseServiceImpl exportMaintenancePlan(Wrapper queryWrapper) { return list(queryWrapper).stream().map(maintenancePlan -> { MaintenancePlanExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(maintenancePlan, MaintenancePlanExportExcel.class)); + excel.setCreateUserName(UserCache.getUserRealName(maintenancePlan.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(maintenancePlan.getUpdateUser())); - excel.setMileage(nonNegative(maintenancePlan.getMileage())); - excel.setNextMaintenanceMileage(nonNegative(maintenancePlan.getNextMaintenanceMileage())); + excel.setMileage(normalizeEmptyMileage(maintenancePlan.getMileage())); + excel.setNextMaintenanceMileage(normalizeEmptyMileage(maintenancePlan.getNextMaintenanceMileage())); if (Func.isEmpty(excel.getMileageUnit())) { excel.setMileageUnit(defaultMileageUnit(maintenancePlan.getVehicleType())); } @@ -164,6 +173,9 @@ public class MaintenancePlanServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyMileage(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_MILEAGE_SENTINEL) == 0 ? null : value; } private String defaultMileageUnit(String vehicleType) { @@ -235,7 +244,7 @@ public class MaintenancePlanServiceImpl extends BaseServiceImpl selectMaintenanceRecordPage(IPage page, MaintenanceRecordVO maintenanceRecord) { List records = baseMapper.selectMaintenanceRecordPage(page, maintenanceRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -82,7 +85,7 @@ public class MaintenanceRecordServiceImpl extends BaseServiceImpl importMaintenanceRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List maintenanceRecordList = new ArrayList<>(); @@ -107,8 +110,9 @@ public class MaintenanceRecordServiceImpl extends BaseServiceImpl buildExceptionFeed() { + /** 待重新派单:司机已拒单,且运单尚未取消/完成 */ + private long countPendingReassignments() { + return waybillService.count(pendingReassignQuery()); + } + + private LambdaQueryWrapper pendingReassignQuery() { + return Wrappers.lambdaQuery() + .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) + .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); + } + + /** + * 首页待处理事项:异常待处置 + 重新派单待处理。 + * 两类各按自己的业务发生时间(上报时刻 / 拒单时刻)倒序,合并后再整体倒序取前 N 条。 + * 排序时间随条目一起携带,不塞进 id 字段——id 在两型下各自是处置主键与运单主键。 + */ + List buildAdminFeed() { + List merged = new ArrayList<>(); + merged.addAll(buildExceptionFeed()); + merged.addAll(buildReassignFeed()); + merged.sort(java.util.Comparator.comparing( + (TimedTodo entry) -> entry.time, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder()))); + java.util.Collections.reverse(merged); + int to = Math.min(merged.size(), FEED_LIMIT); + return merged.subList(0, to).stream().map(TimedTodo::item).collect(Collectors.toList()); + } + + /** 待处理事项 + 它的业务发生时间(仅用于排序) */ + private record TimedTodo(AdminTodoItemVO item, LocalDateTime time) { + } + + private List buildExceptionFeed() { List list = exceptionDisposalService.list(Wrappers.lambdaQuery() .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) .orderByDesc(ExceptionDisposal::getReportTime) .last("LIMIT " + FEED_LIMIT)); - return list.stream().map(this::toTodoItem).collect(Collectors.toList()); + return list.stream() + .map(disposal -> new TimedTodo(toTodoItem(disposal), exceptionTimeOf(disposal))) + .collect(Collectors.toList()); + } + + private List buildReassignFeed() { + List list = waybillService.list(pendingReassignQuery() + .orderByDesc(Waybill::getDriverRejectTime) + .last("LIMIT " + FEED_LIMIT)); + return list.stream() + .map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill))) + .collect(Collectors.toList()); + } + + private LocalDateTime exceptionTimeOf(ExceptionDisposal disposal) { + return disposal.getReportTime() != null + ? disposal.getReportTime() + : toLocalDateTime(disposal.getCreateTime()); } private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) { AdminTodoItemVO item = new AdminTodoItemVO(); item.setId(disposal.getId()); - item.setType("exception"); + item.setType(TODO_EXCEPTION); item.setTitle("异常待处置"); item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null ? disposal.getReportTime() @@ -585,6 +642,38 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return item; } + private AdminTodoItemVO toReassignTodoItem(Waybill waybill) { + AdminTodoItemVO item = new AdminTodoItemVO(); + item.setId(waybill.getId()); + item.setType(TODO_REASSIGN); + item.setTitle("重新派单待处理"); + item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill))); + item.setDesc(buildReassignDesc(waybill)); + item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), "")); + item.setActionLabel("重新派单"); + item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId())); + return item; + } + + /** 拒单时刻是「被拒了多久」的唯一依据;为空(历史数据)才回退到最后修改时间 */ + private LocalDateTime rejectTimeOf(Waybill waybill) { + if (waybill.getDriverRejectTime() != null) { + return toLocalDateTime(waybill.getDriverRejectTime()); + } + return toLocalDateTime(waybill.getUpdateTime()); + } + + /** 拒单原因的输入是可选的,没填时正文不能留下空的「:」 */ + private String buildReassignDesc(Waybill waybill) { + String driver = Func.toStr(waybill.getDriverName(), "原司机"); + String text = "运单已指派" + driver + ",被拒绝,请重新派单"; + String reason = Func.toStr(waybill.getDriverRejectReason(), "").trim(); + if (Func.isNotBlank(reason)) { + text = text + ":" + reason; + } + return text.length() > 80 ? text.substring(0, 80) + "…" : text; + } + private String buildExceptionDesc(ExceptionDisposal disposal) { String reporter = Func.toStr(disposal.getReporterName(), "司机"); String type = Func.toStr(disposal.getExceptionType(), "异常"); @@ -599,13 +688,15 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } private String resolveUserName() { - String realName = UserCache.getUserRealName(AuthUtil.getUserId()); - if (Func.isNotBlank(realName)) { + String realName = UserDisplayCache.getUserRealName(AuthUtil.getUserId()); + if (Func.isNotBlank(realName) && !Func.toStr(AuthUtil.getUserId()).equals(realName)) { return realName; } - return Func.toStr(AuthUtil.getUserName(), ""); + String jwtName = Func.toStr(AuthUtil.getNickName(), AuthUtil.getUserName()); + return Func.toStr(jwtName, realName); } + /** 相对时间文案;时间取不到时留空,避免把「现在」当成发生时刻显示成「刚刚」 */ private String formatTimeAgo(LocalDateTime time) { if (time == null) { return ""; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java index 14468a30..51491538 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java @@ -28,6 +28,8 @@ import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.ITransportPlanService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.WaybillProcessSupport; +import org.springblade.transport.wrapper.WaybillWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -92,16 +94,42 @@ public class MasterOrderServiceImpl extends BaseServiceImpl boundWaybills = waybillsByMasterNo(entity.getMasterNo()); vo.setBoundWaybills(boundWaybills); vo.setRouteProgress(buildProgress(entity, vo.getRoutes(), boundWaybills)); + vo.setDispatchedGoods(maxDispatchedGoodsByCategory(entity)); vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); return vo; } @@ -343,7 +379,89 @@ public class MasterOrderServiceImpl extends BaseServiceImpl maxDispatched = maxDispatchedGoodsByCategory(oldRecord); + if (maxDispatched.isEmpty()) { + return; + } + Map newAvailable = availableGoods(target); + for (Map.Entry entry : maxDispatched.entrySet()) { + BigDecimal dispatched = entry.getValue(); + if (dispatched == null || dispatched.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + BigDecimal newQuantity = newAvailable.getOrDefault(entry.getKey(), BigDecimal.ZERO); + if (newQuantity.compareTo(dispatched) < 0) { + String cargoName = entry.getKey().contains("\u0000") + ? entry.getKey().substring(0, entry.getKey().indexOf('\u0000')) + : entry.getKey(); + throw new ServiceException("货物【" + cargoName + "】数量不能低于已调度量(" + + dispatched.stripTrailingZeros().toPlainString() + ")"); + } + } + } + + /** 各品类已调度量:同一品类取各分段已调度量的最大值 */ + private Map maxDispatchedGoodsByCategory(MasterOrder masterOrder) { + Map result = new LinkedHashMap<>(); + List> routes = parseArray(masterOrder.getRouteJson()); + if (routes.isEmpty()) { + return dispatchedGoods(masterOrder.getMasterNo(), null); + } + for (Map route : routes) { + Map dispatched = dispatchedGoods(masterOrder.getMasterNo(), string(route, "segmentNo")); + for (Map.Entry entry : dispatched.entrySet()) { + result.merge(entry.getKey(), entry.getValue(), BigDecimal::max); + } + } + return result; + } + + private String normalizeText(String value) { + return value == null ? "" : value.trim(); } private void validate(MasterOrder masterOrder, boolean draft) { @@ -403,10 +521,13 @@ public class MasterOrderServiceImpl extends BaseServiceImpl 0) { - throw new ServiceException(segmentNo + "调度总量不能大于上一段已完成数量(" + previousCompletedQuantity.stripTrailingZeros().toPlainString() + ")"); + throw new ServiceException(segmentNo + "调度总量不能大于上一段已到达数量(" + previousCompletedQuantity.stripTrailingZeros().toPlainString() + ")"); } } } @@ -556,7 +677,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl dispatch) { return String.join("\u0000", string(dispatch, "segmentNo", ""), string(dispatch, "carrierType", ""), string(dispatch, "carrierContractId", ""), string(dispatch, "carrierName", ""), string(dispatch, "driverName", ""), string(dispatch, "driverPhone", ""), string(dispatch, "vehicleNo", ""), string(dispatch, "captainName", ""), string(dispatch, "containerNo", ""), string(dispatch, "cabinNo", "")); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MileageRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MileageRecordServiceImpl.java index 0e72973a..e9ddfc6d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MileageRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MileageRecordServiceImpl.java @@ -58,13 +58,22 @@ public class MileageRecordServiceImpl extends BaseServiceImpl selectMileageRecordPage(IPage page, MileageRecordVO mileageRecord) { List records = baseMapper.selectMileageRecordPage(page, mileageRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setPreviousMonthMileage(normalizeEmptyMileage(record.getPreviousMonthMileage())); + record.setCurrentMonthMileage(normalizeEmptyMileage(record.getCurrentMonthMileage())); + record.setMonthlyMileage(normalizeEmptyMileage(record.getMonthlyMileage())); + record.setTotalMileage(normalizeEmptyMileage(record.getTotalMileage())); + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -81,7 +90,7 @@ public class MileageRecordServiceImpl extends BaseServiceImpl importMileageRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List mileageRecordList = new ArrayList<>(); @@ -105,8 +114,9 @@ public class MileageRecordServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyMileage(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_MILEAGE_SENTINEL) == 0 ? null : value; } private String normalizeVehicleNo(String vehicleNo) { @@ -251,7 +264,7 @@ public class MileageRecordServiceImpl extends BaseServiceImpl selectOilElectricRecordPage(IPage page, OilElectricRecordVO oilElectricRecord) { List records = baseMapper.selectOilElectricRecordPage(page, oilElectricRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setQuantity(normalizeEmptyAmount(record.getQuantity())); + record.setUnitPrice(normalizeEmptyAmount(record.getUnitPrice())); + record.setTransactionAmount(normalizeEmptyAmount(record.getTransactionAmount())); + record.setBalance(normalizeEmptyAmount(record.getBalance())); + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -70,7 +79,7 @@ public class OilElectricRecordServiceImpl extends BaseServiceImpl importOilElectricRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List oilElectricRecordList = new ArrayList<>(); @@ -94,8 +103,9 @@ public class OilElectricRecordServiceImpl extends BaseServiceImpl exportOilElectricRecord(Wrapper queryWrapper) { return list(queryWrapper).stream().map(oilElectricRecord -> { OilElectricRecordExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(oilElectricRecord, OilElectricRecordExportExcel.class)); - excel.setQuantity(nonNegative(oilElectricRecord.getQuantity())); - excel.setUnitPrice(nonNegative(oilElectricRecord.getUnitPrice())); - excel.setTransactionAmount(nonNegative(oilElectricRecord.getTransactionAmount())); - excel.setBalance(nonNegative(oilElectricRecord.getBalance())); + excel.setQuantity(normalizeEmptyAmount(oilElectricRecord.getQuantity())); + excel.setUnitPrice(normalizeEmptyAmount(oilElectricRecord.getUnitPrice())); + excel.setTransactionAmount(normalizeEmptyAmount(oilElectricRecord.getTransactionAmount())); + excel.setBalance(normalizeEmptyAmount(oilElectricRecord.getBalance())); + excel.setCreateUserName(UserCache.getUserRealName(oilElectricRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(oilElectricRecord.getUpdateUser())); return excel; }).toList(); @@ -160,6 +171,11 @@ public class OilElectricRecordServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyAmount(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_AMOUNT_SENTINEL) == 0 ? null : value; } private String defaultDataSource(String dataSource) { @@ -245,7 +258,7 @@ public class OilElectricRecordServiceImpl extends BaseServiceImpl selectOtherExpenseRecordPage(IPage page, OtherExpenseRecordVO otherExpenseRecord) { List records = baseMapper.selectOtherExpenseRecordPage(page, otherExpenseRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -67,7 +70,7 @@ public class OtherExpenseRecordServiceImpl extends BaseServiceImpl importOtherExpenseRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List otherExpenseRecordList = new ArrayList<>(); @@ -92,8 +95,9 @@ public class OtherExpenseRecordServiceImpl extends BaseServiceImpl { OtherExpenseRecordExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(otherExpenseRecord, OtherExpenseRecordExportExcel.class)); excel.setAmount(nonNegative(otherExpenseRecord.getAmount())); + excel.setCreateUserName(UserCache.getUserRealName(otherExpenseRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(otherExpenseRecord.getUpdateUser())); return excel; }).toList(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index fffde5e0..ba289e59 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.redis.cache.BladeRedis; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; @@ -47,9 +48,13 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeAttachmentConstant; import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.constant.KingdeeLockConstant; import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.event.KingdeeBillApprovedEvent; import org.springblade.transport.mapper.BillLedgerMapper; import org.springblade.transport.mapper.BillLedgerUsageMapper; import org.springblade.transport.mapper.ContractManageMapper; @@ -80,23 +85,27 @@ import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.FormalSettlementDetail; import org.springblade.transport.pojo.entity.FormalSettlementPayment; import org.springblade.transport.pojo.entity.FormalSettlementSource; +import org.springblade.transport.pojo.entity.KingdeeBillLock; import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.entity.PaymentApplicationInvoice; import org.springblade.transport.pojo.entity.PaymentApplicationRecord; import org.springblade.transport.pojo.entity.PaymentApplicationSettlement; import org.springblade.transport.pojo.entity.PreSettlement; import org.springblade.transport.pojo.entity.PreSettlementAdvance; -import org.springblade.transport.config.BfiProperties; import org.springblade.transport.pojo.entity.ProjectApply; import org.springblade.transport.pojo.entity.TemporaryCreditLimit; import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO; import org.springblade.transport.pojo.vo.PaymentApplicationVO; import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IKingdeeBillLockService; import org.springblade.transport.service.IPaymentApplicationService; import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.support.KingdeeApBillSyncSupport; +import org.springblade.transport.support.KingdeeAttachmentSyncSupport; +import org.springblade.transport.support.KingdeeSyncLockSupport; import org.springblade.transport.support.hcs.HcsCreditCheckSupport; import org.springblade.transport.wrapper.PaymentApplicationWrapper; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -111,6 +120,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -126,6 +136,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl w + .like(PaymentApplication::getSettlementNo, query.getSettlementNo()) + .or() + .like(PaymentApplication::getPreSettlementNo, query.getSettlementNo())) .eq(Func.isNotEmpty(query.getPaymentType()), PaymentApplication::getPaymentType, query.getPaymentType()) .eq(Func.isNotEmpty(query.getApprovalStatus()), PaymentApplication::getApprovalStatus, query.getApprovalStatus()) .eq(Func.isNotEmpty(query.getKingdeeStatus()), PaymentApplication::getKingdeeStatus, query.getKingdeeStatus()) .ge(query.getApplyStartDate() != null, PaymentApplication::getApplyDate, query.getApplyStartDate()) .le(query.getApplyEndDate() != null, PaymentApplication::getApplyDate, query.getApplyEndDate()) .orderByDesc(PaymentApplication::getCreateTime); - return page(page, wrapper).convert(item -> { + IPage voPage = page(page, wrapper).convert(item -> { PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(item); if (!TransportBusiConstant.APPROVED.equals(item.getApprovalStatus())) vo.setPaidAmount(BigDecimal.ZERO); return vo; }); + fillAttachmentSyncStatus(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, voPage.getRecords()); + fillKingdeeLockStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1 + * + * @param bizType 业务单据类型 + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(String bizType, List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(bizType, + rows.stream().map(PaymentApplicationVO::getId).toList()); + for (PaymentApplicationVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } + } + + /** + * 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回): + * 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废, + * failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶) + * + * @param rows 本页 VO 列表 + */ + private void fillKingdeeLockStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_PAY_APPLY, + rows.stream().map(PaymentApplicationVO::getId).toList()); + for (PaymentApplicationVO vo : rows) { + KingdeeBillLock lockRecord = lockMap.get(vo.getId()); + if (lockRecord == null) { + continue; + } + vo.setKingdeeLockStatus(lockRecord.getLockStatus()); + vo.setKingdeeLockFailReason(lockRecord.getFailReason()); + vo.setKingdeeTryCount(lockRecord.getTryCount()); + } } @Override public PaymentApplicationVO detail(Long id) { PaymentApplication entity = existing(id); PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(entity); + fillKingdeeLockStatus(List.of(vo)); vo.setInvoices(invoiceMapper.selectList(Wrappers.lambdaQuery() .eq(PaymentApplicationInvoice::getPaymentApplicationId, id).orderByAsc(PaymentApplicationInvoice::getLineNo))); vo.setPaymentRecords(recordMapper.selectList(Wrappers.lambdaQuery() @@ -339,6 +416,8 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; public void voidBill(PaymentApplicationStatusRequest request) { PaymentApplication entity = lockedPayment(request.getId()); if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的付款申请允许作废"); + // 金蝶锁定中禁止作废(lockBusiData 已锁单,金蝶侧未解锁前单据不可改动) + kingdeeBillLockService.checkEditable(KingdeeLockConstant.SOURCE_PAY_APPLY, request.getId()); if (isBillPayment(entity.getPaymentMethod())) releaseBillBalance(entity); entity.setApprovalStatus(TransportBusiConstant.VOIDED); entity.setCurrentNode("已作废"); @@ -359,6 +438,8 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; entity.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); updateById(entity); refreshFormalSettlement(entity); + // 审批事务提交后自动推送金蝶(AFTER_COMMIT 监听器消费,推送失败不影响审批结果) + eventPublisher.publishEvent(new KingdeeBillApprovedEvent(KingdeeBillApprovedEvent.BIZ_PAYMENT_APPLICATION, entity.getId())); } @Override @@ -368,9 +449,10 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; @Override public String syncKingdee(Long id, KingdeeSyncContext context) { + String kingdeeBillId; try { // 单据级事务经自代理调用:单条失败只回滚自身,避免批量同步共用大事务长持锁 - return self().doSyncKingdee(id, context); + kingdeeBillId = self().doSyncKingdee(id, context); } catch (ServiceException exception) { log.error("付款申请[{}]推送金蝶付款申请单异常", id, exception); // 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态 @@ -381,6 +463,55 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; self().markKingdeeSyncFailed(id); throw new ServiceException("推送金蝶付款申请失败:" + exception.getMessage()); } + // 主单事务提交后锁定金蝶付款申请单(lockBusiData):一张付款申请对一张金蝶付款申请单,接口1对1; + // 失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态;锁定失败摘要拼入返回值,页面直接可见 + String lockFailSummary = lockKingdeeBillAfterPush(id); + // 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传 + syncAttachmentsAfterPush(id); + return Func.isBlank(lockFailSummary) ? kingdeeBillId + : kingdeeBillId + ";注意:金蝶单据锁定未成功(" + lockFailSummary + "),系统将自动补偿重锁,重推同步金蝶可立即补锁"; + } + + /** + * 金蝶付款申请单锁定(lockBusiData):A103-1/A103-3 推送成功事务提交后调用, + * 一张付款申请对一张金蝶付款申请单;锁定异常独立捕获,不影响主单同步结果 + * + * @param id 付款申请id + * @return 锁定失败的金蝶单号摘要,全部锁定成功返回 null + */ + private String lockKingdeeBillAfterPush(Long id) { + try { + PaymentApplication entity = getById(id); + if (entity == null || Func.isBlank(entity.getKingdeeBillNo())) { + return null; + } + return kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_PAY_APPLY, id, entity.getPaymentNo(), + KingdeeLockConstant.TARGET_PAY_APPLY, Func.toStrList(entity.getKingdeeBillNo())); + } catch (Exception exception) { + log.error("付款申请[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception); + return "锁定动作异常,已留档待补偿"; + } + } + + /** + * 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用, + * 以本地 kingdeeBillId(星瀚单据内码)定位金蝶单据;本地缺失(存量数据)跳过待重推补传; + * 附件上传异常独立捕获,绝不影响主单同步结果 + * + * @param id 付款申请id + */ + private void syncAttachmentsAfterPush(Long id) { + try { + PaymentApplication entity = getById(id); + if (entity == null || Func.isBlank(entity.getKingdeeBillId())) { + log.warn("付款申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", id); + return; + } + kingdeeAttachmentSyncSupport.syncUserAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, + id, entity.getKingdeeBillId(), entity.getAttachmentsJson()); + } catch (Exception exception) { + log.error("付款申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception); + } } @Override @@ -475,6 +606,107 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; .set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); } + @Override + public String retryKingdeeFailed() { + List failedIds = list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(PaymentApplication::getCreateTime)) + .stream().map(PaymentApplication::getId).toList(); + if (Func.isEmpty(failedIds)) { + return "无生成失败单据"; + } + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + syncKingdee(id); + success++; + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } + } + String summary = "重推生成失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unlockBill(Long id, String source) { + if (id == null) return; + PaymentApplication entity = getById(id); + if (entity == null) return; + clearPayApplyMissKey(id); + if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus()) + && !KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(entity.getKingdeeStatus())) { + // 仅已生成/付款中单据处于"金蝶已确认收单"的锁定态;生成失败/终态无需解锁 + log.info("付款申请[{}]金蝶单据解锁跳过(来源[{}]):kingdeeStatus=[{}]非锁定态", + entity.getPaymentNo(), source, entity.getKingdeeStatus()); + return; + } + // 原金蝶单据编号/内码日志留档后清空:金蝶侧已反审核删除,原值不再有效, + // 附件上传、A103-4 勾稽均依赖单据内码,重推生成新单后自动重新关联 + log.warn("付款申请[{}]金蝶单据解锁(来源[{}]):kingdeeStatus[{}]->[生成失败],原单据编号[{}]/内码[{}]已留档并清空,不自动重推,等待人工核实后重新推送", + entity.getPaymentNo(), source, entity.getKingdeeStatus(), entity.getKingdeeBillNo(), entity.getKingdeeBillId()); + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(PaymentApplication::getId, id) + .in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, KingdeeBfiConstant.KINGDEE_STATUS_PAYING) + .set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .set(PaymentApplication::getKingdeeBillId, null) + .set(PaymentApplication::getKingdeeBillNo, null)); + } + + /** + * A103-2 未查得已推送单据的兜底判定(ADR-0005"连续两轮查询消失"): + * 仅对已生成/付款中单据经 Redis 计数累计消失轮次(生成失败单据金蝶查不到属正常态,等待人工重推,不参与计数), + * 连续达到阈值即解锁置失败态;重推链 A103-2 勾稽预检兜底,误判场景(金蝶单据实际存在)自动回填不产生重复单据 + * + * @param application 本轮未查得的付款申请 + */ + private void handlePayApplyMissed(PaymentApplication application) { + if (Func.isBlank(application.getPaymentNo()) + || (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(application.getKingdeeStatus()) + && !KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(application.getKingdeeStatus()))) { + return; + } + String missKey = payApplyMissKey(application.getId()); + Long rounds; + try { + rounds = bladeRedis.getStringRedisTemplate().opsForValue().increment(missKey); + bladeRedis.getStringRedisTemplate().expire(missKey, PAY_RESULT_MISS_KEY_TTL_SECONDS, TimeUnit.SECONDS); + } catch (Exception exception) { + // Redis 异常时放弃本轮判定:解锁是破坏性动作(清空金蝶单号),宁可延迟不可误判 + log.warn("付款申请[{}]A103-2 消失轮次累计失败,本轮跳过解锁判定", application.getPaymentNo(), exception); + return; + } + if (rounds != null && rounds >= PAY_RESULT_MISS_UNLOCK_ROUNDS) { + log.warn("付款申请[{}]A103-2 连续[{}]轮未查得金蝶付款申请单,判定金蝶侧已反审核删除,执行解锁", + application.getPaymentNo(), rounds); + self().unlockBill(application.getId(), "A103-2 连续" + rounds + "轮查询消失"); + } else { + log.warn("付款申请[{}]A103-2 未查得金蝶付款申请单(第[{}]轮),金蝶侧可能已反审核删除,下一轮复核", + application.getPaymentNo(), rounds); + } + } + + /** A103-2 消失轮次计数键 */ + private String payApplyMissKey(Long id) { + return PAY_RESULT_MISS_KEY_PREFIX + id; + } + + /** 清除 A103-2 消失轮次计数(金蝶重新查得或单据解锁后调用) */ + private void clearPayApplyMissKey(Long id) { + if (id == null) return; + try { + bladeRedis.getStringRedisTemplate().delete(payApplyMissKey(id)); + } catch (Exception exception) { + log.warn("付款申请[{}]清除 A103-2 消失轮次计数失败", id, exception); + } + } + @Override public List syncKingdeeBatch(List ids) { if (ids == null || ids.isEmpty()) throw new ServiceException("请至少选择一条审批通过的付款申请"); @@ -504,6 +736,17 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; @Override public String syncKingdeePaymentResult() { + return kingdeeSyncLockSupport.runSingleFlight(PAY_RESULT_SYNC_LOCK_PREFIX, bfiProperties.getPayResult().getLockLeaseSeconds(), + "金蝶付款结果回写", this::executePaymentResultSync); + } + + /** + * 金蝶付款结果回写主体:A103-2 付款申请单查询回填 + A103-4 付款处理查询补付款记录, + * 由 {@link #syncKingdeePaymentResult()} 在分布式锁保护下调用 + * + * @return 执行摘要 + */ + private String executePaymentResultSync() { // ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询)。 // 生成失败单据一并纳入勾稽:金蝶侧已生成说明上次推送实际成功(如下推请求超时但远端已落单、本地事务回滚), // 查到即回填纠正为已生成,避免人工重新推送产生重复单据;未查到则保持生成失败等待异常处理后重新推送 @@ -526,8 +769,12 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; for (PaymentApplication application : applications) { BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo()); if (payApply == null) { + // 金蝶侧未查得已推送单据(查询批次其余单据正常返回,排除服务整体故障): + // 累计消失轮次,连续两轮视为金蝶已反审核删除,解锁置失败交人工核实重推 + this.handlePayApplyMissed(application); continue; } + clearPayApplyMissKey(application.getId()); try { if (self.applyPayApplyResult(application, payApply)) paidCount++; updated++; @@ -856,6 +1103,31 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; return kingdeeApBillSyncSupport.bfiDateTimeText(applyDate); } + /** + * 金蝶汇率日期文本(A103-3 预付款路径):优先取付款申请关联结算单页面维护的汇率日期 + * (正式结算单 settlementId、预结算单 preSettlementId,与 A102-2 统一口径), + * 未关联结算单或结算单未维护汇率日期时取付款申请申请日期兜底。 + * + * @param entity 付款申请实体 + * @param context 金蝶同步上下文(缓存载体) + * @return 汇率日期文本 + */ + private String bfiExrateDateText(PaymentApplication entity, KingdeeSyncContext context) { + if (entity.getSettlementId() != null) { + FormalSettlement settlement = context.settlementCache().computeIfAbsent(entity.getSettlementId(), formalSettlementMapper::selectById); + if (settlement != null && settlement.getExchangeRateDate() != null) { + return kingdeeApBillSyncSupport.bfiDateTimeText(settlement.getExchangeRateDate()); + } + } + if (entity.getPreSettlementId() != null) { + PreSettlement preSettlement = preSettlementMapper.selectById(entity.getPreSettlementId()); + if (preSettlement != null && preSettlement.getExchangeRateDate() != null) { + return kingdeeApBillSyncSupport.bfiDateTimeText(preSettlement.getExchangeRateDate()); + } + } + return bfiDateTimeText(entity); + } + /** * 推送金蝶付款申请单(A103-3 付款申请保存,预付款路径),返回金蝶单据id * @@ -870,43 +1142,69 @@ private final CustomerReceiptAccountMapper receiptAccountMapper; String applyDateText = bfiDateTimeText(entity); BigDecimal applyAmount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); BfiPayApplySaveDTO param = new BfiPayApplySaveDTO(); + // 【必填】外部系统来源单据号(防重候选键):付款申请号 param.setAwrpSrcCode(entity.getPaymentNo()); + // 【必填】外部系统编号 param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 结算组织.编码 param.setSettleorgNumber(orgNumber); + // 部门.编码 + param.setAwrpDepartmentNumber(orgNumber); + // 【必填】单据类型.编码 param.setBilltypeNumber(bfiProperties.getPayApply().getBilltype()); + // 【必填】申请日期:yyyy-MM-dd HH:mm:ss(各接口统一格式) param.setApplydate(applyDateText); + // 【必填】申请组织.编码 param.setApplyorgNumber(orgNumber); + // 【必填】付款组织.编码 param.setPayorgNumber(orgNumber); + // 采购组织.编码(非必传) // param.setPurorgNumber(orgNumber); + // 请款事由(非必传) param.setApplycause(bfiApplyCause(entity.getPaymentType())); + // 【必填】付款币种.编码 param.setPaycurrencyNumber(currency); + // 【必填】结算币.编码 param.setSettlecurrencyNumber(currency); + // 【必填】外部单据类型 param.setAwrpSrcType(bfiProperties.getPayApply().getSrcType()); + // 【必填】汇率表.编码 param.setExratetableNumber(bfiProperties.getPayApply().getExratetable()); - param.setExratedate(applyDateText); + // 【必填】汇率日期:取付款申请关联结算单页面维护的汇率日期(与 A102-2 统一口径),未关联/未维护时取申请日期 + param.setExratedate(this.bfiExrateDateText(entity, context)); + // 付款渠道(非必传) param.setAwrpTextfield1(bfiPaymentMethodLabel(entity.getPaymentMethod())); // 来源单据 - param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT); + // param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT); // 有上游合同才传 awrpSrcConNo // if (entity.getContractId() != null) { // param.setAwrpSrcConNo(entity.getContractNo()); // } BfiPayApplySaveEntryDTO entry = new BfiPayApplySaveEntryDTO(); + // 【必填】付款类型.编码:结算付款 201(正式)、预付款 202 entry.setEPaymenttypeNumber(TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(entity.getPaymentType()) ? KingdeeBfiConstant.BFI_PAYMENT_TYPE_FORMAL : KingdeeBfiConstant.BFI_PAYMENT_TYPE_PREPAY); + // 【必填】往来类型 entry.setEAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); - // 往来户.编码:合同乙方关联客商档案客商编码 + // 【必填】往来户.编码:合同乙方关联客商档案客商编码 entry.setEAsstactNumber(kingdeeApBillSyncSupport.bfiAsstactNumber(contract, context)); + // 【必填】往来账户:乙方(收款方)银行账号 entry.setEAssacct(entity.getBankAccount()); - entry.setEBebankNumber(entity.getBankName()); - // 应付金额 + // 【必填】往来银行.编码:与 A103-1 统一口径,取付款申请所选客商收款账户联行号(未维护即拦截) + entry.setEBebankNumber(this.bfiBebankNumber(entity, context)); + // 应付金额(非必传) // entry.setEPayamount(applyAmount); - // 申请金额 + // 【必填】申请金额:取付款申请填写的申请付款金额 entry.setEApplyamount(applyAmount); + // 期望付款日(非必传) // entry.setEExpaydate(applyDateText); - entry.setProjectNumber(bfiProjectNumber(entity, context)); + // 【必填】项目.项目编码 + // entry.setProjectNumber(bfiProjectNumber(entity, context)); + entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST); + // 【必填】对方系统唯一行id(不能重复):付款申请号-1 entry.setAwrpEntryCode(bfiEntryCode(entity.getPaymentNo(), 1)); + // 备注(非必传) entry.setERemark(entity.getRemark()); param.setEntry(List.of(entry)); 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 7f80ae09..a03ae549 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 @@ -320,7 +320,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementSummaryFee::getPreSettlementId, settlement.getId())); } @@ -330,7 +330,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl 0) { + code = code.substring(0, separatorIndex).trim(); + } + if ("CNY".equals(code)) { + return LOCAL_CURRENCY; + } + return Func.isEmpty(code) ? LOCAL_CURRENCY : code; + } + private String resolveSettlementType(PreSettlementSaveRequest request, ContractManage contract) { if (Boolean.TRUE.equals(request.getAllowSourceMismatch()) && Func.isNotEmpty(request.getSettlementType())) { validateSettlementType(request.getSettlementType()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java index a0015e84..6a1793d6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java @@ -252,12 +252,10 @@ public class ProcessConfigServiceImpl extends BaseServiceImpl buildQuery(ProcessConfigVO processConfig) { - TransportBusinessSupport.validateAllDept(processConfig.getAllDept(), "过程配置"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(ProcessConfig::getIsDeleted, 0); - if (!Objects.equals(processConfig.getAllDept(), 1)) { - queryWrapper.eq(ProcessConfig::getDeptId, TransportBusinessSupport.currentDeptId("过程配置")); - } else if (Func.isNotEmpty(processConfig.getDeptId())) { - queryWrapper.eq(ProcessConfig::getDeptId, processConfig.getDeptId()); + List deptIdList = TransportBusinessSupport.resolveDeptFilterIds(processConfig.getAllDept(), processConfig.getDeptId(), "过程配置"); + if (Func.isNotEmpty(deptIdList)) { + queryWrapper.eq(ProcessConfig::getDeptId, deptIdList.get(0)); } if (Func.isNotEmpty(processConfig.getConfigCode())) { queryWrapper.eq(ProcessConfig::getConfigCode, processConfig.getConfigCode()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index 066abafa..f6295f43 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -343,15 +343,24 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl buildQuery(ProjectApplyVO projectApply) { TransportBusinessSupport.validateAllDept(projectApply.getAllDept(), "项目立项"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(ProjectApply::getIsDeleted, 0); - if (!Objects.equals(projectApply.getAllDept(), 1)) { + if (TransportBusinessSupport.isAdministrator()) { + // 超管默认可查看全部未删除数据;仅在明确指定平台公司时按包含匹配筛选。 + if (Objects.equals(projectApply.getAllDept(), 1) && Func.isNotEmpty(projectApply.getUndertakeDeptId())) { + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); + } + } else if (!Objects.equals(projectApply.getAllDept(), 1)) { Long currentDeptId = TransportBusinessSupport.currentDeptId("项目立项"); Long currentUserId = AuthUtil.getUserId(); - queryWrapper.and(wrapper -> wrapper.eq(ProjectApply::getUndertakeDeptId, currentDeptId) - .or().eq(ProjectApply::getCreateUser, currentUserId) - .or().eq(ProjectApply::getHandlerUserId, currentUserId) - .or().eq(ProjectApply::getPrincipalUserId, currentUserId)); + queryWrapper.and(wrapper -> { + if (currentDeptId != null) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", String.valueOf(currentDeptId)); + } + wrapper.or().eq(ProjectApply::getCreateUser, currentUserId) + .or().eq(ProjectApply::getHandlerUserId, currentUserId) + .or().eq(ProjectApply::getPrincipalUserId, currentUserId); + }); } else if (Func.isNotEmpty(projectApply.getUndertakeDeptId())) { - queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId()); + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); } queryWrapper.like(Func.isNotEmpty(projectApply.getApplyNo()), ProjectApply::getApplyNo, projectApply.getApplyNo()) .like(Func.isNotEmpty(projectApply.getProjectCode()), ProjectApply::getProjectCode, projectApply.getProjectCode()) @@ -465,6 +474,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl 0) { - throw new ServiceException("项目应收账款额度不能超过项目资金使用额度"); - } validateDays(projectApply.getReceivableDays(), "应收账款回款期限"); validateDays(projectApply.getPaymentDays(), "回款账期"); validateAmount(projectApply.getProjectScale(), "项目规模", true); @@ -771,6 +781,34 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl situationMap)) { + TransportBusinessSupport.validateLength(situationRemark, 1000, "项目情况说明不能超过1000个字"); + return; + } + Map data = (Map) situationMap; + TransportBusinessSupport.validateLength(Func.toStr(data.get("projectIntro")), 1000, "项目简介不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("profitRemark")), 1000, "盈利模式及利润测算情况说明不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("riskPoint")), 1000, "项目风险点不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("emergencyPlan")), 1000, "风险防范措施及应急预案不能超过1000个字"); } private void validateChange(ProjectApply projectApply, String changeType) { @@ -952,15 +990,91 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl queryWrapper, String undertakeDeptId) { + if (Func.isBlank(undertakeDeptId) || queryWrapper == null) { + return; + } + List deptIds = Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + return; + } + queryWrapper.and(wrapper -> { + boolean first = true; + for (String deptId : deptIds) { + if (first) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + first = false; + } else { + wrapper.or().apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + } + } + }); + } + + private void normalizeUndertakeDept(ProjectApply projectApply) { + if (projectApply == null) { + return; + } + List deptIds = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptId()).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .distinct() + .toList(); + projectApply.setUndertakeDeptId(deptIds.isEmpty() ? null : String.join(",", deptIds)); + List deptNames = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptName()).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + projectApply.setUndertakeDeptName(deptNames.isEmpty() ? null : String.join("、", deptNames)); + } + + private Long firstUndertakeDeptId(String undertakeDeptId) { + return Arrays.stream(Func.toStr(undertakeDeptId).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .map(Func::toLong) + .filter(id -> id != null && id > 0L) + .findFirst() + .orElse(null); + } + + private String firstUndertakeDeptName(String undertakeDeptName) { + return Arrays.stream(Func.toStr(undertakeDeptName).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(null); + } + + private boolean containsUndertakeDept(String undertakeDeptId, Long deptId) { + if (deptId == null || Func.isBlank(undertakeDeptId)) { + return false; + } + String target = String.valueOf(deptId); + return Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .anyMatch(target::equals); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java index 270a5417..8d0116e6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java @@ -184,7 +184,7 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaim::getId, id) .eq(ReceiptClaim::getClaimerId, AuthUtil.getUserId()) @@ -203,6 +203,10 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl relations = claimSettlementMapper.selectList( Wrappers.lambdaQuery() @@ -215,29 +219,47 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl orderedRelations = relations.stream() .sorted(Comparator.comparing(ReceiptClaimSettlement::getFormalSettlementId)) .toList(); - for (ReceiptClaimSettlement relation : orderedRelations) { - FormalSettlement settlement = formalSettlementMapper.selectOne( + // 批量锁行关联结算单(一次 IN 查询替代逐单 selectOne,id 升序加锁防死锁) + List settlementIds = orderedRelations.stream() + .map(ReceiptClaimSettlement::getFormalSettlementId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (settlementIds.isEmpty()) { + throw new ServiceException("关联结算单不存在"); + } + Map settlements = formalSettlementMapper.selectList( Wrappers.lambdaQuery() - .eq(FormalSettlement::getId, relation.getFormalSettlementId()) - .last("FOR UPDATE")); + .in(FormalSettlement::getId, settlementIds) + .orderByAsc(FormalSettlement::getId) + .last("FOR UPDATE")).stream() + .collect(Collectors.toMap(FormalSettlement::getId, Function.identity())); + for (ReceiptClaimSettlement relation : orderedRelations) { + FormalSettlement settlement = settlements.get(relation.getFormalSettlementId()); if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { throw new ServiceException("关联结算单不存在"); } - relation.setStatus(0); - claimSettlementMapper.updateById(relation); - formalSettlementService.refreshPaymentSummary(settlement.getId()); } + // 分摊行批量置无效(单条 UPDATE 替代逐行 updateById,实体携带 status 保留审计字段填充) + ReceiptClaimSettlement voidTemplate = new ReceiptClaimSettlement(); + voidTemplate.setStatus(0); + claimSettlementMapper.update(voidTemplate, Wrappers.lambdaUpdate() + .in(ReceiptClaimSettlement::getId, relations.stream() + .map(ReceiptClaimSettlement::getId).toList())); + // 批量刷新结算单收款汇总(固定数次查询,避免逐单 N+1) + formalSettlementService.refreshPaymentSummaries(settlementIds); BigDecimal flowClaimedAfter = money(flow.getClaimedAmount()) .subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO); flow.setClaimedAmount(flowClaimedAfter); - flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getReceiptAmount())); + // 认领状态对照公告未认领金额重算(作废分摊不计入累计) + flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount())); receiptFlowMapper.updateById(flow); - String kingdeeBillNo = buildKingdeeBillNo(claim.getId()); + // 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息), + // 金蝶侧冲销/红冲由人工处置;冲单逻辑后续迭代如需恢复,放开下行调用即可 + // String kingdeeBillNo = buildKingdeeBillNo(claim.getId()); claim.setClaimStatus(VOIDED); - claim.setKingdeeBillNo(kingdeeBillNo); - claim.setKingdeeBillStatus(APPROVED); claim.setVoidedBy(AuthUtil.getUserId()); claim.setVoidedByName(Func.isEmpty(AuthUtil.getUserName()) ? claim.getClaimerName() : AuthUtil.getUserName()); @@ -254,40 +276,40 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl * 回写口径:A301-1 契约本身不支持按来源单号过滤,故按近 30 天业务日期窗口全量翻页后, - * 以金蝶收款单 sourcebillnumber == 认领记录 awrp_src_code(A301-3 推送时生成)在本地匹配; - * 仅推进金蝶单据状态与补登收款单号,不改本地认领金额/分摊。 + * 以金蝶收款单 sourcebillnumber == 流水 awrp_src_code(A301-3 推送时按流水生成、重试复用)在本地匹配; + * 仅推进金蝶回写状态与补登收款单号,不改本地认领金额/分摊;名下未作废认领主单冗余展示字段级联刷新。 * * @return 回写条数 */ @Override public int refreshKingdeeResult() { - List pending = baseMapper.selectList(Wrappers.lambdaQuery() - .eq(ReceiptClaim::getClaimStatus, CLAIMED) - .isNotNull(ReceiptClaim::getAwrpSrcCode) - .in(ReceiptClaim::getKingdeeBillStatus, - List.of(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED, APPROVED)) - .orderByAsc(ReceiptClaim::getId)); + List pending = receiptFlowMapper.selectList( + Wrappers.lambdaQuery() + .eq(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED) + .in(KingdeeReceiptFlow::getKingdeeBillStatus, + List.of(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED, APPROVED)) + .eq(KingdeeReceiptFlow::getStatus, 1) + .orderByAsc(KingdeeReceiptFlow::getId)); if (pending.isEmpty()) { - log.info("金蝶认领结果回写:无待回写认领记录(已推送/已审核且未终态)"); + log.info("金蝶认领结果回写:无待回写流水(已推送/已审核且未终态)"); return 0; } Set srcCodes = pending.stream() - .map(ReceiptClaim::getAwrpSrcCode) + .map(KingdeeReceiptFlow::getAwrpSrcCode) .filter(Func::isNotEmpty) .collect(Collectors.toSet()); if (srcCodes.isEmpty()) { return 0; } - log.info("金蝶认领结果回写:待回写认领记录 {} 条", pending.size()); + log.info("金蝶认领结果回写:待回写流水 {} 条", pending.size()); LocalDate end = LocalDate.now(); LocalDate begin = end.minusDays(RESULT_WINDOW_DAYS); @@ -310,21 +332,29 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl first, HashMap::new)); int updated = 0; - for (ReceiptClaim claim : pending) { - BfiRecBillVO bill = matched.get(claim.getAwrpSrcCode()); + for (KingdeeReceiptFlow flow : pending) { + BfiRecBillVO bill = matched.get(flow.getAwrpSrcCode()); if (bill == null) { // 窗口内未查到:金蝶回单延迟/状态未同步,不改状态,由下次回写或人工处置兜底 continue; } - if (Func.isEmpty(claim.getKingdeeBillNo())) { - claim.setKingdeeBillNo(bill.getBillno()); + // 推送成功时金蝶收款单号先以来源编码占位(A301-3 响应 number 为空,取回执 keys.awrp_src_code), + // 回写命中后以金蝶真实收款单号覆盖 + if (Func.isNotEmpty(bill.getBillno())) { + flow.setKingdeeBillNo(bill.getBillno()); } - claim.setKingdeeBillStatus(billStatusOf(bill.getBillstatus())); - updateById(claim); + flow.setKingdeeBillStatus(billStatusOf(bill.getBillstatus())); + receiptFlowMapper.updateById(flow); + // 级联刷新名下未作废认领主单的冗余展示字段 + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(ReceiptClaim::getReceiptFlowId, flow.getId()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED) + .set(ReceiptClaim::getKingdeeBillNo, flow.getKingdeeBillNo()) + .set(ReceiptClaim::getKingdeeBillStatus, flow.getKingdeeBillStatus())); updated++; - log.info("金蝶认领结果回写 claimId:{},awrpSrcCode:{},金蝶收款单号:{},状态:{} → {}", - claim.getId(), claim.getAwrpSrcCode(), bill.getBillno(), bill.getBillstatus(), - claim.getKingdeeBillStatus()); + log.info("金蝶认领结果回写 flowId:{},awrpSrcCode:{},金蝶收款单号:{},状态:{} → {}", + flow.getId(), flow.getAwrpSrcCode(), bill.getBillno(), bill.getBillstatus(), + flow.getKingdeeBillStatus()); } log.info("金蝶认领结果回写完成:匹配 {} 条,更新 {} 条", matched.size(), updated); return updated; @@ -369,17 +399,25 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl= 0 ? "claimed" : "partial"; + return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial"; } private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java index b8961645..fe427db1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java @@ -28,6 +28,7 @@ package org.springblade.transport.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.toolkit.Db; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; @@ -37,24 +38,7 @@ import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; -import org.springblade.transport.config.BfiProperties; -import org.springblade.transport.constant.KingdeeBfiConstant; -import org.springblade.transport.mapper.FormalSettlementMapper; -import org.springblade.transport.mapper.KingdeeReceiptFlowMapper; -import org.springblade.transport.mapper.ReceiptClaimMapper; -import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; -import org.springblade.transport.mapper.ReceiptFlowRecordMapper; -import org.springblade.transport.pojo.dto.ReceiptClaimRequest; -import org.springblade.transport.pojo.entity.FormalSettlement; -import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; -import org.springblade.transport.pojo.entity.ReceiptClaim; -import org.springblade.transport.pojo.entity.ReceiptClaimSettlement; -import org.springblade.transport.pojo.entity.ReceiptFlowRecord; -import org.springblade.transport.pojo.vo.ReceiptFlowVO; -import org.springblade.transport.service.IReceiptFlowService; -import org.springblade.transport.service.IFormalSettlementService; -import org.springblade.transport.support.TransportBusinessSupport; -import org.springblade.transport.wrapper.ReceiptFlowWrapper; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushEntryDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; @@ -62,8 +46,29 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.mapper.FormalSettlementMapper; +import org.springblade.transport.mapper.KingdeeReceiptFlowMapper; +import org.springblade.transport.mapper.ReceiptClaimMapper; +import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; +import org.springblade.transport.mapper.ReceiptFlowRecordMapper; +import org.springblade.transport.pojo.dto.ReceiptClaimPushResult; +import org.springblade.transport.pojo.dto.ReceiptClaimRequest; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; +import org.springblade.transport.pojo.entity.ReceiptClaim; +import org.springblade.transport.pojo.entity.ReceiptClaimSettlement; +import org.springblade.transport.pojo.entity.ReceiptFlowRecord; +import org.springblade.transport.pojo.vo.ReceiptFlowVO; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IReceiptFlowService; +import org.springblade.transport.support.KingdeeSyncLockSupport; +import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.wrapper.ReceiptFlowWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.math.RoundingMode; @@ -71,6 +76,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -91,10 +97,15 @@ import java.util.stream.Collectors; public class ReceiptFlowServiceImpl extends BaseServiceImpl implements IReceiptFlowService { + // 未认领 private static final String UNCLAIMED = "unclaimed"; + // 部分认领 private static final String PARTIAL = "partial"; + // 已认领 private static final String CLAIMED = "claimed"; + // 已审批 private static final String APPROVED = "approved"; + // 应收款 private static final String RECEIVABLE = "receivable"; /** * A301-2 认领公告同步分页大小 @@ -105,6 +116,10 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl selectPage(IPage page, ReceiptFlowVO query) { @@ -126,8 +143,18 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl claimedFlowIds = claimMapper.selectList(Wrappers.lambdaQuery() + .select(ReceiptClaim::getReceiptFlowId) + .like(ReceiptClaim::getClaimerName, query.getClaimerName()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED)).stream() + .map(ReceiptClaim::getReceiptFlowId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (claimedFlowIds.isEmpty()) { + page.setRecords(List.of()); + page.setTotal(0); + return page.convert(ReceiptFlowWrapper.build()::entityVO); + } + wrapper.in(KingdeeReceiptFlow::getId, claimedFlowIds); + } return page(page, wrapper).convert(ReceiptFlowWrapper.build()::entityVO); } @@ -148,7 +192,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptFlowRecord::getReceiptFlowId, id) - .eq(ReceiptFlowRecord::getActionType, "claim") + .in(ReceiptFlowRecord::getActionType, List.of("claim", "push")) .orderByDesc(ReceiptFlowRecord::getCreateTime))); return vo; } @@ -167,8 +211,12 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl claimedMap = receiptClaimedAmounts(settlements.stream() + .map(FormalSettlement::getId).toList()); return settlements.stream() - .map(this::candidateRow) + .map(settlement -> candidateRow(settlement, + claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO))) .filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0) .toList(); } @@ -234,76 +282,126 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl allocationMap = allocationMap(request.getSettlements()); + // 结算单id列表(升序) List settlementIds = allocationMap.keySet().stream().sorted().toList(); + // 结算单id → 结算单实体 Map settlementMap = lockSettlements(settlementIds); + // 分摊的结算单列表 List settlements = settlementIds.stream().map(settlementMap::get).toList(); assertCompatible(settlements); - Map previousClaimedMap = new LinkedHashMap<>(); + // 结算单id → 认领前已认领金额(一次聚合查询,避免逐单 N+1) + Map previousClaimedMap = receiptClaimedAmounts(settlementIds); + // 本次分摊总金额 BigDecimal allocatedTotal = BigDecimal.ZERO; for (FormalSettlement settlement : settlements) { + // 本次分摊金额 BigDecimal allocated = allocationMap.get(settlement.getId()); - BigDecimal previousClaimed = settlementClaimedAmount(settlement.getId()); + // 认领前已认领金额 + BigDecimal previousClaimed = previousClaimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO); + // 结算总金额 BigDecimal settlementAmount = positiveMoney(settlement.getSettlementAmount(), "结算总金额"); if (previousClaimed.add(allocated).compareTo(settlementAmount) > 0) { throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的累计认领金额不能超过结算总应收含税金额"); } - previousClaimedMap.put(settlement.getId(), previousClaimed); allocatedTotal = allocatedTotal.add(allocated); } - BigDecimal receiptAmount = positiveMoney(flow.getReceiptAmount(), "收款金额"); + // 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径) + if (flow.getUnclaimedAmount() == null) { + throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水"); + } + BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount()); + if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("该流水公告未认领金额为0,无需认领"); + } + // 流水认领前已认领金额 BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId()); - if (previousFlowClaimed.add(allocatedTotal).compareTo(receiptAmount) > 0) { - throw new ServiceException("本次分摊金额不能超过流水剩余可认领金额"); + if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) { + throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 " + + unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO)); } + // 当前用户部门 Dept dept = TransportBusinessSupport.currentDept("收款流水认领"); + // 认领主单 ReceiptClaim claim = new ReceiptClaim(); + // 收款流水id claim.setReceiptFlowId(flow.getId()); + // 认领金额 claim.setClaimAmount(allocatedTotal); + // 认领人id claim.setClaimerId(AuthUtil.getUserId()); + // 认领人姓名 claim.setClaimerName(UserCache.getUserRealName(AuthUtil.getUserId())); + // 认领人部门id claim.setClaimerDeptId(dept.getId()); + // 认领人部门名称 claim.setClaimerDeptName(dept.getDeptName()); + // 认领日期 claim.setClaimDate(LocalDate.now()); + // 附件json claim.setAttachmentsJson(request.getAttachmentsJson()); + // 备注 claim.setRemark(trimToNull(request.getRemark())); + // 认领状态 claim.setClaimStatus(CLAIMED); - claim.setKingdeeBillStatus("none"); + // 金蝶回写状态(待推送) + claim.setKingdeeBillStatus(KingdeeBfiConstant.RECEIPT_CLAIM_BILL_NONE); claimMapper.insert(claim); for (FormalSettlement settlement : settlements) { - BigDecimal previousClaimed = previousClaimedMap.get(settlement.getId()); + // 认领前已认领金额 + BigDecimal previousClaimed = previousClaimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO); + // 本次分摊金额 BigDecimal allocated = allocationMap.get(settlement.getId()); - BigDecimal claimedAfter = previousClaimed.add(allocated); + // 认领结算单分摊关系 ReceiptClaimSettlement relation = new ReceiptClaimSettlement(); + // 认领主单id relation.setReceiptClaimId(claim.getId()); + // 收款流水id relation.setReceiptFlowId(flow.getId()); + // 结算单id relation.setFormalSettlementId(settlement.getId()); + // 结算单号 relation.setFormalSettlementNo(settlement.getFormalSettlementNo()); + // 结算总金额 relation.setSettlementAmount(money(settlement.getSettlementAmount())); + // 认领前已认领金额 relation.setClaimedReceiptAmount(previousClaimed); + // 本次分摊金额 relation.setAllocatedReceiptAmount(allocated); claimSettlementMapper.insert(relation); - - formalSettlementService.refreshPaymentSummary(settlement.getId()); } + // 批量刷新结算单收款汇总(固定数次查询,避免逐单 N+1) + formalSettlementService.refreshPaymentSummaries(settlementIds); + + // 认领前状态 String fromStatus = normalizeClaimStatus(flow.getClaimStatus()); + // 认领后累计金额 BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal); - String toStatus = amountClaimStatus(claimedAfter, receiptAmount); + // 认领后状态(对照公告未认领金额) + String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount); + // 更新流水已认领金额 flow.setClaimedAmount(claimedAfter); + // 更新流水认领状态 flow.setClaimStatus(toStatus); updateById(flow); + // 记录操作日志 record(flow.getId(), claim.getId(), "claim", "认领收款流水", fromStatus, toStatus, allocatedTotal, "关联" + settlements.size() + "张应收正式结算单"); - pushClaimResultToKingdee(flow, claim, settlements, allocationMap); + // 认领只登记本地,不推送金蝶;推送由定时任务/手动按钮在认领完成后触发 return claim.getId(); } @@ -314,12 +412,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl announces = BfiUtil.queryAllPages(pageNo -> - bfiService.queryClaimAnnounce(claimusers, pageNo, SYNC_PAGE_SIZE)); - log.info("调用BFI接口 收款认领公告查询结束 耗时:{}ms,拉取 {} 条", - System.currentTimeMillis() - start, announces.size()); + List announces = queryClaimAnnounces(claimusers); if (announces.isEmpty()) { record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, @@ -330,57 +423,130 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl serialNumbers = new HashSet<>(); - int syncedCount = 0; - int createdCount = 0; - int updatedCount = 0; + + // 第一遍:数据校验与过滤,收集有效的 detailSerialNo + log.info("收款流水同步开始数据校验,共 {} 条认领公告", announces.size()); + Map validAnnounces = new LinkedHashMap<>(); + int skippedCount = 0; for (BfiClaimAnnounceVO announce : announces) { - // 明细流水号为幂等键,缺省依次回退金蝶交易明细编号、银行流水号 + // 明细流水号优先级:detailid > tradedetailno > bankdetailno String detailSerialNo = firstNonBlank(announce.getDetailid(), announce.getTradedetailno(), announce.getBankdetailno()); if (Func.isEmpty(detailSerialNo)) { log.warn("收款流水同步跳过:认领公告[{}]缺少明细流水号", announce.getBillno()); + skippedCount++; continue; } - if (!serialNumbers.add(detailSerialNo.trim())) { - throw new ServiceException("明细流水号" + detailSerialNo.trim() + "重复"); - } - // 行级锁防并发:同一流水并发手动同步时避免重复落库或覆盖已认领状态 - KingdeeReceiptFlow entity = baseMapper.selectOne(Wrappers.lambdaQuery() - .eq(KingdeeReceiptFlow::getDetailSerialNo, detailSerialNo.trim()) - .last("FOR UPDATE")); - boolean created = entity == null; - if (created) { - entity = new KingdeeReceiptFlow(); - entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); - entity.setClaimStatus(UNCLAIMED); + detailSerialNo = detailSerialNo.trim(); + if (validAnnounces.containsKey(detailSerialNo)) { + throw new ServiceException("明细流水号" + detailSerialNo + "重复"); } BigDecimal receiptAmount = money(announce.getReamount()); if (receiptAmount.compareTo(BigDecimal.ZERO) <= 0) { - // 公告金额异常时跳过单条并告警,不阻断整批同步、不破坏已有流水 log.warn("收款流水同步跳过:认领公告[{}]收款金额为空或小于0", announce.getBillno()); + skippedCount++; continue; } - copyAnnounceRow(entity, announce, detailSerialNo.trim(), receiptAmount); - entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()), receiptAmount)); + validAnnounces.put(detailSerialNo, announce); + } + log.info("收款流水数据校验完成:有效 {} 条,跳过 {} 条", validAnnounces.size(), skippedCount); + + if (validAnnounces.isEmpty()) { + log.info("收款流水同步无有效数据,记录操作日志后返回"); + record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, + "金蝶认领公告无有效数据"); + return 0; + } + + // 批量查询已存在的记录(FOR UPDATE 防并发) + log.info("收款流水同步开始批量查询已存在记录,查询条件:detailSerialNo IN {} 条", validAnnounces.size()); + List existingEntities = baseMapper.selectList( + Wrappers.lambdaQuery() + .in(KingdeeReceiptFlow::getDetailSerialNo, validAnnounces.keySet()) + .last("FOR UPDATE") + ); + Map existingMap = existingEntities.stream() + .collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e)); + log.info("收款流水批量查询完成:已存在 {} 条,待新增 {} 条", + existingEntities.size(), validAnnounces.size() - existingEntities.size()); + + // 第二遍:构建新增和更新列表 + log.info("收款流水同步开始构建新增/更新列表"); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + int createdCount = 0; + int updatedCount = 0; + + for (Map.Entry entry : validAnnounces.entrySet()) { + String detailSerialNo = entry.getKey(); + BfiClaimAnnounceVO announce = entry.getValue(); + BigDecimal receiptAmount = money(announce.getReamount()); + + // 判断是新增还是更新 + KingdeeReceiptFlow existing = existingMap.get(detailSerialNo); + boolean created = (existing == null); + + KingdeeReceiptFlow entity; if (created) { - baseMapper.insert(entity); + // 新增:初始化本地认领金额和状态 + entity = new KingdeeReceiptFlow(); + entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); + entity.setClaimStatus(UNCLAIMED); + } else { + // 更新:保留本地已认领金额和状态 + entity = existing; + } + + // 复制公告数据到实体(不覆盖本地认领字段) + copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount); + // 根据本地累计认领金额与最新公告未认领金额重新计算认领状态 + entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()), + entity.getUnclaimedAmount())); + + if (created) { + toInsert.add(entity); createdCount++; } else { - baseMapper.updateById(entity); + toUpdate.add(entity); updatedCount++; } - record(entity.getId(), null, "sync", created ? "新增金蝶收款流水" : "更新金蝶收款流水", - entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, detailSerialNo.trim()); - syncedCount++; } + log.info("收款流水列表构建完成:待新增 {} 条,待更新 {} 条", createdCount, updatedCount); + + // 批量插入和更新 + if (!toInsert.isEmpty()) { + log.info("收款流水同步开始批量插入 {} 条", toInsert.size()); + saveBatch(toInsert); + log.info("收款流水批量插入完成"); + } + if (!toUpdate.isEmpty()) { + log.info("收款流水同步开始批量更新 {} 条", toUpdate.size()); + updateBatchById(toUpdate); + log.info("收款流水批量更新完成"); + } + + // 记录操作日志(批量插入,避免逐行 N+1) + log.info("收款流水同步开始记录操作日志,共 {} 条", toInsert.size() + toUpdate.size()); + List records = new ArrayList<>(toInsert.size() + toUpdate.size()); + for (KingdeeReceiptFlow entity : toInsert) { + records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水", + entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); + } + for (KingdeeReceiptFlow entity : toUpdate) { + records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水", + entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); + } + if (!records.isEmpty()) { + Db.saveBatch(records); + } + log.info("收款流水操作日志记录完成"); + log.info("金蝶收款流水同步完成 claimusers:{},拉取 {} 条,新增 {} 条,更新 {} 条", claimusers, announces.size(), createdCount, updatedCount); - return syncedCount; + return createdCount + updatedCount; } - private Map candidateRow(FormalSettlement settlement) { - BigDecimal claimedAmount = settlementClaimedAmount(settlement.getId()); + private Map candidateRow(FormalSettlement settlement, BigDecimal claimedAmount) { BigDecimal settlementAmount = money(settlement.getSettlementAmount()); Map row = new LinkedHashMap<>(); row.put("id", settlement.getId()); @@ -416,16 +582,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl lockSettlements(List settlementIds) { - List settlements = new ArrayList<>(); + // 一次锁行查询替代逐单 selectOne(id 升序加锁,与逐单版加锁顺序一致防死锁) + List settlements = formalSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(FormalSettlement::getId, settlementIds) + .orderByAsc(FormalSettlement::getId) + .last("FOR UPDATE")); + Map settlementMap = new LinkedHashMap<>(); for (Long settlementId : settlementIds) { - FormalSettlement settlement = formalSettlementMapper.selectOne( - Wrappers.lambdaQuery() - .eq(FormalSettlement::getId, settlementId) - .last("FOR UPDATE")); - settlements.add(availableSettlement(settlement)); + FormalSettlement settlement = settlements.stream() + .filter(item -> settlementId.equals(item.getId())) + .findFirst().orElse(null); + settlementMap.put(settlementId, availableSettlement(settlement)); } - return settlements.stream().collect(Collectors.toMap(FormalSettlement::getId, - Function.identity(), (first, second) -> first, LinkedHashMap::new)); + return settlementMap; } private FormalSettlement availableSettlement(FormalSettlement settlement) { @@ -451,13 +621,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl receiptClaimedAmounts(List settlementIds) { + List ids = settlementIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } return claimSettlementMapper.selectList(Wrappers.lambdaQuery() - .eq(ReceiptClaimSettlement::getFormalSettlementId, settlementId) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() - .map(ReceiptClaimSettlement::getAllocatedReceiptAmount) - .map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); + .in(ReceiptClaimSettlement::getFormalSettlementId, ids) + .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, + Collectors.reducing(BigDecimal.ZERO, + item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))); } private BigDecimal flowClaimedAmount(Long flowId) { @@ -491,65 +668,244 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl - * 认领在本地落库后立即推送,推送失败抛异常由事务整体回滚,保证金蝶与本地认领口径一致; - * awrpSrcCode 同时作为 A301-1 收款单查询回写的匹配键(金蝶收款单 sourcebillnumber)。 - * - * @param flow 收款流水(须含 billId) - * @param claim 本地认领主单(已落库) - * @param settlements 分摊的应收正式结算单(保持 id 升序) - * @param allocationMap 结算单 id → 分摊金额 + * 仅认领完成且未推送过的流水可推送;推送前以当前用户姓名重查 A301-2 最新公告对账。 + * 推送失败不抛异常跨事务边界(保证失败状态落库),原因随结果对象返回。 */ - private void pushClaimResultToKingdee(KingdeeReceiptFlow flow, ReceiptClaim claim, - List settlements, Map allocationMap) { + @Override + public ReceiptClaimPushResult pushClaimToKingdee(Long flowId) { + if (flowId == null) { + throw new ServiceException("收款流水ID不能为空"); + } + KingdeeReceiptFlow flow = existing(flowId); if (flow.getBillId() == null) { - throw new ServiceException("认领公告id缺失,请先手动同步金蝶收款流水"); + return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水"); } - String awrpSrcCode = buildClaimSrcCode(claim.getId()); - claim.setAwrpSrcCode(awrpSrcCode); - - BfiClaimPushDTO param = new BfiClaimPushDTO(); - param.setBillid(flow.getBillId()); - param.setAwrpSrcSys(bfiProperties.getSrcSys()); - param.setAwrpSrcCode(awrpSrcCode); - param.setRecpaytypeNumber(KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE); - param.setPaymenttype(KingdeeBfiConstant.BFI_CLAIM_PAYMENT_TYPE); - param.setRecviewpayer(flow.getPayerName()); - param.setRecpayer(flow.getPayerId()); - List entries = new ArrayList<>(); - int seq = 1; - BigDecimal allocatedTotal = BigDecimal.ZERO; - for (FormalSettlement settlement : settlements) { - BfiClaimPushEntryDTO entry = new BfiClaimPushEntryDTO(); - entry.setEReceivableamt(allocationMap.get(settlement.getId())); - entry.setSeq(seq++); - if (Func.isNotEmpty(settlement.getKingdeeBillNo())) { - // 结算单已同步金蝶应收单时携带核心单据引用,金蝶按应收单号核销认领金额 - entry.setECorebilltype(KingdeeBfiConstant.BFI_AR_BILL_SRC_BT); - entry.setECorebillno(settlement.getKingdeeBillNo()); - } - allocatedTotal = allocatedTotal.add(money(allocationMap.get(settlement.getId()))); - entries.add(entry); + String claimusers = UserCache.getUserRealName(AuthUtil.getUserId()); + if (Func.isEmpty(claimusers)) { + throw new ServiceException("当前用户无姓名,无法推送金蝶收款认领"); } - param.setEntryentity(entries); - - log.info("调用BFI接口 收款认领结果推送开始 claimId:{},billId:{},awrpSrcCode:{},分摊结算单 {} 张,分摊金额 {}", - claim.getId(), flow.getBillId(), awrpSrcCode, settlements.size(), allocatedTotal); - BfiBillSaveResultVO result = bfiService.pushClaimResult(param); - applyClaimPushResult(claim, result); - claimMapper.updateById(claim); - log.info("调用BFI接口 收款认领结果推送成功 claimId:{},已回写金蝶状态:{}", - claim.getId(), claim.getKingdeeBillStatus()); + // 推送前对账:以当前用户身份重查 A301-2 拿最新公告(手动推送者须在公告认领范围内) + BfiClaimAnnounceVO announce = queryClaimAnnounces(claimusers).stream() + .filter(item -> flow.getBillId().equals(item.getId())) + .findFirst().orElse(null); + if (announce == null) { + return ReceiptClaimPushResult.fail("金蝶侧未查询到该认领公告,当前用户可能不在公告认领范围内,请重新同步后重试"); + } + return pushClaimLocked(flow.getId(), announce); } /** - * 解析 A301-3 推送结果并回写认领记录 + * 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。 + * 多副本部署下由 single-flight 分布式锁保证同一轮全局最多一份在跑,已有轮次进行中则本轮跳过。 * - * @param claim 认领记录 - * @param result 推送结果 + * @return 执行摘要 */ - private void applyClaimPushResult(ReceiptClaim claim, BfiBillSaveResultVO result) { + @Override + public String pushClaimByTask() { + return kingdeeSyncLockSupport.runSingleFlight(RECEIPT_CLAIM_PUSH_LOCK_PREFIX, bfiProperties.getReceiptClaimPush().getLockLeaseSeconds(), + "定时推送收款认领", this::executeReceiptClaimPush); + } + + /** + * 定时推送收款认领主体:按认领人姓名串行处理—— + * 先重查该认领人的 A301-2 最新公告并推完其名下全部流水,再到下一个认领人。 + * A301-2 的 claimusers 必填且为单个用户姓名,定时任务无登录人上下文, + * 故按认领人逐人查询公告,按公告id(bill_id)匹配流水。 + * 单条流水推送失败独立消化,不影响同批其他流水。 + * + * @return 执行摘要 + */ + private String executeReceiptClaimPush() { + List candidates = list(Wrappers.lambdaQuery() + .isNotNull(KingdeeReceiptFlow::getBillId) + .in(KingdeeReceiptFlow::getPushStatus, List.of( + KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_NONE, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED)) + .eq(KingdeeReceiptFlow::getClaimStatus, CLAIMED) + .eq(KingdeeReceiptFlow::getStatus, 1) + .orderByAsc(KingdeeReceiptFlow::getId)); + if (candidates.isEmpty()) { + return "无待推送流水"; + } + Map candidateById = candidates.stream() + .collect(Collectors.toMap(KingdeeReceiptFlow::getId, Function.identity(), + (first, second) -> first, LinkedHashMap::new)); + // 认领人 → 其有效认领的流水id + Map> claimedFlowIdsByClaimer = claimMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceiptClaim::getReceiptFlowId, candidateById.keySet()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED)).stream() + .filter(claim -> Func.isNotEmpty(claim.getClaimerName()) && claim.getReceiptFlowId() != null) + .collect(Collectors.groupingBy(ReceiptClaim::getClaimerName, + Collectors.mapping(ReceiptClaim::getReceiptFlowId, Collectors.toSet()))); + if (claimedFlowIdsByClaimer.isEmpty()) { + return "待推送流水 " + candidates.size() + " 条,但无有效认领人,跳过"; + } + int successCount = 0; + int failedCount = 0; + int skippedCount = 0; + // 本轮已推送成功的流水:同一流水被多个认领人认领时,只在首个覆盖它的认领人处推送一次 + Set pushedFlowIds = new HashSet<>(); + // 按认领人逐人串行:查该认领人公告 → 推完其名下流水 → 下一个认领人 + for (String claimerName : claimedFlowIdsByClaimer.keySet().stream().sorted().toList()) { + Set claimerFlowIds = claimedFlowIdsByClaimer.get(claimerName); + List claimerFlows = candidateById.values().stream() + .filter(flow -> claimerFlowIds.contains(flow.getId())) + .filter(flow -> !pushedFlowIds.contains(flow.getId())) + .toList(); + if (claimerFlows.isEmpty()) { + continue; + } + // 该认领人的公告一次查询,按公告id归集 + Map announceByBillId = new HashMap<>(); + try { + for (BfiClaimAnnounceVO announce : queryClaimAnnounces(claimerName)) { + if (announce.getId() != null) { + announceByBillId.putIfAbsent(announce.getId(), announce); + } + } + } catch (Exception exception) { + log.error("定时推送收款认领:认领人[{}]公告查询失败,其名下流水全部跳过", claimerName, exception); + skippedCount += claimerFlows.size(); + continue; + } + for (KingdeeReceiptFlow flow : claimerFlows) { + BfiClaimAnnounceVO announce = announceByBillId.get(flow.getBillId()); + if (announce == null) { + skippedCount++; + log.info("定时推送收款认领:认领人[{}]流水[{}]未在公告查询结果中命中,跳过", + claimerName, flow.getId()); + continue; + } + try { + ReceiptClaimPushResult result = pushClaimLocked(flow.getId(), announce); + if (result.isSuccess()) { + successCount++; + pushedFlowIds.add(flow.getId()); + } else { + failedCount++; + } + } catch (Exception exception) { + failedCount++; + log.error("定时推送收款认领:认领人[{}]流水[{}]推送异常", claimerName, flow.getId(), exception); + } + } + } + String summary = "扫描 " + candidates.size() + " 条,推送成功 " + successCount + + " 条,推送失败 " + failedCount + " 条,公告未命中跳过 " + skippedCount + " 条"; + log.info("定时推送收款认领完成:{}", summary); + return summary; + } + + /** + * 事务内推送:流水行锁覆盖闸门校验、对账、推送与状态落库全过程, + * 防定时任务与手动按钮并发对同一流水双推 + */ + private ReceiptClaimPushResult pushClaimLocked(Long flowId, BfiClaimAnnounceVO announce) { + return transactionTemplate.execute(status -> doPushClaimToKingdee(flowId, announce)); + } + + /** + * 推送单条流水:静态闸门 → 最新公告对账 → 按流水归集分摊行推送 → 成败状态落库 + */ + private ReceiptClaimPushResult doPushClaimToKingdee(Long flowId, BfiClaimAnnounceVO announce) { + KingdeeReceiptFlow flow = lockedFlow(flowId); + if (flow.getBillId() == null) { + return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水"); + } + // 终态闸门:已推送流水不允许二次推送(作废重认领使等式再满足也不推,金蝶侧人工处置) + if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) { + return ReceiptClaimPushResult.fail("该流水已推送金蝶,不允许重复推送"); + } + // 推送前对账:以最新公告刷新公告侧未认领金额并重算认领状态(快照落库,页面口径同步修正) + flow.setUnclaimedAmount(money(announce.getUnclaimamount())); + flow.setKingdeeClaimStatus(trimToNull(announce.getClaimstatus())); + flow.setClaimMe(announce.getClaimme()); + BigDecimal claimedTotal = flowClaimedAmount(flow.getId()); + flow.setClaimedAmount(claimedTotal); + String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount()); + flow.setClaimStatus(claimStatus); + updateById(flow); + if (!CLAIMED.equals(claimStatus)) { + return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额(" + + money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件"); + } + // 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集) + List allocations = claimSettlementMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId()) + .eq(ReceiptClaimSettlement::getStatus, 1) + .orderByAsc(ReceiptClaimSettlement::getFormalSettlementId)); + if (allocations.isEmpty()) { + return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送"); + } + // 核心单据编号取第一张结算单同步金蝶回写的星瀚内码(应收/应付单金蝶id),TMS本地id金蝶无法识别 + FormalSettlement firstSettlement = formalSettlementMapper.selectById(allocations.get(0).getFormalSettlementId()); + if (firstSettlement == null || Func.isBlank(firstSettlement.getKingdeeBillId())) { + return ReceiptClaimPushResult.fail("结算单" + allocations.get(0).getFormalSettlementNo() + + "未同步金蝶(缺少金蝶单据id),不满足推送条件"); + } + // 推送来源编码:分摊结算单号下划线拼接(A301-3 契约口径;分摊不变则重试编码不变, + // 金蝶按 awrp_src_sys + awrp_src_code 幂等防重试双单) + String awrpSrcCode = allocations.stream() + .map(ReceiptClaimSettlement::getFormalSettlementNo) + .filter(Func::isNotEmpty) + .distinct() + .collect(Collectors.joining("_")); + flow.setAwrpSrcCode(awrpSrcCode); + + BfiClaimPushDTO param = buildClaimPushDTO(flow, claimedTotal, allocations, firstSettlement.getKingdeeBillId()); + log.info("调用BFI接口 收款认领结果推送开始 flowId:{},billId:{},awrpSrcCode:{},分摊明细 {} 行,认领金额 {}", + flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), allocations.size(), claimedTotal); + try { + BfiBillSaveResultVO result = bfiService.pushClaimResult(param); + return applyClaimPushSuccess(flow, extractClaimPushBillNo(result)); + } catch (Exception exception) { + return applyClaimPushFailure(flow, exception); + } + } + + /** + * A301-3 推送报文组装:billid=公告id,单条分录、金额=全部未推送金额(一笔流水一张金蝶收款单), + * 核心单据编号=第一张结算单的金蝶单据id(星瀚内码) + */ + private BfiClaimPushDTO buildClaimPushDTO(KingdeeReceiptFlow flow, BigDecimal claimedTotal, + List allocations, String coreBillId) { + BfiClaimPushDTO param = new BfiClaimPushDTO(); + // 【必填】认领公告id + param.setBillid(flow.getBillId()); + // 【必填】来源系统编码 + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 【必填】来源系统单据编码(分摊结算单号下划线拼接) + param.setAwrpSrcCode(flow.getAwrpSrcCode()); + // 【必填】收款类型.编码 + param.setRecpaytypeNumber(KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE); + // 【必填】付款人类型 + param.setPaymenttype(KingdeeBfiConstant.BFI_CLAIM_PAYMENT_TYPE); + // 【必填】付款人名称 + param.setRecviewpayer(flow.getPayerName()); + // 【必填】付款人id + param.setRecpayer(flow.getPayerId()); + // 【必填】单据体分录:仅一条,应收金额=全部未推送金额(累计有效认领) + BfiClaimPushEntryDTO entry = new BfiClaimPushEntryDTO(); + // 【必填】应收金额(全部未推送金额) + entry.setEReceivableamt(claimedTotal); + // 分录行序号(第一张结算单id,分摊已按结算单id升序,多张取最小) + entry.setSeq(allocations.get(0).getFormalSettlementId()); + // 核心单据类型 + // entry.setECorebilltype(KingdeeBfiConstant.BFI_CLAIM_CORE_BILL_TYPE); + // 核心单据编号(第一张结算单的金蝶单据id,星瀚内码) + entry.setECorebillno(coreBillId); + param.setEntryentity(List.of(entry)); + return param; + } + + /** + * 解析 A301-3 推送结果:全项成功返回单据号,否则抛出汇总错误文本(由失败路径落库)。 + * 成功响应的 number 为空,金蝶收款单号取回执 keys 中的 awrp_src_code(来源编码)。 + */ + private String extractClaimPushBillNo(BfiBillSaveResultVO result) { if (result == null || result.getResult() == null || result.getResult().isEmpty()) { throw new ServiceException("金蝶认领结果推送失败,返回结果为空"); } @@ -563,24 +919,96 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(KingdeeReceiptFlow::getId, flow.getId()) + .set(KingdeeReceiptFlow::getPushStatus, flow.getPushStatus()) + .set(KingdeeReceiptFlow::getPushFailReason, null) + .set(KingdeeReceiptFlow::getPushedTime, flow.getPushedTime()) + .set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode()) + .set(KingdeeReceiptFlow::getKingdeeBillNo, kingdeeBillNo) + .set(KingdeeReceiptFlow::getKingdeeBillStatus, flow.getKingdeeBillStatus())); + // 级联刷新名下未作废认领主单的冗余展示字段 + claimMapper.update(null, Wrappers.lambdaUpdate() + .eq(ReceiptClaim::getReceiptFlowId, flow.getId()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED) + .set(ReceiptClaim::getKingdeeBillNo, kingdeeBillNo) + .set(ReceiptClaim::getKingdeeBillStatus, KingdeeBfiConstant.KINGDEE_STATUS_PUSHED)); + record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(), + "金蝶收款单号:" + (Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo)); + return ReceiptClaimPushResult.success(kingdeeBillNo); + } + + /** + * 推送失败:流水置推送失败并记录原因,本地认领数据保留,可重新推送(重试复用同一来源编码) + */ + private ReceiptClaimPushResult applyClaimPushFailure(KingdeeReceiptFlow flow, Exception exception) { + String reason = Func.isEmpty(exception.getMessage()) + ? exception.getClass().getSimpleName() : exception.getMessage(); + reason = truncateText(reason, 1000); + log.error("调用BFI接口 收款认领结果推送失败 flowId:{},billId:{},awrpSrcCode:{}", + flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), exception); + this.update(Wrappers.lambdaUpdate() + .eq(KingdeeReceiptFlow::getId, flow.getId()) + .set(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED) + .set(KingdeeReceiptFlow::getPushFailReason, reason) + .set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode())); + record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(), + "推送失败:" + truncateText(reason, 200)); + return ReceiptClaimPushResult.fail("金蝶认领结果推送失败:" + reason); + } + + /** + * A301-2 收款认领公告查询:按认领人姓名全量翻页,时间窗口取 Nacos 配置(默认近半年)。 + * 认领公告同步、手动推送对账与定时任务对账共用。 + */ + private List queryClaimAnnounces(String claimusers) { + int months = bfiProperties.getClaimAnnounce().getMonths(); + // 认领人直接取 Nacos 配置值 + String effectiveClaimusers = bfiProperties.getClaimAnnounce().getClaimusers(); + LocalDateTime endDate = LocalDateTime.now(); + LocalDateTime startDate = endDate.minusMonths(months); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 收款认领公告查询开始 claimusers:{},时间窗口:{} ~ {}(近{}个月)", + effectiveClaimusers, startDate.format(BFI_DATE_TIME_FORMATTER), endDate.format(BFI_DATE_TIME_FORMATTER), months); + BfiClaimAnnounceQueryDTO queryDTO = new BfiClaimAnnounceQueryDTO(); + queryDTO.setClaimusers(effectiveClaimusers); + queryDTO.setModifytimeS(startDate.format(BFI_DATE_TIME_FORMATTER)); + queryDTO.setModifytimeE(endDate.format(BFI_DATE_TIME_FORMATTER)); + queryDTO.setPageSize(SYNC_PAGE_SIZE); + List announces = BfiUtil.queryAllPages(pageNo -> { + queryDTO.setPageNo(pageNo); + return bfiService.queryClaimAnnounce(queryDTO); + }); + log.info("调用BFI接口 收款认领公告查询结束 claimusers:{},耗时:{}ms,拉取 {} 条", + effectiveClaimusers, System.currentTimeMillis() - start, announces.size()); + return announces; } /** @@ -588,21 +1016,39 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl= 0 ? CLAIMED : PARTIAL; + return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL; } private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) { @@ -665,13 +1114,30 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl maxLength) { throw new ServiceException(message); } } + private String truncateText(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + private void record(Long flowId, Long claimId, String actionType, String actionName, + String fromStatus, String toStatus, BigDecimal operationAmount, String content) { + recordMapper.insert(buildRecord(flowId, claimId, actionType, actionName, + fromStatus, toStatus, operationAmount, content)); + } + + private ReceiptFlowRecord buildRecord(Long flowId, Long claimId, String actionType, String actionName, String fromStatus, String toStatus, BigDecimal operationAmount, String content) { ReceiptFlowRecord record = new ReceiptFlowRecord(); record.setReceiptFlowId(flowId); @@ -683,6 +1149,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl changeRecords(IPage page, Long detailId) { - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() - .eq(ReceivablePayableChangeRecord::getDetailId, detailId) - .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) - .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime); - IPage entityPage = changeRecordMapper.selectPage(new Page<>(page.getCurrent(), page.getSize()), wrapper); - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream().map(record -> Objects.requireNonNull( + List meaningfulRecords = changeRecordMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceivablePayableChangeRecord::getDetailId, detailId) + .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) + .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime)) + .stream() + .filter(record -> hasMeaningfulChange(record.getChangeContent())) + .toList(); + long current = page.getCurrent(); + long size = page.getSize(); + int fromIndex = (int) Math.max(0L, (current - 1L) * size); + int toIndex = (int) Math.min(meaningfulRecords.size(), fromIndex + size); + List pageRecords = fromIndex >= meaningfulRecords.size() + ? List.of() + : meaningfulRecords.subList(fromIndex, toIndex); + Page voPage = new Page<>(current, size, meaningfulRecords.size()); + voPage.setRecords(pageRecords.stream().map(record -> Objects.requireNonNull( BeanUtil.copyProperties(record, ReceivablePayableChangeRecordVO.class))).toList()); return voPage; } @@ -470,8 +481,10 @@ public class ReceivablePayableDetailServiceImpl } List afterRows = activeCargoFees(detail.getId()); List changes = reconcileUpdatedFeeAmounts(beforeRows, afterRows); + LocalDateTime adjustTime = LocalDateTime.now(); for (int index = 0; index < changes.size(); index++) { - saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), String.format("%04d", index + 1)); + saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), + String.format("%04d", index + 1), adjustTime); } } } @@ -612,14 +625,8 @@ public class ReceivablePayableDetailServiceImpl appendChange(changes, "运输单价", existing.getUnitPrice(), adjusted.getUnitPrice()); appendChange(changes, "里程", existing.getMileage(), mileage); appendChange(changes, "运输费", existing.getFreightAmount(), freightAmount); - if (!Objects.equals(existing.getRemark(), adjusted.getRemark())) { - changes.add("【备注】从[" + Objects.toString(existing.getRemark(), "") + "]调整为[" - + Objects.toString(adjusted.getRemark(), "") + "]"); - } - if (!Objects.equals(existing.getChangeReason(), adjusted.getChangeReason())) { - changes.add("【变更原因】从[" + Objects.toString(existing.getChangeReason(), "") + "]调整为[" - + Objects.toString(adjusted.getChangeReason(), "") + "]"); - } + appendChange(changes, "备注", existing.getRemark(), adjusted.getRemark()); + appendChange(changes, "变更原因", existing.getChangeReason(), adjusted.getChangeReason()); Map oldFeeItems = parseMap(existing.getFeeItemsJson()); for (String name : allowedFeeItems) { appendChange(changes, name, decimal(oldFeeItems.get(name)), money(effectiveFeeItems.get(name))); @@ -645,8 +652,9 @@ public class ReceivablePayableDetailServiceImpl if (changes.isEmpty()) { throw new ServiceException("未修改任何费用数据"); } + LocalDateTime adjustTime = LocalDateTime.now(); for (int i = 0; i < changes.size(); i++) { - saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1)); + saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1), adjustTime); } refreshAdjustedDetail(detail, allRows); } @@ -1471,7 +1479,7 @@ public class ReceivablePayableDetailServiceImpl detail.setMileage(normalizeGeneratedMileage(waybill.getMileage())); detail.setBatchNo(waybill.getBatchNo()); detail.setUnitPrice(unitPrice); - detail.setCurrency("RMB"); + detail.setCurrency(resolveCurrency(contract, waybill)); detail.setFreightAmount(freight); detail.setOtherFeeAmount(other); detail.setTotalAmount(total); @@ -2369,12 +2377,56 @@ public class ReceivablePayableDetailServiceImpl } private void appendChange(List changes, String field, String before, String after) { - if (!Objects.equals(before, after)) { - changes.add("【" + field + "】从[" + Objects.toString(before, "") + "]调整为[" - + Objects.toString(after, "") + "]"); + String oldValue = normalizeChangeText(before); + String newValue = normalizeChangeText(after); + if (!Objects.equals(oldValue, newValue)) { + changes.add("【" + field + "】从[" + oldValue + "]调整为[" + newValue + "]"); } } + /** + * 变更文本归一化:null / 空白视为相同,避免出现「从[]调整为[]」无效记录 + */ + private String normalizeChangeText(String value) { + return value == null ? "" : value.trim(); + } + + /** + * 是否存在实际前后差异;整段内容均无变化时不展示 + */ + private boolean hasMeaningfulChange(String content) { + if (Func.isBlank(content)) { + return false; + } + String text = content.trim(); + int suffixStart = text.lastIndexOf('('); + if (suffixStart > 0 && text.endsWith(")")) { + text = text.substring(0, suffixStart); + } + boolean hasPart = false; + for (String part : text.split(";")) { + if (Func.isBlank(part)) { + continue; + } + hasPart = true; + if (isMeaningfulChangePart(part.trim())) { + return true; + } + } + return !hasPart; + } + + private boolean isMeaningfulChangePart(String part) { + int fromIdx = part.indexOf("从["); + int toIdx = part.indexOf("]调整为["); + if (fromIdx < 0 || toIdx < fromIdx || !part.endsWith("]")) { + return true; + } + String before = part.substring(fromIdx + 2, toIdx); + String after = part.substring(toIdx + 5, part.length() - 1); + return !Objects.equals(before, after); + } + private String formatValue(BigDecimal value) { return money(value).stripTrailingZeros().toPlainString(); } @@ -2526,6 +2578,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, waybill)); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2557,6 +2610,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, masterGoods.isEmpty() ? null : masterGoods.get(0))); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2591,6 +2645,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, waybills.get(0))); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2612,10 +2667,18 @@ public class ReceivablePayableDetailServiceImpl } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason) { - saveChangeRecord(detail, content, reason, "0001"); + saveChangeRecord(detail, content, reason, "0001", LocalDateTime.now()); } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo) { + saveChangeRecord(detail, content, reason, lineNo, LocalDateTime.now()); + } + + private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo, + LocalDateTime adjustTime) { + if (!hasMeaningfulChange(content)) { + return; + } ReceivablePayableChangeRecord record = new ReceivablePayableChangeRecord(); record.setDetailId(detail.getId()); record.setLineNo(lineNo); @@ -2624,7 +2687,7 @@ public class ReceivablePayableDetailServiceImpl record.setAdjustUser(AuthUtil.getUserId()); record.setAdjustUserName(AuthUtil.getUserName()); record.setAdjustReason(reason); - record.setAdjustTime(LocalDateTime.now()); + record.setAdjustTime(adjustTime == null ? LocalDateTime.now() : adjustTime); changeRecordMapper.insert(record); } @@ -2889,6 +2952,43 @@ public class ReceivablePayableDetailServiceImpl return map; } + /** + * 结算币种优先取合同 settlementCurrency,其次运单 freightJson.currency,默认 RMB。 + */ + private String resolveCurrency(ContractManage contract, Waybill waybill) { + if (contract != null && Func.isNotEmpty(contract.getSettlementCurrency())) { + return normalizeCurrencyCode(contract.getSettlementCurrency()); + } + String freightCurrency = extractFreightCurrency(waybill); + if (Func.isNotEmpty(freightCurrency)) { + return normalizeCurrencyCode(freightCurrency); + } + return "RMB"; + } + + private String extractFreightCurrency(Waybill waybill) { + if (waybill == null || Func.isEmpty(waybill.getFreightJson())) { + return null; + } + Object currency = parseMap(waybill.getFreightJson()).get("currency"); + return currency == null ? null : String.valueOf(currency); + } + + private String normalizeCurrencyCode(String currency) { + if (Func.isEmpty(currency)) { + return "RMB"; + } + String code = currency.trim().toUpperCase(Locale.ROOT); + int separatorIndex = code.indexOf(" - "); + if (separatorIndex > 0) { + code = code.substring(0, separatorIndex).trim(); + } + if ("CNY".equals(code)) { + return "RMB"; + } + return code; + } + private Map parseMap(String json) { if (Func.isEmpty(json)) { return new LinkedHashMap<>(); @@ -2940,6 +3040,11 @@ public class ReceivablePayableDetailServiceImpl } private String formatQuantity(BigDecimal quantity, String unit) { + // 无有效运输量(空/0/-1)时不展示数值与单位 + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) == 0 + || quantity.compareTo(BigDecimal.valueOf(-1)) == 0) { + return "-"; + } return money(quantity).stripTrailingZeros().toPlainString() + (unit == null ? "" : " " + unit); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ShippingTemplateServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ShippingTemplateServiceImpl.java index d695b0b6..109024c8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ShippingTemplateServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ShippingTemplateServiceImpl.java @@ -173,7 +173,7 @@ public class ShippingTemplateServiceImpl extends BaseServiceImpl importGoods(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List failures = new ArrayList<>(); for (int index = 0; index < data.size(); index++) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java index b374c714..b75e757a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java @@ -185,7 +185,11 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl buildQuery(TemporaryCreditLimitVO temporaryCreditLimit) { TransportBusinessSupport.validateAllDept(temporaryCreditLimit.getAllDept(), "临时额度管理"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(TemporaryCreditLimit::getIsDeleted, 0); - if (!Objects.equals(temporaryCreditLimit.getAllDept(), 1)) { + if (TransportBusinessSupport.isAdministrator()) { + if (Objects.equals(temporaryCreditLimit.getAllDept(), 1) && Func.isNotEmpty(temporaryCreditLimit.getApplyDeptId())) { + queryWrapper.eq(TemporaryCreditLimit::getApplyDeptId, temporaryCreditLimit.getApplyDeptId()); + } + } else if (!Objects.equals(temporaryCreditLimit.getAllDept(), 1)) { queryWrapper.eq(TemporaryCreditLimit::getApplyDeptId, TransportBusinessSupport.currentDeptId("临时额度管理")); } else if (Func.isNotEmpty(temporaryCreditLimit.getApplyDeptId())) { queryWrapper.eq(TemporaryCreditLimit::getApplyDeptId, temporaryCreditLimit.getApplyDeptId()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TireReplacementRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TireReplacementRecordServiceImpl.java index 28c01fe5..a359c9f5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TireReplacementRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TireReplacementRecordServiceImpl.java @@ -62,11 +62,19 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl selectTireReplacementRecordPage(IPage page, TireReplacementRecordVO tireReplacementRecord) { List records = baseMapper.selectTireReplacementRecordPage(page, tireReplacementRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setTireQuantity(normalizeEmptyInteger(record.getTireQuantity())); + record.setReplacementCost(normalizeEmptyAmount(record.getReplacementCost())); + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -82,7 +90,7 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl importTireReplacementRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List tireReplacementRecordList = new ArrayList<>(); @@ -107,8 +115,9 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl exportTireReplacementRecord(Wrapper queryWrapper) { return list(queryWrapper).stream().map(tireReplacementRecord -> { TireReplacementRecordExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(tireReplacementRecord, TireReplacementRecordExportExcel.class)); - excel.setTireQuantity(validQuantity(tireReplacementRecord.getTireQuantity())); - excel.setReplacementCost(nonNegative(tireReplacementRecord.getReplacementCost())); + excel.setTireQuantity(normalizeEmptyInteger(tireReplacementRecord.getTireQuantity())); + excel.setReplacementCost(normalizeEmptyAmount(tireReplacementRecord.getReplacementCost())); + excel.setCreateUserName(UserCache.getUserRealName(tireReplacementRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(tireReplacementRecord.getUpdateUser())); return excel; }).toList(); @@ -166,6 +176,9 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyAmount(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_AMOUNT_SENTINEL) == 0 ? null : value; } - private Integer validQuantity(Integer value) { - if (Func.isEmpty(value) || value > 0) { - return value; - } - return null; + private Integer normalizeEmptyInteger(Integer value) { + return Func.isNotEmpty(value) && value.equals(EMPTY_INTEGER_SENTINEL) ? null : value; } private String trimToEmpty(String value) { @@ -233,7 +240,7 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.wrapper.TransportCertRecordWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 认证记录 服务实现类 + * + * @author Chill + */ +@Service +public class TransportCertRecordServiceImpl extends BaseServiceImpl implements ITransportCertRecordService { + + /** + * 合法的审核状态取值 + */ + private static final Set VALID_STATUS = Set.of( + TransportCertRecord.STATUS_PENDING, + TransportCertRecord.STATUS_APPROVED, + TransportCertRecord.STATUS_REJECTED, + TransportCertRecord.STATUS_SUPERSEDED + ); + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean save(TransportCertRecord transportCertRecord) { + validateRecord(transportCertRecord); + return super.save(transportCertRecord); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean saveBatch(Collection entityList, int batchSize) { + entityList.forEach(this::validateRecord); + return super.saveBatch(entityList, batchSize); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean saveOrUpdateBatch(Collection entityList, int batchSize) { + entityList.forEach(this::validateRecord); + return super.saveOrUpdateBatch(entityList, batchSize); + } + + /** + * 禁用继承自 {@code BaseService} 的「修改状态」 + *

+ * ⚠️ 本表的 {@code status} 是审核状态,不是「启用 / 停用」。 + * 而 {@code BaseServiceImpl#changeStatus} 会绕过 {@link #validateRecord} 直接写 status, + * 调用 {@code changeStatus(ids, 1)} 即等于批量通过审核—— + * 审核人、审核时间、驳回原因全部缺失,既丢失审核留痕,也会凭空造出生效快照。 + * 审核必须逐条走带留痕的审核方法。 + * + * @param ids 主键集合 + * @param status 状态 + * @return 永不返回,直接抛出 + */ + @Override + public boolean changeStatus(List ids, Integer status) { + throw new ServiceException("认证记录不支持直接修改状态,请使用审核接口"); + } + + @Override + public List selectRecordList(String bizType, Long bizId) { + return list(recordQuery(bizType, bizId)).stream() + .map(TransportCertRecordWrapper.build()::entityVO) + .collect(Collectors.toList()); + } + + @Override + public TransportCertRecord selectPendingRecord(String bizType, Long bizId) { + validateBizTarget(bizType, bizId); + return getOne(Wrappers.lambdaQuery() + .eq(TransportCertRecord::getBizType, bizType) + .eq(TransportCertRecord::getBizId, bizId) + .eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING) + .last("LIMIT 1")); + } + + /** + * 认证对象检索条件:按提交时间倒序 + *

+ * 排序用 {@code submit_time} 而非框架默认的 {@code create_time},是刻意为之: + * 认证记录的业务时间轴是提交时间,列表需按提交时间倒序展示每次提交。 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + * @return 检索条件 + */ + private LambdaQueryWrapper recordQuery(String bizType, Long bizId) { + validateBizTarget(bizType, bizId); + return Wrappers.lambdaQuery() + .eq(TransportCertRecord::getBizType, bizType) + .eq(TransportCertRecord::getBizId, bizId) + .orderByDesc(TransportCertRecord::getSubmitTime); + } + + /** + * 认证对象标识校验 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + */ + private void validateBizTarget(String bizType, Long bizId) { + if (Func.isEmpty(bizType) || Func.isEmpty(bizId)) { + throw new ServiceException("认证对象类型与主键不能为空"); + } + } + + /** + * 记录落库前的强制校验 + *

+ * ⚠️ 这里防的是框架的一个静默行为:{@code BaseServiceImpl#resolveEntity} 在插入时 + * 若 {@code status} 为 {@code null},会填充 {@code BladeConstant.DB_STATUS_NORMAL}(值 1), + * 而 1 在本表恰好是 {@link TransportCertRecord#STATUS_APPROVED}。 + * 那会让一条「忘了设状态」的提交凭空变成「已通过」,进而可能被当成生效快照。 + * 故此处对空状态与非法取值直接拒绝,不依赖调用方自觉。 + * + * @param transportCertRecord 待落库的认证记录 + */ + private void validateRecord(TransportCertRecord transportCertRecord) { + if (Func.isEmpty(transportCertRecord)) { + throw new ServiceException("认证记录不能为空"); + } + if (Func.isEmpty(transportCertRecord.getBizType())) { + throw new ServiceException("认证对象类型不能为空"); + } + if (Func.isEmpty(transportCertRecord.getBizId())) { + throw new ServiceException("认证对象主键不能为空"); + } + if (Func.isEmpty(transportCertRecord.getSnapshotJson())) { + throw new ServiceException("认证内容快照不能为空"); + } + Integer status = transportCertRecord.getStatus(); + if (Func.isEmpty(status)) { + throw new ServiceException("认证记录状态不能为空,不允许依赖框架默认值(默认值 1 会被误判为已通过)"); + } + if (!VALID_STATUS.contains(status)) { + throw new ServiceException("认证记录状态取值不正确"); + } + if (TransportCertRecord.STATUS_REJECTED.equals(status) && Func.isEmpty(transportCertRecord.getRejectReason())) { + throw new ServiceException("请输入认证驳回原因"); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportChangeRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportChangeRecordServiceImpl.java index d807c894..205507a0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportChangeRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportChangeRecordServiceImpl.java @@ -65,7 +65,10 @@ public class TransportChangeRecordServiceImpl extends BaseServiceImpl selectTransportChangeRecordPage(IPage page, TransportChangeRecordVO transportChangeRecord) { List records = baseMapper.selectTransportChangeRecordPage(page, transportChangeRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -81,7 +84,7 @@ public class TransportChangeRecordServiceImpl extends BaseServiceImpl importTransportChangeRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List transportChangeRecordList = new ArrayList<>(); @@ -104,8 +107,9 @@ public class TransportChangeRecordServiceImpl extends BaseServiceImpl { TransportChangeRecordExportExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(transportChangeRecord, TransportChangeRecordExportExcel.class)); + excel.setCreateUserName(UserCache.getUserRealName(transportChangeRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(transportChangeRecord.getUpdateUser())); return excel; }) @@ -191,7 +196,7 @@ public class TransportChangeRecordServiceImpl extends BaseServiceImpl validateTransportPlan(List data, Long projectId, String projectName, Long contractId, String contractName, String customerName) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } if (Func.isEmpty(projectId)) { throw new ServiceException("项目不能为空"); @@ -250,7 +250,7 @@ public class TransportPlanServiceImpl extends BaseServiceImpl importTransportPlan(List data, Long projectId, String projectName, Long contractId, String contractName, String customerName) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } if (Func.isEmpty(projectId)) { throw new ServiceException("项目不能为空"); @@ -294,8 +294,9 @@ public class TransportPlanServiceImpl extends BaseServiceImpl(errorMap.values()); + // 任一行失败即整批回滚:抛异常触发事务回滚(替代旧的 setRollbackOnly), + // 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。 + throw new org.springblade.common.excel.ImportFailureException(data); } // 第二阶段:批量导入 @@ -453,12 +454,14 @@ public class TransportPlanServiceImpl extends BaseServiceImpl importVehicles(Long id, List rows) { - if (Func.isEmpty(rows)) throw new ServiceException("导入数据不能为空"); + if (Func.isEmpty(rows)) throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); TransportReconciliation bill = editable(id); if (!VEHICLE.equals(bill.getReconciliationMode())) throw new ServiceException("当前对账模式不是整车总额对账"); resetExternal(id); @@ -300,22 +300,33 @@ public class TransportReconciliationServiceImpl VehicleReconciliationExcel row = rows.get(index); List validationErrors = validateImportVehicle(row); if (Func.isNotEmpty(validationErrors)) { + // 原因同时写回原行:异常携带的是原表 rows, + // 导出工具从行对象的 errorMessage 字段取原因,只写副本会丢原因。 + row.setErrorMessage(org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(validationErrors)); VehicleReconciliationFailureExcel failure = new VehicleReconciliationFailureExcel(); BeanUtil.copyProperties(row, failure); - failure.setErrorMessage(org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(validationErrors)); + failure.setErrorMessage(row.getErrorMessage()); failures.add(failure); continue; } try { validExternals.add(buildVehicleExternal(id, index, row)); } catch (Exception exception) { + String message = org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(List.of( + exception instanceof ServiceException ? exception.getMessage() : "导入失败")); + row.setErrorMessage(message); VehicleReconciliationFailureExcel failure = new VehicleReconciliationFailureExcel(); BeanUtil.copyProperties(row, failure); - failure.setErrorMessage(org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(List.of( - exception instanceof ServiceException ? exception.getMessage() : "导入失败"))); + failure.setErrorMessage(message); failures.add(failure); } } + if (Func.isNotEmpty(failures)) { + // 任一行失败即整批回滚:抛异常触发事务回滚,使前置的 resetExternal + // 清空与本批正确行的插入一并退掉;否则会出现「旧外部账单被清空、 + // 正确行却已入库」的数据损坏。 + throw new org.springblade.common.excel.ImportFailureException(rows); + } for (TransportReconciliationExternal external : validExternals) { if (externalMapper.insert(external) <= 0) throw new ServiceException("外部账单保存失败"); } @@ -408,7 +419,7 @@ public class TransportReconciliationServiceImpl @Override @Transactional(rollbackFor = Exception.class) public List importCargoes(Long id, List rows) { - if (Func.isEmpty(rows)) throw new ServiceException("导入数据不能为空"); + if (Func.isEmpty(rows)) throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); TransportReconciliation bill = editable(id); if (!CARGO.equals(bill.getReconciliationMode())) throw new ServiceException("当前对账模式不是货物明细对账"); resetExternal(id); @@ -418,22 +429,33 @@ public class TransportReconciliationServiceImpl CargoReconciliationExcel row = rows.get(index); List validationErrors = validateImportCargo(row); if (Func.isNotEmpty(validationErrors)) { + // 原因同时写回原行:异常携带的是原表 rows, + // 导出工具从行对象的 errorMessage 字段取原因,只写副本会丢原因。 + row.setErrorMessage(org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(validationErrors)); CargoReconciliationFailureExcel failure = new CargoReconciliationFailureExcel(); BeanUtil.copyProperties(row, failure); - failure.setErrorMessage(org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(validationErrors)); + failure.setErrorMessage(row.getErrorMessage()); failures.add(failure); continue; } try { validExternals.add(buildCargoExternal(id, index, row)); } catch (Exception exception) { + String message = org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(List.of( + exception instanceof ServiceException ? exception.getMessage() : "导入失败")); + row.setErrorMessage(message); CargoReconciliationFailureExcel failure = new CargoReconciliationFailureExcel(); BeanUtil.copyProperties(row, failure); - failure.setErrorMessage(org.springblade.common.excel.ImportFailureExcelUtil.formatErrorMessage(List.of( - exception instanceof ServiceException ? exception.getMessage() : "导入失败"))); + failure.setErrorMessage(message); failures.add(failure); } } + if (Func.isNotEmpty(failures)) { + // 任一行失败即整批回滚:抛异常触发事务回滚,使前置的 resetExternal + // 清空与本批正确行的插入一并退掉;否则会出现「旧外部账单被清空、 + // 正确行却已入库」的数据损坏。 + throw new org.springblade.common.excel.ImportFailureException(rows); + } for (TransportReconciliationExternal external : validExternals) { if (externalMapper.insert(external) <= 0) throw new ServiceException("外部账单保存失败"); } @@ -465,7 +487,7 @@ public class TransportReconciliationServiceImpl private List validateImportCargo(CargoReconciliationExcel row) { List validationErrors = new ArrayList<>(); if (row == null) { - validationErrors.add("导入数据不能为空"); + validationErrors.add("未读取到数据行,请确认表头下方已填写内容"); return validationErrors; } org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportShipServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportShipServiceImpl.java index 06d4f09e..dba18bd6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportShipServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportShipServiceImpl.java @@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.system.cache.UserCache; import org.springblade.core.tool.utils.Func; import org.springblade.transport.excel.TransportShipExcel; import org.springblade.transport.mapper.TransportShipMapper; @@ -66,7 +67,9 @@ public class TransportShipServiceImpl extends BaseServiceImpl selectTransportShipPage(IPage page, TransportShipVO ship) { prepareQuery(ship); - return page.setRecords(baseMapper.selectTransportShipPage(page, ship)); + List records = baseMapper.selectTransportShipPage(page, ship); + records.forEach(record -> record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser()))); + return page.setRecords(records); } @Override @@ -111,6 +114,8 @@ public class TransportShipServiceImpl extends BaseServiceImpl exportTransportShip(Wrapper queryWrapper) { return list(queryWrapper).stream().map(ship -> { TransportShipExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(ship, TransportShipExcel.class)); + excel.setCreateUserName(UserCache.getUserRealName(ship.getCreateUser())); + excel.setCreateTime(ship.getCreateTime()); excel.setConstructionDateRange(formatDateRange( ship.getKeelLayingDate(), ship.getBuildCompletionDate() )); 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 32462dc5..e28d7329 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 @@ -32,6 +32,7 @@ import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.system.cache.UserCache; import org.springblade.core.tool.utils.Func; import org.springblade.transport.excel.TransportVehicleExcel; import org.springblade.transport.mapper.DriverMapper; @@ -41,6 +42,7 @@ import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO; import org.springblade.transport.pojo.vo.TransportVehicleVO; import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,7 +52,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -62,7 +63,6 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class TransportVehicleServiceImpl extends BaseServiceImpl implements ITransportVehicleService { - private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$"); private static final String PLATE_COLOR_BLUE = "蓝牌"; private static final String PLATE_COLOR_YELLOW = "黄牌"; private static final String PLATE_COLOR_GREEN = "绿牌"; @@ -81,6 +81,7 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl selectTransportVehiclePage(IPage page, TransportVehicleVO vehicle) { prepareQuery(vehicle); List records = baseMapper.selectTransportVehiclePage(page, vehicle); + records.forEach(record -> record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser()))); fillBoundDrivers(records); return page.setRecords(records); } @@ -188,6 +189,8 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl exportTransportVehicle(Wrapper queryWrapper) { return list(queryWrapper).stream().map(vehicle -> { TransportVehicleExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(vehicle, TransportVehicleExcel.class)); + excel.setCreateUserName(UserCache.getUserRealName(vehicle.getCreateUser())); + excel.setCreateTime(vehicle.getCreateTime()); excel.setStatusName(vehicle.getStatus() != null && vehicle.getStatus() == 2 ? "停用" : "启用"); return excel; }).toList(); @@ -222,6 +225,11 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl selectViolationRecordPage(IPage page, ViolationRecordVO violationRecord) { List records = baseMapper.selectViolationRecordPage(page, violationRecord); - records.forEach(record -> record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()))); + records.forEach(record -> { + record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); + record.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + }); return page.setRecords(records); } @@ -91,7 +97,7 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl importViolationRecord(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); List violationRecordList = new ArrayList<>(); @@ -116,8 +122,11 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl exportViolationRecord(Wrapper queryWrapper) { return list(queryWrapper).stream().map(violationRecord -> { ViolationRecordExcel excel = Objects.requireNonNull(BeanUtil.copyProperties(violationRecord, ViolationRecordExcel.class)); - excel.setFineAmount(nonNegative(violationRecord.getFineAmount())); - excel.setDeductPoints(validDeductPoints(violationRecord.getDeductPoints())); + excel.setFineAmount(normalizeEmptyAmount(violationRecord.getFineAmount())); + excel.setDeductPoints(validDeductPoints(normalizeEmptyInteger(violationRecord.getDeductPoints()))); + excel.setCreateUserName(UserCache.getUserRealName(violationRecord.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(violationRecord.getUpdateUser())); return excel; }).toList(); @@ -184,6 +194,9 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl= 0) { - return value; - } - return BigDecimal.ZERO; + private BigDecimal normalizeEmptyAmount(BigDecimal value) { + return Func.isNotEmpty(value) && value.compareTo(EMPTY_AMOUNT_SENTINEL) == 0 ? null : value; + } + + private Integer normalizeEmptyInteger(Integer value) { + return Func.isNotEmpty(value) && value.equals(EMPTY_INTEGER_SENTINEL) ? null : value; } private Integer validDeductPoints(Integer value) { @@ -320,7 +334,7 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl minioClientProvider; private final String minioBucketName; private final String minioRootDirectory; + private final String minioTransformEndpoint; + private final String minioAccessKey; + private final String minioSecretKey; + private volatile MinioClient transformPresignClient; public VoucherManageServiceImpl(VoucherWaybillBatchMapper voucherWaybillBatchMapper, IProjectApplyService projectApplyService, IWaybillService waybillService, VoucherImageMapper voucherImageMapper, VoucherFileMapper voucherFileMapper, + LoadingManageMapper loadingManageMapper, ApplicationEventPublisher eventPublisher, ObjectProvider minioClientProvider, @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName, - @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) { + @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory, + @Value("${file.storage.minio.transform-endpoint:${minio.transform-endpoint:}}") String minioTransformEndpoint, + @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String minioAccessKey, + @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String minioSecretKey) { this.voucherWaybillBatchMapper = voucherWaybillBatchMapper; this.projectApplyService = projectApplyService; this.waybillService = waybillService; this.voucherImageMapper = voucherImageMapper; this.voucherFileMapper = voucherFileMapper; + this.loadingManageMapper = loadingManageMapper; this.eventPublisher = eventPublisher; this.minioClientProvider = minioClientProvider; this.minioBucketName = minioBucketName; this.minioRootDirectory = minioRootDirectory; + this.minioTransformEndpoint = minioTransformEndpoint; + this.minioAccessKey = minioAccessKey; + this.minioSecretKey = minioSecretKey; } @Override public IPage selectPage(IPage page, VoucherManageVO query) { IPage result = VoucherManageWrapper.build().pageVO(page(page, buildQuery(query))); - // 凭证数量按文件夹(车牌目录)统计,兼容历史记录中仍保存图片数量的旧数据。 + // 凭证数量、已关联数量、未关联数量都按压缩包中的车牌文件夹统计,不按图片张数。 if (result.getRecords() != null) { result.getRecords().forEach(item -> { VoucherFolderCounts folderCounts = countVoucherFolderRelations(item.getTenantId(), item.getId()); @@ -182,12 +199,22 @@ public class VoucherManageServiceImpl extends BaseServiceImpl oldFiles = voucherFileMapper.selectList(Wrappers.lambdaQuery() - .eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucherId) - .eq(VoucherFile::getPlateNo, normalizedPlateNo).eq(VoucherFile::getIsDeleted, 0)); - List oldImages = voucherImageMapper.selectList(Wrappers.lambdaQuery() - .eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucherId) - .eq(VoucherImage::getPlateNo, normalizedPlateNo).eq(VoucherImage::getIsDeleted, 0)); + if (isZipFile) { + try { + // 压缩包里只要有一个文件夹名对上已有车牌或运单车牌,就替换对应凭证,不必整包都匹配。 + if (replaceZipFoldersByName(voucher, normalizedPlateNo, uploadFile)) { + refreshVoucherCounts(voucher); + return; + } + } catch (ServiceException exception) { + throw exception; + } catch (Exception exception) { + log.error("按文件夹替换车牌凭证失败 voucherId={}, plateNo={}", voucherId, normalizedPlateNo, exception); + throw new ServiceException("凭证文件上传失败"); + } + } + List oldFiles = listNormalizedPlateFiles(voucher, normalizedPlateNo); + List oldImages = listNormalizedPlateImages(voucher, normalizedPlateNo); if (oldFiles.isEmpty() && oldImages.isEmpty()) throw new ServiceException("车牌凭证不存在"); if (oldFiles.stream().anyMatch(item -> Objects.equals(item.getMatched(), 1)) || oldImages.stream().anyMatch(item -> Objects.equals(item.getMatched(), 1))) { @@ -228,11 +255,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl listWaybillFolders(Long waybillId) { + if (waybillId == null) return List.of(); + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) return List.of(); + Set voucherIds = new HashSet<>(); + voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getWaybillId, waybillId) + .eq(VoucherImage::getIsDeleted, 0)) + .forEach(image -> { + if (image.getVoucherId() != null) voucherIds.add(image.getVoucherId()); + }); + voucherFileMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherFile::getWaybillId, waybillId) + .eq(VoucherFile::getIsDeleted, 0)) + .forEach(file -> { + if (file.getVoucherId() != null) voucherIds.add(file.getVoucherId()); + }); + if (voucherIds.isEmpty() && Func.isNotEmpty(waybill.getBatchNo())) { + list(Wrappers.lambdaQuery() + .eq(VoucherManage::getTenantId, waybill.getTenantId()) + .eq(VoucherManage::getIsDeleted, 0)) + .stream() + .filter(voucher -> containsWaybillBatchNo(voucher.getWaybillBatchNo(), waybill.getBatchNo())) + .forEach(voucher -> voucherIds.add(voucher.getId())); + } + List folders = new ArrayList<>(); + for (Long voucherId : voucherIds) { + VoucherManage voucher = getById(voucherId); + if (!visibleWaybillVoucher(voucher)) continue; + folderRecords(voucher, false).stream() + .filter(folder -> folderBelongsToWaybill(folder, waybill)) + .forEach(folders::add); + } + folders.sort(Comparator.comparing(VoucherFolderVO::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder()))); + return folders; + } + + private boolean folderBelongsToWaybill(VoucherFolderVO folder, Waybill waybill) { + if (Objects.equals(folder.getWaybillId(), waybill.getId())) return true; + if (folder.getWaybillId() != null) return false; + String plateNo = normalizePlateNo(folder.getPlateNo()); + return Func.isNotEmpty(plateNo) && waybillPlateNumbers(waybill).contains(plateNo); + } + + /** + * 承运商上传的凭证只在审核通过后展示到运单执行详情;内部上传保持原展示规则。 + */ + private boolean visibleWaybillVoucher(VoucherManage voucher) { + return voucher != null && !Objects.equals(voucher.getIsDeleted(), 1) + && (!"承运商".equals(voucher.getUploadSource()) || "审核通过".equals(voucher.getAuditStatus())); + } + + private boolean containsWaybillBatchNo(String batchNos, String batchNo) { + return Func.isNotEmpty(batchNos) && Func.isNotEmpty(batchNo) + && Arrays.stream(batchNos.split(",")).map(String::trim).anyMatch(batchNo::equals); + } + + private void fillFolderLoadingNo(List folders) { + List waybillIds = folders.stream().map(VoucherFolderVO::getWaybillId).filter(Objects::nonNull).distinct().toList(); + if (waybillIds.isEmpty()) return; + Map waybillMap = waybillService.listByIds(waybillIds).stream() + .collect(Collectors.toMap(Waybill::getId, item -> item, (left, right) -> left)); + Map loadingNoMap = new HashMap<>(); + for (Long waybillId : waybillIds) { + Waybill waybill = waybillMap.get(waybillId); + String loadingNo = waybill == null ? null : waybill.getLoadingNo(); + if (Func.isEmpty(loadingNo)) { + loadingNo = findLoadingNo(waybill == null ? null : waybill.getTenantId(), waybillId); + } + if (Func.isNotEmpty(loadingNo)) loadingNoMap.put(waybillId, loadingNo); + } + folders.forEach(folder -> { + if (folder.getWaybillId() != null) folder.setLoadingNo(loadingNoMap.get(folder.getWaybillId())); + }); + } + + private String findLoadingNo(String tenantId, Long waybillId) { + if (waybillId == null) return null; + String waybillIdText = String.valueOf(waybillId); + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .like(LoadingManage::getWaybillIdsJson, waybillIdText) + .orderByDesc(LoadingManage::getCreateTime) + .last("limit 20"); + if (Func.isNotEmpty(tenantId)) query.eq(LoadingManage::getTenantId, tenantId); + return loadingManageMapper.selectList(query).stream() + .filter(item -> containsJsonId(item.getWaybillIdsJson(), waybillIdText)) + .map(LoadingManage::getLoadingNo) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(null); + } + + private boolean containsJsonId(String json, String idText) { + if (Func.isEmpty(json) || Func.isEmpty(idText)) return false; + return Arrays.stream(json.split("\\D+")).anyMatch(idText::equals); + } + private void addFolderFile(Map grouped, VoucherManage voucher, VoucherFileVO file, java.util.Date createTime, java.util.Date updateTime) { String plate = normalizePlateNo(file.getPlateNo()); if (Func.isEmpty(plate)) plate = "未识别车牌"; @@ -398,6 +523,174 @@ public class VoucherManageServiceImpl extends BaseServiceImpl waybillByPlate = new HashMap<>(); + for (Waybill waybill : listRelatedWaybills(voucher)) { + for (String waybillPlateNo : waybillPlateNumbers(waybill)) { + waybillByPlate.putIfAbsent(waybillPlateNo, waybill); + } + } + Set existingPlates = new HashSet<>(); + voucherFileMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucher.getId()) + .eq(VoucherFile::getIsDeleted, 0)).forEach(file -> addPlateNumber(existingPlates, file.getPlateNo())); + voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucher.getId()) + .eq(VoucherImage::getIsDeleted, 0)).forEach(image -> addPlateNumber(existingPlates, image.getPlateNo())); + Charset archiveCharset = detectArchiveCharset(archivePath); + Set matchedPlates = new HashSet<>(); + try (InputStream source = Files.newInputStream(archivePath); + ZipInputStream zipInputStream = new ZipInputStream(new BufferedInputStream(source), archiveCharset)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String plateNo = plateFromZipEntry(entry.getName(), requestedPlateNo, existingPlates, waybillByPlate); + if (Func.isNotEmpty(plateNo)) matchedPlates.add(plateNo); + } + } + if (matchedPlates.isEmpty()) return false; + log.info("[单个上传] 压缩包文件夹命中,准备替换 voucherId={}, requestedPlateNo={}, matchedPlates={}", + voucher.getId(), requestedPlateNo, matchedPlates); + matchedPlates.forEach(plateNo -> deletePlateVoucherFiles(voucher, plateNo)); + int fileCount = 0; + int imageCount = 0; + try (InputStream source = Files.newInputStream(archivePath); + ZipInputStream zipInputStream = new ZipInputStream(new BufferedInputStream(source), archiveCharset)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String entryName = entry.getName().replace('\\', '/'); + String plateNo = plateFromZipEntry(entryName, requestedPlateNo, existingPlates, waybillByPlate); + if (!matchedPlates.contains(plateNo)) continue; + List pathParts = Arrays.stream(entryName.split("/")).filter(Func::isNotEmpty).toList(); + if (pathParts.isEmpty()) continue; + String rawFileName = pathParts.get(pathParts.size() - 1); + boolean imageFile = isImageFile(rawFileName); + String storedFileName = safeFileName(rawFileName); + if (Func.isEmpty(storedFileName)) continue; + Waybill matchedWaybill = waybillByPlate.get(plateNo); + if (saveZipVoucherFile(voucher, plateNo, matchedWaybill, entryName, storedFileName, imageFile, entry, zipInputStream)) { + fileCount++; + if (imageFile) imageCount++; + } + } + } + if (fileCount == 0) throw new ServiceException("压缩包内没有找到有效的文件"); + log.info("[单个上传] 按文件夹替换完成 voucherId={}, matchedPlates={}, fileCount={}, imageCount={}", + voucher.getId(), matchedPlates, fileCount, imageCount); + return true; + } finally { + deleteTempArchive(archivePath); + } + } + + /** + * 从压缩包路径的每一层目录识别车牌。车牌文件夹外面还套了其它目录时,也不能漏掉。 + * 优先当前重新上传的车牌,其次关联运单车牌,再其次批次里已有车牌。 + */ + private String plateFromZipEntry(String entryName, String requestedPlateNo, Set existingPlates, Map waybillByPlate) { + if (Func.isEmpty(entryName)) return null; + String normalizedName = entryName.replace('\\', '/'); + if (normalizedName.equals("__MACOSX") || normalizedName.startsWith("__MACOSX/")) return null; + List pathParts = Arrays.stream(normalizedName.split("/")).filter(Func::isNotEmpty).toList(); + if (pathParts.size() <= 1) return null; + List directories = new ArrayList<>(); + for (int index = 0; index < pathParts.size() - 1; index++) { + String plateNo = normalizePlateNo(pathParts.get(index)); + if (Func.isEmpty(plateNo) || "凭证导入".equals(plateNo) || plateNo.startsWith("__MACOSX") || ".".equals(plateNo)) continue; + directories.add(plateNo); + } + if (directories.isEmpty()) return null; + if (Func.isNotEmpty(requestedPlateNo) && directories.contains(requestedPlateNo)) return requestedPlateNo; + for (String plateNo : directories) { + if (waybillByPlate.containsKey(plateNo)) return plateNo; + } + for (String plateNo : directories) { + if (existingPlates.contains(plateNo)) return plateNo; + } + return null; + } + + private List listNormalizedPlateFiles(VoucherManage voucher, String plateNo) { + String normalizedPlateNo = normalizePlateNo(plateNo); + return voucherFileMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherFile::getTenantId, voucher.getTenantId()) + .eq(VoucherFile::getVoucherId, voucher.getId()) + .eq(VoucherFile::getIsDeleted, 0)) + .stream() + .filter(file -> normalizedPlateNo.equals(normalizePlateNo(file.getPlateNo()))) + .toList(); + } + + private List listNormalizedPlateImages(VoucherManage voucher, String plateNo) { + String normalizedPlateNo = normalizePlateNo(plateNo); + return voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, voucher.getTenantId()) + .eq(VoucherImage::getVoucherId, voucher.getId()) + .eq(VoucherImage::getIsDeleted, 0)) + .stream() + .filter(image -> normalizedPlateNo.equals(normalizePlateNo(image.getPlateNo()))) + .toList(); + } + + private void deletePlateVoucherFiles(VoucherManage voucher, String plateNo) { + List oldFiles = listNormalizedPlateFiles(voucher, plateNo); + List oldImages = listNormalizedPlateImages(voucher, plateNo); + oldFiles.forEach(item -> voucherFileMapper.deleteById(item.getId())); + oldImages.forEach(item -> voucherImageMapper.deleteById(item.getId())); + java.util.stream.Stream.concat(oldFiles.stream().map(VoucherFile::getObjectKey), oldImages.stream().map(VoucherImage::getObjectKey)) + .filter(Func::isNotEmpty).distinct().forEach(this::deleteObjectQuietly); + } + + private boolean saveZipVoucherFile(VoucherManage voucher, String plateNo, Waybill matchedWaybill, String entryName, + String fileName, boolean imageFile, ZipEntry entry, ZipInputStream zipInputStream) throws Exception { + String waybillNo = matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()); + String objectKey = buildObjectKey(voucher.getId(), waybillNo, plateNo, entryName); + String contentType = contentType(fileName); + minioClient().putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) + .stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024) + .contentType(contentType).build()); + VoucherFile file = new VoucherFile(); + file.setVoucherId(voucher.getId()); + file.setVoucherBatchNo(voucher.getVoucherBatchNo()); + file.setWaybillId(matchedWaybill == null ? null : matchedWaybill.getId()); + file.setWaybillNo(matchedWaybill == null ? null : matchedWaybill.getWaybillNo()); + file.setPlateNo(plateNo); + file.setFolderName(plateNo); + file.setEntryName(entryName); + file.setFileName(fileName); + file.setObjectKey(objectKey); + file.setFileSize(entry.getSize() < 0 ? null : entry.getSize()); + file.setContentType(contentType); + file.setFileType(imageFile ? "image" : "file"); + file.setMatched(matchedWaybill == null ? 0 : 1); + file.setTenantId(voucher.getTenantId()); + voucherFileMapper.insert(file); + if (!imageFile) return true; + VoucherImage image = new VoucherImage(); + image.setVoucherId(voucher.getId()); + image.setVoucherBatchNo(voucher.getVoucherBatchNo()); + image.setWaybillId(matchedWaybill == null ? null : matchedWaybill.getId()); + image.setWaybillNo(matchedWaybill == null ? null : matchedWaybill.getWaybillNo()); + image.setPlateNo(plateNo); + image.setImageName(fileName); + image.setObjectKey(objectKey); + image.setMatched(matchedWaybill == null ? 0 : 1); + image.setTenantId(voucher.getTenantId()); + voucherImageMapper.insert(image); + return true; + } + private void replaceFolderWithZip(VoucherManage voucher, String plateNo, Waybill matchedWaybill, MultipartFile zipFile) throws Exception { log.info("[单个上传] 开始解压替换 voucherId={}, plateNo={}, fileName={}", voucher.getId(), plateNo, zipFile.getOriginalFilename()); Charset zipCharset = StandardCharsets.UTF_8; @@ -583,23 +876,82 @@ public class VoucherManageServiceImpl extends BaseServiceImpl waybillPlates = new HashSet<>(); + if (voucher != null) { + for (Waybill waybill : listRelatedWaybills(voucher)) { + waybillPlates.addAll(waybillPlateNumbers(waybill)); + } + } List files = voucherFileMapper.selectList(Wrappers.lambdaQuery() .eq(VoucherFile::getTenantId, tenantId).eq(VoucherFile::getVoucherId, voucherId).eq(VoucherFile::getIsDeleted, 0)); + Map relatedByFolder = new LinkedHashMap<>(); if (!files.isEmpty()) { - Set folders = files.stream().map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - Set relatedFolders = files.stream().filter(item -> Objects.equals(item.getMatched(), 1)) - .map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - return new VoucherFolderCounts(folders.size(), relatedFolders.size(), folders.size() - relatedFolders.size()); + for (VoucherFile file : files) { + accumulatePlateFolder(relatedByFolder, waybillPlates, file.getPlateNo(), file.getFolderName(), file.getEntryName(), file.getMatched()); + } + } else { + List images = voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, tenantId).eq(VoucherImage::getVoucherId, voucherId).eq(VoucherImage::getIsDeleted, 0)); + for (VoucherImage image : images) { + accumulatePlateFolder(relatedByFolder, waybillPlates, image.getPlateNo(), image.getPlateNo(), null, image.getMatched()); + } } - List images = voucherImageMapper.selectList(Wrappers.lambdaQuery() - .eq(VoucherImage::getTenantId, tenantId).eq(VoucherImage::getVoucherId, voucherId).eq(VoucherImage::getIsDeleted, 0)); - Set folders = images.stream().map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - Set relatedFolders = images.stream().filter(item -> Objects.equals(item.getMatched(), 1)) - .map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - return new VoucherFolderCounts(folders.size(), relatedFolders.size(), folders.size() - relatedFolders.size()); + int related = (int) relatedByFolder.values().stream().filter(Boolean::booleanValue).count(); + int total = relatedByFolder.size(); + return new VoucherFolderCounts(total, related, total - related); } + private void accumulatePlateFolder(Map relatedByFolder, Set waybillPlates, + String plateNo, String folderName, String entryName, Integer matched) { + String plateFolder = countedPlateFolder(plateNo, folderName, entryName, waybillPlates); + if (Func.isEmpty(plateFolder)) return; + boolean related = Objects.equals(matched, 1) || waybillPlates.contains(plateFolder); + relatedByFolder.merge(plateFolder, related, (left, right) -> left || right); + } + + /** + * 从车牌字段和压缩包路径中识别车牌文件夹。外层业务目录、内层图片目录都不计入凭证数量。 + */ + private String countedPlateFolder(String plateNo, String folderName, String entryName, Set waybillPlates) { + List candidates = new ArrayList<>(); + addPlateCandidate(candidates, plateNo); + addPlateCandidate(candidates, folderName); + if (Func.isNotEmpty(entryName)) { + List pathParts = Arrays.stream(entryName.replace('\\', '/').split("/")).filter(Func::isNotEmpty).toList(); + int directoryCount = Math.max(pathParts.size() - 1, 0); + for (int index = 0; index < directoryCount; index++) { + addPlateCandidate(candidates, pathParts.get(index)); + } + } + for (String candidate : candidates) { + if (waybillPlates.contains(candidate)) return candidate; + } + for (String candidate : candidates) { + if (isPlateFolderName(candidate)) return candidate; + } + return null; + } + + private void addPlateCandidate(List candidates, String plateNo) { + String normalizedPlateNo = normalizePlateNo(plateNo); + if (Func.isNotEmpty(normalizedPlateNo) && !"未识别车牌".equals(normalizedPlateNo) && !candidates.contains(normalizedPlateNo)) { + candidates.add(normalizedPlateNo); + } + } + + private boolean isPlateFolderName(String plateNo) { + return Func.isNotEmpty(plateNo) && PLATE_FOLDER_PATTERN.matcher(plateNo).matches(); + } + + private static final Pattern PLATE_FOLDER_PATTERN = Pattern.compile( + "^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-Z][A-Z0-9]{4,6}(?:挂|学|警|港|澳)?$"); + private record VoucherFolderCounts(int total, int related, int unrelated) {} private String folderKey(String plateNo) { @@ -927,19 +1279,19 @@ public class VoucherManageServiceImpl extends BaseServiceImpl> selectableWaybillBatches(IPage page, String batchNo, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd) { return voucherWaybillBatchMapper.selectVoucherWaybillBatchPage(page, AuthUtil.getTenantId(), batchNo, createUser, waybillCount, createTimeStart, createTimeEnd, - !AuthUtil.isAdministrator(), currentCarrierName()); + !TransportBusinessSupport.isAdministrator(), currentCarrierName()); } private List> selectableWaybillBatchesByIds(List ids) { if (Func.isEmpty(ids)) return List.of(); - return voucherWaybillBatchMapper.selectWaybillBatchesByIds(AuthUtil.getTenantId(), ids, !AuthUtil.isAdministrator(), currentCarrierName()); + return voucherWaybillBatchMapper.selectWaybillBatchesByIds(AuthUtil.getTenantId(), ids, !TransportBusinessSupport.isAdministrator(), currentCarrierName()); } /** * 普通用户只能关联承运商名称与其当前组织名称一致的运单批次;超级管理员不受此限制。 */ private String currentCarrierName() { - if (AuthUtil.isAdministrator()) return null; + if (TransportBusinessSupport.isAdministrator()) return null; Long currentDeptId = Func.firstLong(AuthUtil.getDeptId()); return getOrganizationName(currentDeptId); } @@ -1020,13 +1372,19 @@ public class VoucherManageServiceImpl extends BaseServiceImpl 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (Func.isNotEmpty(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (Func.isEmpty(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String normalizePlateNo(String plateNo) { return plateNo == null ? null : plateNo.replaceAll("[\\s-]", "").toUpperCase(); } 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 5e510289..5a40decd 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 @@ -18,7 +18,6 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.core.tool.utils.WebUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.system.cache.DictCache; import org.springblade.system.cache.DictBizCache; @@ -36,6 +35,8 @@ import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillImportBatch; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.WaybillImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportRowErrorVO; +import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; import org.springblade.transport.service.ICustomerArchiveService; import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.IReceivablePayableDetailService; @@ -44,11 +45,10 @@ import org.springblade.transport.service.ITransportPlanService; import org.springblade.transport.service.IWaybillImportBatchService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.interceptor.TransactionAspectSupport; -import java.io.IOException; import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -69,8 +69,9 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class WaybillImportBatchServiceImpl extends BaseServiceImpl implements IWaybillImportBatchService { - /** 批量导入状态仅保留草稿与导入完成两种。 */ + /** 保存草稿仍使用 draft;确认导入的状态与运单业务状态一致。 */ private static final String STATUS_DRAFT = "draft"; + private static final String STATUS_RUNNING = "running"; private static final String STATUS_COMPLETED = "completed"; private static final String IMPORT_TYPE_WAYBILL = "waybill"; private static final String IMPORT_TYPE_SETTLEMENT = "settlement"; @@ -98,89 +99,97 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl> rows = request.getRows(); Map validationErrors = validateImportRows(rows, importStatus); - - // 如果有校验错误,导出错误明细Excel - if (!validationErrors.isEmpty()) { - List failureList = new ArrayList<>(); - for (int i = 0; i < rows.size(); i++) { - WaybillImportBatchExcel excel = mapToExcel(rows.get(i)); - String errorMessage = validationErrors.get(i); - excel.setErrorMessage(Func.isNotEmpty(errorMessage) ? errorMessage : ""); - failureList.add(excel); - } - ImportFailureExcelUtil.export(response, "运单导入失败明细" + DateUtil.time(), "导入失败明细", failureList, WaybillImportBatchExcel.class); - return; + List errors = new ArrayList<>(); + for (Map.Entry entry : validationErrors.entrySet()) { + WaybillImportRowErrorVO error = new WaybillImportRowErrorVO(); + error.setRowIndex(entry.getKey()); + error.setErrorMessage(entry.getValue()); + errors.add(error); } + result.setErrors(errors); + result.setSuccess(errors.isEmpty()); + return result; + } - // 校验通过,返回成功响应 - WebUtil.renderJson(response, R.success("校验通过")); + @Override + public void exportFailures(WaybillImportBatchRequest request, HttpServletResponse response) { + if (Func.isEmpty(request.getRows())) throw new ServiceException("请至少提供一条失败明细"); + List failureList = new ArrayList<>(); + for (Map row : request.getRows()) { + WaybillImportBatchExcel excel = mapToExcel(row); + excel.setErrorMessage(stringValue(row, "errorMessage", "导入失败原因", "失败原因")); + failureList.add(excel); + } + ImportFailureExcelUtil.export(response, "运单导入失败明细" + DateUtil.time(), "导入失败明细", failureList, WaybillImportBatchExcel.class); } @Override @Transactional(rollbackFor = Exception.class) - public void confirm(WaybillImportBatchRequest request, HttpServletResponse response) { + public WaybillImportBatchVO confirm(WaybillImportBatchRequest request) { if (Func.isEmpty(request.getRows())) throw new ServiceException("请上传至少一条运单明细"); - // 执行校验 - String importStatus = STATUS_DRAFT.equals(request.getStatus()) ? STATUS_DRAFT : STATUS_COMPLETED; + String importStatus = resolveImportStatus(request.getStatus()); boolean draft = STATUS_DRAFT.equals(importStatus); if (!draft) { - List> rows = request.getRows(); - Map validationErrors = validateImportRows(rows, importStatus); - - // 如果有校验错误,导出错误明细Excel + Map validationErrors = validateImportRows(request.getRows(), importStatus); if (!validationErrors.isEmpty()) { - List failureList = new ArrayList<>(); - for (int i = 0; i < rows.size(); i++) { - WaybillImportBatchExcel excel = mapToExcel(rows.get(i)); - String errorMessage = validationErrors.get(i); - excel.setErrorMessage(Func.isNotEmpty(errorMessage) ? errorMessage : ""); - failureList.add(excel); - } - ImportFailureExcelUtil.export(response, "运单导入失败明细" + DateUtil.time(), "导入失败明细", failureList, WaybillImportBatchExcel.class); - return; + String firstError = validationErrors.values().iterator().next(); + Integer firstIndex = validationErrors.keySet().iterator().next(); + throw new ServiceException("第" + (firstIndex + 1) + "行校验失败:" + firstError); } } - // 校验通过,执行导入 WaybillImportBatch batch = persist(request, importStatus); - - // 返回成功响应 - WebUtil.renderJson(response, R.success("操作成功")); + return toVO(batch); } /** 草稿与确认导入共用落库流程,差异仅在于运单是否走校验以及是否生成应收应付明细。 */ private WaybillImportBatch persist(WaybillImportBatchRequest request, String importStatus) { boolean draft = STATUS_DRAFT.equals(importStatus); - WaybillImportBatch batch = buildBatch(request, importStatus); - if (Func.isNotEmpty(request.getId())) { + boolean append = Boolean.TRUE.equals(request.getAppend()); + WaybillImportBatch batch; + int existingWaybillCount = 0; + + if (append) { + if (Func.isEmpty(request.getId())) { + throw new ServiceException("分批追加导入必须指定批次ID"); + } WaybillImportBatch oldBatch = getById(request.getId()); - if (oldBatch == null || Objects.equals(oldBatch.getIsDeleted(), 1)) throw new ServiceException("运单批次不存在"); - if (!STATUS_DRAFT.equals(oldBatch.getImportStatus())) throw new ServiceException("仅草稿状态的批次允许编辑"); - batch.setId(oldBatch.getId()); - batch.setBatchNo(oldBatch.getBatchNo()); + if (oldBatch == null || Objects.equals(oldBatch.getIsDeleted(), 1)) { + throw new ServiceException("运单批次不存在"); + } + batch = oldBatch; + existingWaybillCount = Func.toInt(oldBatch.getWaybillCount(), 0); + } else { + batch = buildBatch(request, importStatus); + if (Func.isNotEmpty(request.getId())) { + WaybillImportBatch oldBatch = getById(request.getId()); + if (oldBatch == null || Objects.equals(oldBatch.getIsDeleted(), 1)) throw new ServiceException("运单批次不存在"); + if (!STATUS_DRAFT.equals(oldBatch.getImportStatus())) throw new ServiceException("仅草稿状态的批次允许编辑"); + batch.setId(oldBatch.getId()); + batch.setBatchNo(oldBatch.getBatchNo()); + } + batch.setWaybillCount(0); + saveOrUpdate(batch); + // 重新落库前清理批次已生成的运单,避免草稿反复保存产生重复运单。 + clearBatchWaybills(batch.getId()); } - batch.setWaybillCount(0); - saveOrUpdate(batch); - // 重新落库前清理批次已生成的运单,避免草稿反复保存产生重复运单。 - clearBatchWaybills(batch.getId()); List> rows = Func.isEmpty(request.getRows()) ? List.of() : request.getRows(); @@ -199,32 +208,62 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl waybills = new ArrayList<>(); - Map> loadingWaybills = new TreeMap<>(); for (int index = 0; index < rows.size(); index++) { if (skipRows.contains(index)) continue; Waybill waybill = waybillByRow.getOrDefault(index, rowWaybills.get(index)); - // 配载标识号必须取自行数据:运单落库(prepareForSave)会把新建运单的 loadingNo 置空 - String loadingIdentifier = stringValue(rows.get(index), "loadingIdentifier", "配载标识号"); try { if (draft) waybillService.saveDraft(waybill); else waybillService.submit(waybill); } catch (Exception exception) { throw new ServiceException("第" + (index + 1) + "行" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage()); } - waybills.add(waybill); - if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType()) && Func.isNotEmpty(loadingIdentifier)) { - loadingWaybills.computeIfAbsent(loadingIdentifier, key -> new ArrayList<>()).add(waybill); + // 进行中导入要保持运单为进行中,避免过程接单配置在查询时把状态改回待执行。 + if (STATUS_RUNNING.equals(importStatus)) { + waybillService.update(Wrappers.lambdaUpdate() + .eq(Waybill::getId, waybill.getId()) + .set(Waybill::getBusinessStatus, STATUS_RUNNING) + .set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_ACCEPTED)); + waybill.setBusinessStatus(STATUS_RUNNING); + waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_ACCEPTED); } + waybills.add(waybill); } - loadingWaybills.forEach(loadingManageService::createFromImportedWaybills); - batch.setWaybillCount(waybills.size()); - updateById(batch); - // 导入完成且导入类型为运单时,按合同费用生成模式(系统生成)同步生成应收应付明细。 + // 同一配载标识号有多条运单时,生成一张配载单并回写运单关联。 if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType())) { + createLoadingsForSharedIdentifiers(batch); + } + batch.setWaybillCount(existingWaybillCount + waybills.size()); + if (append && !draft) { + batch.setImportStatus(importStatus); + } + updateById(batch); + // 导入状态为已完成时,按合同费用生成模式(系统生成)同步生成应收应付明细。 + if (STATUS_COMPLETED.equals(importStatus) && IMPORT_TYPE_WAYBILL.equals(batch.getImportType())) { receivablePayableDetailService.generateForImportedWaybills(waybills); } return batch; } + /** + * 按批次内已落库运单的配载标识号分组。标识号相同且不少于两条时创建或补齐配载单。 + */ + private void createLoadingsForSharedIdentifiers(WaybillImportBatch batch) { + List batchWaybills = waybillService.list(Wrappers.lambdaQuery() + .eq(Waybill::getImportBatchId, batch.getId()) + .eq(Waybill::getIsDeleted, 0) + .orderByAsc(Waybill::getCreateTime) + .orderByAsc(Waybill::getId)); + Map> loadingWaybills = new java.util.LinkedHashMap<>(); + for (Waybill waybill : batchWaybills) { + if (Func.isEmpty(waybill.getLoadingIdentifier())) continue; + loadingWaybills.computeIfAbsent(waybill.getLoadingIdentifier(), key -> new ArrayList<>()).add(waybill); + } + loadingWaybills.forEach((loadingIdentifier, groupedWaybills) -> { + if (groupedWaybills.size() > 1) { + loadingManageService.createFromImportedWaybills(loadingIdentifier, groupedWaybills); + } + }); + } + private void clearBatchWaybills(Long batchId) { if (Func.isEmpty(batchId)) return; List waybills = waybillService.list(Wrappers.lambdaQuery() @@ -301,10 +340,21 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl page(IPage page, WaybillImportBatchRequest request) { LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery() .eq(WaybillImportBatch::getIsDeleted, 0) + .eq(request.getId() != null, WaybillImportBatch::getId, request.getId()) .like(Func.isNotEmpty(request.getBatchNo()), WaybillImportBatch::getBatchNo, request.getBatchNo()) - .apply(Func.isNotEmpty(request.getCarrierId()), "FIND_IN_SET({0}, carrier_ids)", request.getCarrierId()) .eq(Func.isNotEmpty(request.getCarrierName()), WaybillImportBatch::getCarrierName, request.getCarrierName()) + .eq(Func.isNotEmpty(request.getCreateUser()), WaybillImportBatch::getCreateUser, request.getCreateUser()) .orderByDesc(WaybillImportBatch::getCreateTime); + // 历史批次可能只落了承运商名称、carrier_ids 为空,筛选时需同时按 ID 与名称兼容匹配 + applyCarrierFilter(queryWrapper, request.getCarrierId()); + if (Func.isNotEmpty(request.getCreateTimeStart())) { + queryWrapper.ge(WaybillImportBatch::getCreateTime, + DateUtil.parse(request.getCreateTimeStart(), DateUtil.PATTERN_DATETIME)); + } + if (Func.isNotEmpty(request.getCreateTimeEnd())) { + queryWrapper.le(WaybillImportBatch::getCreateTime, + DateUtil.parse(request.getCreateTimeEnd(), DateUtil.PATTERN_DATETIME)); + } IPage entityPage = page(page, queryWrapper); List records = entityPage.getRecords().stream().map(this::toVO).toList(); Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); @@ -312,6 +362,24 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl queryWrapper, Long carrierId) { + Long normalizedCarrierId = normalizeOptionalId(carrierId); + if (normalizedCarrierId == null) { + return; + } + CustomerArchive carrier = customerArchiveService.getById(normalizedCarrierId); + String carrierName = carrier == null ? null : Func.toStr(carrier.getFullName()).trim(); + queryWrapper.and(wrapper -> { + wrapper.apply("FIND_IN_SET({0}, IFNULL(carrier_ids,''))", String.valueOf(normalizedCarrierId)); + if (Func.isNotEmpty(carrierName)) { + wrapper.or().eq(WaybillImportBatch::getCarrierName, carrierName); + } + }); + } + @Override @Transactional(rollbackFor = Exception.class) public BusinessRemoveResultVO removeBatches(String ids) { @@ -331,6 +399,23 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl carrierIds = resolveCarrierIds(request); + batch.setCarrierIds(joinIds(carrierIds)); + batch.setCarrierName(resolveCarrierNames(carrierIds, request.getCarrierName())); batch.setImportStatus(importStatus); batch.setImportType(IMPORT_TYPE_SETTLEMENT.equals(request.getImportType()) ? IMPORT_TYPE_SETTLEMENT : IMPORT_TYPE_WAYBILL); ProjectApply project = projectApplyService.getById(request.getProjectId()); @@ -349,17 +435,28 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, WaybillImportBatch batch, Long carrierContractId, boolean draft) { if (row == null) throw new ServiceException("运单数据不正确"); Waybill waybill = new Waybill(); waybill.setOriginalNo(stringValue(row, "originalNo")); - waybill.setLoadingNo(stringValue(row, "loadingIdentifier", "配载标识号")); - waybill.setVehicleNo(stringValue(row, "vehicleNo", "车牌号/航班号/船号/班列号")); + waybill.setLoadingIdentifier(stringValue(row, "loadingIdentifier", "配载标识号")); + waybill.setVehicleNo(stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号")); waybill.setDriverId(longValue(row, "driverId", "司机ID")); waybill.setDriverName(stringValue(row, "driverName", "司机/船长姓名", "司机/船长")); waybill.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号")); @@ -387,12 +484,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, String field, String fieldName, String... aliases) { String value = stringValue(row, field, aliases); - if (Func.isEmpty(value)) return null; + if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) { + return null; + } try { return new BigDecimal(value); } catch (NumberFormatException exception) { @@ -455,8 +555,32 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl resolveCarrierIds(WaybillImportBatchRequest request) { + if (request == null) { + return List.of(); + } + List carrierIds = Func.isEmpty(request.getCarrierIds()) + ? new ArrayList<>() + : request.getCarrierIds().stream() + .map(this::normalizeOptionalId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + if (Func.isNotEmpty(carrierIds)) { + return carrierIds; + } + Long carrierId = normalizeOptionalId(request.getCarrierId()); + if (carrierId != null) { + return List.of(carrierId); + } + String carrierName = Func.toStr(request.getCarrierName()).trim(); + if (Func.isEmpty(carrierName)) { + return List.of(); + } + CustomerArchive carrier = customerArchiveService.getOne(Wrappers.lambdaQuery() + .eq(CustomerArchive::getFullName, carrierName) + .eq(CustomerArchive::getIsDeleted, 0) + .last("limit 1")); + return carrier == null || carrier.getId() == null ? List.of() : List.of(carrier.getId()); + } + private String resolveCarrierNames(List carrierIds, String carrierName) { if (Func.isNotEmpty(carrierName)) return carrierName; if (Func.isEmpty(carrierIds)) return null; @@ -497,7 +653,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List errors) { - String vehicleNo = stringValue(row, "vehicleNo", "车牌号/航班号/船号/班列号"); + String vehicleNo = stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号"); String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"); if (Func.isEmpty(transportType)) { return; } - // 公路运输必须填写车牌号;其他运输方式的该字段可填写航班号、船号或班列号。 + // 公路运输必须填写车牌号;其他运输方式的该字段可填写船班号、船次号或班次号。 if (!isRoadTransport(transportType)) { return; } @@ -740,7 +896,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, String field, String fieldName, List errors) { String value = stringValue(row, field, fieldName); - if (Func.isEmpty(value)) { + if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) { return; } @@ -767,7 +923,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List errors) { String freightTotalStr = stringValue(row, "freightTotal", "运费合计"); - if (Func.isEmpty(freightTotalStr)) { + if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) { return; } @@ -776,8 +932,10 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl private static final String STATUS_DRAFT = "draft"; + /** 打卡记录类型:在途打卡(其余一律按过程节点打卡处理) */ + private static final String ENROUTE_PUNCH_TYPE = "enroute"; + @jakarta.annotation.Resource private ILoadingManageService loadingManageService; @@ -452,6 +458,29 @@ public class WaybillServiceImpl extends BaseServiceImpl continue; } + // 接单节点不落打卡流水:它是否完成由运单的接单事实决定,不看打卡表。 + // 司机确认接单即为已打卡;项目「无需确认接单」时,运单进入进行中即视为该环节已完成。 + // 后者没有接单动作,也就没有完成时刻,时间栏留空——运单上没有任何字段记着那一刻。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + boolean accepted = WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus()); + boolean noConfirm = !WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson); + if (accepted) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(formatPunchTime(waybill.getDriverAcceptTime())); + } else if (noConfirm && passedAcceptStage(waybill.getBusinessStatus())) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(""); + } else { + item.setPunched(false); + item.setStatusName("未打卡"); + item.setPunchTime(""); + } + records.add(item); + continue; + } + WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName); if (punched != null) { List types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName); @@ -483,6 +512,300 @@ public class WaybillServiceImpl extends BaseServiceImpl return vo; } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean savePunchRecord(WaybillPunchRecordSaveRequest request) { + if (request == null || request.getWaybillId() == null) { + throw new ServiceException("运单ID不能为空"); + } + Waybill waybill = getById(request.getWaybillId()); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + throw new ServiceException("运单不存在"); + } + // 只有显式传 false 才是取消打卡,未传按已打卡处理 + boolean punched = !Boolean.FALSE.equals(request.getPunched()); + if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) { + return saveEnroutePunchRecord(waybill, request, punched); + } + return saveNodePunchRecord(waybill, request, punched); + } + + /** + * 过程节点打卡:已打卡补录/修正,未打卡清除该节点流水。 + */ + private boolean saveNodePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + Long waybillId = waybill.getId(); + String processJson = resolveLiveProcessJson(waybill); + Map nodeConfig = findEnabledNodeConfig(processJson, request.getNodeCode(), request.getNodeName()); + // 节点以过程配置为准:配置改过 key/name 后,前端带上来的可能是旧值 + String nodeCode = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeCode()) + : WaybillProcessSupport.nodeKey(nodeConfig); + String nodeName = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeName()) + : WaybillProcessSupport.nodeName(nodeConfig); + if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) { + throw new ServiceException("过程节点不能为空"); + } + // 接单节点没有打卡流水:它是否完成由运单的接单事实决定,补录与取消都改不动它。 + // 放行只会造出一条与运单事实打架的流水——时间轴显示已打卡,运单却还没人接单。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + throw new ServiceException("接单节点由接单动作决定,不支持补录或取消打卡"); + } + List rows = nodePunchRows(waybillId, nodeCode, nodeName); + if (!punched) { + if (!rows.isEmpty()) { + waybillNodePunchMapper.deleteBatchIds(rows.stream().map(WaybillNodePunch::getId).toList()); + } + return true; + } + WaybillNodePunch punch = pickLatestNodePunch(rows, nodeCode, nodeName); + boolean isNew = punch == null; + if (isNew) { + punch = new WaybillNodePunch(); + punch.setWaybillId(waybillId); + punch.setWaybillNo(waybill.getWaybillNo()); + // 管理端补录没有打卡司机,driver_id 留空,不记到任何司机头上 + punch.setExceptionFlag(0); + } + if (Func.isNotEmpty(nodeCode)) { + punch.setNodeCode(nodeCode); + } + if (Func.isNotEmpty(nodeName)) { + punch.setNodeName(nodeName); + } + punch.setPunchTime(parsePunchTime(request.getPunchTime())); + punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress())); + punch.setLongitude(parseCoordinate(request.getLongitude(), "经度")); + punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度")); + punch.setWeight(TransportBusinessSupport.trimToNull(request.getWeight())); + punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume())); + punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity())); + punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark())); + List> photoItems = punchPhotoItems(request.getPhotos()); + assertPunchPhotoTypeLimit(photoItems); + punch.setPhotos(photoItems.isEmpty() ? null : JsonUtil.toJson(photoItems)); + // 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护 + if (isNew) { + waybillNodePunchMapper.insert(punch); + } else { + updateNodePunchFields(punch); + } + return true; + } + + /** + * 显式按字段更新节点打卡。 + *

+ * 不能用 updateById:它按「非空才更新」生成 SQL,把地址/货量/照片清空时这些字段为 null, + * 会被整条跳过——界面上删掉的内容保存后还在。 + */ + private void updateNodePunchFields(WaybillNodePunch punch) { + waybillNodePunchMapper.update(null, Wrappers.lambdaUpdate() + .eq(WaybillNodePunch::getId, punch.getId()) + .set(WaybillNodePunch::getNodeCode, punch.getNodeCode()) + .set(WaybillNodePunch::getNodeName, punch.getNodeName()) + .set(WaybillNodePunch::getPunchTime, punch.getPunchTime()) + .set(WaybillNodePunch::getAddress, punch.getAddress()) + .set(WaybillNodePunch::getLongitude, punch.getLongitude()) + .set(WaybillNodePunch::getLatitude, punch.getLatitude()) + .set(WaybillNodePunch::getWeight, punch.getWeight()) + .set(WaybillNodePunch::getVolume, punch.getVolume()) + .set(WaybillNodePunch::getQuantity, punch.getQuantity()) + .set(WaybillNodePunch::getRemark, punch.getRemark()) + .set(WaybillNodePunch::getPhotos, punch.getPhotos()) + // entity 传 null,MP 不会自动填充审计字段,这里补上 + .set(WaybillNodePunch::getUpdateTime, new Date()) + .set(WaybillNodePunch::getUpdateUser, AuthUtil.getUserId())); + } + + /** + * 在途打卡:时间轴上的在途节点只展示最新一条流水。 + */ + private boolean saveEnroutePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + Long waybillId = waybill.getId(); + List rows = waybillEnroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybillId) + .orderByAsc(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getId)); + if (!punched) { + // 在途节点没有节点 key 可定位单条,取消打卡即清空该运单全部在途流水, + // 否则更早的流水会顶上来继续显示已打卡 + if (!rows.isEmpty()) { + waybillEnroutePunchMapper.deleteBatchIds(rows.stream().map(WaybillEnroutePunch::getId).toList()); + } + return true; + } + WaybillEnroutePunch punch = rows.isEmpty() ? null : rows.get(rows.size() - 1); + boolean isNew = punch == null; + if (isNew) { + punch = new WaybillEnroutePunch(); + punch.setWaybillId(waybillId); + punch.setWaybillNo(waybill.getWaybillNo()); + } + punch.setPunchTime(parsePunchTime(request.getPunchTime())); + punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress())); + punch.setLongitude(parseCoordinate(request.getLongitude(), "经度")); + punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度")); + // 在途打卡表只有一个照片字段(单张),多张只保留第一张 + punch.setPhoto(firstPunchPhotoUrl(request.getPhotos())); + if (isNew) { + waybillEnroutePunchMapper.insert(punch); + } else { + // 同节点打卡:显式 set,否则清空地址/照片保存后不生效 + waybillEnroutePunchMapper.update(null, Wrappers.lambdaUpdate() + .eq(WaybillEnroutePunch::getId, punch.getId()) + .set(WaybillEnroutePunch::getPunchTime, punch.getPunchTime()) + .set(WaybillEnroutePunch::getAddress, punch.getAddress()) + .set(WaybillEnroutePunch::getLongitude, punch.getLongitude()) + .set(WaybillEnroutePunch::getLatitude, punch.getLatitude()) + .set(WaybillEnroutePunch::getPhoto, punch.getPhoto()) + .set(WaybillEnroutePunch::getUpdateTime, new Date()) + .set(WaybillEnroutePunch::getUpdateUser, AuthUtil.getUserId())); + } + return true; + } + + /** + * 该节点名下的全部打卡流水(含历史),按打卡时间升序。 + *

+ * 取消打卡要清空全部,只软删最新一条会让更早的历史流水顶上来继续显示已打卡。 + */ + private List nodePunchRows(Long waybillId, String nodeCode, String nodeName) { + return waybillNodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId) + .orderByAsc(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getId)) + .stream() + .filter(punch -> { + String rowCode = TransportBusinessSupport.trimToEmpty(punch.getNodeCode()); + String rowName = TransportBusinessSupport.trimToEmpty(punch.getNodeName()); + if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(rowCode)) { + return true; + } + return Func.isNotEmpty(nodeName) && nodeName.equals(rowName); + }) + .toList(); + } + + /** + * 取该节点最新一条流水,口径与展示层一致:nodeCode 命中优先,未命中才回退 nodeName。 + */ + private WaybillNodePunch pickLatestNodePunch(List rows, String nodeCode, String nodeName) { + WaybillNodePunch byCode = null; + WaybillNodePunch byName = null; + for (WaybillNodePunch row : rows) { + if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(row.getNodeCode()))) { + byCode = row; + } else if (Func.isNotEmpty(nodeName) && nodeName.equals(TransportBusinessSupport.trimToEmpty(row.getNodeName()))) { + byName = row; + } + } + return byCode != null ? byCode : byName; + } + + /** + * 过程配置中启用(未启用时不参与打卡,补录无从校验)的节点配置;找不到返回 null。 + */ + private Map findEnabledNodeConfig(String processJson, String nodeCode, String nodeName) { + String code = TransportBusinessSupport.trimToEmpty(nodeCode); + String name = TransportBusinessSupport.trimToEmpty(nodeName); + List> nodes = WaybillProcessSupport.listEnabledProcessNodes(processJson); + if (nodes.isEmpty()) { + nodes = WaybillProcessSupport.listDriverPunchNodes(processJson); + } + for (Map node : nodes) { + if (Func.isNotEmpty(code) && code.equalsIgnoreCase(WaybillProcessSupport.nodeKey(node))) { + return node; + } + if (Func.isNotEmpty(name) && name.equals(WaybillProcessSupport.nodeName(node))) { + return node; + } + } + return null; + } + + /** + * 打卡照片规范成 [{type,url}]:保留原结构,URL 为空的一律丢弃,类型为空按序号补「凭证N」。 + */ + private List> punchPhotoItems(List photos) { + List> items = new ArrayList<>(); + if (photos == null || photos.isEmpty()) { + return items; + } + for (WaybillPunchRecordSaveRequest.Photo photo : photos) { + String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl()); + if (Func.isEmpty(url)) { + continue; + } + String type = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getType()); + Map item = new LinkedHashMap<>(); + item.put("type", Func.isEmpty(type) ? ("凭证" + (items.size() + 1)) : type); + item.put("url", url); + items.add(item); + } + return items; + } + + /** + * 断言打卡照片每种类型都不超过单类上限。 + *

+ * 上限按类型各自计数,不是节点合计:一个节点可以同时有「打卡照片」与多类凭证,各自最多 9 张。 + * 订正不校验必填——订正的是已经发生的事实,不是重新走一遍流程;必填只约束司机提交。 + */ + private void assertPunchPhotoTypeLimit(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + for (Map.Entry entry : countByType.entrySet()) { + if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) { + throw new ServiceException("「" + entry.getKey() + "」最多上传 " + + WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片"); + } + } + } + + private String firstPunchPhotoUrl(List photos) { + if (photos == null) { + return null; + } + for (WaybillPunchRecordSaveRequest.Photo photo : photos) { + String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl()); + if (Func.isNotEmpty(url)) { + return url; + } + } + return null; + } + + /** + * 打卡时间为空表示「此刻补录」;格式非法直接报错,不静默改成当前时间。 + */ + private Date parsePunchTime(String text) { + String value = TransportBusinessSupport.trimToEmpty(text); + if (Func.isEmpty(value)) { + return new Date(); + } + try { + return DateUtil.parse(value, DateUtil.PATTERN_DATETIME); + } catch (Exception exception) { + throw new ServiceException("打卡时间格式不正确,应为 yyyy-MM-dd HH:mm:ss"); + } + } + + private BigDecimal parseCoordinate(String text, String label) { + String value = TransportBusinessSupport.trimToEmpty(text); + if (Func.isEmpty(value)) { + return null; + } + try { + return new BigDecimal(value); + } catch (NumberFormatException exception) { + throw new ServiceException(label + "格式不正确"); + } + } + private WaybillNodePunch findLatestNodePunch(Map index, String nodeCode, String nodeName) { if (index == null || index.isEmpty()) { return null; @@ -636,6 +959,18 @@ public class WaybillServiceImpl extends BaseServiceImpl return photo; } + /** + * 运单是否已越过接单阶段。 + *

+ * 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、 + * 调度中这些并未越过接单的阶段,取反会把它们判成已接单。 + */ + private static boolean passedAcceptStage(String businessStatus) { + return "running".equals(businessStatus) + || "completed".equals(businessStatus) + || "cancelled".equals(businessStatus); + } + private String formatPunchTime(Date time) { if (time == null) { return ""; @@ -685,6 +1020,10 @@ public class WaybillServiceImpl extends BaseServiceImpl @Transactional(rollbackFor = Exception.class) public boolean submit(Waybill waybill) { boolean created = Func.isEmpty(waybill.getId()); + // 正式提交须脱离草稿:draft 在过程校正中被视为终态不会提升,需先清空再按接单配置校正为 pending/running + if (STATUS_DRAFT.equals(waybill.getBusinessStatus())) { + waybill.setBusinessStatus(null); + } prepareForSave(waybill); validate(waybill); boolean result = saveOrUpdate(waybill); @@ -711,6 +1050,7 @@ public class WaybillServiceImpl extends BaseServiceImpl assertNotLoaded(oldRecord); waybill.setWaybillNo(oldRecord.getWaybillNo()); waybill.setLoadingNo(oldRecord.getLoadingNo()); + waybill.setLoadingIdentifier(oldRecord.getLoadingIdentifier()); waybill.setDeptId(oldRecord.getDeptId()); waybill.setDeptName(oldRecord.getDeptName()); waybill.setMileageRemark(oldRecord.getMileageRemark()); @@ -722,10 +1062,13 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setMasterNo(null); waybill.setMileageRemark(null); } - // 新建若已带业务状态(如复制提交)则在过程校正后回写,保证与原运单一致 + // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致 String preservedBusinessStatus = created ? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()) : null; + if (isCompletedBusinessStatus(preservedBusinessStatus)) { + preservedBusinessStatus = "completed"; + } // 无过程快照时按项目回填;再按接单设置决定 pending / running fillProjectProcessConfig(waybill); prepare(waybill); @@ -901,7 +1244,7 @@ public class WaybillServiceImpl extends BaseServiceImpl @Transactional(rollbackFor = Exception.class) public List importWaybill(List data) { if (Func.isEmpty(data)) { - throw new ServiceException("导入数据不能为空"); + throw new ServiceException("未读取到数据行,请确认表头下方已填写内容"); } List errorList = new ArrayList<>(); for (int index = 0; index < data.size(); index++) { @@ -922,6 +1265,13 @@ public class WaybillServiceImpl extends BaseServiceImpl errorList.add(excel); } } + if (Func.isNotEmpty(errorList)) { + // 抛出携带失败明细的异常,触发事务回滚:submit 内部一次写运单主表+ + // 货物+配载路线等多张关联表,回滚边界必须覆盖全部关联表。 + // 注意不能只往 errorList 里塞数据就 return——异常被吞掉后方法会正常返回, + // Spring 会照常提交事务,@Transactional 起不到回滚作用。 + throw new org.springblade.common.excel.ImportFailureException(data); + } return errorList; } @@ -982,6 +1332,7 @@ public class WaybillServiceImpl extends BaseServiceImpl target.setPlanName(source.getPlanName()); target.setMasterNo(null); target.setLoadingNo(null); + target.setLoadingIdentifier(null); target.setBatchNo(source.getBatchNo()); target.setRelationNo(source.getRelationNo()); target.setCurrentProcessNode(source.getCurrentProcessNode()); @@ -993,36 +1344,48 @@ public class WaybillServiceImpl extends BaseServiceImpl target.setFreightJson(source.getFreightJson()); target.setAttachmentsJson(source.getAttachmentsJson()); target.setRemark(source.getRemark()); - // 复制后业务状态与原运单保持一致 - String sourceBusinessStatus = source.getBusinessStatus(); - target.setBusinessStatus(sourceBusinessStatus); + // 复制新建固定为草稿,不沿用原运单业务状态 + target.setBusinessStatus(STATUS_DRAFT); target.setWaybillNo(nextCode()); clearDriverAcceptRecord(target); fillProjectProcessConfig(target); prepare(target); - applyDriverAcceptBusinessStatus(target); - if (Func.isNotEmpty(sourceBusinessStatus)) { - target.setBusinessStatus(sourceBusinessStatus); - } + target.setBusinessStatus(STATUS_DRAFT); validate(target); save(target); - // 原运单已完成时,复制后按合同系统计费规则生成应收应付明细 - generateReceivablePayableIfCompleted(target); return detail(target.getId()); } /** - * 新建/复制运单若业务状态为已完成,按合同系统计费模式生成应收、应付明细。 - *

使用同事务入口,避免 REQUIRES_NEW 读不到尚未提交的新运单。

+ * 新建/复制运单若业务状态为已完成,按与「完成运单」相同的合同系统计费规则生成应收、应付明细。 + *

使用同事务入口 {@link IReceivablePayableDetailService#generateForImportedWaybills}, + * 避免 {@code generateForCompletedWaybills} 的 REQUIRES_NEW 读不到尚未提交的新运单。

*/ private void generateReceivablePayableIfCompleted(Waybill waybill) { if (waybill == null || Func.isEmpty(waybill.getId())) { return; } - if (!"completed".equals(waybill.getBusinessStatus())) { + Waybill saved = getById(waybill.getId()); + if (saved == null || Objects.equals(saved.getIsDeleted(), 1)) { return; } - receivablePayableDetailService.generateForImportedWaybills(List.of(waybill)); + String businessStatus = TransportBusinessSupport.trimToNull(saved.getBusinessStatus()); + if (Func.isEmpty(businessStatus)) { + businessStatus = TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()); + } + if (!isCompletedBusinessStatus(businessStatus)) { + return; + } + // 统一落库英文状态码,与完成运单、列表展示口径一致 + if (!"completed".equals(businessStatus)) { + saved.setBusinessStatus("completed"); + updateById(saved); + } + receivablePayableDetailService.generateForImportedWaybills(List.of(saved)); + } + + private static boolean isCompletedBusinessStatus(String businessStatus) { + return "completed".equals(businessStatus) || "已完成".equals(businessStatus); } @Override @@ -1302,7 +1665,16 @@ public class WaybillServiceImpl extends BaseServiceImpl queryWrapper.like(Waybill::getOriginalNo, waybill.getOriginalNo()); } if (Func.isNotEmpty(waybill.getBusinessStatus())) { - queryWrapper.eq(Waybill::getBusinessStatus, waybill.getBusinessStatus()); + List businessStatuses = Arrays.stream(waybill.getBusinessStatus().split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + if (businessStatuses.size() == 1) { + queryWrapper.eq(Waybill::getBusinessStatus, businessStatuses.get(0)); + } else if (!businessStatuses.isEmpty()) { + queryWrapper.in(Waybill::getBusinessStatus, businessStatuses); + } } if (Func.isNotEmpty(waybill.getDataSource())) { queryWrapper.eq(Waybill::getDataSource, waybill.getDataSource()); @@ -1396,8 +1768,16 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setBusinessStatus(TransportBusinessSupport.trimToNull(waybill.getBusinessStatus())); waybill.setDataSource(TransportBusinessSupport.normalizeWaybillDataSource(waybill.getDataSource())); waybill.setPlanName(TransportBusinessSupport.trimToNull(waybill.getPlanName())); + // BladeX 数字 null 序列化为 -1;未选计划时归一为空,避免占位值落库/回显 + if (waybill.getPlanId() != null && waybill.getPlanId() <= 0L) { + waybill.setPlanId(null); + } + if (waybill.getPlanId() == null || "-1".equals(waybill.getPlanName())) { + waybill.setPlanName(null); + } waybill.setMasterNo(TransportBusinessSupport.trimToNull(waybill.getMasterNo())); waybill.setLoadingNo(TransportBusinessSupport.trimToNull(waybill.getLoadingNo())); + waybill.setLoadingIdentifier(TransportBusinessSupport.trimToNull(waybill.getLoadingIdentifier())); waybill.setBatchNo(TransportBusinessSupport.trimToNull(waybill.getBatchNo())); waybill.setRelationNo(TransportBusinessSupport.trimToNull(waybill.getRelationNo())); waybill.setCurrentProcessNode(TransportBusinessSupport.trimToNull(waybill.getCurrentProcessNode())); @@ -1428,7 +1808,9 @@ public class WaybillServiceImpl extends BaseServiceImpl } /** - * 完整录入多货物时,运单主表 quantity 按 goodsJson 数量合计回写,避免只落首行数量。 + * 从 goodsJson 回写运单主表货物字段: + * 1. 顶层数量单位 / 货物名称 / 货物类型为空时,取首条有效货物补齐; + * 2. 完整录入多货物时,数量按 goodsJson 合计回写。 */ private void syncQuantityFromGoodsJson(Waybill waybill) { if (waybill == null || Func.isEmpty(waybill.getGoodsJson())) { @@ -1440,7 +1822,53 @@ public class WaybillServiceImpl extends BaseServiceImpl } catch (Exception exception) { return; } - if (Func.isEmpty(goodsList) || goodsList.size() <= 1) { + if (Func.isEmpty(goodsList)) { + return; + } + Map firstGoods = null; + for (Map goods : goodsList) { + if (goods == null) { + continue; + } + firstGoods = goods; + break; + } + if (firstGoods != null) { + if (Func.isEmpty(waybill.getQuantityUnit())) { + String quantityUnit = firstNonBlank(firstGoods, "quantityUnit", "goodsQuantityUnit", "unit"); + if (Func.isNotEmpty(quantityUnit)) { + waybill.setQuantityUnit(quantityUnit); + } + } + if (Func.isEmpty(waybill.getCargoName())) { + String cargoName = firstNonBlank(firstGoods, "cargoName", "goodsName"); + if (Func.isNotEmpty(cargoName)) { + waybill.setCargoName(cargoName); + } + } + if (Func.isEmpty(waybill.getCargoType())) { + String cargoType = firstNonBlank(firstGoods, "cargoType", "goodsType"); + if (Func.isNotEmpty(cargoType)) { + waybill.setCargoType(cargoType); + } + } + if (waybill.getQuantity() == null && goodsList.size() == 1) { + Object quantityValue = firstGoods.get("quantity"); + if (quantityValue == null) { + quantityValue = firstGoods.get("cargoQuantity"); + } + if (quantityValue == null) { + quantityValue = firstGoods.get("goodsQuantity"); + } + if (quantityValue != null && Func.isNotEmpty(String.valueOf(quantityValue).trim())) { + BigDecimal quantity = new BigDecimal(String.valueOf(quantityValue).trim()); + if (!isSentinelMinusOne(quantity)) { + waybill.setQuantity(quantity); + } + } + } + } + if (goodsList.size() <= 1) { return; } BigDecimal total = BigDecimal.ZERO; @@ -1461,6 +1889,23 @@ public class WaybillServiceImpl extends BaseServiceImpl } } + private String firstNonBlank(Map goods, String... keys) { + if (goods == null || keys == null) { + return null; + } + for (String key : keys) { + Object value = goods.get(key); + if (value == null) { + continue; + } + String text = String.valueOf(value).trim(); + if (Func.isNotEmpty(text)) { + return text; + } + } + return null; + } + private void validate(Waybill waybill) { TransportBusinessSupport.validateRequired(waybill.getProjectName(), "项目不能为空"); TransportBusinessSupport.validateRequired(waybill.getContractName(), "客户合同不能为空"); @@ -1470,12 +1915,12 @@ public class WaybillServiceImpl extends BaseServiceImpl TransportBusinessSupport.validateRequired(waybill.getQuantityUnit(), "数量单位不能为空"); TransportBusinessSupport.validateRequired(waybill.getCarrierType(), "承运类型不能为空"); TransportBusinessSupport.validateRequired( - waybill.getVehicleNo(), isRoadTransport(waybill.getTransportType()) ? "车牌号不能为空" : "船/航/班列号不能为空" + waybill.getVehicleNo(), isRoadTransport(waybill.getTransportType()) ? "车牌号不能为空" : "船班号/船次号/班次号不能为空" ); if (isRoadTransport(waybill.getTransportType())) { if ("承运商".equals(waybill.getCarrierType())) { TransportBusinessSupport.validateRequired(waybill.getCarrierName(), "承运商不能为空"); - } else { + } else if (!isBatchImport(waybill)) { TransportBusinessSupport.validateRequired(waybill.getDriverName(), "司机不能为空"); } } @@ -1507,7 +1952,7 @@ public class WaybillServiceImpl extends BaseServiceImpl TransportBusinessSupport.validateLength(waybill.getCarrierName(), 255, "承运商名称不能超过255字"); TransportBusinessSupport.validateLength(waybill.getDriverName(), 255, "司机姓名不能超过255字"); TransportBusinessSupport.validateLength(waybill.getDriverPhone(), 50, "司机手机号不能超过50字"); - TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 255, "车/船/航班/班列号不能超过255字"); + TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 255, "车牌号/船班号/船次号/班次号不能超过255字"); TransportBusinessSupport.validateLength(waybill.getCaptainName(), 20, "船长不能超过20字"); TransportBusinessSupport.validateLength(waybill.getCabinNo(), 30, "舱位不能超过30字"); TransportBusinessSupport.validateLength(waybill.getContainerNo(), 30, "箱号不能超过30字"); @@ -1617,7 +2062,7 @@ public class WaybillServiceImpl extends BaseServiceImpl if (isRoadTransport(waybill.getTransportType())) { return; } - TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 30, "船/航/班列号不能超过30字"); + TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 30, "船班号/船次号/班次号不能超过30字"); TransportBusinessSupport.validateLength(waybill.getCaptainName(), 20, "船长不能超过20字"); TransportBusinessSupport.validateLength(waybill.getCabinNo(), 30, "舱位不能超过30字"); TransportBusinessSupport.validateLength(waybill.getContainerNo(), 30, "箱号不能超过30字"); @@ -1628,6 +2073,10 @@ public class WaybillServiceImpl extends BaseServiceImpl return value.contains("公路") || value.contains("道路") || value.contains("road") || "gl".equals(value); } + private boolean isBatchImport(Waybill waybill) { + return waybill != null && TransportBusinessSupport.DATA_SOURCE_BATCH_IMPORT.equals(waybill.getDataSource()); + } + private void fillJsonFromFlatFields(Waybill waybill) { if (Func.isEmpty(waybill.getTaskEntryMode())) { waybill.setTaskEntryMode("simple"); @@ -1781,9 +2230,20 @@ public class WaybillServiceImpl extends BaseServiceImpl } private void putIfNotEmpty(Map map, String key, Object value) { - if (Func.isNotEmpty(value)) { - map.put(key, value); + if (Func.isEmpty(value) || isSentinelMinusOneValue(value)) { + return; } + map.put(key, value); + } + + private boolean isSentinelMinusOneValue(Object value) { + if (value instanceof BigDecimal bigDecimal) { + return isSentinelMinusOne(bigDecimal); + } + if (value instanceof Number number) { + return Double.compare(number.doubleValue(), -1D) == 0; + } + return false; } private synchronized String nextCode() { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java new file mode 100644 index 00000000..4817baaf --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java @@ -0,0 +1,62 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +/** + * 认证审核的权限表达式 + *

+ * 用菜单码而不是角色名。本项目的权限走「角色-菜单」授权,写死角色名有两个坑: + * 角色别名可能对不上(曾写成 {@code admin},而库里只有 {@code administrator}), + * 以及会漏掉实际承担审核的岗位(例如「运管」)。 + *

+ * 用表达式而不是 {@code @PreAuth(menu = "...")} 的单值形式,是因为同一组接口 + * 由 {@code bizType} 参数区分、同时服务司机与车辆两侧,单值 menu 表达不了「二者之一」。 + *

+ * 表达式最终由框架交给 SpEL 求值,root object 是 {@code AuthFun}, + * 故这里用的函数名必须与它的公开方法一致({@code hasMenu})。 + * {@code CertAuditPermissionsTest} 会实际解析并求值这两个表达式, + * 一旦写错函数名或括号,测试即失败——而不是等到线上请求时抛 500。 + * + * @author Chill + */ +public final class CertAuditPermissions { + + /** + * 待审列表与审核详情:司机或车辆任一审核菜单即可 + */ + public static final String AUDIT_VIEW = + "hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')"; + + /** + * 审核动作:司机或车辆任一「审核」按钮权限即可 + */ + public static final String AUDIT_ACTION = + "hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')"; + + private CertAuditPermissions() { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java new file mode 100644 index 00000000..5a4ed782 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java @@ -0,0 +1,127 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 认证可用性门槛 + *

+ * 在运营状态(启用 / 停用)之上叠加一层「是否已认证」: + * 车辆需运营状态为启用且存在生效快照;司机需存在生效快照。 + *

+ * 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存 + * 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。 + *

+ * 两个开关默认开启,可关掉以回退到不校验认证的行为: + *

+ * transport:
+ *   certification:
+ *     vehicle-gate-enabled: false
+ *     driver-gate-enabled: false
+ * 
+ * + * @author Chill + */ +@Component +@Getter +@Setter +public class CertificationGate { + + /** + * 车辆认证门槛开关 + */ + @Value("${transport.certification.vehicle-gate-enabled:true}") + private boolean vehicleGateEnabled = true; + + /** + * 司机认证门槛开关 + */ + @Value("${transport.certification.driver-gate-enabled:true}") + private boolean driverGateEnabled = true; + + /** + * 判定车辆是否可用 + *

+ * 运营状态优先:停用的车即便已认证也不可用(认证门槛只叠加,不覆盖运营状态)。 + * + * @param enabledState 运营状态:TRUE 启用 + * @param effectiveRecordId 生效快照指针 + * @return 是否可用 + */ + public boolean isVehicleAvailable(Boolean enabledState, Long effectiveRecordId) { + return vehicleUnavailableReason(enabledState, effectiveRecordId) == null; + } + + /** + * 车辆不可用的原因 + *

+ * 停用与未认证是两件事,处置方式也不同:一个要找调度启用车辆,一个要去提交认证。 + * 合并成同一句话,会把「车已认证、只是被停用」误导成「认证没通过」, + * 让人白做一轮重新认证。 + * + * @param enabledState 运营状态:TRUE 启用 + * @param effectiveRecordId 生效快照指针 + * @return 不可用原因;可用时返回 null + */ + public String vehicleUnavailableReason(Boolean enabledState, Long effectiveRecordId) { + if (!Boolean.TRUE.equals(enabledState)) { + return "该车辆已停用,暂不可接单,请联系调度启用"; + } + if (!vehicleGateEnabled) { + return null; + } + return CertificationRuleSupport.isCertified(effectiveRecordId) + ? null + : "该车辆尚未通过认证审核,暂不可接单,请先提交认证"; + } + + /** + * 判定司机是否可接单 + * + * @param effectiveRecordId 生效快照指针 + * @return 是否可用 + */ + public boolean isDriverAvailable(Long effectiveRecordId) { + if (!driverGateEnabled) { + return true; + } + return CertificationRuleSupport.isCertified(effectiveRecordId); + } + + /** + * 司机不可用的提示文案 + * + * @return 面向司机的中文提示 + */ + public String driverUnavailableReason() { + return "您尚未通过认证审核,暂不可接单,请先完成认证"; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java new file mode 100644 index 00000000..7fc8ce28 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java @@ -0,0 +1,180 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.TransportCertRecord; + +/** + * 认证记录状态机规则 + *

+ * 把「什么状态能做什么」集中在一处,与持久化分离,便于单独验证。 + * 这几条规则是整个认证改造的地基,改坏的后果是线上事故: + * 司机重新提交认证后车辆掉出可用池,或同一辆车出现两条待审记录。 + * + * @author Chill + */ +public final class CertificationRuleSupport { + + private static final String STATUS_TEXT_PENDING = "审核中"; + private static final String STATUS_TEXT_APPROVED = "已通过"; + private static final String STATUS_TEXT_REJECTED = "已驳回"; + private static final String STATUS_TEXT_SUPERSEDED = "已作废"; + private static final String STATUS_TEXT_UNKNOWN = "未知状态"; + + private CertificationRuleSupport() { + } + + /** + * 把一条新提交的记录准备成待审 + *

+ * 必须清空驳回原因:新提交是全新一次认证,不能继承上一次的驳回文案, + * 否则前端会显示「审核中」却带着旧的驳回原因。 + * + * @param incoming 待入库的记录 + */ + public static void prepareForSubmit(TransportCertRecord incoming) { + incoming.setStatus(TransportCertRecord.STATUS_PENDING); + incoming.setRejectReason(null); + } + + /** + * 再次提交时,把该认证对象**前一条待审**记录转为已作废 + *

+ * 只动待审记录:已通过、已驳回、已作废都是终态,一律不碰。 + * 特别是**不得改动已通过的记录**——它可能正被生效快照指针指着。 + * + * @param previousPending 该对象当前那条待审记录,可为 null + * @return 需要更新为已作废的记录;无需更新时返回 null + */ + public static TransportCertRecord supersedeIfPending(TransportCertRecord previousPending) { + if (previousPending == null) { + return null; + } + if (!TransportCertRecord.STATUS_PENDING.equals(previousPending.getStatus())) { + return null; + } + previousPending.setStatus(TransportCertRecord.STATUS_SUPERSEDED); + return previousPending; + } + + /** + * 判定认证对象是否已认证 + *

+ * 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」, + * 重新提交、待审、被驳回都不会让对象变回未认证。 + * + * @param effectiveRecordId 生效快照指针 + * @return 是否已认证 + */ + public static boolean isCertified(Long effectiveRecordId) { + return Func.isNotEmpty(effectiveRecordId); + } + + /** + * 判定一条记录是否可被审核 + *

+ * 只有待审记录可审。已通过/已驳回/已作废都是终态,再次审核会把生效快照改坏 + * (例如把指针指着的那条「已通过」改成「已驳回」,生效值就没了)。 + * + * @param record 认证记录 + * @return 是否可审核 + */ + public static boolean isAuditable(TransportCertRecord record) { + return record != null && TransportCertRecord.STATUS_PENDING.equals(record.getStatus()); + } + + /** + * 校验审核入参是否合法 + * + * @param pass 是否通过 + * @param rejectReason 驳回原因 + * @return 是否合法 + */ + public static boolean isRejectReasonValid(boolean pass, String rejectReason) { + if (pass) { + return true; + } + String reason = Func.toStr(rejectReason, "").trim(); + return Func.isNotEmpty(reason) + && reason.length() <= TransportCertRecord.REJECT_REASON_MAX_LENGTH; + } + + /** + * 审核通过时的驳回原因:必须为 null,避免残留上一次的驳回文案 + * + * @return 恒为 null + */ + public static String reasonForApprove() { + return null; + } + + /** + * 审核驳回时的驳回原因:去首尾空白后落库 + * + * @param rejectReason 原始原因 + * @return 去空白后的原因 + */ + public static String reasonForReject(String rejectReason) { + return Func.toStr(rejectReason, "").trim(); + } + + /** + * 状态对应的中文文案 + *

+ * 四端(小程序、Web、接口、库注释)必须用同一套说法,不要各造一套。 + * + * @param status 状态值 + * @return 中文文案 + */ + public static String statusText(Integer status) { + if (TransportCertRecord.STATUS_PENDING.equals(status)) { + return STATUS_TEXT_PENDING; + } + if (TransportCertRecord.STATUS_APPROVED.equals(status)) { + return STATUS_TEXT_APPROVED; + } + if (TransportCertRecord.STATUS_REJECTED.equals(status)) { + return STATUS_TEXT_REJECTED; + } + if (TransportCertRecord.STATUS_SUPERSEDED.equals(status)) { + return STATUS_TEXT_SUPERSEDED; + } + return STATUS_TEXT_UNKNOWN; + } + + /** + * 校验认证对象类型 + * + * @param bizType 类型 + * @return 是否为合法类型 + */ + public static boolean isValidBizType(String bizType) { + return TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType) + || TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java new file mode 100644 index 00000000..730f7453 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java @@ -0,0 +1,47 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +/** + * 异常处置的权限表达式 + *

+ * 用菜单码而不是角色名:本项目的权限走「角色-菜单」授权。 + *

+ * 表达式由框架交给 SpEL 求值,root object 是 {@code AuthFun}, + * 故函数名必须与它的公开方法一致({@code hasMenu})。 + * 写成 {@code @PreAuth("exception_disposal_edit")} 这样的裸字符串会被当作 + * 属性访问而非权限判断,请求时抛 500 而不是 403。 + * + * @author Chill + */ +public final class ExceptionDisposalPermissions { + + /** + * 编辑异常处置的上报内容 + */ + public static final String EDIT = "hasMenu('exception_disposal_edit')"; + + private ExceptionDisposalPermissions() { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java index d1b606b9..f091b00f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java @@ -17,6 +17,7 @@ *

* 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. *

@@ -31,10 +32,12 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.api.R; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictBizCache; import org.springblade.system.cache.SysCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.Dept; -import org.springblade.system.pojo.entity.MeasurementUnit; +import org.springblade.system.pojo.entity.DictBiz; +import org.springblade.system.pojo.entity.FeeItem; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO; @@ -81,10 +84,10 @@ import java.util.stream.Collectors; * 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。 * 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件; * 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。

- *

明细分录口径:取正式结算单页面结算明细(每行一条)+ 结算合计费用项(每行一条); - * 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber}); - * 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码; - * 数量运费费用项目取运输量、其余默认 1;单价按结算金额/数量反算。

+ *

明细分录口径:取正式结算单页面结算明细(每行一个运单,按 货物 → 费用大类 → 税率 拆分多行) + * + 结算合计费用项(手动添加的每行一条); + * 物料编码按装卸大类/项目业务模式/运单运输方式映射,编码维护在 Nacos 配置 thirdparty.bfi.material.*(见 {@link #resolveMaterialNumber}); + * 运输类分录数量取运输量(按税率拆行时每行都取运输量)、装卸类默认 1;单价按结算金额/数量反算。

* * @author Chill */ @@ -97,10 +100,10 @@ public class KingdeeApBillSyncSupport { private static final int PAY_RESULT_QUERY_BATCH = 50; /** A102-4 分页查询页大小 */ private static final int PAY_RESULT_PAGE_SIZE = 200; - /** 结算明细行(运费)计费方案匹配费用项 */ + /** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */ private static final String FREIGHT_FEE_ITEM = "运输费"; - /** 结算明细行(运费)计费方案匹配费用类型 */ - private static final String FREIGHT_FEE_TYPE = "物流配送"; + /** 业务字典:费用类型(fee_category),费用项档案分类编码 -> 名称 */ + private static final String FEE_CATEGORY_DICT = "fee_category"; private final IBfiService bfiService; private final ContractManageMapper contractManageMapper; @@ -171,13 +174,39 @@ public class KingdeeApBillSyncSupport { return existBillNos; } + /** + * A102-4 单防重键查询已生成财务应付单的星瀚内码(A902 附件上传定位用): + * 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填 + * + * @param awrpSrcCode 财务应付单防重键 + * @return 星瀚应付单内码,未生成返回 null + */ + public String queryApBillExistId(String awrpSrcCode) { + if (Func.isBlank(awrpSrcCode)) { + return null; + } + BfiApBillQueryDTO param = new BfiApBillQueryDTO(); + param.setAwrpSrcCode(List.of(awrpSrcCode)); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(PAY_RESULT_PAGE_SIZE); + return bfiService.queryApBill(param); + })) { + if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && apBill.getId() != null) { + return String.valueOf(apBill.getId()); + } + } + return null; + } + // ==================== A102-2 财务应付单保存 ==================== /** * 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。 * 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时 * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 - * 明细分录取页面结算明细(每行一条)+ 结算合计费用项(每行一条),结算金额逐行直送; + * 明细分录取页面结算明细(每行按 货物→费用大类→税率 拆分)+ 结算合计费用项(每行一条),结算金额逐行直送; * 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释), * 当前由金蝶按明细自动汇总生成 * @@ -196,7 +225,7 @@ public class KingdeeApBillSyncSupport { queryApBillExistNos(List.of(code)).getOrDefault(code, "")); if (Func.isNotEmpty(existBillNo)) { log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存", subject, existBillNo); - return new BfiApBillPushOutcome(existBillNo, true); + return new BfiApBillPushOutcome(existBillNo, null, true); } ContractManage contract = requireContract(settlement.getContractId(), context); // 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可 @@ -264,23 +293,17 @@ public class KingdeeApBillSyncSupport { // 【必填】外部单据类型 param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE); - // 明细分录:结算明细行 + 结算合计费用项各一条(不聚合) + // 明细分录:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条 List detailentry = new ArrayList<>(); for (BfiEntryGroup entryGroup : entryGroups) { BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO(); - // 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(国内道路货物运输服务-运输方式 / 国际货物运输代理服务 / 装卸) - String materialNumber = entryGroup.materialNumber(); - - - - entry.setMaterialNumber(materialNumber); + // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置) + entry.setMaterialNumber(entryGroup.materialNumber()); // 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传 entry.setAwrpTransport(entryGroup.transportGoods()); // 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传 entry.setAwrpLpn(entryGroup.vehicleNo()); - // 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码 - entry.setMeasureunitNumber(entryGroup.measureunitNumber()); - // 【必填】数量:运费费用项目取运输量,其它费用项目默认 1 + // 数量:运输类取运输量,装卸类/其它默认 1 entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); // 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考) entry.setPrice(entryGroup.price()); @@ -295,63 +318,6 @@ public class KingdeeApBillSyncSupport { // 分录备注(非必传) // entry.setERemark(subject); - // 调试:物料编码和计量单位编码映射(4个固定组合) - if (true) { - // 物料+单位组合映射,只保留【已审核】物料 - Map comboMap = Map.of( - "国际货运代理服务费", new String[]{"Item-00001081", "ci"}, - "物流配送", new String[]{"Item-00001080", "TON"}, - "国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"}, - "国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"} - ); - // ⚠️兜底改成【已审核】物流配送 Item-00001080 + TON - String[] defaultCombo = comboMap.get("物流配送"); - - // 构建组合键:物料名称-计量单位 - String measureunitName = entryGroup.measureunitNumber(); - String comboKey = materialNumber; - if (materialNumber.contains("国内道路货物运输服务")) { - comboKey = materialNumber + "-" + measureunitName; - } - - // 匹配组合,不匹配使用兜底组合 - String[] combo = comboMap.getOrDefault(comboKey, defaultCombo); - materialNumber = combo[0]; - entry.setMaterialNumber(materialNumber); - entry.setMeasureunitNumber(combo[1]); - } - - - /*if (true) { - // 物料+单位组合映射 - Map comboMap = Map.of( - "国际货运代理服务费", new String[]{"Item-00001081", "ci"}, - "物流配送", new String[]{"Item-00001080", "TON"}, - "国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"}, - "国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"} - ); - - // 构建组合键:物料名称-计量单位 - String measureunitName = entryGroup.measureunitNumber(); - String comboKey = materialNumber; - if (materialNumber.contains("国内道路货物运输服务")) { - comboKey = materialNumber + "-" + measureunitName; - } - - // 匹配组合或随机取一个 - if (comboMap.containsKey(comboKey)) { - String[] combo = comboMap.get(comboKey); - materialNumber = combo[0]; - entry.setMeasureunitNumber(combo[1]); - } else { - List combos = new ArrayList<>(comboMap.values()); - String[] randomCombo = combos.get(new java.util.Random().nextInt(combos.size())); - materialNumber = randomCombo[0]; - entry.setMeasureunitNumber(randomCombo[1]); - } - }*/ - - detailentry.add(entry); } param.setDetailentry(detailentry); @@ -370,7 +336,9 @@ public class KingdeeApBillSyncSupport { requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单"); String billNo = saveResult.getResult().get(0).getNumber(); log.info("金蝶财务应付单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, detailentry.size()); - return new BfiApBillPushOutcome(billNo, false); + // 星瀚应付单内码随结果返回(A902 附件上传定位用) + BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0); + return new BfiApBillPushOutcome(savedItem.getNumber(), savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false); } // ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ==================== @@ -403,14 +371,16 @@ public class KingdeeApBillSyncSupport { } /** - * 解析单张正式结算单分录:页面结算明细每行一条 + 结算合计费用项每行一条。 + * 解析单张正式结算单分录:页面结算明细每行(一个运单)按 货物 → 费用大类 → 税率 拆分多行 + * + 手动添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送)。 *

取数口径: - * 物料编码按项目业务类型/业务模式/装卸大类映射——装卸大类费用行映射“装卸”;国际运输业务模式 - * (或国际货物运输代理服务项目业务类型)映射“国际货物运输代理服务”;国内映射“国内道路货物运输服务-”+运单运输方式; - * 计量单位编码按费用类型/费用项匹配合同计费方案规则计费单位,再按计量单位档案取编码; - * 数量运费费用项目取运输量(未维护兜底 1)、结算合计费用项默认 1;单价按结算金额/数量反算; + * 结算明细拆行——一车多货(存在货物费用行)先按货物拆;费用项名称含“装卸”归装卸大类、其余归运输大类, + * 各大类内按合同计费方案规则税率分组、不同税率分行,运输类每行数量取运单运输量、装卸类每行数量默认 1, + * 费用项金额合计与结算金额的残差并入运输类无税率组保证勾稽; + * 物料编码按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置); + * 单价按结算金额/数量反算; * 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额; - * 运输货物/车号仅结算明细行传(货物名称/车号);分录标识:结算明细行取单据号、 + * 运输货物/车号仅结算明细拆分行传(货物名称/车号);分录标识:结算明细拆分行取单据号-货物序号-大类-税率、 * 结算合计费用项行取结算单号-费用类型-费用项拼接。

* * @param settlement 正式结算单 @@ -425,75 +395,127 @@ public class KingdeeApBillSyncSupport { } ProjectApply project = requireProject(settlement.getProjectId(), context); ContractManage contract = requireContract(settlement.getContractId(), context); - // 合同计费方案规则:费用类型/费用项 -> 计费单位 - Map billingUnitByRule = billingUnitByRule(contract); - // 计量单位档案:单位名称 -> 计量单位编码 - Map unitCodeByName = measureUnitCodeByName(context); - // 结算明细费用行:判断运单是否存在装卸大类费用 + // 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用) + Map taxRateByRule = taxRateByRule(contract); + // 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译) + Map feeCategoryByName = feeCategoryByName(context); + EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName); + // 结算明细费用行:一车多货按货物拆分的费用快照行 Map> feesByDetailId = detailFeesByDetailId(settlementDetails); List entryGroups = new ArrayList<>(); for (FormalSettlementDetail settlementDetail : settlementDetails) { - appendDetailEntry(entryGroups, settlement, project, settlementDetail, - feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()), billingUnitByRule, unitCodeByName); + appendDetailEntries(entryGroups, resolveContext, settlementDetail, + feesByDetailId.getOrDefault(settlementDetail.getId(), List.of())); } // 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复 List manualSummaryFees = listSummaryFees(settlement.getId()).stream() .filter(summaryFee -> Integer.valueOf(1).equals(summaryFee.getManualFlag())).toList(); for (FormalSettlementSummaryFee summaryFee : manualSummaryFees) { - appendSummaryEntry(entryGroups, settlement, project, summaryFee, - settlementDetails.get(0).getTransportType(), billingUnitByRule, unitCodeByName); + appendSummaryEntry(entryGroups, resolveContext, summaryFee, + settlementDetails.get(0).getTransportType()); } if (entryGroups.isEmpty()) { throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo()) + "]无结算明细与结算合计费用项,无法解析分录"); } - // 分录解析摘要(联调排查:明细条数=结算明细行+手工添加的结算合计费用项,物料/计量单位/分录标识逐条见下方分录生成日志) + // 分录解析摘要(联调排查:分录数=结算明细拆分行+手动添加的结算合计费用项,物料/分录标识逐条见下方分录生成日志) log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录", - settlement.getFormalSettlementNo(), settlementDetails.size(), - entryGroups.size() - settlementDetails.size(), entryGroups.size()); + settlement.getFormalSettlementNo(), settlementDetails.size(), manualSummaryFees.size(), entryGroups.size()); return entryGroups; } - /** - * 单条结算明细行生成一条分录:物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1), - * 金额取结算金额(含税),不含税取结算金额(不含税),运输货物/车号取货物名称/车号,分录标识取单据号 - */ - private void appendDetailEntry(List entryGroups, FormalSettlement settlement, ProjectApply project, - FormalSettlementDetail detail, List detailFees, - Map billingUnitByRule, Map unitCodeByName) { - String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]"; - // 该条运单存在装卸大类费用(明细费用项或明细费用行费用项含“装卸”)时物料映射为装卸 - boolean loadingFee = hasLoadingFeeItem(detail.getFeeItemsJson()) - || detailFees.stream().anyMatch(fee -> hasLoadingFeeItem(fee.getFeeItemsJson())); - String materialNumber = resolveMaterialNumber(loadingFee, project, detail.getTransportType(), sourceDesc); - // 计量单位编码:运费费用项匹配合同计费方案计费单位,再匹配计量单位档案编码 - String measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, FREIGHT_FEE_ITEM, FREIGHT_FEE_TYPE, sourceDesc); - // 数量:运费费用项目取运输量,未维护兜底 1 - BigDecimal quantity = detail.getTransportQuantity() != null && detail.getTransportQuantity().compareTo(BigDecimal.ZERO) > 0 - ? money(detail.getTransportQuantity()) : BigDecimal.ONE; - BigDecimal amount = money(detail.getSettlementAmountTax()); - BigDecimal price = entryPrice(amount, quantity); - // 分录唯一标识(awrp_entry_code):结算明细单据号;未维护时兜底明细行id,避免候选键为空被金蝶拦截 - String entryCode = Func.isBlank(detail.getDocumentNo()) ? String.valueOf(detail.getId()) : detail.getDocumentNo().trim(); - entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, quantity, price, amount, - money(detail.getSettlementAmountNoTax()), detail.getCargoName(), detail.getVehicleNo(), settlement.getId())); - log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]", - sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount, money(detail.getSettlementAmountNoTax())); + /** 单张结算单分录解析上下文:结算单/项目档案与合同计费方案税率、费用项档案分类映射 */ + private record EntryResolveContext(FormalSettlement settlement, ProjectApply project, + Map taxRateByRule, Map feeCategoryByName) { } /** - * 单条结算合计费用项生成一条分录:物料按装卸大类/项目业务类型映射(运输方式取结算明细首行), - * 数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接 + * 单条结算明细行(一个运单)按 货物 → 费用大类 → 税率 拆分多条分录: + * 一车多货(存在货物费用行)先按货物拆分,每个货物费用行再按费用大类/税率拆行; + * 无货物费用行时结算明细行本身即唯一货物 */ - private void appendSummaryEntry(List entryGroups, FormalSettlement settlement, ProjectApply project, - FormalSettlementSummaryFee summaryFee, String fallbackTransportType, - Map billingUnitByRule, Map unitCodeByName) { + private void appendDetailEntries(List entryGroups, EntryResolveContext context, + FormalSettlementDetail detail, List detailFees) { + if (Func.isEmpty(detailFees)) { + appendCargoLines(entryGroups, context, detail, detail.getCargoName(), detail.getFeeItemsJson(), + detail.getTransportQuantity(), money(detail.getSettlementAmountTax()), detail.getSettlementAmountNoTax(), 1); + return; + } + int cargoSeq = 0; + for (FormalSettlementDetailFee cargoFee : detailFees) { + appendCargoLines(entryGroups, context, detail, cargoFee.getCargoName(), cargoFee.getFeeItemsJson(), + cargoFee.getTransportQuantity(), money(cargoFee.getSettlementAmountTax()), cargoFee.getSettlementAmountNoTax(), ++cargoSeq); + } + } + + /** + * 单个货物费用行按费用大类/税率拆分生成分录: + * 费用项名称含“装卸”归装卸大类、其余归运输大类,各大类内按合同计费方案税率分组、不同税率分行; + * 运输类每行数量取运单运输量、装卸类每行数量默认 1; + * 运输类首行携带行不含税金额(拆分行无分类不含税金额,仅保证不含税合计勾稽); + * 运输货物取货物名称、车号取结算明细车号(同一车牌号就是一个车) + */ + private void appendCargoLines(List entryGroups, EntryResolveContext context, + FormalSettlementDetail detail, String cargoName, String feeItemsJson, + BigDecimal transportQuantity, BigDecimal rowAmount, BigDecimal rowAmountNoTax, int cargoSeq) { + String documentNo = entryDocumentNo(detail); + String sourceDesc = "结算明细[" + documentNo + "-" + (Func.isBlank(cargoName) ? String.valueOf(cargoSeq) : cargoName) + "]"; + List splits = splitFeeItems(parseFeeItems(feeItemsJson), context.feeCategoryByName(), + context.taxRateByRule(), rowAmount); + if (splits.isEmpty()) { + // 0元货物行(费用项与结算金额均为0)跳过不生成0元分录 + log.info("金蝶应付单分录生成:来源[{}],费用项与结算金额均为0,跳过该货物行", sourceDesc); + return; + } + // 运输类数量:取运单运输量(未维护兜底 1) + BigDecimal freightQuantity = transportQuantity != null && transportQuantity.compareTo(BigDecimal.ZERO) > 0 + ? money(transportQuantity) : BigDecimal.ONE; + String vehicleNo = detail.getVehicleNo(); + // 分录唯一标识前缀(awrp_entry_code):单据号-货物序号,后续拼接费用大类与税率 + String entryCodePrefix = documentNo + "-" + cargoSeq; + boolean amountNoTaxSent = false; + for (FeeLineSplit split : splits) { + String category = split.isLoading() ? "装卸" : "运输费"; + // 物料:装卸大类映射装卸物料;运输类按项目业务模式/运单运输方式映射 + String materialNumber = resolveMaterialNumber(split.isLoading(), context.project(), detail.getTransportType(), sourceDesc); + // 数量:运输类取运输量,装卸类默认 1 + BigDecimal quantity = split.isLoading() ? BigDecimal.ONE : freightQuantity; + BigDecimal amount = money(split.getAmount()); + BigDecimal price = entryPrice(amount, quantity); + // 不含税金额:仅运输类首行携带,保证不含税合计与结算明细勾稽 + BigDecimal amountNoTax = null; + if (!split.isLoading() && !amountNoTaxSent && rowAmountNoTax != null) { + amountNoTax = money(rowAmountNoTax); + amountNoTaxSent = true; + } + // 分录唯一标识:单据号-货物序号-费用大类-税率(税率未配置不拼) + String entryCode = entryCodePrefix + "-" + category + split.taxRateSuffix(); + entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount, + amountNoTax, cargoName, vehicleNo, context.settlement().getId())); + log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]", + sourceDesc, entryCode, materialNumber, quantity, price, amount, amountNoTax); + } + } + + /** 结算明细分录标识单据号:单据号为空时兜底明细行id,避免候选键为空被金蝶拦截 */ + private String entryDocumentNo(FormalSettlementDetail detail) { + return Func.isBlank(detail.getDocumentNo()) ? String.valueOf(detail.getId()) : detail.getDocumentNo().trim(); + } + + /** + * 单条手动添加的结算合计费用项生成一条分录:非运费类(装卸/仓储等杂费)映射装卸物料, + * 运费类按项目业务模式/运输方式映射(运输方式取结算明细首行); + * 非运费类数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接 + */ + private void appendSummaryEntry(List entryGroups, EntryResolveContext context, + FormalSettlementSummaryFee summaryFee, String fallbackTransportType) { + FormalSettlement settlement = context.settlement(); String feeType = Func.toStr(summaryFee.getFeeType()).trim(); String feeItem = Func.toStr(summaryFee.getFeeItem()).trim(); String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]"; - boolean loadingFee = feeType.contains("装卸") || feeItem.contains("装卸"); - String materialNumber = resolveMaterialNumber(loadingFee, project, fallbackTransportType, sourceDesc); - String measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, feeItem, feeType, sourceDesc); + // 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料 + boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName()); + String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc); // 结算合计费用项行无运输量,数量默认 1 BigDecimal quantity = BigDecimal.ONE; BigDecimal amount = money(summaryFee.getSettlementAmount()); @@ -501,34 +523,43 @@ public class KingdeeApBillSyncSupport { // 分录唯一标识(awrp_entry_code):结算单号-费用类型-费用项(费用项为空时仅拼结算单号-费用类型) String entryCode = settlement.getFormalSettlementNo() + "-" + (Func.isBlank(feeItem) ? feeType : feeType + "-" + feeItem); - entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, quantity, price, amount, + entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount, null, null, null, settlement.getId())); - log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}]", - sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount); + log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}]", + sourceDesc, entryCode, materialNumber, quantity, price, amount); } /** - * 物料编码映射:装卸大类费用映射“装卸”;业务模式/项目业务类型含“国际”(国际运输、国际货物运输代理服务) - * 映射“国际货物运输代理服务”;其余(国内运输/国内道路货物运输服务)拼接运单运输方式, - * 如“国内道路货物运输服务-水路” + * 物料编码映射(Nacos 配置 thirdparty.bfi.material.*): + * 装卸大类(及手动添加的非运费杂费)映射装卸物料 loading;业务模式/项目业务类型含“国际”(国际运输、 + * 国际货物运输代理服务)映射国际货代服务 international;国内运输按运单运输方式查 domestic 映射 + * (公路运输/铁路运输/水路运输/航空运输),运输方式未配置映射时直接拦截报错 * - * @param loadingFee 该行是否属于装卸大类费用 + * @param loadingFee 该行是否属于装卸大类(或非运费类杂费) * @param project 结算单关联项目(业务类型/业务模式取数来源) * @param transportType 运单运输方式(结算合计费用项行取结算明细首行运输方式) * @param sourceDesc 来源行描述,用于异常提示 - * @return 金蝶物料编码(按映射规则拼出的物料名称) + * @return 金蝶物料编码 */ private String resolveMaterialNumber(boolean loadingFee, ProjectApply project, String transportType, String sourceDesc) { + BfiProperties.Material material = bfiProperties.getMaterial(); String materialNumber; if (loadingFee) { - materialNumber = KingdeeBfiConstant.BFI_MATERIAL_LOADING; + materialNumber = material.getLoading(); } else if (isInternationalBusiness(project)) { - materialNumber = KingdeeBfiConstant.BFI_MATERIAL_INTERNATIONAL; + materialNumber = material.getInternational(); } else { if (Func.isBlank(transportType)) { - throw new ServiceException("推送金蝶失败:" + sourceDesc + "运单运输方式为空,无法拼接国内道路货物运输服务物料,请先维护运输方式"); + throw new ServiceException("推送金蝶失败:" + sourceDesc + "运单运输方式为空,无法匹配国内运输物料,请先维护运输方式"); } - materialNumber = KingdeeBfiConstant.BFI_MATERIAL_DOMESTIC_PREFIX + transportType.trim(); + materialNumber = material.getDomestic().get(transportType.trim()); + if (Func.isBlank(materialNumber)) { + throw new ServiceException("推送金蝶失败:" + sourceDesc + "运输方式[" + transportType.trim() + + "]未在配置 thirdparty.bfi.material.domestic 中维护物料编码,请补充后重试"); + } + } + if (Func.isBlank(materialNumber)) { + throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在配置 thirdparty.bfi.material 中维护对应物料编码,请补充后重试"); } // 物料映射判定依据(联调排查:装卸大类/业务模式/项目业务类型/运输方式 -> 物料) log.info("金蝶物料映射:来源[{}],装卸大类[{}],业务模式[{}],项目业务类型[{}],运单运输方式[{}] -> 物料编码[{}]", @@ -551,102 +582,219 @@ public class KingdeeApBillSyncSupport { } /** - * 计量单位编码解析:费用项优先、费用类型兜底匹配合同计费方案规则计费单位, - * 再按计量单位档案(单位名称匹配)取计量单位编码,任一环节缺失直接拦截报错 - * - * @param billingUnitByRule 合同计费方案:费用类型/费用项 -> 计费单位 - * @param unitCodeByName 计量单位档案:单位名称 -> 计量单位编码 - * @param feeItem 费用项目(结算合计费用项行取行费用项,结算明细行固定运输费) - * @param feeType 费用类型(结算合计费用项行取行费用类型,结算明细行固定物流配送) - * @param sourceDesc 来源行描述,用于异常提示 - * @return 计量单位编码 - */ - private String resolveMeasureunitNumber(Map billingUnitByRule, Map unitCodeByName, - String feeItem, String feeType, String sourceDesc) { - String billingUnit = Func.isBlank(feeItem) ? null : billingUnitByRule.get(feeItem.trim()); - if (Func.isBlank(billingUnit) && Func.isNotBlank(feeType)) { - billingUnit = billingUnitByRule.get(feeType.trim()); - } - if (Func.isBlank(billingUnit)) { - throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在合同计费方案中配置计费单位,请先维护计费方案规则"); - } - String unitCode = unitCodeByName.get(billingUnit.trim()); - if (Func.isBlank(unitCode)) { - throw new ServiceException("推送金蝶失败:" + sourceDesc + "计费单位[" + billingUnit + "]未在计量单位档案中维护编码,请补充后重试"); - } - // 计量单位匹配链路(联调排查:费用项/费用类型 -> 合同计费方案计费单位 -> 计量单位档案编码) - log.info("金蝶计量单位匹配:来源[{}],费用项[{}],费用类型[{}] -> 计费单位[{}] -> 计量单位编码[{}]", - sourceDesc, feeItem, feeType, billingUnit, unitCode); - return unitCode; - } - - /** - * 合同计费方案规则解析:费用类型/费用项 -> 计费单位(取首个配置了计费单位的规则, - * 计费方案 JSON 解析失败按未配置处理,由计量单位解析处统一拦截报错) + * 合同计费方案规则税率解析:费用类型/费用项 -> 税率(取首个配置了税率的规则, + * 计费方案 JSON 解析失败按未配置处理,由税率分组处兜底为无税率组) * * @param contract 结算合同 - * @return 费用类型/费用项 -> 计费单位映射 + * @return 费用类型/费用项 -> 税率(百分数)映射 */ - private Map billingUnitByRule(ContractManage contract) { - Map mapping = new HashMap<>(); + private Map taxRateByRule(ContractManage contract) { + Map taxRateByRule = new HashMap<>(); if (Func.isBlank(contract.getBillingPlanJson())) { - return mapping; + return taxRateByRule; } try { Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class); - if (!(parsed instanceof List plans)) { - return mapping; - } - for (Object planValue : plans) { - if (!(planValue instanceof Map plan) || !(plan.get("rules") instanceof List rules)) { - continue; - } - for (Object ruleValue : rules) { - if (!(ruleValue instanceof Map rule)) { + if (parsed instanceof List plans) { + for (Object planValue : plans) { + if (!(planValue instanceof Map plan) || !(plan.get("rules") instanceof List rules)) { continue; } - String billingUnit = textValue(rule.get("billingUnit")); - if (Func.isBlank(billingUnit)) { - continue; - } - String feeItem = textValue(rule.get("feeItem")); - String feeType = textValue(rule.get("feeType")); - if (Func.isNotBlank(feeItem)) { - mapping.putIfAbsent(feeItem, billingUnit); - } - if (Func.isNotBlank(feeType)) { - mapping.putIfAbsent(feeType, billingUnit); + for (Object ruleValue : rules) { + if (!(ruleValue instanceof Map rule)) { + continue; + } + BigDecimal taxRate = taxRateValue(rule.get("taxRate")); + if (taxRate == null) { + continue; + } + String feeItem = textValue(rule.get("feeItem")); + String feeType = textValue(rule.get("feeType")); + if (Func.isNotBlank(feeItem)) { + taxRateByRule.putIfAbsent(feeItem, taxRate); + } + if (Func.isNotBlank(feeType)) { + taxRateByRule.putIfAbsent(feeType, taxRate); + } } } } } catch (RuntimeException ignored) { // 计费方案 JSON 兼容历史数据,解析失败按未配置处理 } - log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置计费单位{}", contract.getContractNo(), mapping.size(), mapping); - return mapping; + log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置税率{}", contract.getContractNo(), taxRateByRule.size(), taxRateByRule); + return taxRateByRule; } - /** 费用项 JSON 是否包含装卸大类费用(费用项名称含“装卸”) */ - private boolean hasLoadingFeeItem(String feeItemsJson) { + /** 合同计费方案规则税率:百分数文本转数值(如 9 表示 9%),格式非法按未配置处理 */ + private BigDecimal taxRateValue(Object value) { + if (value == null) { + return null; + } + try { + return new BigDecimal(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + /** + * 费用项按费用大类/税率拆分(纯函数): + * 费用项经费用项档案/业务字典判为装卸大类(名称含“装卸”的费用类型)、其余归运输大类; + * 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组), + * 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽 + * + * @param feeItems 费用项名称 -> 金额(费用项JSON解析结果,保持行序) + * @param feeCategoryByName 费用项档案:费用项名称 -> 费用类型名称(空 Map 时回退名称关键字判定) + * @param taxRateByRule 合同计费方案:费用类型/费用项 -> 税率(百分数) + * @param rowAmount 货物费用行结算金额(含税),残差勾稽基准 + * @return 拆分行列表(保持费用项出现顺序) + */ + public static List splitFeeItems(Map feeItems, Map feeCategoryByName, + Map taxRateByRule, BigDecimal rowAmount) { + Map grouped = new LinkedHashMap<>(); + for (Map.Entry item : feeItems.entrySet()) { + if (Func.isBlank(item.getKey())) { + continue; + } + BigDecimal amount = item.getValue() == null ? BigDecimal.ZERO : item.getValue(); + // 0元费用项跳过,不生成0元分录(金额缺失由运输类无税率组残差兜底) + if (amount.signum() == 0) { + continue; + } + boolean loading = isLoadingCategory(item.getKey(), feeCategoryByName); + BigDecimal taxRate = taxRateByRule == null ? null : taxRateByRule.get(item.getKey()); + FeeLineSplit split = grouped.get(splitGroupKey(loading, taxRate)); + if (split == null) { + grouped.put(splitGroupKey(loading, taxRate), new FeeLineSplit(loading, taxRate, amount, item.getKey())); + } else { + split.addAmount(amount); + } + } + // 残差(结算金额 - 费用项合计,如费用项JSON未含运输费的运费金额)并入运输类无税率组,保证合计勾稽 + BigDecimal residual = (rowAmount == null ? BigDecimal.ZERO : rowAmount).subtract(grouped.values().stream() + .map(FeeLineSplit::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)); + if (residual.signum() != 0) { + FeeLineSplit freightDefault = grouped.computeIfAbsent(splitGroupKey(false, null), + key -> new FeeLineSplit(false, null, BigDecimal.ZERO, FREIGHT_FEE_ITEM)); + freightDefault.addAmount(residual); + } + return new ArrayList<>(grouped.values()); + } + + /** 费用大类/税率分组键(装卸大类与运输大类、税率去重分组) */ + private static String splitGroupKey(boolean loading, BigDecimal taxRate) { + return (loading ? "装卸" : "运输费") + "@" + (taxRate == null ? "" : taxRate.stripTrailingZeros().toPlainString()); + } + + /** 装卸大类费用项判定(关键字兜底):费用项名称含“装卸” */ + static boolean isLoadingFeeItem(String feeItem) { + return feeItem != null && feeItem.contains("装卸"); + } + + /** + * 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用类型名称(业务字典 fee_category 翻译) + * 含“装卸”归装卸大类;档案未维护该费用项时回退费用项名称关键字判定 + */ + static boolean isLoadingCategory(String feeItemName, Map feeCategoryByName) { + String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); + if (Func.isNotBlank(categoryName)) { + return categoryName.contains("装卸"); + } + return isLoadingFeeItem(feeItemName); + } + + /** 运费类费用判定(关键字兜底):名称含“运费”或“运输费” */ + static boolean isFreightFeeText(String name) { + return name != null && (name.contains("运费") || name.contains("运输费")); + } + + /** + * 运费类判定(手动添加费用行物料映射用):费用项档案分类名称、费用类型、费用项名称任一 + * 含“运费/运输费”即归运费类映射运输物料;档案未维护时退化为名称关键字判定(与原口径一致) + */ + static boolean isFreightCategory(String feeTypeName, String feeItemName, Map feeCategoryByName) { + String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); + return isFreightFeeText(categoryName) || isFreightFeeText(feeTypeName) || isFreightFeeText(feeItemName); + } + + /** + * 费用项JSON解析:费用项名称 -> 金额(保持行序,解析失败/非法金额按空处理, + * 金额缺失由运输类无税率组残差兜底) + */ + private Map parseFeeItems(String feeItemsJson) { + Map feeItems = new LinkedHashMap<>(); if (Func.isEmpty(feeItemsJson)) { - return false; + return feeItems; } try { Object parsed = JsonUtil.parse(feeItemsJson, Map.class); if (parsed instanceof Map map) { - return map.keySet().stream().map(String::valueOf).anyMatch(key -> key.contains("装卸")); + for (Map.Entry item : map.entrySet()) { + if (item.getKey() == null || item.getValue() == null) { + continue; + } + try { + feeItems.put(String.valueOf(item.getKey()), new BigDecimal(String.valueOf(item.getValue()).trim())); + } catch (NumberFormatException ignored) { + // 兼容历史费用JSON的非法金额 + } + } } } catch (RuntimeException ignored) { // 兼容历史费用JSON } - return false; + return feeItems; + } + + /** + * 费用大类/税率拆分行:装卸大类 loading(true=装卸)、税率 taxRate(百分数,未配置为 null)、 + * 组内金额合计 amount、组内首个费用项 firstFeeItem + */ + public static final class FeeLineSplit { + private final boolean loading; + private final BigDecimal taxRate; + private final String firstFeeItem; + private BigDecimal amount; + + FeeLineSplit(boolean loading, BigDecimal taxRate, BigDecimal amount, String firstFeeItem) { + this.loading = loading; + this.taxRate = taxRate; + this.amount = amount; + this.firstFeeItem = firstFeeItem; + } + + void addAmount(BigDecimal value) { + amount = amount.add(value); + } + + public boolean isLoading() { + return loading; + } + + public BigDecimal getTaxRate() { + return taxRate; + } + + public BigDecimal getAmount() { + return amount; + } + + public String getFirstFeeItem() { + return firstFeeItem; + } + + /** 分录标识税率后缀:税率未配置返回空串,配置返回“-税率”(如 -9、-6.5) */ + public String taxRateSuffix() { + return taxRate == null ? "" : "-" + taxRate.stripTrailingZeros().toPlainString(); + } } private String textValue(Object value) { return value == null ? null : String.valueOf(value).trim(); } - /** 结算明细id -> 结算明细费用行(判断运单是否存在装卸大类费用) */ + /** 结算明细id -> 结算明细费用行(一车多货按货物拆分的费用快照行) */ private Map> detailFeesByDetailId(List settlementDetails) { List detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId) .filter(Objects::nonNull).distinct().toList(); @@ -669,26 +817,30 @@ public class KingdeeApBillSyncSupport { } /** - * 计量单位档案:单位名称 -> 计量单位编码(批量同步时全批共享一次加载结果; - * 应收单同步组件复用) + * 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典 + * {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用; + * 档案读取失败直接拦截 */ - public Map measureUnitCodeByName(KingdeeSyncContext context) { - Map cache = context.measureUnitCodeByName(); + public Map feeCategoryByName(KingdeeSyncContext context) { + Map cache = context.feeCategoryByName(); if (!cache.isEmpty()) { return cache; } - R> response = sysClient.getMeasurementUnits(); + Map categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream() + .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); + R> response = sysClient.getFeeItems(); if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { - throw new ServiceException("推送金蝶失败:计量单位档案读取失败,请稍后重试"); + throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试"); } Map loaded = new HashMap<>(); - for (MeasurementUnit measurementUnit : response.getData()) { - if (measurementUnit == null || Func.isBlank(measurementUnit.getUnitName()) || Func.isBlank(measurementUnit.getUnitCode())) { + for (FeeItem feeItem : response.getData()) { + if (feeItem == null || Func.isBlank(feeItem.getName()) || Func.isBlank(feeItem.getFeeCategory())) { continue; } - loaded.putIfAbsent(measurementUnit.getUnitName().trim(), measurementUnit.getUnitCode().trim()); + loaded.putIfAbsent(feeItem.getName().trim(), + categoryNames.getOrDefault(feeItem.getFeeCategory().trim(), feeItem.getFeeCategory().trim())); } - log.info("计量单位档案加载:{}项(单位名称 -> 金蝶计量单位编码)", loaded.size()); + log.info("费用项档案加载:{}项(费用项名称 -> 费用类型名称)", loaded.size()); cache.putAll(loaded); return cache; } @@ -784,11 +936,11 @@ public class KingdeeApBillSyncSupport { if (dept == null) { throw new ServiceException("推送金蝶失败,合同所属组织不存在,请检查合同所属组织配置"); } - // deptCode为空,直接报错 - if (Func.isBlank(dept.getDeptCode())) { - throw new ServiceException("推送金蝶失败,合同所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护部门编码"); + // kingdeeDeptCode为空,直接报错 + if (Func.isBlank(dept.getKingdeeDeptCode())) { + throw new ServiceException("推送金蝶失败,合同所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护金蝶组织编码"); } - return dept.getDeptCode(); + return dept.getKingdeeDeptCode(); } /** @@ -805,8 +957,9 @@ public class KingdeeApBillSyncSupport { /** 财务应付单往来类型:合同类别为“客户合同”时为 bd_customer(客户),否则 bd_supplier(供应商) */ public String bfiAsstactType(ContractManage contract) { - return "客户合同".equals(contract.getContractCategory()) - ? KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE : KingdeeBfiConstant.BFI_SUPPLIER_ASSTACT_TYPE; + return KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE; + /*return "客户合同".equals(contract.getContractCategory()) + ? KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE : KingdeeBfiConstant.BFI_SUPPLIER_ASSTACT_TYPE;*/ } /** 财务应付单往来户.编码:合同乙方关联客商档案取客商编码,未维护时拦截 */ @@ -865,12 +1018,11 @@ public class KingdeeApBillSyncSupport { } /** - * 财务应付单/付款申请分录:结算明细行 + 结算合计费用项 各一条(不聚合) + * 财务应付单/付款申请分录:结算明细按 货物→费用大类→税率 拆分行 + 结算合计费用项(手动添加的每行一条) * - * @param materialNumber 物料编码(按项目业务类型/业务模式/装卸大类映射) - * @param measureunitNumber 计量单位编码(费用类型匹配合同计费方案计费单位,再匹配计量单位档案) - * @param entryCode 分录唯一标识(awrp_entry_code):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项 - * @param quantity 该行分录数量(运费费用项目取运输量,其它费用项目默认 1) + * @param materialNumber 物料编码(按装卸大类/项目业务模式/运单运输方式映射,thirdparty.bfi.material.* 配置) + * @param entryCode 分录唯一标识(awrp_entry_code):结算明细拆分行取单据号-货物序号-大类-税率,结算合计费用项行取结算单号-费用类型-费用项 + * @param quantity 该行分录数量(运输类取运输量,装卸类/其它默认 1) * @param price 该行分录单价(结算金额/数量反算) * @param amount 该行金额(含税),对应明细应付金额 e_pricetaxtotal * @param amountNoTax 该行金额(不含税),对应明细金额 e_amount;结算合计费用项行无不含税金额为 null 不传 @@ -878,7 +1030,7 @@ public class KingdeeApBillSyncSupport { * @param vehicleNo 车号(金蝶接口新字段 awrp_lpn):结算明细车号,结算合计费用项行为 null 不传 * @param formalSettlementId 分录所属正式结算单id(多结算单付款申请 A103-1 按结算单勾稽各自应付单) */ - public record BfiEntryGroup(String materialNumber, String measureunitNumber, String entryCode, + public record BfiEntryGroup(String materialNumber, String entryCode, BigDecimal quantity, BigDecimal price, BigDecimal amount, BigDecimal amountNoTax, String transportGoods, String vehicleNo, Long formalSettlementId) { } @@ -887,8 +1039,9 @@ public class KingdeeApBillSyncSupport { * 财务应付单(A102-2)推送结果 * * @param billNo 财务应付单单据编号(金蝶已存在时返回已有单据编号) + * @param billId 财务应付单星瀚内码(A902 附件上传定位用;金蝶已存在幂等跳过时为空,经 A102-4 查询回填) * @param alreadyExist 金蝶是否已存在该防重键的财务应付单,true 表示本次幂等跳过保存(上次同步已在后续环节中断) */ - public record BfiApBillPushOutcome(String billNo, boolean alreadyExist) { + public record BfiApBillPushOutcome(String billNo, String billId, boolean alreadyExist) { } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java index c2b05b9d..34bf1de3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java @@ -27,6 +27,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveEntryDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; @@ -111,13 +112,39 @@ public class KingdeeArBillSyncSupport { return existBillNos; } + /** + * A302-4 单防重键查询已生成财务应收单的星瀚内码(A902 附件上传定位用): + * 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填 + * + * @param arBillSrcCode 财务应收单防重键 + * @return 星瀚应收单内码,未生成返回 null + */ + public String queryArBillExistId(String arBillSrcCode) { + if (Func.isBlank(arBillSrcCode)) { + return null; + } + BfiArBillQueryDTO param = new BfiArBillQueryDTO(); + param.setAwrpSrcCode(List.of(arBillSrcCode)); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(AR_RESULT_PAGE_SIZE); + return bfiService.queryArBill(param); + })) { + if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && arBill.getId() != null) { + return String.valueOf(arBill.getId()); + } + } + return null; + } + // ==================== A302-2 财务应收单保存 ==================== /** * 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。 * 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时 * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 - * 明细分录与应付单(A102-2)同源同口径:页面结算明细(每行一条)+ 结算合计费用项(每行一条); + * 明细分录与应付单(A102-2)同源同口径:页面结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项(每行一条); * 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成 * * @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接) @@ -135,7 +162,7 @@ public class KingdeeArBillSyncSupport { queryArBillExistNos(List.of(code)).getOrDefault(code, "")); if (Func.isNotEmpty(existBillNo)) { log.info("正式结算单[{}]金蝶财务应收单已生成(单据编号[{}]),幂等跳过 A302-2 保存", subject, existBillNo); - return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, true); + return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, null, true); } ContractManage contract = kingdeeApBillSyncSupport.requireContract(settlement.getContractId(), context); // 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可 @@ -198,18 +225,14 @@ public class KingdeeArBillSyncSupport { param.setExchangerate(settlement.getExchangeRate()); } - // 明细分录:与应付单同源同口径——结算明细行 + 结算合计费用项各一条(不聚合),无吨位时数量兜底 1 + // 明细分录:与应付单同源同口径——结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条,无吨位时数量兜底 1 List entry = new ArrayList<>(); for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO(); - String materialNumber = entryGroup.materialNumber(); - - // 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源) - row.setEMaterialNumber(materialNumber); - // 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码 - row.setEMeasureunitNumber(entryGroup.measureunitNumber()); - // 【必填】数量:运费费用项目取运输量,其它费用项目默认 1 + // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置) + row.setEMaterialNumber(entryGroup.materialNumber()); + // 数量:运输类取运输量,装卸类/其它默认 1 row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); // 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传 row.setAwrpTransport(entryGroup.transportGoods()); @@ -229,34 +252,6 @@ public class KingdeeArBillSyncSupport { // 分录备注(非必传) // row.setERemark(subject); - - // 调试:物料编码和计量单位编码映射(4个固定组合) - if (true) { - // 物料+单位组合映射,只保留【已审核】物料 - Map comboMap = Map.of( - "国际货运代理服务费", new String[]{"Item-00001081", "ci"}, - "物流配送", new String[]{"Item-00001080", "TON"}, - "国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"}, - "国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"} - ); - // ⚠️兜底改成【已审核】物流配送 Item-00001080 + TON - String[] defaultCombo = comboMap.get("物流配送"); - - // 构建组合键:物料名称-计量单位 - String measureunitName = entryGroup.measureunitNumber(); - String comboKey = materialNumber; - if (materialNumber.contains("国内道路货物运输服务")) { - comboKey = materialNumber + "-" + measureunitName; - } - - // 匹配组合,不匹配使用兜底组合 - String[] combo = comboMap.getOrDefault(comboKey, defaultCombo); - materialNumber = combo[0]; - row.setEMaterialNumber(materialNumber); - row.setEMeasureunitNumber(combo[1]); - } - - entry.add(row); } param.setEntry(entry); @@ -269,14 +264,17 @@ public class KingdeeArBillSyncSupport { kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单"); String billNo = saveResult.getResult().get(0).getNumber(); log.info("金蝶财务应收单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, entry.size()); - return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(billNo, false); + // 星瀚应收单内码随结果返回(A902 附件上传定位用) + BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0); + return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(savedItem.getNumber(), + savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false); } // ==================== 结算明细分录解析 ==================== /** * 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径—— - * 页面结算明细每行一条 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送), + * 页面结算明细按 货物→费用大类→税率 拆分行 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送), * 物料/计量单位/数量/单价/金额/分录标识取数规则一致, * 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税)) * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java new file mode 100644 index 00000000..dd20ddde --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java @@ -0,0 +1,463 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import feign.Response; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.transport.constant.KingdeeAttachmentConstant; +import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper; +import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 金蝶附件同步支撑组件(A902 附件上传到指定单据)。 + *

设计不变式: + * 1. 附件上传晚于主单推送成功、拿到并落库 billPkId 之后(由各模块接入点保证,本组件在事务提交后调用); + * 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch); + * 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过); + * 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。

+ *

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

+ * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeAttachmentSyncSupport { + + /** 用户附件下载连接/请求超时 */ + private static final Duration DOWNLOAD_TIMEOUT = Duration.ofSeconds(60); + + private final IBfiService bfiService; + private final KingdeeAttachmentSyncMapper attachmentSyncMapper; + + /** 附件下载客户端(无状态,进程内复用) */ + private final HttpClient downloadClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + /** + * 待同步附件项 + * + * @param name 附件名称(含扩展名,展示/金蝶侧文件名) + * @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path + * @param size 附件大小(字节,可空:空时下载后按实际字节校验) + * @param kingdeePathSource true 表示 key 为星瀚侧文件 path(经 A801-2 下载);false 表示 http(s) 附件 url + */ + public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) { + } + + /** + * 单据附件同步结果(各模块用于追加操作流水与前端提示) + * + * @param total 参与同步的附件数(不含已成功跳过) + * @param success 本次上传成功数 + * @param failed 失败数 + * @param skipCount 已成功幂等跳过数 + * @param failReasons 失败原因明细(附件名:原因) + */ + public record AttachmentSyncSummary(int total, int success, int failed, int skipCount, List failReasons) { + } + + // ==================== 附件同步主流程 ==================== + + /** + * 同步单据附件至金蝶对应单据附件面板(A902,逐附件独立 try/catch:成功落成功记录、失败落失败记录)。 + * billPkId 为空(星瀚单据内码未落库)时整单跳过并记日志,由后续重推补传。 + * + * @param bizType 业务单据类型(KingdeeAttachmentConstant.BIZ_*) + * @param bizId 业务单据id + * @param billPkId 星瀚单据内码 + * @param items 待同步附件列表 + * @return 同步结果汇总 + */ + public AttachmentSyncSummary syncAttachments(String bizType, Long bizId, String billPkId, List items) { + if (Func.isEmpty(items)) { + return new AttachmentSyncSummary(0, 0, 0, 0, List.of()); + } + if (Func.isBlank(billPkId)) { + log.warn("金蝶附件同步跳过:业务单据[{}:{}]星瀚单据内码为空,待重推补传", bizType, bizId); + return new AttachmentSyncSummary(items.size(), 0, 0, 0, List.of()); + } + // 载入已成功集合:命中即幂等跳过(重推主单时 A902 零调用) + Set successKeys = successKeys(bizType, bizId); + // 载入既有同步记录(含失败记录,重试成功时覆盖为成功) + Map existingByKey = existingByKey(bizType, bizId); + String entityNumber = KingdeeAttachmentConstant.entityNumber(bizType); + log.info("金蝶附件同步开始:业务单据[{}:{}],星瀚内码={},entityNumber={},待同步附件 {} 个,历史已成功 {} 个", + bizType, bizId, billPkId, entityNumber, items.size(), successKeys.size()); + int success = 0; + int skipCount = 0; + List failReasons = new ArrayList<>(); + List pendingRecords = new ArrayList<>(); + for (KingdeeAttachmentItem item : items) { + if (Func.isBlank(item.key())) { + continue; + } + if (successKeys.contains(item.key())) { + skipCount++; + continue; + } + try { + // 声明大小超限直接拦截,不发起下载 + if (item.size() != null && item.size() > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("附件超过20MB大小限制(" + + formatSize(item.size()) + "),请压缩后重试"); + } + byte[] content = fetchContent(item); + if (content.length > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("附件超过20MB大小限制(" + + formatSize((long) content.length) + "),请压缩后重试"); + } + String fileName = Func.isBlank(item.name()) ? "attachment" : item.name(); + BfiAttachmentUploadVO uploadVO = bfiService.uploadAttachment(entityNumber, billPkId, + KingdeeAttachmentConstant.CONTROL_KEY, fileName, content); + log.info("金蝶附件上传成功:业务单据[{}:{}],附件[{}],kingdeePath={}", bizType, bizId, item.name(), uploadVO.getPath()); + pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS, + null, uploadVO.getPath(), existingByKey.get(item.key()))); + success++; + } catch (Exception exception) { + String reason = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage(); + log.warn("金蝶附件同步失败:业务单据[{}:{}],附件[{}]", bizType, bizId, item.name(), exception); + failReasons.add(Func.toStr(item.name()) + ":" + reason); + pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_FAILED, + truncateReason(reason), null, existingByKey.get(item.key()))); + } + } + for (KingdeeAttachmentSync record : pendingRecords) { + if (record.getId() == null) { + attachmentSyncMapper.insert(record); + } else { + // updateById 默认忽略 null 字段,失败转成功时 fail_reason 须显式置空,避免残留历史失败原因 + attachmentSyncMapper.update(record, Wrappers.lambdaUpdate() + .eq(KingdeeAttachmentSync::getId, record.getId()) + .set(KingdeeAttachmentSync::getFailReason, record.getFailReason())); + } + } + int total = success + failReasons.size(); + log.info("金蝶附件同步完成:业务单据[{}:{}],本次待传 {},成功 {},失败 {},历史已传跳过 {}", + bizType, bizId, total, success, failReasons.size(), skipCount); + return new AttachmentSyncSummary(total, success, failReasons.size(), skipCount, failReasons); + } + + /** + * 同步用户附件(单据 attachmentsJson):解析后逐个上传 + * + * @param bizType 业务单据类型 + * @param bizId 业务单据id + * @param billPkId 星瀚单据内码 + * @param attachmentsJson 单据附件JSON(数组,容忍单对象) + * @return 同步结果汇总 + */ + public AttachmentSyncSummary syncUserAttachments(String bizType, Long bizId, String billPkId, String attachmentsJson) { + return syncAttachments(bizType, bizId, billPkId, parseUserAttachments(attachmentsJson)); + } + + // ==================== 附件解析 ==================== + + /** + * 解析单据用户附件(attachmentsJson):取 url/name/size,无 url 的项忽略。 + * 前端附件行字段容忍取值:名称 originalName/name/fileName,地址 url/link/fileUrl/downloadUrl/domain/src + * + * @param attachmentsJson 附件JSON + * @return 附件项列表 + */ + public List parseUserAttachments(String attachmentsJson) { + Object parsed = parseJson(attachmentsJson); + if (parsed == null) { + return List.of(); + } + List rows = parsed instanceof List list ? list : List.of(parsed); + List items = new ArrayList<>(); + for (Object row : rows) { + if (!(row instanceof Map map)) { + continue; + } + String url = firstText(map, "url", "link", "fileUrl", "downloadUrl", "domain", "src"); + if (Func.isBlank(url)) { + continue; + } + String name = firstText(map, "originalName", "name", "fileName"); + Long size = firstLong(map, "size"); + items.add(new KingdeeAttachmentItem(name, url, size, false)); + } + return items; + } + + /** + * 解析票池镜像发票文件(attachmentsJson 单对象 {source,path,name}):幂等键=path(经 A801-2 下载), + * path 变化(红冲重开)自动视为新文件触发重传 + * + * @param attachmentsJson 镜像附件JSON + * @return 附件项列表(无发票文件时为空) + */ + public List parseInvoicePoolFile(String attachmentsJson) { + Object parsed = parseJson(attachmentsJson); + if (parsed == null) { + return List.of(); + } + List rows = parsed instanceof List list ? list : List.of(parsed); + List items = new ArrayList<>(); + for (Object row : rows) { + if (!(row instanceof Map map)) { + continue; + } + String path = firstText(map, "path"); + if (Func.isBlank(path)) { + continue; + } + String name = firstText(map, "name"); + items.add(new KingdeeAttachmentItem(Func.isBlank(name) ? "发票文件" : name, path, null, true)); + } + return items; + } + + // ==================== 列表聚合态 ==================== + + /** + * 单据附件数量(列表聚合态判定用):解析失败按 0 处理 + * + * @param attachmentsJson 单据附件JSON + * @return 附件数量 + */ + public int countAttachments(String attachmentsJson) { + return parseUserAttachments(attachmentsJson).size(); + } + + /** + * 各业务单据已成功上传附件数(一次 GROUP BY 查询,避免 N+1) + * + * @param bizType 业务单据类型 + * @param bizIds 业务单据id集合 + * @return 业务单据id -> 已成功附件数 + */ + public Map successCountByBizId(String bizType, Collection bizIds) { + if (Func.isEmpty(bizIds)) { + return Map.of(); + } + QueryWrapper wrapper = new QueryWrapper() + .select("biz_id", "count(*) AS success_count") + .eq("biz_type", bizType) + .eq("sync_status", KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS) + .in("biz_id", bizIds) + .groupBy("biz_id"); + Map result = new HashMap<>(); + for (Map row : attachmentSyncMapper.selectMaps(wrapper)) { + Object bizId = row.get("biz_id"); + Object count = row.get("success_count"); + if (bizId != null && count != null) { + result.put(Long.valueOf(String.valueOf(bizId)), Integer.parseInt(String.valueOf(count))); + } + } + return result; + } + + /** + * 聚合态判定:无附件 / 未上传 / 部分未传 / 已上传(运行时聚合,非存储字段) + * + * @param attachmentCount 单据附件数量 + * @param successCount 已成功上传数量 + * @return 聚合态(KingdeeAttachmentConstant.AGGREGATE_*) + */ + public String aggregateStatus(int attachmentCount, int successCount) { + if (attachmentCount <= 0) { + return KingdeeAttachmentConstant.AGGREGATE_NONE; + } + if (successCount <= 0) { + return KingdeeAttachmentConstant.AGGREGATE_PENDING; + } + return successCount >= attachmentCount + ? KingdeeAttachmentConstant.AGGREGATE_DONE : KingdeeAttachmentConstant.AGGREGATE_PARTIAL; + } + + // ==================== 内部实现 ==================== + + /** 已成功附件幂等键集合 */ + private Set successKeys(String bizType, Long bizId) { + return attachmentSyncMapper.selectList(Wrappers.lambdaQuery() + .select(KingdeeAttachmentSync::getAttachmentKey) + .eq(KingdeeAttachmentSync::getBizType, bizType) + .eq(KingdeeAttachmentSync::getBizId, bizId) + .eq(KingdeeAttachmentSync::getSyncStatus, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS)) + .stream().map(KingdeeAttachmentSync::getAttachmentKey).collect(Collectors.toSet()); + } + + /** 既有同步记录(含失败),key -> 记录(重试成功时覆盖为成功) */ + private Map existingByKey(String bizType, Long bizId) { + Map result = new LinkedHashMap<>(); + for (KingdeeAttachmentSync record : attachmentSyncMapper.selectList(Wrappers.lambdaQuery() + .eq(KingdeeAttachmentSync::getBizType, bizType) + .eq(KingdeeAttachmentSync::getBizId, bizId))) { + result.putIfAbsent(record.getAttachmentKey(), record); + } + return result; + } + + /** + * 组装同步记录:有既有记录时复用主键覆盖更新(失败转成功/成功后清理失败原因),否则新增 + */ + private KingdeeAttachmentSync buildRecord(String bizType, Long bizId, KingdeeAttachmentItem item, int syncStatus, + String failReason, String kingdeePath, KingdeeAttachmentSync existing) { + KingdeeAttachmentSync record = existing == null ? new KingdeeAttachmentSync() : existing; + record.setBizType(bizType); + record.setBizId(bizId); + record.setAttachmentKey(item.key()); + record.setAttachmentName(truncate(item.name(), 250)); + record.setKingdeePath(kingdeePath); + record.setSyncStatus(syncStatus); + record.setFailReason(failReason); + record.setSyncTime(LocalDateTime.now()); + return record; + } + + /** + * 拉取附件内容:星瀚侧文件经 A801-2 下载(流式读取,防大文件全量进内存失控); + * 用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验 + */ + private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception { + if (item.kingdeePathSource()) { + Response response = bfiService.downloadInvoice(item.key()); + try (InputStream inputStream = response.body().asInputStream()) { + return readCapped(inputStream); + } finally { + response.close(); + } + } + HttpRequest request = HttpRequest.newBuilder(URI.create(item.key())) + .timeout(DOWNLOAD_TIMEOUT) + .GET() + .build(); + HttpResponse response = downloadClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("附件下载失败,HTTP状态码 " + response.statusCode()); + } + try (InputStream inputStream = response.body()) { + return readCapped(inputStream); + } + } + + /** 流式读取并封顶(超出软限制即中断读取,避免大文件占满内存) */ + private byte[] readCapped(InputStream inputStream) throws IOException { + int bufferSize = 64 * 1024; + long limit = KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(bufferSize); + byte[] chunk = new byte[bufferSize]; + long total = 0; + int read; + while ((read = inputStream.read(chunk)) != -1) { + if (total + read > limit) { + throw new IllegalArgumentException("附件超过20MB大小限制,请压缩后重试"); + } + buffer.write(chunk, 0, read); + total += read; + } + return buffer.toByteArray(); + } + + private Object parseJson(String json) { + if (Func.isBlank(json)) { + return null; + } + try { + return JsonUtil.parse(json.trim(), Object.class); + } catch (Exception exception) { + log.warn("附件JSON解析失败,按无附件处理:{}", exception.getMessage()); + return null; + } + } + + /** 附件行字段容忍取值(多个候选键取第一个非空值) */ + private String firstText(Map map, String... keys) { + for (String key : keys) { + Object value = map.get(key); + if (value != null && Func.isNotBlank(String.valueOf(value))) { + return String.valueOf(value).trim(); + } + } + return null; + } + + private Long firstLong(Map map, String key) { + Object value = map.get(key); + if (value instanceof Number number) { + return number.longValue(); + } + if (value != null) { + try { + return Long.parseLong(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private String truncate(String text, int maxLength) { + if (text == null || text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength); + } + + private String truncateReason(String reason) { + return truncate(reason, 490); + } + + private String formatSize(Long bytes) { + if (bytes == null) { + return "未知"; + } + return String.format("%.1fMB", bytes / 1024.0 / 1024.0); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java index 7844082e..cd80fd69 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java @@ -35,6 +35,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignRefDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.service.IBfiService; @@ -93,10 +94,10 @@ public class KingdeeReceivedInvoiceSyncSupport { * 契约无来源单据号查询参数,防重键匹配在返回行上进行;查询失败中断本轮,由任务层记录异常 * * @param buyerName 购方名称(接口必填,取镜像受票单位) - * @param srcCodes 待匹配的收票单防重键(发票号码)集合 + * @param srcCodes 待匹配的收票单防重键(收票登记id,与 A303-2 的 awrp_src_code 同源)集合 * @param windowStart 修改时间-开始(窗口不超一个月,调用方控制) * @param windowEnd 修改时间-结束 - * @return 发票号码 -> 收票单行(同键多行取修改时间最新) + * @return 防重键(收票登记id) -> 收票单行(同键多行取修改时间最新) */ public Map queryReceivedInvoiceBySrcCodes(String buyerName, Collection srcCodes, LocalDateTime windowStart, LocalDateTime windowEnd) { @@ -148,7 +149,7 @@ public class KingdeeReceivedInvoiceSyncSupport { /** * 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。 * 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位; - * 明细分录与财务应付单 A102-2 同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/金额取自 + * 明细分录与财务应付单 A102-2 同源同口径:结算明细按 货物→费用大类→税率 拆分行+手动添加结算合计费用项每行一条,物料/数量/单价/金额取自 * 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。 * 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码), * 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开 @@ -158,10 +159,10 @@ public class KingdeeReceivedInvoiceSyncSupport { * @param contract 分摊应付结算单关联合同(组织/币种/往来户派生) * @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果) * @param context 金蝶同步上下文(缓存载体) - * @return 金蝶收票单单据编号 + * @return 推送结果(单据编号 + 星瀚收票单内码,A902 附件上传定位用) */ - public String pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract, - List entryGroups, KingdeeSyncContext context) { + public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract, + List entryGroups, KingdeeSyncContext context) { if (Func.isBlank(mirror.getInvoiceTypeCode())) { throw new ServiceException("推送金蝶收票单失败:镜像缺少票易通发票类型编码,请先重新绑定票池发票"); } @@ -180,8 +181,8 @@ public class KingdeeReceivedInvoiceSyncSupport { param.setBiztype(KingdeeBfiConstant.BFI_RECEIVED_BIZ_TYPE); // 【必填】发票类型.编码:票易通原始编码(26/27 等) param.setInvoicetypef7Number(mirror.getInvoiceTypeCode()); - // 【必填】付款方式:赊购(与应付单一致,不允许金蝶自动推付款) - param.setPaymenttype(BfiConstant.PAY_MODE_CREDIT); + // 【必填】付款方式:(与应付单一致,不允许金蝶自动推付款) + param.setPaymenttype(BfiConstant.PAY_MODE_CASH); // 【必填】往来类型:进项收票固定供应商 param.setAsstacttype(BfiConstant.ASSTACT_TYPE_SUPPLIER); // 【必填】结算组织.编码:合同所属组织(与应付单同源) @@ -216,11 +217,23 @@ public class KingdeeReceivedInvoiceSyncSupport { BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param); kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶收票单"); - return saveResult.getResult().get(0).getNumber(); + BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0); + return new ReceivedInvoicePushOutcome(savedItem.getNumber(), + savedItem.getId() == null ? null : String.valueOf(savedItem.getId())); } /** - * 分录组装与应付单 A102-2 明细同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/含税/不含税金额 + * A303-2 收票单推送结果 + * + * @param billNo 金蝶收票单单据编号 + * @param billId 星瀚收票单内码(A902 附件上传定位用) + */ + public record ReceivedInvoicePushOutcome(String billNo, String billId) { + } + + /** + * 分录组装与应付单 A102-2 明细同源同口径:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条, + * 物料/数量/单价/含税/不含税金额 * 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键, * 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1; * 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补) @@ -238,32 +251,8 @@ public class KingdeeReceivedInvoiceSyncSupport { entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); // 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考) entry.setPrice(entryGroup.price()); - // 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源) - String materialNumber = entryGroup.materialNumber(); - // 调试:物料编码和计量单位编码映射(收票单场景) - if (true) { - Map comboMap = Map.of( - "国际货运代理服务费", new String[]{"Item-00001081", "ci"}, - "物流配送", new String[]{"Item-00001080", "TON"}, - "国内道路货物运输服务-公路运输-吨", new String[]{"Item-00001080", "TON"}, - "国内道路货物运输服务-公路运输-公斤", new String[]{"Item-00001080", "KG"}, - "装卸", new String[]{"Item-00001080", "KG"} - ); - String measureunitName = entryGroup.measureunitNumber(); - String comboKey = materialNumber; - if (materialNumber.contains("国内道路货物运输服务")) { - comboKey = materialNumber + "-" + measureunitName; - } - if (comboMap.containsKey(comboKey)) { - String[] combo = comboMap.get(comboKey); - materialNumber = combo[0]; - } else { - List combos = new ArrayList<>(comboMap.values()); - String[] randomCombo = combos.get(new java.util.Random().nextInt(combos.size())); - materialNumber = randomCombo[0]; - } - } - entry.setMaterialNumber(materialNumber); + // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置,与应付单同源) + entry.setMaterialNumber(entryGroup.materialNumber()); // 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传) if (entryGroup.amountNoTax() != null) { entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax())); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeSyncLockSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeSyncLockSupport.java new file mode 100644 index 00000000..684068a2 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeSyncLockSupport.java @@ -0,0 +1,93 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.redis.lock.LockType; +import org.springblade.core.redis.lock.RedisLockClient; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.utils.Func; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * 金蝶定时同步 single-flight 分布式锁支撑组件(模式同全票池同步 syncKingdeeInvoicePool): + * 多副本部署的定时任务与手动触发端点共用一道闸,同一轮同步全局最多一份在跑。 + *

零等待抢锁:已有轮次进行中则立刻放弃本轮并返回跳过摘要(定时任务下轮幂等重试,手动触发稍后再试); + * 租约须大于最坏一轮耗时(含翻页远程查询),否则租约到期后另一副本会并发起跑;进程异常退出时锁在租约到期后自动释放; + * 未启用 blade.lock.enabled 时降级为直接执行(多副本各自起跑,联调环境可接受,生产须开启)。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeSyncLockSupport { + + /** 分布式锁客户端,仅在 blade.lock.enabled=true 时存在,缺失时同步降级为无 single-flight 保护 */ + private final ObjectProvider redisLockClientProvider; + + /** + * 在 single-flight 分布式锁保护下执行一轮同步 + * + * @param lockPrefix 锁键前缀(业务侧隔离,组件内自动追加租户标识) + * @param leaseSeconds 锁租约秒数,须大于最坏一轮耗时 + * @param taskName 同步任务名,用于日志与跳过摘要 + * @param action 同步主体 + * @return 同步摘要;已有轮次进行中时返回跳过摘要 + */ + public String runSingleFlight(String lockPrefix, long leaseSeconds, String taskName, Supplier action) { + RedisLockClient redisLockClient = redisLockClientProvider.getIfAvailable(); + if (redisLockClient == null) { + // 未启用 blade.lock.enabled:多副本部署会各自起跑同一轮同步,联调环境可接受,生产须开启 + log.warn("未启用分布式锁(blade.lock.enabled=false),{}无 single-flight 保护", taskName); + return action.get(); + } + String lockKey = lockPrefix + currentTenant(); + boolean locked = false; + try { + // 零等待抢锁:已有轮次在跑就立刻放弃本轮,多副本与手动触发共用这一道闸 + locked = redisLockClient.tryLock(lockKey, LockType.REENTRANT, 0, leaseSeconds, TimeUnit.SECONDS); + if (!locked) { + log.warn("{}已有轮次进行中,本轮跳过,锁键={}", taskName, lockKey); + return taskName + "进行中,本轮跳过"; + } + return action.get(); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new ServiceException("等待" + taskName + "分布式锁被中断"); + } finally { + if (locked) { + redisLockClient.unLock(lockKey, LockType.REENTRANT); + } + } + } + + /** 租户标识:定时任务上下文无登录用户时以 default 加锁,锁粒度与数据隔离边界一致 */ + private String currentTenant() { + String tenantId = AuthUtil.getTenantId(); + return Func.isEmpty(tenantId) ? "default" : tenantId; + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java new file mode 100644 index 00000000..ef4e7798 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java @@ -0,0 +1,403 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 把百度 OCR 的原始结果归一化成业务字段 + *

+ * 百度返回的 {@code words_result} 键是**中文**(「号牌号码」「车辆识别代码」…), + * 同一含义在纸质证与电子证照之间还有不同写法。让小程序去认识这些中文键, + * 等于把百度接口的形状泄漏到前端,接口一改就要动多个端。 + *

+ * 归一化后下发的键与业务字段同名({@code plateNo}、{@code ownerName}…),前端拿到即可回填; + * 原始结果仍一并下发,归一化没覆盖到的字段不至于丢失。 + *

+ * 必须做别名容错的原因:官方文档列举的是「号牌号码」「车辆识别代码」, + * 而实际返回里也出现过「车牌号码」「车辆识别代号」这类写法,电子行驶证还多出 + * 「强制报废期止」「能源种类」等字段。写死单个键名的后果是**识别成功但什么也没填**—— + * 不报错、不抛异常,最难排查。 + * + * @author Chill + */ +public final class OcrFieldSupport { + + /** + * {@code words_result} 里每个条目形如 {@code {"words": "桂A12345"}} + */ + private static final String WORDS = "words"; + + /** + * 表示「长期有效」的词:命中后不产出日期,而把对应的长期有效标志置 1 + */ + private static final String LONG_TERM_TEXT = "长期"; + + /** + * 日期写法:4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(年月日 / 点 / 横线) + */ + private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})"); + + private OcrFieldSupport() { + } + + /** + * 按证件类型把原始结果归一化 + * + * @param type 证件类型 + * @param side 证件面:front / back(目前两个面共用一套字段别名) + * @param result 百度原始返回 + * @return 归一化字段(键与业务字段同名);识别不到任何字段时返回空 Map + */ + public static Map extract(BaiduOcrType type, String side, JsonNode result) { + Map words = readWords(result); + Map fields = new LinkedHashMap<>(); + if (words.isEmpty()) { + return fields; + } + for (Map.Entry> entry : aliasTable(type).entrySet()) { + String raw = firstMatch(words, entry.getValue()); + if (Func.isEmpty(raw)) { + continue; + } + putField(fields, entry.getKey(), raw); + } + // 外廓尺寸是唯一「一项要拆成三个字段」的,单独处理 + splitOuterSize(words, fields); + // 道路运输证的「有效期」常写成一个区间:止期已由上面取到最后一段, + // 起期要从同一段文本里补,否则用户得照着一张图再敲一遍 + splitRangeStart(words, fields, "有效期", "roadTransportCertStartDate"); + return fields; + } + + /** + * 从「有效期」这类区间文本里取出起期,补进对应的起期字段 + * + * @param words 识别结果 + * @param fields 结果集 + * @param alias 区间所在的键名 + * @param startKey 要补的起期字段名 + */ + private static void splitRangeStart(Map words, Map fields, + String alias, String startKey) { + if (fields.containsKey(startKey)) { + return; + } + String text = firstMatch(words, List.of(alias)); + if (Func.isEmpty(text)) { + return; + } + List dates = matchDates(text); + // 至少两段才算区间:只有一段说明那是止期本身,硬当起期会填错 + if (dates.size() >= 2) { + fields.put(startKey, dates.get(0)); + } + } + + /** + * 读取 {@code words_result} 成「中文键 → 文本值」 + * + * @param result 百度原始返回 + * @return 键值对;结构不符时返回空 Map + */ + private static Map readWords(JsonNode result) { + Map words = new LinkedHashMap<>(); + if (result == null) { + return words; + } + JsonNode wordsResult = result.path("words_result"); + if (!wordsResult.isObject()) { + return words; + } + wordsResult.fields().forEachRemaining(entry -> { + JsonNode value = entry.getValue(); + if (value == null || value.isNull()) { + return; + } + // 可能是 {"words": "..."},也可能直接是字符串 + String text = value.isObject() ? value.path(WORDS).asText("") : value.asText(""); + if (Func.isNotEmpty(text)) { + words.put(entry.getKey(), text.trim()); + } + }); + return words; + } + + /** + * 取第一个命中的别名值 + * + * @param words 识别结果 + * @param aliases 候选键名,按优先级排列 + * @return 命中值;都没命中返回 null + */ + private static String firstMatch(Map words, List aliases) { + for (String alias : aliases) { + String value = words.get(alias); + if (Func.isNotEmpty(value)) { + return value; + } + } + // 精确键名没命中时按「包含」兜底:百度偶有「核定载质量(kg)」这类带后缀的键 + for (String alias : aliases) { + for (Map.Entry entry : words.entrySet()) { + if (entry.getKey().contains(alias) && Func.isNotEmpty(entry.getValue())) { + return entry.getValue(); + } + } + } + return null; + } + + /** + * 写入一个字段,按字段语义做值归一化 + *

+ * 数字字段转数字、日期字段转 {@code yyyy-MM-dd};「长期」不产出日期, + * 而是把对应的长期有效标志置 1——否则「长期」会被当成一个非法日期塞进日期框。 + * + * @param fields 结果集 + * @param key 业务字段名 + * @param raw 识别到的原始文本 + */ + private static void putField(Map fields, String key, String raw) { + String text = raw.trim(); + if (isDateField(key)) { + if (text.contains(LONG_TERM_TEXT)) { + String longTermKey = longTermKeyOf(key); + if (longTermKey != null) { + fields.put(longTermKey, 1); + } + return; + } + String date = normalizeDate(text, key); + if (date != null) { + fields.put(key, date); + } + return; + } + if (isNumberField(key)) { + Integer number = normalizeNumber(text); + if (number != null) { + fields.put(key, number); + } + return; + } + fields.put(key, text); + } + + /** + * 「外廓尺寸」一项拆成外廓长/宽/高三个字段 + *

+ * 形如 {@code 5990×2300×3100},分隔符有 ×、x、*、空格等多种写法。 + * + * @param words 识别结果 + * @param fields 结果集 + */ + private static void splitOuterSize(Map words, Map fields) { + String size = firstMatch(words, List.of("外廓尺寸")); + if (Func.isEmpty(size)) { + return; + } + List parts = new ArrayList<>(); + for (String part : size.split("[^0-9]+")) { + if (Func.isNotEmpty(part)) { + parts.add(part); + } + } + // 必须正好三段才写回:只识别到两段时多半是漏识别, + // 按顺序硬填会把宽度填成长度、高度填成宽度 + if (parts.size() != 3) { + return; + } + String[] keys = {"outerLength", "outerWidth", "outerHeight"}; + for (int index = 0; index < keys.length; index++) { + Integer number = normalizeNumber(parts.get(index)); + if (number != null) { + fields.put(keys[index], number); + } + } + } + + /** + * 各证件类型的字段别名表 + * + * @param type 证件类型 + * @return 「业务字段名 → 百度键名候选」 + */ + private static Map> aliasTable(BaiduOcrType type) { + if (type == null) { + return Map.of(); + } + String name = type.name(); + if (BaiduOcrType.ID_CARD.name().equals(name)) { + return idCardAliases(); + } + if (BaiduOcrType.DRIVING_LICENSE.name().equals(name)) { + return drivingLicenseAliases(); + } + if (BaiduOcrType.VEHICLE_LICENSE.name().equals(name)) { + return vehicleLicenseAliases(); + } + if (BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE.name().equals(name)) { + return roadTransportAliases(); + } + return Map.of(); + } + + private static Map> idCardAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("realName", List.of("姓名")); + aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码")); + aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起")); + aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止")); + return aliases; + } + + private static Map> drivingLicenseAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("driverFileNo", List.of("档案编号", "证号")); + aliases.put("driverClass", List.of("准驾车型")); + aliases.put("driverValidStart", List.of("有效起始日期", "有效期起", "初次领证日期")); + aliases.put("driverValidEnd", List.of("有效期限", "有效期止", "有效期限止")); + return aliases; + } + + private static Map> vehicleLicenseAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("plateNo", List.of("号牌号码", "车牌号码", "号牌")); + aliases.put("vehicleType", List.of("车辆类型")); + aliases.put("ownerName", List.of("所有人")); + aliases.put("vin", List.of("车辆识别代码", "车辆识别代号", "车架号", "VIN")); + aliases.put("drivingLicenseNo", List.of("档案编号", "行驶证档案编号")); + aliases.put("plateColor", List.of("号牌颜色", "车牌颜色")); + aliases.put("energyType", List.of("能源种类", "能源类型")); + aliases.put("approvedLoadKg", List.of("核定载质量")); + aliases.put("tractionMassKg", List.of("准牵引总质量")); + aliases.put("compulsoryScrapDate", List.of("强制报废期止", "强制报废日期")); + aliases.put("drivingLicenseStartDate", List.of("注册日期", "发证日期")); + aliases.put("drivingLicenseEndDate", List.of("检验有效期止", "有效期止")); + return aliases; + } + + private static Map> roadTransportAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("roadTransportCertNo", List.of("道路运输证号", "运输证号")); + aliases.put("ownerName", List.of("业户名称", "所有人")); + aliases.put("roadTransportCertEndDate", List.of("有效期", "有效期止", "有效期至")); + return aliases; + } + + private static boolean isDateField(String key) { + return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd"); + } + + private static boolean isNumberField(String key) { + return "approvedLoadKg".equals(key) || "tractionMassKg".equals(key); + } + + private static String longTermKeyOf(String dateKey) { + if ("compulsoryScrapDate".equals(dateKey)) { + return "compulsoryScrapLongTerm"; + } + if ("drivingLicenseEndDate".equals(dateKey)) { + return "drivingLicenseLongTerm"; + } + if ("roadTransportCertEndDate".equals(dateKey)) { + return "roadTransportCertLongTerm"; + } + return null; + } + + /** + * 数字归一化:去掉「kg」「毫米」等单位与千分位后取整 + * + * @param text 识别文本 + * @return 整数;取不到合理数字返回 null + */ + private static Integer normalizeNumber(String text) { + String digits = text.replaceAll("[^0-9]", ""); + // 载质量、外廓尺寸最多 6 位;过长说明识别串了字段,宁可不填也不填错 + if (digits.isEmpty() || digits.length() > 6) { + return null; + } + return Integer.valueOf(digits); + } + + /** + * 日期归一化 + *

+ * 覆盖 {@code 2015年03月12日} / {@code 2015.3.9} / {@code 2015-03-12} / {@code 20150312} + * 这几种写法,统一成 {@code yyyy-MM-dd}。 + *

+ * 区间(如「2015.03.12-2035.03.12」「2020-01-01 至 2030-01-01」)**按字段语义取一端**: + * 以 Start 结尾的取第一段,其余(止期类)取最后一段。取错一端不会报错, + * 只会把有效期起填成止——比不填更难发现。 + * + * @param text 识别文本 + * @param key 业务字段名,用于判断该取区间的哪一端 + * @return 归一化日期;无法识别返回 null + */ + private static String normalizeDate(String text, String key) { + List dates = matchDates(text); + if (!dates.isEmpty()) { + return key.endsWith("Start") ? dates.get(0) : dates.get(dates.size() - 1); + } + String compact = text.replaceAll("[^0-9]", ""); + if (compact.length() == 8) { + return formatDate(compact); + } + return null; + } + + /** + * 取出文本里所有日期(按出现顺序) + * + * @param text 识别文本 + * @return 归一化后的日期列表 + */ + private static List matchDates(String text) { + List dates = new ArrayList<>(); + Matcher matcher = DATE_PATTERN.matcher(text); + while (matcher.find()) { + dates.add(String.format("%s-%02d-%02d", matcher.group(1), + Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)))); + } + return dates; + } + + private static String formatDate(String digits) { + return digits.substring(0, 4) + "-" + digits.substring(4, 6) + "-" + digits.substring(6, 8); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java new file mode 100644 index 00000000..83afbf81 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java @@ -0,0 +1,148 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import java.util.Locale; +import java.util.Map; + +/** + * 证件类型映射:小程序命名 → 百度 OCR 证件类型 + *

+ * ⚠️ 两侧命名对「行驶证」和「驾驶证」的用词正好相反,是本类存在的主要理由: + * + * + * + * + * + *
命名对照
证件小程序传后端 OCR 用
驾驶证{@code driver}{@code driving_license}
行驶证{@code vehicle}{@code vehicle_license}
+ * 若把 {@code vehicle} 直接透传给百度按 {@code driving_license} 处理,或反之, + * 调用方不会收到任何错误——拿到的是另一种证件的字段,问题只在人工核对时才发现。 + * 故此处用显式白名单逐一对应,而不是把请求参数直接丢给下游。 + *

+ * 采用白名单而非「透传 + 兜底为通用识别」还有一个原因: + * 从业资格证没有对应的百度接口,静默降级成通用文字识别会让前端以为识别成功。 + * + * @author Chill + */ +public final class OcrTypeMapping { + + /** + * 允许的证件类型:小程序命名与后端既有命名都收,其余一律拒绝 + */ + private static final Map TYPE_ALIASES = Map.ofEntries( + // 身份证 + Map.entry("IDCARD", BaiduOcrType.ID_CARD), + Map.entry("ID_CARD", BaiduOcrType.ID_CARD), + // 驾驶证:小程序叫 driver + Map.entry("DRIVER", BaiduOcrType.DRIVING_LICENSE), + Map.entry("DRIVING_LICENSE", BaiduOcrType.DRIVING_LICENSE), + // 行驶证:小程序叫 vehicle + Map.entry("VEHICLE", BaiduOcrType.VEHICLE_LICENSE), + Map.entry("VEHICLE_LICENSE", BaiduOcrType.VEHICLE_LICENSE), + // 道路运输证 + Map.entry("ROAD_TRANSPORT", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE), + Map.entry("ROAD_TRANSPORT_CERTIFICATE", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE) + ); + + /** + * 明确知道「没有对应 OCR 能力」的类型,单独给出可读提示 + */ + private static final Map KNOWN_UNSUPPORTED = Map.of( + "QUALIFICATION", "从业资格证" + ); + + private static final String SIDE_FRONT = "front"; + private static final String SIDE_BACK = "back"; + + private OcrTypeMapping() { + } + + /** + * 把调用方传来的证件类型转成百度 OCR 证件类型 + * + * @param type 证件类型:小程序传 idcard / driver / vehicle / road_transport / qualification, + * 也接受后端既有命名 id_card / driving_license / vehicle_license / road_transport_certificate + * @return 百度 OCR 证件类型 + * @throws IllegalArgumentException 类型为空、未知,或该证件不支持 OCR + */ + public static BaiduOcrType toBaiduType(String type) { + String normalized = normalize(type); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("OCR证件类型不能为空"); + } + BaiduOcrType baiduOcrType = TYPE_ALIASES.get(normalized); + if (baiduOcrType != null) { + return baiduOcrType; + } + String unsupportedName = KNOWN_UNSUPPORTED.get(normalized); + if (unsupportedName != null) { + // 不降级为通用识别:否则前端会拿到一堆通用文本却以为识别成功 + throw new IllegalArgumentException(unsupportedName + "暂不支持自动识别,请手动填写"); + } + throw new IllegalArgumentException("不支持的OCR证件类型:" + type); + } + + /** + * 规范化正反面参数 + *

+ * 小程序不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。 + *

+ * ⚠️ 调用方**必须原样把返回值交给下游,不要在传参前自行补默认值**: + * 这里的「默认正面」只对**支持正副面**的证件成立。道路运输证没有正副面参数, + * 若在调用方先把空值填成 {@code front} 再传下去,下游会看到「传了 side 但该证件 + * 不支持」而直接报错——本方法返回 null 才是「调用方没指定」,下游据此才分得清 + * 「默认取正面」和「这个证件根本不该带这个参数」。 + * + * @param side 正反面:front / back + * @return 规范化结果 front / back;调用方未指定时返回 null + */ + public static String normalizeSide(String side) { + String normalized = normalize(side); + if (normalized.isEmpty()) { + return null; + } + return SIDE_BACK.equals(normalized.toLowerCase(Locale.ROOT)) ? SIDE_BACK : SIDE_FRONT; + } + + /** + * 归一化:去首尾空白、去分隔符差异、统一大写 + * + * @param value 原始值 + * @return 归一化结果;空值返回空串 + */ + private static String normalize(String value) { + if (value == null) { + return ""; + } + return value.trim() + .replace('-', '_') + .replace(' ', '_') + .toUpperCase(Locale.ROOT); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java index 1a6082ee..f5b9b143 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java @@ -25,12 +25,15 @@ package org.springblade.transport.support; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.constant.RoleConstant; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; import org.springblade.system.pojo.entity.Dept; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.Collections; +import java.util.List; import java.util.Objects; import java.util.regex.Pattern; @@ -55,6 +58,24 @@ public final class TransportBusinessSupport { private TransportBusinessSupport() { } + /** + * 是否超级管理员:当前用户任一角色别名等于 administrator 即为超级管理员。 + * + * @return true-超级管理员 + */ + public static boolean isAdministrator() { + String userRole = AuthUtil.getUserRole(); + if (Func.isBlank(userRole)) { + return false; + } + for (String roleAlias : Func.toStrArray(userRole)) { + if (RoleConstant.ADMINISTRATOR.equals(Func.toStr(roleAlias).trim())) { + return true; + } + } + return false; + } + public static Long currentDeptId(String moduleName) { Long deptId = Func.firstLong(AuthUtil.getDeptId()); if (Func.isEmpty(deptId) || deptId <= 0) { @@ -78,11 +99,40 @@ public final class TransportBusinessSupport { } public static void validateAllDept(Integer allDept, String moduleName) { - if (Objects.equals(allDept, 1) && !AuthUtil.isAdministrator()) { + if (Objects.equals(allDept, 1) && !isAdministrator()) { throw new ServiceException("无权查看全部组织" + moduleName); } } + /** + * 解析列表组织筛选 ID。 + *

+ * 普通用户 allDept!=1 时强制当前组织;超管默认不按组织过滤,仅在 allDept=1 且传入 deptId 时精确匹配该组织。 + * + * @param allDept 是否查看全部组织 + * @param deptId 筛选组织 ID + * @param moduleName 模块名称 + * @return 需要过滤的组织 ID 集合;空集合表示不过滤组织 + */ + public static List resolveDeptFilterIds(Integer allDept, Long deptId, String moduleName) { + validateAllDept(allDept, moduleName); + if (isAdministrator()) { + if (Objects.equals(allDept, 1) && Func.isNotEmpty(deptId) && deptId > 0) { + return Collections.singletonList(deptId); + } + return Collections.emptyList(); + } + if (!Objects.equals(allDept, 1)) { + return Collections.singletonList(currentDeptId(moduleName)); + } + // Jackson 空值可能被反序列化为 -1,这里与空值同等对待,避免误过滤。 + if (Func.isEmpty(deptId) || deptId <= 0) { + return Collections.emptyList(); + } + // 列表组织筛选项为单选,按所选组织精确过滤,不自动展开下级。 + return Collections.singletonList(deptId); + } + public static String trimToEmpty(String value) { return value == null ? "" : value.trim(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/VehiclePlateSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/VehiclePlateSupport.java new file mode 100644 index 00000000..30a3b8c0 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/VehiclePlateSupport.java @@ -0,0 +1,207 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.TransportVehicle; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 司机档案与车辆表之间的车牌软关联支持类 + *

+ * 司机「驾驶车辆」与车辆表「车牌号」之间**没有外键**,只有文本关联, + * 且库中实际存在写法差异:间隔符(·、•、.)、横线、空格、大小写不一致, + * 以及一个字段里用逗号/顿号/分号/斜杠/竖线并列多辆车。 + * 认证提交时的「只能操作本人绑定车辆」校验依赖本类,因此匹配规则集中在此处维护, + * 避免各业务各自实现一套导致口径分叉。 + * + * @author Chill + */ +public final class VehiclePlateSupport { + + /** + * 并列车牌的常见分隔符(中英文顿号、逗号、分号、斜杠、竖线) + */ + private static final String SEPARATOR_PATTERN = "[,,、;;/|]"; + + /** + * 车牌中需要剔除的排版字符(各类空白、横线与间隔符) + */ + private static final String NOISE_PATTERN = "[\\s\\-·•..]"; + + private VehiclePlateSupport() { + } + + /** + * 把「驾驶车辆」字段拆成规范化后的车牌列表(已去重、保持原顺序) + * + * @param drivingVehicle 司机档案里的驾驶车辆字段,可能并列多辆 + * @return 规范化车牌列表;空值返回空列表 + */ + public static List splitPlates(String drivingVehicle) { + if (Func.isEmpty(drivingVehicle)) { + return new ArrayList<>(); + } + String normalized = drivingVehicle.replaceAll(SEPARATOR_PATTERN, ","); + Set plates = new LinkedHashSet<>(); + for (String part : Func.toStrList(",", normalized)) { + String plate = normalize(part); + if (Func.isNotEmpty(plate)) { + plates.add(plate); + } + } + return new ArrayList<>(plates); + } + + /** + * 车牌规范化:去掉排版字符并转大写,使不同写法能比对 + * + * @param plateNo 原始车牌 + * @return 规范化结果;空值返回空串 + */ + public static String normalize(String plateNo) { + if (Func.isEmpty(plateNo)) { + return ""; + } + return plateNo.trim().replaceAll(NOISE_PATTERN, "").toUpperCase(Locale.ROOT); + } + + /** + * 判定某车牌是否属于该司机绑定的车辆 + *

+ * 空值一律判为「不绑定」——不能因为双方都为空就放行,那等于越权。 + * + * @param drivingVehicle 司机档案里的驾驶车辆字段 + * @param plateNo 待判定的车牌 + * @return 是否绑定 + */ + public static boolean isBound(String drivingVehicle, String plateNo) { + String target = normalize(plateNo); + if (Func.isEmpty(target)) { + return false; + } + return splitPlates(drivingVehicle).contains(target); + } + + /** + * 一个司机最多能绑定的车辆数 + *

+ * 与小程序端「一个司机最多添加 5 辆」的上限一致。后端此前完全没有这个约束, + * 而 {@code driving_vehicle} 只有 varchar(30)——5 个车牌连分隔符要 39 字符, + * 第 4 辆起就会写不进去或被静默截断(截断后剩下半个车牌,还会被当成一辆车)。 + */ + public static final int MAX_BOUND_VEHICLES = 5; + + /** + * 车牌格式:中文省份简称 + 字母 + 5~6 位(含挂/学/警/港/澳等特殊字) + *

+ * 与车辆建档时的车牌校验共用同一条规则:能绑到司机名下的车牌, + * 必须先能作为一辆车建档,否则两处口径分叉,会出现「绑得上但建不了档」。 + * 传入值必须是 {@link #normalize} 之后的形态(已去排版字符并转大写)。 + */ + private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$"); + + /** + * 车牌格式是否合法 + * + * @param plateNo 车牌(会先做规范化) + * @return 是否合法 + */ + public static boolean isValidPlateNo(String plateNo) { + String normalized = normalize(plateNo); + return Func.isNotEmpty(normalized) && PLATE_NO_PATTERN.matcher(normalized).matches(); + } + + /** + * 校验「驾驶车辆」字段:车辆数上限与逐个车牌的格式 + *

+ * 规则只在这里维护一处——小程序新增车辆、管理端新增/编辑司机都调它。 + * 此前管理端把这个字段当作**单个**车牌校验(拿整串去车牌表里精确查), + * 而小程序写入的是逗号并列的多辆车,两边口径本就不一致: + * 一个绑了两辆车的司机,管理员在司机管理页一保存就会撞上「驾驶车辆不存在」。 + * + * @param drivingVehicle 司机档案里的驾驶车辆字段,可并列多辆 + * @throws ServiceException 车辆数超过上限,或存在格式非法的车牌 + */ + public static void validateBoundVehicles(String drivingVehicle) { + List plates = splitPlates(drivingVehicle); + if (plates.size() > MAX_BOUND_VEHICLES) { + throw new ServiceException("一个司机最多绑定" + MAX_BOUND_VEHICLES + "辆车"); + } + for (String plate : plates) { + if (!PLATE_NO_PATTERN.matcher(plate).matches()) { + throw new ServiceException("车牌号格式不正确:" + plate); + } + } + } + + /** + * 按车牌匹配车辆的 SQL 条件:精确匹配 **或** 规范化后匹配 + *

+ * ⚠️ 只做精确匹配是不够的。{@link #splitPlates} 返回的已是**规范化**车牌 + * (去空格/间隔符/横线并转大写),若直接拿它与 {@code plate_no} 原始列做 IN, + * 库中任何带间隔符或大小写差异的车牌都会**匹配不到**。 + * 后果不是「少查一条」,而是调用方据此判定「该司机名下没有可用车辆」—— + * 认证门槛会因此把**已认证的司机误判成未认证并拒绝接单**。 + *

+ * 库中已知存在这类写法差异({@code DriverAppServiceImpl.myVehicles()} 早就为此加了 + * 同样的兜底),故此处把规则收敛成一处,避免各调用点各自实现、各自漏掉。 + * + * @param wrapper 查询条件 + * @param normalizedPlates 已规范化的车牌列表 + */ + public static void applyPlateMatch(LambdaQueryWrapper wrapper, + List normalizedPlates) { + if (normalizedPlates == null || normalizedPlates.isEmpty()) { + return; + } + wrapper.and(w -> { + w.in(TransportVehicle::getPlateNo, normalizedPlates); + for (String plate : normalizedPlates) { + w.or().apply(NORMALIZED_PLATE_SQL + " = {0}", plate); + } + }); + } + + /** + * 与 {@link #normalize} 等价的 SQL 表达式(列名固定为 {@code plate_no}) + *

+ * 两者必须保持一致:Java 侧剔除的字符集变了,这里也要跟着变。 + * {@code VehiclePlateSupportTest} 会断言两边覆盖同一批字符,防止悄悄分叉。 + */ + public static final String NORMALIZED_PLATE_SQL = + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no)" + + ",'·',''),'•',''),'.',''),'.',''),'-',''),' ','')"; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 88140021..8de4c8b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -32,10 +32,14 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * 运单过程配置解析(对齐 web 端 waybill-manage / process-config) @@ -49,8 +53,18 @@ public final class WaybillProcessSupport { public static final String ACCEPT_REJECTED = "rejected"; private static final String CONFIRM_YES = "yes"; private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept"; + private static final String CONFIRM_NO_COMPLETE = "no_confirm_complete"; private static final String NODE_TRANSIT = "transit"; private static final String NODE_TRANSIT_NAME = "在途"; + /** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */ + private static final String DISPOSAL_COMPLETED = "completed"; + private static final String DISPOSAL_REVOKED = "revoked"; + /** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */ + public static final List SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED); + /** 每个打卡节点必有的照片类型,不由过程配置的凭证类型决定 */ + public static final String PUNCH_PHOTO_TYPE = "打卡照片"; + /** 单个照片类型的张数上限;打卡照片与每个凭证类型各自独立计数,不是节点合计 */ + public static final int MAX_PHOTOS_PER_TYPE = 9; private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm"); private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm"); @@ -231,6 +245,21 @@ public final class WaybillProcessSupport { return result; } + /** + * 本次打卡的节点是否是司机可打卡节点的末位。 + *

+ * 末位取「司机可打卡节点」序列:接单与回单不打卡,不参与排位—— + * 回单只要启用就必然排在配置最末,按全部启用节点取末位会永远取到回单。 + */ + public static boolean isLastDriverPunchNode(String processJson, String nodeCode) { + List> nodes = listDriverPunchNodes(processJson); + if (nodes.isEmpty()) { + return false; + } + String code = stringVal(nodeCode); + return !code.isEmpty() && code.equals(nodeKey(nodes.get(nodes.size() - 1))); + } + /** * 启用中的过程节点(按配置顺序,含接单/回单)。 */ @@ -285,14 +314,38 @@ public final class WaybillProcessSupport { return isTruthy(node.get("location")); } + /** + * 是否需要采集货量:勾了「上传货量」且至少勾了一个货量类型。 + *

+ * 以已勾选的类型为准——类型为空即不采集,不存在「没配类型所以重量/体积/数量全采集」。 + * 过程配置页对「到场」「回单」这类不采集货量的节点不提供「上传货量」开关, + * 这些节点曾被默认写成 uploadCargo=true 且 cargoTypes 为空,只看 uploadCargo 会误判为需要采集。 + */ public static boolean nodeNeedCargo(Map node) { - return isTruthy(node.get("uploadCargo")); + return isTruthy(node.get("uploadCargo")) && !nodeStringList(node, "cargoTypes").isEmpty(); } public static boolean nodeNeedVoucher(Map node) { return isTruthy(node.get("uploadVoucher")); } + /** + * 该节点的照片类型取值域:固定的「打卡照片」加过程配置勾选的凭证类型。 + *

+ * 打卡照片是每个打卡节点必有的照片类型,不由过程配置决定,与各凭证类型并列且互不替代。 + * 类型是照片分组的唯一依据,提交上来的类型必须落在这个取值域内,否则回显分组会错乱。 + */ + public static List nodePhotoTypes(Map node) { + List types = new ArrayList<>(); + types.add(PUNCH_PHOTO_TYPE); + for (String voucherType : nodeStringList(node, "voucherTypes")) { + if (!types.contains(voucherType)) { + types.add(voucherType); + } + } + return types; + } + @SuppressWarnings("unchecked") public static List nodeStringList(Map node, String field) { Object raw = node.get(field); @@ -327,6 +380,176 @@ public final class WaybillProcessSupport { return Func.isEmpty(name) ? nodeKey(node) : name; } + /** + * 指定节点因「顺序门禁」而不能打卡时,返回它前面尚未打卡的打卡节点 key;可以打卡时返回空集。 + *

+ * 「前面」按过程配置里打卡节点的排列计算;接单、回单不参与打卡,故不作为前置 + * (否则第一个打卡节点永远等不到前置打卡记录)。 + *

+ * **在途豁免顺序门禁**:它只回答「此刻在哪」,不表示某个环节完成; + * 因前置未打卡就禁止报位置,会让运管员在最需要知道车在哪的时候失去可见性。 + * + * @param processJson 过程配置 + * @param punchedNodeKeys 该运单已打卡的节点 key + * @param nodeCode 待判定的节点 key + */ + public static Set unfinishedPrerequisiteKeys( + String processJson, Collection punchedNodeKeys, String nodeCode) { + if (Func.isEmpty(nodeCode)) { + return Collections.emptySet(); + } + Set punched = punchedNodeKeys == null + ? Collections.emptySet() : new HashSet<>(punchedNodeKeys); + Set unfinished = new LinkedHashSet<>(); + for (Map node : listDriverPunchNodes(processJson)) { + // 在途不参与顺序:既不作为别人的前置,也不被别人拦 + if (isTransitNode(node)) { + continue; + } + String key = nodeKey(node); + if (nodeCode.equals(key)) { + // 走到自己:只关心它前面那些 + return unfinished; + } + if (!punched.contains(key)) { + unfinished.add(key); + } + } + // 节点不在本配置里:不做顺序判定,交给节点存在性校验去报错 + return Collections.emptySet(); + } + + /** + * 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。 + *

+ * 空与空不算相等——节点编码为空表示整单,它不归属任何过程节点。 + */ + public static boolean sameNodeCode(String left, String right) { + String leftCode = stringVal(left); + String rightCode = stringVal(right); + return !leftCode.isEmpty() && leftCode.equalsIgnoreCase(rightCode); + } + + /** + * 该异常是否为整单异常:异常不归属任何过程节点时,节点编码为空。 + */ + public static boolean isWaybillLevelException(String exceptionNodeCode) { + return stringVal(exceptionNodeCode).isEmpty(); + } + + /** + * 该异常是否与某节点相关。 + *

+ * 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。 + * 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。 + */ + public static boolean relatesToNode(String nodeKey, String exceptionNodeCode) { + return isWaybillLevelException(exceptionNodeCode) || sameNodeCode(nodeKey, exceptionNodeCode); + } + + /** + * 过程配置里的在途节点 key;配置中无在途节点时返回空串。 + */ + public static String transitNodeKey(String processJson) { + for (Map node : listDriverPunchNodes(processJson)) { + if (isTransitNode(node)) { + return nodeKey(node); + } + } + return ""; + } + + /** + * 计算被异常门禁阻断的过程节点 key。 + *

+ * 普通打卡节点按区间阻断:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。 + * 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。 + *

+ * **在途节点按运单级阻断**:该运单只要存在任何未了结异常,在途就不可打卡, + * 与异常落在哪个节点无关。在途只回答「此刻在哪」,不表示某个环节完成, + * 区间对它没有意义——按区间算的话,异常报在「装货」(在途之前)会锁住在途, + * 报在「到货」(在途之后)反而放行,等于越接近目的地越能报在途。 + *

+ * 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。 + * 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。 + * + * @param processJson 过程配置 + * @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常 + */ + public static Set blockedNodeKeys(String processJson, Collection unresolvedNodeCodes) { + Set blocked = new LinkedHashSet<>( + blockedNodeKeysFrom(processJson, earliestBlockingNodeCode(processJson, unresolvedNodeCodes))); + if (unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return blocked; + } + String transitKey = transitNodeKey(processJson); + if (Func.isNotEmpty(transitKey)) { + blocked.add(transitKey); + } + return blocked; + } + + /** + * 取「最早的可打卡节点」上的未了结异常节点 key;未指明节点的异常(空串)优先。 + *

+ * 返回值供按 key 反查具体异常用;无异常可阻断时返回 null。 + */ + public static String earliestBlockingNodeCode(String processJson, Collection unresolvedNodeCodes) { + List> punchNodes = listDriverPunchNodes(processJson); + if (punchNodes.isEmpty() || unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return null; + } + Set reported = new HashSet<>(); + for (String code : unresolvedNodeCodes) { + String key = stringVal(code); + // 未指明节点的异常锁全单,优先级高于任何具体节点 + if (Func.isEmpty(key)) { + return ""; + } + reported.add(key); + } + for (Map node : punchNodes) { + String key = nodeKey(node); + if (reported.contains(key)) { + return key; + } + } + return null; + } + + /** + * 由某个起点节点计算阻断区间:该节点及其后所有节点。起点为 null 时返回空集。 + */ + public static Set blockedNodeKeysFrom(String processJson, String fromNodeCode) { + List> punchNodes = listDriverPunchNodes(processJson); + Set blocked = new LinkedHashSet<>(); + if (punchNodes.isEmpty() || fromNodeCode == null) { + return blocked; + } + // 空串代表「未指明节点」——全局入口(首页快捷格、打卡页底部按钮)不指定环节, + // 这类异常无从定位区间,只能阻断全部可打卡节点。 + if (Func.isEmpty(fromNodeCode)) { + for (Map node : punchNodes) { + blocked.add(nodeKey(node)); + } + return blocked; + } + int start = -1; + for (int i = 0; i < punchNodes.size(); i++) { + if (fromNodeCode.equals(nodeKey(punchNodes.get(i)))) { + start = i; + break; + } + } + if (start < 0) { + return blocked; + } + for (int i = start; i < punchNodes.size(); i++) { + blocked.add(nodeKey(punchNodes.get(i))); + } + return blocked; + } + @SuppressWarnings("unchecked") public static List> parseProcessNodes(String processJson) { if (Func.isEmpty(processJson)) { @@ -370,10 +593,48 @@ public final class WaybillProcessSupport { return "return".equals(key) || "回单".equals(name); } + /** + * 是否接单节点:key=accept 或 name=接单。 + *

+ * 管理端打卡时间轴与补录校验共用它——接单节点的完成由运单的接单事实决定, + * 不落打卡流水,两处的判据必须是同一个。 + */ + public static boolean isAcceptNode(String nodeCode, String nodeName) { + return "accept".equals(nodeCode) || "接单".equals(nodeName); + } + private static boolean isAcceptNode(Map node) { - String key = stringVal(node.get("key")); - String name = stringVal(node.get("name")); - return "accept".equals(key) || "接单".equals(name); + return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name"))); + } + + /** + * 是否到货 / 卸货 / 签收节点:key 或中文名任一命中即算。 + */ + public static boolean isDeliveryNode(String nodeCode, String nodeName) { + String key = stringVal(nodeCode); + String name = stringVal(nodeName); + return "arrival".equals(key) || "到货".equals(name) + || "unload".equals(key) || "卸货".equals(name) + || "sign".equals(key) || "签收".equals(name); + } + + /** + * 回单是否无需确认完成:配置里没有回单节点、回单未启用,或启用且配置为「无需确认完成」。 + *

+ * 未启用的回单节点不看 confirmMode——没有回单环节就不会有人来确认; + * 启用但 confirmMode 缺失按「需确认完成」算。 + */ + public static boolean returnNeedsNoConfirmation(String processJson) { + for (Map node : parseProcessNodes(processJson)) { + if (!isReturnNode(node)) { + continue; + } + if (!isEnabled(node)) { + return true; + } + return CONFIRM_NO_COMPLETE.equals(stringVal(node.get("confirmMode"))); + } + return true; } private static boolean isTransitNode(Map node) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java index 6d4162d4..02127d66 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java @@ -27,6 +27,7 @@ package org.springblade.transport.task; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; import org.springblade.transport.service.ICustomerArchiveService; import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.context.config.annotation.RefreshScope; @@ -53,15 +54,12 @@ import org.springframework.stereotype.Component; public class CustomerKingdeeSyncTask { private final ICustomerArchiveService customerArchiveService; - - /** 客商金蝶数据同步开关,默认关闭 */ - @Value("${thirdParty.bfi.customer.syncEnabled:false}") - private boolean syncEnabled; + private final BfiProperties bfiProperties; /** 客商金蝶数据同步调度,默认每天 02:00 执行 */ @Scheduled(cron = "${thirdParty.bfi.customer.cron:0 0 2 * * ?}") public void syncKingdeeCustomerData() { - if (!syncEnabled) { + if (!bfiProperties.getCustomer().isSyncEnabled()) { return; } try { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java new file mode 100644 index 00000000..91c6ed0f --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java @@ -0,0 +1,66 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

+ * 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 the License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 收票管理金蝶全票池发票定时同步任务(瘦身版) + *

+ * 定时按增量窗口分页拉取金蝶发票云全票池发票数据(A801-1,经 BFI 网关 rim 契约透传), + * 镜像表 blade_kingdee_invoice_pool 全列批量 upsert,供选票统一查本地(真分页/名称模糊/认领登记过滤)。 + * 业务逻辑收敛在 {@link IInvoiceReceiptService#syncKingdeeInvoicePool()},任务类只做开关与调度,便于手动触发联调。 + *

+ * 调度配置(Nacos thirdParty.bfi.invoiceReceipt.*):syncEnabled 开关、cron 表达式、incrementDays 增量窗口。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class InvoiceReceiptKingdeeSyncTask { + + private final IInvoiceReceiptService invoiceReceiptService; + private final BfiProperties bfiProperties; + + /** 金蝶全票池同步调度,默认每30分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.invoiceReceipt.cron:0 16/30 * * * ?}") + public void syncKingdeeInvoicePool() { + if (!bfiProperties.getInvoiceReceipt().isSyncEnabled()) { + return; + } + try { + String summary = invoiceReceiptService.syncKingdeeInvoicePool(); + log.info("定时同步金蝶全票池发票:{}", summary); + } catch (Exception exception) { + log.error("定时同步金蝶全票池发票异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeAutoSyncRetryTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeAutoSyncRetryTask.java new file mode 100644 index 00000000..2ea845b7 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeAutoSyncRetryTask.java @@ -0,0 +1,92 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springblade.transport.service.IPaymentApplicationService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 金蝶自动同步失败定时重推任务(兜底):仅重推各域 failed 单据,不扫描 unsynced—— + * 存量"审批通过但从未推送"的历史单不自动补推(避免上线即批量推历史数据),仍走手动按钮。 + *

+ * 前置未就绪(如上游应付单未生成)→ 预检拦截 → 再次 failed → 下轮继续; + * 前置就绪 → 自动续推成功(节点硬顺序由既有 A102-4/A302-4/A303-4/A801-3 预检保证,不做级联)。 + *

+ * ponytail: 重推无次数上限,永久性坏数据(如联行号未维护)会每轮失败一次; + * 噪音可接受后,再加 retry_count 上限列。 + *

+ * 调度配置(Nacos thirdParty.bfi.autoSync.*):retryEnabled 开关、retryCron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeAutoSyncRetryTask { + + private final IPaymentApplicationService paymentApplicationService; + private final IFormalSettlementService formalSettlementService; + private final IInvoiceApplicationService invoiceApplicationService; + private final IInvoiceReceiptService invoiceReceiptService; + private final BfiProperties bfiProperties; + + /** failed 定时重推调度,默认每10分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.autoSync.retryCron:0 5/10 * * * ?}") + public void retryKingdeeFailed() { + if (!bfiProperties.getAutoSync().isRetryEnabled()) { + return; + } + // ① 结算单(A102-2/A302-2) + try { + String summary = formalSettlementService.retryKingdeeFailed(); + log.info("定时重推金蝶生成失败结算单:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶生成失败结算单异常", exception); + } + // ② 付款申请(A103-1/A103-3) + try { + String summary = paymentApplicationService.retryKingdeeFailed(); + log.info("定时重推金蝶生成失败付款申请:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶生成失败付款申请异常", exception); + } + // ③ 开票申请(A303-1) + try { + String summary = invoiceApplicationService.retryKingdeeFailed(); + log.info("定时重推金蝶生成失败开票申请:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶生成失败开票申请异常", exception); + } + // ④ 收票登记(A303-2+A302-6) + try { + String summary = invoiceReceiptService.retryKingdeeFailed(); + log.info("定时重推金蝶推送失败收票登记:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶推送失败收票登记异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeBillLockRetryTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeBillLockRetryTask.java new file mode 100644 index 00000000..d306ed92 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeBillLockRetryTask.java @@ -0,0 +1,50 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.service.IKingdeeBillLockService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 金蝶单据锁定补偿定时任务 + *

+ * 扫描锁定失败的锁记录(lock_status=failed 且未超重试上限)重调 lockBusiData, + * 补偿推送成功但锁单失败的单据,不依赖人工重推「同步金蝶」。 + * 业务逻辑收敛在 {@link IKingdeeBillLockService#retryFailedLocks()},任务类只做开关与调度(模式同 PaymentKingdeeResultTask)。 + *

+ * 调度配置(Nacos thirdParty.bfi.billLock.*):retryEnabled 开关、retryCron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeBillLockRetryTask { + + private final IKingdeeBillLockService kingdeeBillLockService; + private final BfiProperties bfiProperties; + + /** 锁定补偿调度,默认每5分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.billLock.retryCron:0 2/5 * * * ?}") + public void retryFailedBillLocks() { + if (!bfiProperties.getBillLock().isRetryEnabled()) { + return; + } + try { + String summary = kingdeeBillLockService.retryFailedLocks(); + log.info("定时补偿金蝶单据锁定:{}", summary); + } catch (Exception exception) { + log.error("定时补偿金蝶单据锁定异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java index 8f4edbce..e5e4fb87 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java @@ -21,6 +21,7 @@ package org.springblade.transport.task; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IInvoiceApplicationService; import org.springblade.transport.service.IInvoiceReceiptService; @@ -56,15 +57,12 @@ public class PaymentKingdeeResultTask { private final IFormalSettlementService formalSettlementService; private final IInvoiceApplicationService invoiceApplicationService; private final IInvoiceReceiptService invoiceReceiptService; - - /** 付款结果回写开关,默认开启 */ - @Value("${thirdParty.bfi.payResult.syncEnabled:false}") - private boolean syncEnabled; + private final BfiProperties bfiProperties; /** 金蝶结果回写调度,默认每10分钟执行一次 */ - @Scheduled(cron = "${thirdParty.bfi.payResult.cron:0 */10 * * * ?}") + @Scheduled(cron = "${thirdParty.bfi.payResult.cron:0 1/10 * * * ?}") public void syncKingdeePaymentResult() { - if (!syncEnabled) { + if (!bfiProperties.getPayResult().isSyncEnabled()) { return; } // ① A103-2/A103-4 付款结果回写 @@ -90,14 +88,10 @@ public class PaymentKingdeeResultTask { } } - /** 金蝶收票单结果回写开关(A801-3),默认关闭(联调稳定后开启) */ - @Value("${thirdParty.bfi.receivedInvoiceResult.syncEnabled:false}") - private boolean receivedInvoiceResultEnabled; - /** 金蝶收票单结果回写调度(异常回收/指定补偿/单号补登),默认每10分钟执行一次 */ - @Scheduled(cron = "${thirdParty.bfi.receivedInvoiceResult.cron:0 */10 * * * ?}") + @Scheduled(cron = "${thirdParty.bfi.receivedInvoiceResult.cron:0 8/10 * * * ?}") public void syncKingdeeReceivedInvoiceResult() { - if (!receivedInvoiceResultEnabled) { + if (!bfiProperties.getReceivedInvoiceResult().isSyncEnabled()) { return; } try { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java new file mode 100644 index 00000000..ec681cf7 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java @@ -0,0 +1,67 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

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

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

+ * 调度配置(Nacos thirdParty.bfi.receiptClaimPush.*):syncEnabled 开关、cron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ReceiptClaimPushTask { + + private final IReceiptFlowService receiptFlowService; + private final BfiProperties bfiProperties; + + /** 收款认领推送调度,默认每10分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.receiptClaimPush.cron:0 4/10 * * * ?}") + public void pushClaimToKingdee() { + if (!bfiProperties.getReceiptClaimPush().isSyncEnabled()) { + return; + } + try { + String summary = receiptFlowService.pushClaimByTask(); + log.info("定时推送收款认领:{}", summary); + } catch (Exception exception) { + log.error("定时推送收款认领异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/AccidentRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/AccidentRecordWrapper.java index 19e476f3..002d23fb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/AccidentRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/AccidentRecordWrapper.java @@ -31,6 +31,7 @@ import org.springblade.system.cache.UserCache; import org.springblade.transport.pojo.entity.AccidentRecord; import org.springblade.transport.pojo.vo.AccidentRecordVO; +import java.math.BigDecimal; import java.util.Objects; /** @@ -40,6 +41,8 @@ import java.util.Objects; */ public class AccidentRecordWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_AMOUNT_SENTINEL = BigDecimal.valueOf(-1); + public static AccidentRecordWrapper build() { return new AccidentRecordWrapper(); } @@ -47,8 +50,16 @@ public class AccidentRecordWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_AMOUNT_SENTINEL = BigDecimal.valueOf(-1); + public static AnnualInspectionRecordWrapper build() { return new AnnualInspectionRecordWrapper(); } @@ -47,8 +50,15 @@ public class AnnualInspectionRecordWrapper extends BaseEntityWrapper { @Override public DriverVO entityVO(Driver driver) { - return Objects.requireNonNull(BeanUtil.copyProperties(driver, DriverVO.class)); + DriverVO vo = Objects.requireNonNull(BeanUtil.copyProperties(driver, DriverVO.class)); + vo.setCreateUserName(UserCache.getUserRealName(driver.getCreateUser())); + return vo; } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/EtcRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/EtcRecordWrapper.java index ba05721d..1492bcbb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/EtcRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/EtcRecordWrapper.java @@ -28,6 +28,7 @@ public class EtcRecordWrapper extends BaseEntityWrapper @Override public EtcRecordVO entityVO(EtcRecord etcRecord) { EtcRecordVO etcRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(etcRecord, EtcRecordVO.class)); + etcRecordVO.setCreateUserName(UserCache.getUserRealName(etcRecord.getCreateUser())); etcRecordVO.setUpdateUserName(UserCache.getUserRealName(etcRecord.getUpdateUser())); return etcRecordVO; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InsuranceRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InsuranceRecordWrapper.java index 63f97dad..2e667dbd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InsuranceRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InsuranceRecordWrapper.java @@ -31,6 +31,7 @@ import org.springblade.system.cache.UserCache; import org.springblade.transport.pojo.entity.InsuranceRecord; import org.springblade.transport.pojo.vo.InsuranceRecordVO; +import java.math.BigDecimal; import java.util.Objects; /** @@ -40,6 +41,8 @@ import java.util.Objects; */ public class InsuranceRecordWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_AMOUNT_SENTINEL = BigDecimal.valueOf(-1); + public static InsuranceRecordWrapper build() { return new InsuranceRecordWrapper(); } @@ -47,8 +50,16 @@ public class InsuranceRecordWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_MILEAGE_SENTINEL = BigDecimal.valueOf(-1); + public static MaintenancePlanWrapper build() { return new MaintenancePlanWrapper(); } @@ -47,8 +50,16 @@ public class MaintenancePlanWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_MILEAGE_SENTINEL = BigDecimal.valueOf(-1); + public static MileageRecordWrapper build() { return new MileageRecordWrapper(); } @@ -47,8 +50,18 @@ public class MileageRecordWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_AMOUNT_SENTINEL = BigDecimal.valueOf(-1); + public static OilElectricRecordWrapper build() { return new OilElectricRecordWrapper(); } @@ -28,8 +31,18 @@ public class OilElectricRecordWrapper extends BaseEntityWrapper "未认领"; default -> Objects.toString(entity.getKingdeeClaimStatus(), ""); }); - BigDecimal receiptAmount = money(entity.getReceiptAmount()); + vo.setPushStatusName(switch (Objects.toString(entity.getPushStatus(), "")) { + case "pushed" -> "已推送"; + case "failed" -> "推送失败"; + default -> "未推送"; + }); + vo.setKingdeeBillStatusName(switch (Objects.toString(entity.getKingdeeBillStatus(), "")) { + case "pushed" -> "已推送"; + case "approved" -> "审核通过"; + case "received" -> "已收款"; + case "failed" -> "处理失败"; + default -> "未推送"; + }); + // 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径) + BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount()); BigDecimal claimedAmount = money(entity.getClaimedAmount()); - vo.setRemainingAmount(receiptAmount.subtract(claimedAmount).max(BigDecimal.ZERO)); + vo.setRemainingAmount(unclaimedAmount.subtract(claimedAmount).max(BigDecimal.ZERO)); vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); return vo; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TireReplacementRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TireReplacementRecordWrapper.java index 649544fa..608ae0bb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TireReplacementRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TireReplacementRecordWrapper.java @@ -31,6 +31,7 @@ import org.springblade.system.cache.UserCache; import org.springblade.transport.pojo.entity.TireReplacementRecord; import org.springblade.transport.pojo.vo.TireReplacementRecordVO; +import java.math.BigDecimal; import java.util.Objects; /** @@ -40,6 +41,9 @@ import java.util.Objects; */ public class TireReplacementRecordWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_AMOUNT_SENTINEL = BigDecimal.valueOf(-1); + private static final Integer EMPTY_INTEGER_SENTINEL = -1; + public static TireReplacementRecordWrapper build() { return new TireReplacementRecordWrapper(); } @@ -47,8 +51,20 @@ public class TireReplacementRecordWrapper extends BaseEntityWrapper + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.wrapper; + +import org.springblade.core.mp.support.BaseEntityWrapper; +import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.support.CertificationRuleSupport; + +import java.util.Objects; + +/** + * 认证记录包装类 + * + * @author Chill + */ +public class TransportCertRecordWrapper extends BaseEntityWrapper { + + public static TransportCertRecordWrapper build() { + return new TransportCertRecordWrapper(); + } + + @Override + public TransportCertRecordVO entityVO(TransportCertRecord transportCertRecord) { + TransportCertRecordVO transportCertRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(transportCertRecord, TransportCertRecordVO.class)); + return fillDisplayFields(transportCertRecordVO); + } + + /** + * 填充面向前端的展示字段:四个姓名与状态文案 + *

+ * 审核列表走的是自定义 SQL,查出来已经是 VO——它多带司机姓名与手机号两个字段, + * 这两个字段实体上没有、无法由实体转换得到,所以那条路径不能走 {@link #entityVO}。 + * 把填充逻辑单独暴露一次,避免 Service 里再复制一份翻译规则,日后两处走偏。 + * + * @param transportCertRecordVO 认证记录视图对象 + * @return 同一个对象,便于链式调用 + */ + public TransportCertRecordVO fillDisplayFields(TransportCertRecordVO transportCertRecordVO) { + // create/update/submit 可能来自小程序用户;audit 一般为后台系统用户 + transportCertRecordVO.setCreateUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getCreateUser())); + transportCertRecordVO.setUpdateUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getUpdateUser())); + transportCertRecordVO.setSubmitUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getSubmitUser())); + transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecordVO.getAuditUser())); + // 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法 + transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecordVO.getStatus())); + return transportCertRecordVO; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportChangeRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportChangeRecordWrapper.java index 2767ee7f..4c543f5e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportChangeRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportChangeRecordWrapper.java @@ -47,6 +47,7 @@ public class TransportChangeRecordWrapper extends BaseEntityWrapper { + private static final BigDecimal EMPTY_AMOUNT_SENTINEL = BigDecimal.valueOf(-1); + private static final Integer EMPTY_INTEGER_SENTINEL = -1; + public static ViolationRecordWrapper build() { return new ViolationRecordWrapper(); } @@ -47,8 +51,20 @@ public class ViolationRecordWrapper extends BaseEntityWrapper { waybill.getBusinessStatus(), waybill.getProcessJson(), waybill.getDriverAcceptStatus()); waybillVO.setBusinessStatus(displayStatus); waybillVO.setBusinessStatusName(businessStatusName(displayStatus)); + waybillVO.setTransportTypeName(transportTypeName(waybill.getTransportType())); waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); - // 未维护里程等占位值 -1 对外展示为空 + // 未维护里程/费用等占位值 -1 对外展示为空 if (isSentinelMinusOne(waybillVO.getMileage())) { waybillVO.setMileage(null); } @@ -69,6 +72,16 @@ public class WaybillWrapper extends BaseEntityWrapper { if (isSentinelMinusOne(waybillVO.getUnitPrice())) { waybillVO.setUnitPrice(null); } + if (isSentinelMinusOne(waybillVO.getOtherFeeTotal())) { + waybillVO.setOtherFeeTotal(null); + } + // 计划未选时 planId 可能为库内 -1 或序列化占位,统一清空避免前端回显 -1 + if (waybillVO.getPlanId() != null && waybillVO.getPlanId() <= 0L) { + waybillVO.setPlanId(null); + } + if (waybillVO.getPlanId() == null || "-1".equals(waybillVO.getPlanName())) { + waybillVO.setPlanName(null); + } return waybillVO; } @@ -76,6 +89,29 @@ public class WaybillWrapper extends BaseEntityWrapper { return value != null && value.compareTo(BigDecimal.valueOf(-1)) == 0; } + /** + * 运输方式按业务字典名称展示,系统内仍保存字典键。 + */ + public static String transportTypeName(String value) { + if (Func.isEmpty(value)) { + return value; + } + String name = ""; + try { + name = DictBizCache.getValue("transport_type", value); + } catch (Exception exception) { + name = ""; + } + if (Func.isEmpty(name)) { + try { + name = DictCache.getValue("transport_type", value); + } catch (Exception exception) { + name = ""; + } + } + return Func.isEmpty(name) ? value : name; + } + public static String businessStatusName(String status) { if (status == null) { return "未知"; @@ -85,9 +121,10 @@ public class WaybillWrapper extends BaseEntityWrapper { case "pending" -> "待执行"; case "waiting_dispatch" -> "待调度"; case "dispatching" -> "调度中"; - case "running" -> "进行中"; + case "running", "processing", "in_progress", "inProgress" -> "进行中"; case "completed" -> "已完成"; case "cancelled" -> "已取消"; + case "closed" -> "已关闭"; default -> "未知"; }; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java new file mode 100644 index 00000000..9b0575b1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java @@ -0,0 +1,169 @@ +/** + * 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 the 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; + +import org.junit.jupiter.api.Test; +import org.springblade.transport.support.KingdeeApBillSyncSupport; +import org.springblade.transport.support.KingdeeApBillSyncSupport.FeeLineSplit; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 金蝶应付/应收单分录拆分逻辑测试(纯函数,无环境依赖): + * 结算明细费用项按 费用大类(运输/装卸)→ 税率 拆分行,验证三个联调场景与残差勾稽 + * + * @author Chill + */ +class KingdeeEntrySplitTest { + + /** 场景1:同费用大类(运输)、多税率 → 按税率拆两行 */ + @Test + void splitFreightByTaxRate() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("2000")); + feeItems.put("附加运输补贴", new BigDecimal("1120")); + Map taxRateByRule = Map.of( + "运输费", new BigDecimal("9"), + "附加运输补贴", new BigDecimal("6")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), taxRateByRule, new BigDecimal("3120")); + assertEquals(2, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertEquals(new BigDecimal("9"), splits.get(0).getTaxRate()); + assertEquals(new BigDecimal("2000"), splits.get(0).getAmount()); + assertEquals("-9", splits.get(0).taxRateSuffix()); + assertFalse(splits.get(1).isLoading()); + assertEquals(new BigDecimal("6"), splits.get(1).getTaxRate()); + assertEquals(new BigDecimal("1120"), splits.get(1).getAmount()); + assertEquals("-6", splits.get(1).taxRateSuffix()); + } + + /** 场景2:运输费 + 装卸费 → 运输行 + 装卸行(装卸行税率独立) */ + @Test + void splitFreightAndLoading() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("3120")); + feeItems.put("装卸费", new BigDecimal("800")); + Map taxRateByRule = Map.of( + "运输费", new BigDecimal("9"), + "装卸费", new BigDecimal("6")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), taxRateByRule, new BigDecimal("3920")); + assertEquals(2, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertEquals(new BigDecimal("3120"), splits.get(0).getAmount()); + assertTrue(splits.get(1).isLoading()); + assertEquals(new BigDecimal("6"), splits.get(1).getTaxRate()); + assertEquals(new BigDecimal("800"), splits.get(1).getAmount()); + } + + /** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用类型名称含“装卸”即归装卸大类 */ + @Test + void loadingCategoryFromFeeItemArchive() { + Map feeItems = Map.of("吊装作业", new BigDecimal("500")); + Map feeCategoryByName = Map.of("吊装作业", "装卸"); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, feeCategoryByName, Map.of(), new BigDecimal("500")); + assertEquals(1, splits.size()); + assertTrue(splits.get(0).isLoading()); + } + + /** 残差勾稽:费用项JSON未含运费(历史数据)时,结算金额与费用项合计的差额并入运输类无税率组 */ + @Test + void residualGoesToFreightDefaultGroup() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("2000")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of("运输费", new BigDecimal("9")), new BigDecimal("2020")); + assertEquals(2, splits.size()); + assertEquals(new BigDecimal("2000"), splits.get(0).getAmount()); + assertFalse(splits.get(1).isLoading()); + assertNull(splits.get(1).getTaxRate()); + assertEquals(new BigDecimal("20"), splits.get(1).getAmount()); + assertEquals("", splits.get(1).taxRateSuffix()); + } + + /** 费用项JSON为空:整行结算金额归运输类无税率组单行 */ + @Test + void emptyFeeItemsFallbackToFreightLine() { + List splits = KingdeeApBillSyncSupport.splitFeeItems(Map.of(), Map.of(), Map.of(), new BigDecimal("3120")); + assertEquals(1, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertNull(splits.get(0).getTaxRate()); + assertEquals(new BigDecimal("3120"), splits.get(0).getAmount()); + } + + /** 税率未配置的费用项归入无税率组,同大类同税率费用项金额合并 */ + @Test + void mergeSameCategorySameRate() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("装卸费", new BigDecimal("500")); + feeItems.put("装卸吊装费", new BigDecimal("300")); + feeItems.put("其他费用", new BigDecimal("100")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), new BigDecimal("900")); + assertEquals(2, splits.size()); + assertTrue(splits.get(0).isLoading()); + assertEquals(new BigDecimal("800"), splits.get(0).getAmount()); + assertNull(splits.get(0).getTaxRate()); + assertEquals("装卸费", splits.get(0).getFirstFeeItem()); + assertFalse(splits.get(1).isLoading()); + assertEquals(new BigDecimal("100"), splits.get(1).getAmount()); + } + + /** 0元费用项跳过:不生成0元分录(FJS2026101300005 结算单8条分录问题的回归用例) */ + @Test + void zeroAmountFeeItemsSkipped() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("3120")); + feeItems.put("附加运输补贴", BigDecimal.ZERO); + feeItems.put("装卸费", new BigDecimal("800")); + feeItems.put("仓储服务费", BigDecimal.ZERO); + Map taxRateByRule = Map.of( + "运输费", new BigDecimal("9"), + "附加运输补贴", new BigDecimal("6"), + "装卸费", new BigDecimal("6")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), taxRateByRule, new BigDecimal("3920")); + assertEquals(2, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertEquals(new BigDecimal("3120"), splits.get(0).getAmount()); + assertTrue(splits.get(1).isLoading()); + assertEquals(new BigDecimal("800"), splits.get(1).getAmount()); + } + + /** 全部费用项为0且结算金额为0:拆分结果为空(调用方跳过该0元货物行) */ + @Test + void allZeroFeeItemsYieldEmptySplits() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", BigDecimal.ZERO); + feeItems.put("装卸费", BigDecimal.ZERO); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), BigDecimal.ZERO); + assertTrue(splits.isEmpty()); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/TestInfraSmokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/TestInfraSmokeTest.java new file mode 100644 index 00000000..27b727d4 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/TestInfraSmokeTest.java @@ -0,0 +1,17 @@ +package org.springblade.transport; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 测试基础设施自检:确认 JUnit 5 能在本模块被发现并执行。 + */ +class TestInfraSmokeTest { + + @Test + void junit5Runs() { + assertEquals(4, 2 + 2); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java new file mode 100644 index 00000000..81bbb9e1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java @@ -0,0 +1,136 @@ +package org.springblade.transport.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.ICustomerArchiveService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.ITransportPlanService; +import org.springblade.transport.service.IWaybillImportBatchService; +import org.springblade.transport.service.IWaybillService; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 打卡记录编辑接口的入参绑定测试。 + *

+ * 用真实的 HTTP 请求走一遍路由与 JSON 反序列化:字段名写错、凭证图数组结构对不上, + * 都会在这里失败,而不是等前端点保存时才发现。 + */ +class WaybillPunchRecordEndpointTest { + + private IWaybillService waybillService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + WaybillController controller = new WaybillController( + waybillService, + mock(IProjectApplyService.class), + mock(IContractManageService.class), + mock(ICustomerArchiveService.class), + mock(ITransportPlanService.class), + mock(IWaybillImportBatchService.class)); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + @DisplayName("POST /waybill-manage/punch-record/save:绑定打卡明细与凭证图") + void bindPunchRecordBody() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(true); + String body = """ + { + "waybillId": 6001, + "type": "node", + "nodeCode": "load", + "nodeName": "装货", + "punched": true, + "punchTime": "2026-10-06 09:30:00", + "address": "上海市青浦区", + "longitude": "121.47", + "latitude": "31.23", + "weight": "12.5", + "volume": "", + "quantity": "3", + "remark": "补录", + "photos": [{"type": "委托单", "url": "https://oss/a.jpg"}] + } + """; + + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.success").value(true)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class); + verify(waybillService).savePunchRecord(captor.capture()); + WaybillPunchRecordSaveRequest request = captor.getValue(); + assertEquals(6001L, request.getWaybillId()); + assertEquals("node", request.getType()); + assertEquals("load", request.getNodeCode()); + assertEquals("装货", request.getNodeName()); + assertEquals(Boolean.TRUE, request.getPunched()); + assertEquals("2026-10-06 09:30:00", request.getPunchTime()); + assertEquals("上海市青浦区", request.getAddress()); + assertEquals("121.47", request.getLongitude()); + assertEquals("31.23", request.getLatitude()); + assertEquals("12.5", request.getWeight()); + assertEquals("3", request.getQuantity()); + assertEquals("补录", request.getRemark()); + assertNotNull(request.getPhotos()); + assertEquals(1, request.getPhotos().size()); + assertEquals("委托单", request.getPhotos().get(0).getType()); + assertEquals("https://oss/a.jpg", request.getPhotos().get(0).getUrl()); + } + + @Test + @DisplayName("取消打卡:punched=false 原样传入服务层") + void bindUnpunchedFlag() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(true); + String body = """ + {"waybillId": 6001, "type": "enroute", "punched": false} + """; + + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class); + verify(waybillService).savePunchRecord(captor.capture()); + assertEquals(Boolean.FALSE, captor.getValue().getPunched()); + assertEquals("enroute", captor.getValue().getType()); + } + + @Test + @DisplayName("服务层返回 false:响应标记为失败") + void reportFailureWhenServiceRejects() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(false); + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"waybillId\": 6001}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java new file mode 100644 index 00000000..e570361d --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -0,0 +1,475 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.CertAuditDetailVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 管理端认证审核测试。 + *

+ * 守住两条最容易写坏的不变式: + * 驳回**不得改变**生效快照;已终态记录**不可再审**(再审会把生效快照改坏)。 + */ +class CertAuditServiceImplTest { + + private static final Long RECORD_ID = 5001L; + private static final Long VEHICLE_ID = 8001L; + private static final Long DRIVER_ID = 9001L; + + private ITransportCertRecordService certRecordService; + private TransportCertRecordMapper certRecordMapper; + private ITransportVehicleService transportVehicleService; + private IDriverService driverService; + private CertAuditServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @BeforeEach + void setUp() { + certRecordService = mock(ITransportCertRecordService.class); + certRecordMapper = mock(TransportCertRecordMapper.class); + transportVehicleService = mock(ITransportVehicleService.class); + driverService = mock(IDriverService.class); + service = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService); + // 目标对象的写入默认成功;「写入一行都没命中」由专门的用例覆盖 + when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(true); + when(driverService.updateById(any(Driver.class))).thenReturn(true); + } + + // ------------------------------------------------------------ 通过 + + @Test + @DisplayName("通过:记录转已通过,并把该对象的生效快照指针切到它") + void approveSwitchesEffectivePointer() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(recordCaptor.capture()); + assertEquals(TransportCertRecord.STATUS_APPROVED, recordCaptor.getValue().getStatus()); + assertNull(recordCaptor.getValue().getRejectReason(), "通过时必须清空驳回原因"); + assertNotNull(recordCaptor.getValue().getAuditTime(), "审核时间必须留痕"); + assertNotNull(recordCaptor.getValue().getAuditUser(), "审核人必须留痕"); + + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(vehicleCaptor.capture()); + assertEquals(RECORD_ID, vehicleCaptor.getValue().getEffectiveCertRecordId(), + "通过后指针应指向该记录"); + } + + @Test + @DisplayName("通过车辆认证:把快照的证件内容写回车辆档案,让「档案 = 生效值」成立") + void approveWritesSnapshotBackToVehicle() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + TransportVehicle patched = captor.getValue(); + assertEquals("桂物物流集团", patched.getOwnerName(), "所有人要跟着生效值更新"); + assertEquals("管理挂靠", patched.getBusinessRelation()); + assertEquals(18000, patched.getApprovedLoadKg()); + assertEquals(RECORD_ID, patched.getEffectiveCertRecordId(), "内容与指针必须是同一次 UPDATE"); + assertNull(patched.getPlateNo(), "车牌是车辆身份,不是本次提交能改的内容"); + assertNull(patched.getOrganizationName(), "所属组织由管理端维护,不该被提交内容覆盖"); + } + + @Test + @DisplayName("通过车辆认证:快照是旧格式时明确失败,不能「通过但什么也没写」") + void approveRejectsLegacySnapshot() { + // 旧快照的键名与当前字段定义不同,会解析成「全是 null」而不报错——最危险的一种情况 + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING, + "{\"licenseNo\":\"450100123456\",\"licenseFrontUrl\":\"http://oss/old.jpg\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("字段定义不匹配"), exception.getMessage()); + verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); + } + + @Test + @DisplayName("通过:切换指针而不是追加,同对象仍只有一条生效快照") + void approveReplacesPointerInsteadOfAppending() { + Long oldEffectiveId = 111L; + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId()); + // 只更新了这一个字段所在的车辆行,没有新增任何"生效标记"行 + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + } + + @Test + @DisplayName("通过:同步刷新展示缓存为已通过") + void approveRefreshesDisplayCache() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals(TransportCertRecord.STATUS_APPROVED, captor.getValue().getCertificationStatus()); + } + + @Test + @DisplayName("通过司机认证:指针写到司机表") + void approveDriverSwitchesDriverPointer() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId()); + } + + @Test + @DisplayName("通过司机认证:patch 必须带上车辆绑定,不得把 driving_vehicle 清空") + void approveDriverKeepsDrivingVehicle() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + Driver existing = driver(null); + existing.setDrivingVehicle("桂A12345,桂B67890"); + when(driverService.getById(DRIVER_ID)).thenReturn(existing); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + // driving_vehicle 标了 FieldStrategy.ALWAYS,patch 里为 null 就会把该列写成 NULL; + // 而司机端车辆列表正是按这一列匹配的,清空即「暂无绑定车辆」且界面上无法恢复 + assertEquals("桂A12345,桂B67890", captor.getValue().getDrivingVehicle(), + "审核通过不得把司机的车辆绑定清空"); + } + + @Test + @DisplayName("通过:目标对象一行都没命中时必须整体失败,不得「记录已通过、档案没变」") + void approveFailsWhenVehicleNotUpdated() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(false); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("未生效"), exception.getMessage()); + } + + @Test + @DisplayName("通过司机认证:司机档案没命中时同样必须整体失败,不得静默放行") + void approveFailsWhenDriverNotUpdated() { + when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING)); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + when(driverService.updateById(any(Driver.class))).thenReturn(false); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("司机"), exception.getMessage()); + } + + // ------------------------------------------------------------ 驳回 + + @Test + @DisplayName("驳回:记录转已驳回并留原因,但**生效快照与可用性不变**(核心不变式)") + void rejectKeepsEffectivePointer() { + Long existingEffectiveId = 777L; + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); + + service.audit(RECORD_ID, false, "证件照片模糊"); + + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(recordCaptor.capture()); + assertEquals(TransportCertRecord.STATUS_REJECTED, recordCaptor.getValue().getStatus()); + assertEquals("证件照片模糊", recordCaptor.getValue().getRejectReason()); + + // 展示缓存可以更新,但**指针绝不能被改动** + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(vehicleCaptor.capture()); + assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(), + "驳回不得修改生效快照指针,否则已认证的车会被无辜下线"); + assertEquals(TransportCertRecord.STATUS_REJECTED, vehicleCaptor.getValue().getCertificationStatus()); + } + + @Test + @DisplayName("驳回:已认证的对象被驳回后,生效快照指针仍指向原来那条") + void rejectNeverClearsExistingPointer() { + Long existingEffectiveId = 777L; + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); + + service.audit(RECORD_ID, false, "运输证过期"); + + // 指针字段没有被写成 null,也没有被写成新记录 + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertNull(captor.getValue().getEffectiveCertRecordId()); + } + + @Test + @DisplayName("驳回:不填原因必须拒绝") + void rejectRequiresReason() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null)); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " ")); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("驳回:原因超过 200 字必须拒绝(与列宽一致)") + void rejectReasonTooLong() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + + assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, false, "长".repeat(201))); + } + + // ------------------------------------------------------------ 终态保护 + + @Test + @DisplayName("终态不可再审:已通过/已驳回/已作废一律拒绝") + void terminalRecordsCannotBeAuditedAgain() { + for (Integer terminal : new Integer[]{ + TransportCertRecord.STATUS_APPROVED, + TransportCertRecord.STATUS_REJECTED, + TransportCertRecord.STATUS_SUPERSEDED}) { + TransportCertRecord record = vehicleRecord(terminal); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null), + "状态 " + terminal + " 属终态,不应可再审"); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"), + "状态 " + terminal + " 属终态,不应可再审"); + } + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("记录不存在:拒绝") + void recordNotFound() { + when(certRecordService.getById(RECORD_ID)).thenReturn(null); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null)); + } + + @Test + @DisplayName("记录主键为空:拒绝") + void recordIdRequired() { + assertThrows(ServiceException.class, () -> service.audit(null, true, null)); + } + + // ------------------------------------------------------------ 审核详情(diff 用) + + @Test + @DisplayName("审核详情:同时给出待审快照与当前生效值,供前端做 diff") + void detailProvidesBothSides() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson("{\"vin\":\"NEW\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + + TransportCertRecord oldEffective = new TransportCertRecord(); + oldEffective.setId(777L); + oldEffective.setSnapshotJson("{\"vin\":\"OLD\"}"); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + when(certRecordService.getById(777L)).thenReturn(oldEffective); + + CertAuditDetailVO detail = service.detail(RECORD_ID); + + assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson()); + assertEquals("{\"vin\":\"OLD\"}", detail.getEffectiveSnapshotJson(), + "审核页需要旧生效值才能看出司机改了什么"); + assertEquals(777L, detail.getEffectiveRecordId()); + } + + @Test + @DisplayName("审核详情:从未认证过时,生效值一侧为 null(而不是报错)") + void detailWithoutEffective() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson("{\"vin\":\"NEW\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + CertAuditDetailVO detail = service.detail(RECORD_ID); + + assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson()); + assertNull(detail.getEffectiveSnapshotJson()); + assertNull(detail.getEffectiveRecordId()); + } + + // -------------------------------------------- 驳回原因的展示缓存 + + @Test + @DisplayName("通过车辆认证:显式清空档案上的驳回原因(not_null 策略会让 null 到不了 SQL)") + @SuppressWarnings("unchecked") + void approveClearsStaleVehicleRejectReason() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(transportVehicleService).update(captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null(该列无原因时的取值)"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == VEHICLE_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + + @Test + @DisplayName("通过司机认证:同样显式清空驳回原因,且不顺带动车辆绑定") + @SuppressWarnings("unchecked") + void approveClearsStaleDriverRejectReason() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null)); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + assertFalse(wrapper.getSqlSet().contains("driving_vehicle"), + "清空驳回原因不得顺带动车辆绑定"); + } + + @Test + @DisplayName("驳回:原因去空白后写入档案,且不发清空语句") + void rejectWritesReasonAndDoesNotClear() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + + service.audit(RECORD_ID, false, " 行驶证模糊 "); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals("行驶证模糊", captor.getValue().getCertificationRejectReason(), + "驳回原因去首尾空白后落库"); + verify(transportVehicleService, never()).update(any()); + } + + // ------------------------------------------------------------ 辅助 + + private TransportCertRecord vehicleRecord(Integer status) { + return vehicleRecord(status, completeVehicleSnapshot()); + } + + private TransportCertRecord vehicleRecord(Integer status, String snapshotJson) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(RECORD_ID); + record.setBizType(TransportCertRecord.BIZ_TYPE_VEHICLE); + record.setBizId(VEHICLE_ID); + record.setBizKey("桂A32523"); + record.setStatus(status); + record.setSnapshotJson(snapshotJson); + return record; + } + + /** + * 一份字段齐全的车辆认证快照 + *

+ * 审核通过时会把快照内容写回车辆档案,故用例不能再拿空快照走通过流程; + * 关键字段(档案编号、核定载质量)是「这份快照是否为当前格式」的判据。 + */ + static String completeVehicleSnapshot() { + return "{\"drivingLicenseNo\":\"450100123456\",\"approvedLoadKg\":18000," + + "\"ownerName\":\"桂物物流集团\",\"businessRelation\":\"管理挂靠\"," + + "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}"; + } + + private TransportCertRecord driverRecord(Integer status) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(RECORD_ID); + record.setBizType(TransportCertRecord.BIZ_TYPE_DRIVER); + record.setBizId(DRIVER_ID); + record.setBizKey("张三"); + record.setStatus(status); + record.setSnapshotJson("{}"); + return record; + } + + private TransportVehicle vehicle(Long effectiveId) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(VEHICLE_ID); + vehicle.setPlateNo("桂A32523"); + vehicle.setEffectiveCertRecordId(effectiveId); + return vehicle; + } + + private Driver driver(Long effectiveId) { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setDriverName("张三"); + driver.setEffectiveCertRecordId(effectiveId); + return driver; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java new file mode 100644 index 00000000..9878ad99 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java @@ -0,0 +1,181 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.DriverVehicleCardVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 司机端车辆卡片(重新认证表单的数据来源)测试。 + *

+ * 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约: + * 日期字段只能是 {@code yyyy-MM-dd} 或空串(不能是「长期」这类文字); + * 标了长期有效的字段不能再下发一个作废的止期。 + */ +class DriverAppServiceImplTest { + + private static final String MOBILE = "15577122721"; + private static final Long USER_ID = 7001L; + + private IDriverService driverService; + private ITransportVehicleService transportVehicleService; + private DriverAppServiceImpl service; + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + transportVehicleService = mock(ITransportVehicleService.class); + service = new DriverAppServiceImpl(driverService, transportVehicleService, mock(IMpUserClient.class)); + } + + @Test + @DisplayName("长期有效且没有止期时下发空串,不能下发「长期」——否则它会被当日期填进日期框") + void longTermWithoutEndDateSendsEmptyText() { + DriverVehicleCardVO card = firstCard(vehicle(LocalDate.of(2022, 8, 17), null, 1)); + + assertEquals("", card.getDrivingLicenseEndDate(), "长期有效时日期字段只能是空串"); + assertEquals(1, card.getDrivingLicenseLongTerm(), "长期有效标志本身要照常下发"); + assertEquals("2022-08-17", card.getDrivingLicenseStartDate(), "起期有值时照常下发"); + } + + @Test + @DisplayName("长期有效时即使库里还留着止期也不下发:那个止期是上一版证件作废的值") + void longTermWithStaleEndDateSendsEmptyText() { + DriverVehicleCardVO card = firstCard(vehicle(null, LocalDate.of(2040, 8, 14), 1)); + + assertEquals("", card.getDrivingLicenseEndDate()); + assertEquals("", card.getRoadTransportCertEndDate()); + assertEquals("", card.getCompulsoryScrapDate()); + } + + @Test + @DisplayName("未标长期有效时照常下发止期,空值一律是空串而不是 null 字面量") + void normalVehicleSendsRealDates() { + TransportVehicle vehicle = vehicle(null, LocalDate.of(2030, 8, 14), 0); + vehicle.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1)); + vehicle.setRoadTransportCertEndDate(null); + vehicle.setRoadTransportCertLongTerm(0); + vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 4)); + DriverVehicleCardVO card = firstCard(vehicle); + + assertEquals("2030-08-14", card.getDrivingLicenseEndDate()); + assertEquals("2035-01-01", card.getCompulsoryScrapDate()); + assertEquals("", card.getRoadTransportCertEndDate(), "没填的止期是空串"); + assertEquals("2028-12-04", card.getRoadTransportCertStartDate()); + } + + // ---------------------------------------------------------------- 手机号解析 + + @Test + @DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案") + void twelveDigitAccountIsUsableAsPhone() { + assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName()); + } + + @Test + @DisplayName("小程序用户 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权") + void maskedUserPhoneFallsBackToAccount() { + MpUser user = new MpUser(); + user.setPhone("175****12082"); + user.setAccount("175874212082"); + + assertEquals("黄大富", currentByPhone(null, null, user).getDriverName()); + } + + @Test + @DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案") + void otherPeoplesMobileIsRejected() { + assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class, + () -> currentByPhone("13900000001", "175874212082", null)).getMessage()); + } + + // ---------------------------------------------------------------- 辅助 + + /** 造一辆只关心有效期三个字段的车 */ + private TransportVehicle vehicle(LocalDate licenseStart, LocalDate licenseEnd, Integer licenseLongTerm) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(8001L); + vehicle.setPlateNo("桂AAW3255"); + vehicle.setDrivingLicenseStartDate(licenseStart); + vehicle.setDrivingLicenseEndDate(licenseEnd); + vehicle.setDrivingLicenseLongTerm(licenseLongTerm); + // 三组有效期都按同一个长期有效标志造,便于一次断言三处 + vehicle.setCompulsoryScrapDate(null); + vehicle.setCompulsoryScrapLongTerm(licenseLongTerm); + vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 3)); + vehicle.setRoadTransportCertEndDate(LocalDate.of(2043, 8, 18)); + vehicle.setRoadTransportCertLongTerm(licenseLongTerm); + return vehicle; + } + + /** 按当前登录司机的手机号跑一次「我的车辆」,取第一张卡片 */ + private DriverVehicleCardVO firstCard(TransportVehicle vehicle) { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDriverName("韦司机"); + driver.setMobile(MOBILE); + driver.setDrivingVehicle(vehicle.getPlateNo()); + + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of(vehicle)); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + // 账号本身就是手机号,resolvePhone 走这条短路分支,不再回调用户中心 + authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE); + List cards = service.myVehicles(); + assertEquals(1, cards.size(), "应当只返回绑定到本人的这一辆车"); + return cards.get(0); + } + } + + /** + * 按给定的登录态跑一次 currentByPhone。 + * + * @param mobileHint 前端传入的 mobile,可为 null + * @param account JWT 里的账号,可为 null + * @param user 小程序用户,可为 null(表示走不到小程序用户中心) + */ + private Driver currentByPhone(String mobileHint, String account, MpUser user) { + IMpUserClient mpUserClient = mock(IMpUserClient.class); + if (user != null) { + when(mpUserClient.userById(USER_ID)).thenReturn(R.data(user)); + } + DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, mpUserClient); + + Driver driver = new Driver(); + driver.setId(9002L); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setMobile("175874212082"); + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUserAccount).thenReturn(account); + return scoped.currentByPhone(mobileHint); + } + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java new file mode 100644 index 00000000..505f57a1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -0,0 +1,715 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.alibaba.fastjson2.JSON; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.service.IDriverAppService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机端认证提交链路测试。 + *

+ * 重点守住票 02 的三条验收点与不变式: + * 提交不得改变生效快照、同对象至多一条待审、越权必须拒绝。 + */ +class DriverCertServiceImplTest { + + private static final Long DRIVER_ID = 9001L; + private static final Long USER_ID = 7001L; + private static final Long VEHICLE_ID = 8001L; + private static final String PLATE = "桂A32523"; + + private IDriverAppService driverAppService; + private IDriverService driverService; + private ITransportVehicleService transportVehicleService; + private ITransportCertRecordService certRecordService; + private DriverCertServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @BeforeEach + void setUp() { + driverAppService = mock(IDriverAppService.class); + driverService = mock(IDriverService.class); + transportVehicleService = mock(ITransportVehicleService.class); + certRecordService = mock(ITransportCertRecordService.class); + service = new DriverCertServiceImpl(driverAppService, driverService, + transportVehicleService, certRecordService); + } + + // ---------------------------------------------------------------- 车辆提交 + + @Test + @DisplayName("车辆提交:落一条待审记录,且状态必须是待审(不能被框架默认值变成已通过)") + void submitVehicleCreatesPendingRecord() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)) + .thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + request.setVin("VIN123"); + + service.submitVehicleCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(captor.capture()); + TransportCertRecord saved = captor.getValue(); + + assertEquals(TransportCertRecord.STATUS_PENDING, saved.getStatus()); + assertEquals(TransportCertRecord.BIZ_TYPE_VEHICLE, saved.getBizType()); + assertEquals(VEHICLE_ID, saved.getBizId()); + assertEquals(PLATE, saved.getBizKey()); + assertTrue(saved.getSnapshotJson().contains("VIN123"), "快照应承载本次提交的全量内容"); + assertNull(saved.getRejectReason(), "新提交不得继承上一条的驳回原因"); + } + + @Test + @DisplayName("提交人必须记用户ID:记成司机档案ID会让审核页「提交人」显示一串数字") + void submitRecordsUserIdAsSubmitUser() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest vehicleRequest = completeVehicleRequest(PLATE); + vehicleRequest.setId(VEHICLE_ID); + service.submitVehicleCert(vehicleRequest); + + PersonCertSubmitRequest personRequest = new PersonCertSubmitRequest(); + personRequest.setRealName("张三"); + service.submitPersonCert(personRequest); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService, times(2)).save(captor.capture()); + + for (TransportCertRecord saved : captor.getAllValues()) { + assertEquals(USER_ID, saved.getSubmitUser(), + "提交人必须是用户ID(审核页靠它翻译姓名),不能是司机档案ID"); + assertNotEquals(saved.getBizId(), saved.getSubmitUser(), + "提交人与认证对象主键是两种ID,不应相同"); + } + } + + @Test + @DisplayName("车辆提交:不修改生效快照指针(提交不影响车辆可用性)") + void submitVehicleDoesNotTouchEffectivePointer() { + + Long effectiveId = 777L; + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, PLATE, effectiveId)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + service.submitVehicleCert(request); + + // 更新车辆时只能用展示缓存字段,且绝不能带上 effectiveCertRecordId + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + TransportVehicle updated = captor.getValue(); + assertNull(updated.getEffectiveCertRecordId(), + "提交链路不得写生效快照指针,否则「曾通过即永久可用」会被破坏"); + assertEquals(TransportCertRecord.STATUS_PENDING, updated.getCertificationStatus()); + } + + @Test + @DisplayName("车辆提交:前一条待审被置为已作废,不存在两条待审") + void submitVehicleSupersedesPreviousPending() { + TransportCertRecord previous = record(500L, TransportCertRecord.STATUS_PENDING); + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)) + .thenReturn(previous); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + service.submitVehicleCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, captor.getValue().getStatus()); + assertEquals(500L, captor.getValue().getId(), "被作废的应是原来那条待审"); + } + + @Test + @DisplayName("车辆提交:前一条已是终态时不得被改动(尤其不能碰已通过的那条)") + void submitVehicleLeavesTerminalRecordsAlone() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L)); + // selectPendingRecord 只返回待审;已通过的不该被它返回 + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + service.submitVehicleCert(request); + + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + // ---------------------------------------------------------------- 越权 + + @Test + @DisplayName("越权:提交未绑定到自己名下的车辆必须拒绝") + void submitVehicleRejectsUnboundVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, "桂B99999", null)); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + + assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("越权:车牌写法差异不影响绑定判定(间隔符/大小写)") + void submitVehicleAcceptsBoundVehicleWithMessyPlate() { + // 司机档案里带间隔符,车辆表里是干净写法 + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523")); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523"); + request.setId(VEHICLE_ID); + + service.submitVehicleCert(request); + verify(certRecordService).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("越权:司机未绑定任何车辆时提交必须拒绝") + void submitVehicleRejectsWhenNoBoundVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(null)); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + + assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); + } + + @Test + @DisplayName("越权:不可查看他人车辆的认证记录列表") + void listRecordsRejectsOthersVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, "桂B99999", null)); + + assertThrows(ServiceException.class, + () -> service.listRecords(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)); + } + + @Test + @DisplayName("越权:不可查看其他司机的认证记录列表") + void listRecordsRejectsOtherDriver() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + + assertThrows(ServiceException.class, + () -> service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, 12345L)); + } + + // ---------------------------------------------------------------- 个人认证 + + @Test + @DisplayName("个人提交:落待审记录,且再提交时作废前一条待审") + void submitPersonCertSupersedes() { + TransportCertRecord previous = record(600L, TransportCertRecord.STATUS_PENDING); + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID)) + .thenReturn(previous); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + request.setIdCardNo("110101199001011234"); + + service.submitPersonCert(request); + + ArgumentCaptor saved = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(saved.capture()); + assertEquals(TransportCertRecord.STATUS_PENDING, saved.getValue().getStatus()); + assertEquals(TransportCertRecord.BIZ_TYPE_DRIVER, saved.getValue().getBizType()); + assertEquals(DRIVER_ID, saved.getValue().getBizId()); + + ArgumentCaptor updated = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(updated.capture()); + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, updated.getValue().getStatus()); + } + + @Test + @DisplayName("个人提交:不写生效快照指针(只刷展示缓存)") + void submitPersonCertDoesNotTouchEffectivePointer() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + service.submitPersonCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + assertNull(captor.getValue().getEffectiveCertRecordId(), + "提交链路不得写生效快照指针"); + } + + @Test + @DisplayName("个人提交:清空上一次的驳回原因(null 交给 updateById 到不了 SQL)") + @SuppressWarnings("unchecked") + void submitPersonCertClearsStaleRejectReason() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + request.setIdCardNo("110101199001011234"); + + service.submitPersonCert(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "重新提交时旧驳回原因必须清掉,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + + @Test + @DisplayName("个人提交:内容为空必须拒绝") + void submitPersonCertRejectsEmpty() { + assertThrows(ServiceException.class, () -> service.submitPersonCert(null)); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + // ---------------------------------------------------------------- 生效值读取 + + @Test + @DisplayName("读生效值:有指针时返回指针指向的那条快照内容(不是最新提交)") + void getEffectivePersonCertReadsPointedRecord() { + Long effectiveId = 777L; + Driver driver = driver(PLATE); + driver.setEffectiveCertRecordId(effectiveId); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + + PersonCertSubmitRequest effectiveContent = new PersonCertSubmitRequest(); + effectiveContent.setRealName("旧生效值"); + TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED); + effective.setSnapshotJson(JSON.toJSONString(effectiveContent)); + when(certRecordService.getById(effectiveId)).thenReturn(effective); + + PersonCertSubmitRequest result = service.getEffectivePersonCert(); + + assertEquals("旧生效值", result.getRealName()); + } + + @Test + @DisplayName("读生效值:从未认证通过时返回 null(而不是抛错或返回最新提交)") + void getEffectivePersonCertReturnsNullWhenNeverCertified() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + + assertNull(service.getEffectivePersonCert()); + } + + @Test + @DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)") + void resubmitKeepsEffectiveValue() { + Long effectiveId = 777L; + Driver driver = driver(PLATE); + driver.setEffectiveCertRecordId(effectiveId); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest oldContent = new PersonCertSubmitRequest(); + oldContent.setRealName("旧的已通过内容"); + TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED); + effective.setSnapshotJson(JSON.toJSONString(oldContent)); + when(certRecordService.getById(effectiveId)).thenReturn(effective); + + // 提交新内容 + PersonCertSubmitRequest newContent = new PersonCertSubmitRequest(); + newContent.setRealName("新提交待审内容"); + service.submitPersonCert(newContent); + + // 生效值不受影响 + PersonCertSubmitRequest stillEffective = service.getEffectivePersonCert(); + assertEquals("旧的已通过内容", stillEffective.getRealName()); + assertNotEquals("新提交待审内容", stillEffective.getRealName()); + } + + @Test + @DisplayName("记录列表:类型非法必须拒绝") + void listRecordsRejectsInvalidBizType() { + assertThrows(ServiceException.class, () -> service.listRecords("truck", 1L)); + assertThrows(ServiceException.class, () -> service.listRecords(null, 1L)); + } + + @Test + @DisplayName("记录列表:本人司机记录按传入条件查询(走统一查询入口)") + void listRecordsForSelf() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID)) + .thenReturn(List.of()); + + assertTrue(service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID).isEmpty()); + verify(certRecordService, times(1)) + .selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID); + } + + // ---------------------------------------------------------------- 删除车辆认证 + + @Test + @DisplayName("删除车辆认证:逻辑删除车辆,且**不删**认证记录") + void deleteVehicleCertIsLogicalOnly() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L)); + + service.deleteVehicleCert(VEHICLE_ID); + + // 走的是 deleteLogic(is_deleted=1),不是物理删除 + verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID)); + verify(transportVehicleService, never()).removeById(anyLong()); + // 认证记录是历史依据,任何情况下都不得被删 + verify(certRecordService, never()).removeById(anyLong()); + verify(certRecordService, never()).removeByIds(any()); + } + + @Test + @DisplayName("删除车辆认证:越权必须拒绝") + void deleteVehicleCertRejectsUnbound() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, "桂B99999", null)); + + assertThrows(ServiceException.class, () -> service.deleteVehicleCert(VEHICLE_ID)); + verify(transportVehicleService, never()).deleteLogic(any()); + } + + @Test + @DisplayName("删除车辆认证:车牌写法差异不影响绑定判定") + void deleteVehicleCertAcceptsMessyPlate() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523")); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null)); + + service.deleteVehicleCert(VEHICLE_ID); + verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID)); + } + + // ---------------------------------------------------------------- 新增车辆 + + @Test + @DisplayName("新增车辆:车牌未登记时就地建档并绑定到本人,且新车不被视为已认证") + void submitVehicleCreatesUnregisteredVehicle() { + Driver driver = driver(null); // 司机名下还没有任何车 + when(driverAppService.currentDriverEntity()).thenReturn(driver); + // 全库都查不到这个车牌 + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂A·32523"); + + service.submitVehicleCert(request); + + // 建档:保存了一辆车,车挂牌照为规范化结果,且状态为启用 + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).save(vehicleCaptor.capture()); + assertEquals("桂A32523", vehicleCaptor.getValue().getPlateNo()); + assertEquals("重型货车", vehicleCaptor.getValue().getVehicleType()); + assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(), + "新建的车不得自带生效快照——是否已认证只能由审核通过决定"); + assertEquals("桂物物流集团", vehicleCaptor.getValue().getOrganizationName(), + "所属组织必须从司机档案带出:该列 NOT NULL 且无默认值,空着这条 INSERT 直接报错"); + assertEquals("管理挂靠", vehicleCaptor.getValue().getBusinessRelation(), + "业务关系由司机在表单里选,服务端照实落库、不替用户猜"); + assertNotNull(vehicleCaptor.getValue().getRegistrationNo(), + "机动车登记编号该列 NOT NULL:司机端选填,缺值要写空串而不是 null"); + assertEquals("桂物物流集团", vehicleCaptor.getValue().getOwnerName(), + "所有人是行驶证上的登记主体,与所属组织是两个字段"); + + // 绑定:司机的 driving_vehicle 被追加该车牌 + ArgumentCaptor driverCaptor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(driverCaptor.capture()); + assertEquals("桂A32523", driverCaptor.getValue().getDrivingVehicle()); + + // 仍然产生一条待审记录 + verify(certRecordService).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("新增车辆:车牌已登记在别人名下必须拒绝,不能顺手改归属") + void submitVehicleRejectsPlateOwnedByAnotherDriver() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂B11111")); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(vehicle(VEHICLE_ID, "桂A32523", null))); // 已登记,但不在本人名下 + + VehicleCertSubmitRequest request = completeVehicleRequest("桂A32523"); + + assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("新增车辆:已登记且属于本人时直接复用,不重复建档") + void submitVehicleReusesOwnRegisteredVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523")); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(vehicle(VEHICLE_ID, "桂A·32523", null))); // 写法不同但同一辆 + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523"); + + service.submitVehicleCert(request); + + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(captor.capture()); + assertEquals(VEHICLE_ID, captor.getValue().getBizId(), "应挂到既有车辆上"); + } + + @Test + @DisplayName("新增车辆:车牌为空必须拒绝") + void submitVehicleRejectsBlankPlate() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523")); + + // 内容给全,确保被拒的原因是车牌为空而不是别的字段(否则这条用例形同虚设) + VehicleCertSubmitRequest request = completeVehicleRequest(" "); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertTrue(exception.getMessage().contains("车牌"), exception.getMessage()); + } + + // ---------------------------------------------------------------- 辅助 + + @Test + @DisplayName("重新认证同样要过内容校验:只校验「新建车辆」等于给重新认证留了后门") + void submitVehicleValidatesContentOnResubmit() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + request.setOwnerName(""); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertEquals("所有人不能为空", exception.getMessage()); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("重新认证不要求司机档案有所属组织:那一列只在新建车辆时写,与管理归属无关") + void submitVehicleResubmitDoesNotRequireOrganization() { + Driver driver = driver(PLATE); + driver.setOrganizationName(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + + service.submitVehicleCert(request); + + verify(certRecordService).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("司机建车:字段不齐时给可读提示,而不是让数据库报 NOT NULL") + void submitVehicleRejectsIncompleteRequest() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂A32523"); + request.setApprovedLoadKg(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertEquals("核定载质量不能为空", exception.getMessage()); + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + } + + @Test + @DisplayName("司机建车:没有所属组织时明确拒绝,不落一辆查不到归属的车") + void submitVehicleRejectsDriverWithoutOrganization() { + Driver driver = driver(null); + driver.setOrganizationName(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(completeVehicleRequest("桂A32523"))); + assertTrue(exception.getMessage().contains("所属组织")); + } + + @Test + @DisplayName("有效期只填止期:起期为空也允许提交(司机端不采集起期)") + void submitVehicleAcceptsBlankStartDate() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setDrivingLicenseStartDate(null); + request.setRoadTransportCertStartDate(null); + + service.submitVehicleCert(request); + + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).save(vehicleCaptor.capture()); + assertNotNull(vehicleCaptor.getValue().getDrivingLicenseEndDate(), "止期照常写入车辆行"); + assertNull(vehicleCaptor.getValue().getDrivingLicenseStartDate(), "起期为空就是空,不该编一个值出来"); + } + + @Test + @DisplayName("有效期未勾长期有效时止期必填:起期可以空,止期不能") + void submitVehicleRejectsBlankEndDate() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setDrivingLicenseStartDate(null); + request.setDrivingLicenseEndDate(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertEquals("请选择行驶证有效期", exception.getMessage()); + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + } + + @Test + @DisplayName("长期有效时止期可以为空,此时不再比较起止先后") + void submitVehicleAllowsLongTermWithoutEndDate() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setDrivingLicenseStartDate(LocalDate.of(2020, 1, 1)); + request.setDrivingLicenseEndDate(null); + request.setDrivingLicenseLongTerm(1); + + service.submitVehicleCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(captor.capture()); + assertTrue(captor.getValue().getSnapshotJson().contains("\"drivingLicenseLongTerm\":1"), + "长期有效标志要落进快照"); + } + + /** + * 一份字段齐全的车辆认证提交内容 + *

+ * 车辆表有十个 NOT NULL 且无默认值的列,少一个就会触发数据库错误而不是业务提示, + * 故用例统一从这里取请求,避免每个用例各自拼一半字段。 + */ + private VehicleCertSubmitRequest completeVehicleRequest(String plateNo) { + VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + request.setPlateNo(plateNo); + request.setVehicleType("重型货车"); + request.setOwnerName("桂物物流集团"); + request.setPlateColor("黄牌"); + request.setEnergyType("柴油"); + request.setBusinessRelation("管理挂靠"); + request.setApprovedLoadKg(18000); + request.setTractionMassKg(35000); + request.setOuterLength(5990); + request.setOuterWidth(2300); + request.setOuterHeight(3100); + request.setDrivingLicenseNo("450100123456"); + request.setDrivingLicenseImage("http://oss.example.com/xsz-front.jpg"); + request.setDrivingLicenseStartDate(LocalDate.of(2018, 3, 9)); + request.setDrivingLicenseEndDate(LocalDate.of(2028, 3, 9)); + request.setRoadTransportCertNo("桂交运管0001"); + request.setRoadTransportCertStartDate(LocalDate.of(2020, 1, 1)); + request.setRoadTransportCertEndDate(LocalDate.of(2030, 1, 1)); + request.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1)); + return request; + } + + private Driver driver(String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + // 司机档案ID与用户ID是两个不同的ID:提交人字段要的是后者 + driver.setUserId(USER_ID); + driver.setDriverName("张三"); + // 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败, + // 故建车链路要求它非空,fixture 也必须给 + driver.setOrganizationName("桂物物流集团"); + driver.setDrivingVehicle(drivingVehicle); + return driver; + } + + private TransportVehicle vehicle(Long id, String plateNo, Long effectiveId) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(id); + vehicle.setPlateNo(plateNo); + vehicle.setEffectiveCertRecordId(effectiveId); + return vehicle; + } + + private TransportCertRecord record(Long id, Integer status) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(id); + record.setStatus(status); + record.setSnapshotJson("{}"); + return record; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java new file mode 100644 index 00000000..86b9a3e7 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -0,0 +1,339 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverPunchNodeVO; +import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 节点打卡照片的类型与张数口径。 + *

+ * 新版小程序按类型提交照片,类型由提交方给出;旧版仍提交裸 URL 列表,类型只能按下标回推凭证类型。 + * 两者并存期间,新版每个类型至少 1 张(含固定的「打卡照片」)、每类各自至多 9 张; + * 旧版必须照旧提交成功——用「每个类型至少 1 张」去拦旧版,等于让未升级的小程序一次性失效, + * 而旧版根本表达不出「打卡照片」。 + */ +class DriverNodePunchPhotoTest { + + private static final Long WAYBILL_ID = 5601L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; + private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg"; + private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg"; + /** 该节点勾了两类凭证,故照片类型取值域是「打卡照片 + 磅单 + 签收单」 */ + private static final String PROCESS_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true, + "location":false,"uploadVoucher":true,"voucherTypes":["磅单","签收单"]}] + """; + + private IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + exceptionDisposalMapper); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.getOne(any())).thenReturn(waybill()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.updateById(any())).thenReturn(true); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + // ------------------------------------------------------------ 新版:显式类型 + + @Test + @DisplayName("显式类型按提交原样落库,「打卡照片」不会被改写成凭证类型") + void typedPhotoItemsKeepExplicitType() { + submit(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO)))); + + List> items = savedPhotoItems(); + assertEquals(List.of("打卡照片", "磅单", "签收单"), typesOf(items)); + assertEquals(List.of(CLOCK_PHOTO, WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items)); + } + + @Test + @DisplayName("域外类型被拒且不落库") + void typeOutsideNodeDomainIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO), + photo("卸货照片", "https://oss/unload.jpg")))); + + assertTrue(exception.getMessage().contains("卸货照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("缺少某个必填类型时被拒:每个类型至少 1 张") + void missingVoucherTypeIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO)))); + + assertTrue(exception.getMessage().contains("签收单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("缺少「打卡照片」时被拒:它不由过程配置决定,每个打卡节点都必须有") + void missingClockPhotoIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO)))); + + assertTrue(exception.getMessage().contains("打卡照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("同一类型第 9 张可提交") + void ninthPhotoOfSameTypeIsAccepted() { + submit(nodeSubmit(typedPhotos("磅单", 9))); + + assertEquals(9, countByType(savedPhotoItems(), "磅单")); + } + + @Test + @DisplayName("同一类型第 10 张被拒且不落库:上限按类型各自计数") + void tenthPhotoOfSameTypeIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(typedPhotos("磅单", 10))); + + assertTrue(exception.getMessage().contains("磅单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 回显 + + @Test + @DisplayName("回显按类型原样透传:「打卡照片」不会被丢弃,也不会被改写成凭证类型") + void punchNodeDisplayKeepsEveryType() { + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(storedPunch())); + + DriverPunchNodeVO node = service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream() + .filter(item -> "load".equals(item.getKey())) + .findFirst() + .orElseThrow(); + + assertEquals(List.of("打卡照片", "磅单"), + node.getPhotos().stream().map(DriverPunchPhotoVO::getType).toList()); + } + + // ------------------------------------------------------------ 旧版:裸 URL 列表 + + @Test + @DisplayName("旧版裸 URL 列表仍能提交成功,类型按下标回推凭证类型") + void legacyPhotoUrlsStillSubmit() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotos(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO)); + + submit(dto); + + List> items = savedPhotoItems(); + assertEquals(List.of("磅单", "签收单"), typesOf(items)); + assertEquals(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items)); + } + + @Test + @DisplayName("旧版不带「打卡照片」也放行:裸 URL 列表表达不出这个类型") + void legacyPhotoUrlsWithoutClockPhotoStillSubmit() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotos(List.of(WAYBILL_PHOTO)); + + submit(dto); + + assertEquals(List.of("磅单"), typesOf(savedPhotoItems())); + } + + @Test + @DisplayName("旧版与新版都没带照片时被拒") + void withoutAnyPhotoIsRejected() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + + ServiceException exception = submitExpectRejected(dto); + + assertTrue(exception.getMessage().contains("照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 夹具 + + private void submit(NodeSubmitDTO dto) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + service.submitNode(dto); + } + } + + private ServiceException submitExpectRejected(NodeSubmitDTO dto) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return assertThrows(ServiceException.class, () -> service.submitNode(dto)); + } + } + + /** 已入库的打卡流水;断言只在这里取一次插入参数 */ + private WaybillNodePunch savedPunch() { + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + return captor.getValue(); + } + + @SuppressWarnings("unchecked") + private List> savedPhotoItems() { + return (List>) JsonUtil.parse(savedPunch().getPhotos(), List.class); + } + + private List typesOf(List> items) { + return items.stream().map(item -> item.get("type")).toList(); + } + + private List urlsOf(List> items) { + return items.stream().map(item -> item.get("url")).toList(); + } + + private int countByType(List> items, String type) { + return (int) items.stream().filter(item -> type.equals(item.get("type"))).count(); + } + + /** 每个必填类型各 1 张,另有 count 张「磅单」 */ + private List typedPhotos(String type, int count) { + List items = new ArrayList<>(); + items.add(photo("打卡照片", CLOCK_PHOTO)); + items.add(photo("签收单", RECEIPT_PHOTO)); + for (int i = 0; i < count; i++) { + items.add(photo(type, "https://oss/" + type + i + ".jpg")); + } + return items; + } + + private NodeSubmitDTO nodeSubmit(List photoItems) { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotoItems(photoItems); + return dto; + } + + private PunchPhotoDTO photo(String type, String url) { + PunchPhotoDTO photo = new PunchPhotoDTO(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setVehicleNo(PLATE); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + + /** 库里的打卡流水:一条带「打卡照片」与「磅单」的回显数据 */ + private WaybillNodePunch storedPunch() { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6601L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode("load"); + punch.setNodeName("装货"); + punch.setPunchTime(new Date()); + punch.setPhotos(JsonUtil.toJson(List.of( + Map.of("type", "打卡照片", "url", CLOCK_PHOTO), + Map.of("type", "磅单", "url", WAYBILL_PHOTO)))); + return punch; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java new file mode 100644 index 00000000..ed1e5495 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -0,0 +1,234 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverPunchNodeVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机端节点卡片上的异常标签与「相关异常」列表。 + *

+ * 两个字段回答两个不同的问题:这个节点上挂着哪些未了结异常、这张运单有没有整单异常。 + * 整单异常不归属任何节点,所以每个可打卡节点都挂它——包括已打卡的与在途的; + * 而某条节点异常只算它自己那个节点,被它牵连阻断的后续节点不算相关。 + */ +class DriverPunchNodeCardTest { + + private static final Long WAYBILL_ID = 5501L; + + /** 接单不参与打卡,故不出现在节点卡片里 */ + private static final String PROCESS_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private IWaybillService waybillService; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper); + + Waybill waybill = waybill(); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch("load"))); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("整单异常挂到每个可打卡节点,含已打卡的与在途的") + void waybillLevelExceptionIsTaggedOnEveryNode() { + stubUnresolved(nodeException("load", 11L), waybillException(12L)); + + Map nodes = punchNodes(); + + assertEquals(3, nodes.size(), "接单不是打卡节点,不该出现在卡片里"); + assertTrue(Boolean.TRUE.equals(nodes.get("load").getDone()), "装货这条流水已打卡"); + for (DriverPunchNodeVO node : nodes.values()) { + assertTrue(Boolean.TRUE.equals(node.getHasWaybillException()), + "整单异常作用于整张运单," + node.getKey() + " 也该挂标签"); + assertTrue(node.getRelatedExceptionIds().contains("12"), + node.getKey() + " 的相关异常里应当有整单异常"); + } + } + + @Test + @DisplayName("节点自己的异常只进它自己的相关列表,不牵连后续节点") + void nodeExceptionOnlyBelongsToItsOwnNode() { + stubUnresolved(nodeException("load", 11L)); + + Map nodes = punchNodes(); + + assertTrue(Boolean.TRUE.equals(nodes.get("load").getHasException())); + assertEquals(List.of("11"), nodes.get("load").getRelatedExceptionIds()); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasException()), + "签收只是被装货的异常牵连阻断,不挂「异常」标签"); + assertTrue(nodes.get("sign").getRelatedExceptionIds().isEmpty()); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasWaybillException())); + } + + @Test + @DisplayName("两种异常同时存在时,该节点的相关列表有两条且按上报时间升序") + void bothKindsAreListedInReportOrder() { + stubUnresolved(nodeException("load", 11L), waybillException(12L)); + + DriverPunchNodeVO load = punchNodes().get("load"); + + assertEquals(List.of("11", "12"), load.getRelatedExceptionIds()); + } + + @Test + @DisplayName("异常全部了结后两个字段都回到「无」") + void withoutUnresolvedExceptionsNoTagAndNoRelatedIds() { + Map nodes = punchNodes(); + + for (DriverPunchNodeVO node : nodes.values()) { + assertFalse(Boolean.TRUE.equals(node.getHasWaybillException())); + assertTrue(node.getRelatedExceptionIds().isEmpty()); + } + } + + @Test + @DisplayName("未了结异常按上报时间升序取出,卡片顺序才有意义") + void unresolvedExceptionsAreReadInReportOrder() { + stubUnresolved(nodeException("load", 11L)); + + punchNodes(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(exceptionDisposalMapper, atLeastOnce()).selectList(captor.capture()); + for (LambdaQueryWrapper wrapper : captor.getAllValues()) { + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("report_time"), "未了结异常要按上报时间取:" + sql); + assertTrue(sql.contains("disposal_status"), "未了结的判据要落在处置状态上:" + sql); + } + } + + @Test + @DisplayName("相关异常 id 序列化成字符串,前端拿到大整数不会丢精度") + void relatedExceptionIdsSerializeAsStrings() { + stubUnresolved(nodeException("load", 9007199254740993L)); + + String json = JsonUtil.toJson(punchNodes().get("load")); + + assertTrue(json.contains("\"9007199254740993\""), json); + } + + // ------------------------------------------------------------ 夹具 + + private void stubUnresolved(ExceptionDisposal... disposals) { + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(disposals)); + } + + private Map punchNodes() { + return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream() + .collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity())); + } + + private ExceptionDisposal nodeException(String nodeCode, Long id) { + return exception(id, nodeCode, LocalDateTime.of(2026, 10, 6, 9, 30)); + } + + private ExceptionDisposal waybillException(Long id) { + return exception(id, null, LocalDateTime.of(2026, 10, 6, 10, 0)); + } + + private ExceptionDisposal exception(Long id, String nodeCode, LocalDateTime reportTime) { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(id); + disposal.setWaybillId(WAYBILL_ID); + disposal.setNodeCode(nodeCode); + disposal.setDisposalStatus("pending"); + disposal.setIsDeleted(0); + disposal.setReportTime(reportTime); + return disposal; + } + + private WaybillNodePunch nodePunch(String nodeCode) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6501L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + punch.setPunchTime(new Date()); + return punch; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java new file mode 100644 index 00000000..1aef3f7c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -0,0 +1,250 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverNodePunchVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机打完末位打卡节点后运单自动完成。 + *

+ * 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、 + * 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水, + * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。 + */ +class DriverWaybillAutoCompleteTest { + + private static final Long WAYBILL_ID = 5801L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; + + /** 末位打卡节点是「签收」,没有回单节点 */ + private static final String SIGN_LAST_JSON = """ + [{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + /** 末位打卡节点是「签收」,回单启用且需确认完成 */ + private static final String SIGN_LAST_RETURN_CONFIRM_JSON = """ + [{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 末位打卡节点是「发货」,不是到货/卸货/签收 */ + private static final String DISPATCH_LAST_JSON = """ + [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}] + """; + + private IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + exceptionDisposalMapper); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.updateById(any())).thenReturn(true); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 命中 + + @Test + @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted") + void lastDeliveryPunchCompletesWaybill() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成"); + } + + @Test + @DisplayName("waybillCompleted 取完成接口的返回值,不按判定命中臆断") + void completionFlagFollowsServiceReturn() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(false); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成"); + } + + // ------------------------------------------------------------ 不命中 + + @Test + @DisplayName("末位是发货时只打卡不完成") + void punchOnNonDeliveryLastNodeDoesNotComplete() { + givenWaybill(DISPATCH_LAST_JSON, "running"); + + DriverNodePunchVO vo = submit("dispatch"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("回单启用且需确认完成时只打卡不完成") + void returnNeedingConfirmationDoesNotComplete() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + // ------------------------------------------------------------ 前置:运单本身能否完成 + + @Test + @DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功") + void draftWaybillIsPunchedButNotCompleted() { + givenWaybill(SIGN_LAST_JSON, "draft"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("待执行与调度中间态同样允许自动完成") + void dispatchableStatusesStillComplete() { + for (String status : List.of("pending", "waiting_dispatch", "dispatching")) { + givenWaybill(SIGN_LAST_JSON, status); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态"); + } + } + + @Test + @DisplayName("有未了结异常时不自动完成——与司机端「完成运单」同一口径,异常不落在打卡节点上也拦") + void unresolvedExceptionBlocksAutoComplete() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload"))); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + // ------------------------------------------------------------ 夹具 + + private DriverNodePunchVO submit(String nodeCode) { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode(nodeCode); + dto.setPhotoItems(List.of(photo("打卡照片", CLOCK_PHOTO))); + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return service.submitNode(dto); + } + } + + private void givenWaybill(String processJson, String businessStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610080001"); + waybill.setVehicleNo(PLATE); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setIsDeleted(0); + when(waybillService.getOne(any())).thenReturn(waybill); + } + + private PunchPhotoDTO photo(String type, String url) { + PunchPhotoDTO photo = new PunchPhotoDTO(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private ExceptionDisposal unresolvedException(String nodeCode) { + ExceptionDisposal exception = new ExceptionDisposal(); + exception.setNodeCode(nodeCode); + exception.setDisposalStatus("processing"); + return exception; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java new file mode 100644 index 00000000..86033d39 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -0,0 +1,138 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 司机端卡片上的发货地 / 到货地。 + *

+ * 地点名称与详细地址是两列:批量导入的运单只填了地址(导入模板里没有「发货地」「收货地」列), + * 名称为空时卡片要回退到地址,否则司机端的地点位置是空的。 + */ +class DriverWaybillCardLocationTest { + + private static final Long WAYBILL_ID = 5601L; + + private static final String PROCESS_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private static final String DEPARTURE_ADDRESS = "广西壮族自治区南宁市西乡塘区安吉大道"; + private static final String ARRIVAL_ADDRESS = "广西壮族自治区河池市金城江区金城中路1号"; + + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + WaybillNodePunchMapper nodePunchMapper = mock(WaybillNodePunchMapper.class); + WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper); + + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("地点名称为空(导入的运单)时,名称回退到详细地址") + void nameFallsBackToAddressWhenBlank() { + Waybill waybill = waybill(); + waybill.setDepartureName(null); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName(""); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals(DEPARTURE_ADDRESS, card.getFromName()); + assertEquals(ARRIVAL_ADDRESS, card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + assertEquals(DEPARTURE_ADDRESS, card.getPickupAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getUnloadAddress()); + } + + @Test + @DisplayName("地点名称有值时用它,不被地址覆盖") + void nameWinsWhenPresent() { + Waybill waybill = waybill(); + waybill.setDepartureName("南宁市西乡塘区"); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName("河池市金城江区"); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals("南宁市西乡塘区", card.getFromName()); + assertEquals("河池市金城江区", card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD-20261007-0006"); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java new file mode 100644 index 00000000..483ed637 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -0,0 +1,113 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 节点是否已打卡的判定。 + *

+ * 它读的是打卡流水表,而不是运单的当前节点:管理端取消打卡会把流水逻辑删除, + * 那时这里必须立刻回到未打卡,否则已取消的打卡仍会挡住异常上报。 + * 节点编码按忽略大小写比对——流水里的写法与过程配置的 key 不保证一致。 + */ +class DriverWaybillNodePunchTest { + + private static final Long WAYBILL_ID = 5201L; + + private WaybillNodePunchMapper nodePunchMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + } + + @BeforeEach + void setUp() { + nodePunchMapper = mock(WaybillNodePunchMapper.class); + service = new DriverWaybillServiceImpl( + mock(IWaybillService.class), + mock(IDriverService.class), + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + mock(ExceptionDisposalMapper.class)); + } + + @Test + @DisplayName("没有该节点的打卡流水就是未打卡") + void withoutPunchRecordNodeIsNotPunched() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + assertFalse(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("有该节点的打卡流水即已打卡") + void punchRecordMakesNodePunched() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("load"))); + + assertTrue(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("别的节点的打卡流水不算本节点已打卡") + void otherNodePunchDoesNotCount() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("arrive_scene"))); + + assertFalse(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("流水里的节点编码大小写与查询不一致时仍算同一节点") + void nodeCodeComparisonIgnoresCase() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("LOAD"))); + + assertTrue(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("运单或节点缺失时判为未打卡,不抛异常") + void missingArgumentsMeanNotPunched() { + assertFalse(service.isNodePunched(null, "load")); + assertFalse(service.isNodePunched(WAYBILL_ID, " ")); + } + + private WaybillNodePunch punch(String nodeCode) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6201L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + return punch; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java new file mode 100644 index 00000000..035a2db1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -0,0 +1,171 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 + *

+ * 口径:当前用户手机号匹配运单 {@code driverPhone}。 + * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 + */ +class DriverWaybillOwnershipTest { + + private static final Long WAYBILL_ID = 5301L; + private static final Long USER_ID = 7001L; + private static final String PHONE = "13800138000"; + + private IDriverService driverService; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + } + + @Test + @DisplayName("登录账号没有司机档案时被拒:管理端与调度员不提供代报") + void withoutDriverProfileIsRejected() { + when(driverService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("未找到当前登录司机档案", exception.getMessage()); + verify(waybillService, never()).getOne(any()); + } + + @Test + @DisplayName("司机档案与登录态都没有手机号时被拒") + void withoutPhoneIsRejected() { + Driver driver = driver(null); + when(driverService.getOne(any())).thenReturn(driver); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage()); + } + + @Test + @DisplayName("运单司机手机号与当前用户不一致时被拒") + void waybillOfAnotherPhoneIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("本人手机号运单放行,且查询确实按司机手机号收敛") + void ownWaybillPasses() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); + when(waybillService.getOne(any())).thenReturn(waybill()); + + assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).getOne(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + assertTrue(wrapper.getTargetSql().contains("driver_phone"), + "归属查询必须落在运单司机手机号上:" + wrapper.getTargetSql()); + assertTrue(wrapper.getParamNameValuePairs().values().stream() + .anyMatch(PHONE::equals), + "归属查询必须带上当前用户手机号:" + wrapper.getParamNameValuePairs()); + } + + @Test + @DisplayName("运单ID为空时被拒") + void withoutWaybillIdIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(null)); + + assertEquals("运单ID不能为空", exception.getMessage()); + } + + // ------------------------------------------------------------ 夹具 + + private void runOwnershipCheck(Long waybillId) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUserAccount).thenReturn(PHONE); + service.assertCurrentDriverOwnsWaybill(waybillId); + } + } + + private Driver driver(String mobile) { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDriverName("韦司机"); + driver.setMobile(mobile); + return driver; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setDriverPhone(PHONE); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java new file mode 100644 index 00000000..3553c9c3 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -0,0 +1,232 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机拒单后运单对本人脱离。 + *

+ * 拒单不再只是一个标记:该运单从列表、计数、详情到接单全部不可达,等管理员重新派单。 + *

+ * 拦截落在司机端共用的取单条件上,而单元测试里 {@code waybillService} 是 mock、不会真的执行 + * 这个条件,所以「已拒单被排除」只能断言条件本身;过滤之后的行为则用「已过滤的输入」来验, + * 不假装 mock 会替我们过滤。 + */ +class DriverWaybillRejectDetachTest { + + private static final Long USER_ID = 7401L; + private static final Long DRIVER_ID = 9001L; + private static final String PLATE = "沪AE31312"; + private static final Long REJECTED_ID = 7201L; + + private IDriverService driverService; + private CertificationGate certificationGate; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + certificationGate = mock(CertificationGate.class); + lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + certificationGate, + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + lenient().when(driverService.getOne(any())).thenReturn(driver()); + lenient().when(waybillService.updateById(any())).thenReturn(true); + // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; + // 真实实现是把入参校正后返回同一个对象 + lenient().when(waybillService.syncDriverAcceptState(any())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列") + void pendingPreviewQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.pendingPreview(5)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), + "取单条件必须排除已拒单:" + sql); + assertTrue(sql.toUpperCase().contains("AND"), + "排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql); + } + + @Test + @DisplayName("运单列表的取单条件同样排除已拒单") + void pageQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.page(1, 10, 0, null)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), "列表也要排除已拒单:" + sql); + } + + @Test + @DisplayName("Tab 计数不再把已拒单算进待接单") + void tabCountsOnFilteredInputCountsOnlyUnrejected() { + // 查询层已排除拒单后落回来的输入:一张待接、一张运输中、一张已完成 + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"), + waybill(7204L, "YD-20261001-0004", "completed", "accepted"))); + + DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tabCounts()); + + assertEquals(1, counts.getPending(), "待接单只算那张未被拒的"); + assertEquals(1, counts.getDoing()); + assertEquals(1, counts.getDone()); + assertEquals(3, counts.getAll(), "全部 = 待接 + 进行中 + 已完成"); + } + + @Test + @DisplayName("未拒单的运单不被误伤:待接与运输中照常出现") + void unrejectedWaybillsUnaffected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"))); + + IPage page = runAsDriver(() -> service.page(1, 10, null, null)); + + assertEquals(2, page.getTotal(), "未被拒的运单条数不变"); + assertEquals(2, page.getRecords().size()); + } + + @Test + @DisplayName("已拒单运单的详情不可达:取单条件已把它挡住") + void detailOfRejectedIsUnreachable() { + // 被取单条件排除后 getOne 落回 null,与「不属于这个司机」同一条报错 + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.detail(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("已拒单运单不能自行接回来") + void acceptOfRejectedIsRejected() { + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.accept(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("在途打卡保留原取单判据,不排除已拒单的历史脏数据") + void enrouteSubmitKeepsUnfilteredScope() { + EnrouteSubmitDTO dto = new EnrouteSubmitDTO(); + dto.setWaybillId(REJECTED_ID); + when(waybillService.getOne(any())).thenReturn(rejectedWaybill()); + + // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, + // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.submitEnroute(dto))); + + assertEquals("该运单未启用在途打卡", exception.getMessage(), + "在途打卡不应因运单已拒单而在取单阶段被挡下"); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).getOne(any()); + } + + // ------------------------------------------------------------ 夹具 + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return action.get(); + } + } + + private LambdaQueryWrapper capturedListQuery() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).list(captor.capture()); + return captor.getValue(); + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill rejectedWaybill() { + return waybill(REJECTED_ID, "YD-20261010-0001", "pending", "rejected"); + } + + private Waybill waybill(Long id, String no, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(no); + waybill.setVehicleNo(PLATE); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java new file mode 100644 index 00000000..9d0dd4a1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -0,0 +1,115 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常处置列表按运单过滤。 + *

+ * 过滤必须落在运单主键上:运单号是模糊匹配,{@code YD001} 会命中 {@code YD0011}, + * 从某个节点点进来的「本运单异常」会多出别人的记录。 + */ +class ExceptionDisposalListQueryTest { + + private static final Long WAYBILL_ID = 5401L; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + service = new ExceptionDisposalServiceImpl( + mock(ExceptionDisposalFollowRecordMapper.class), + mock(IWaybillService.class), + mock(IDriverWaybillService.class)); + inject("baseMapper", disposalMapper); + when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); + } + + @Test + @DisplayName("传运单ID时按主键精确过滤,而不是拿运单号模糊匹配") + void waybillIdFilterIsExact() { + ExceptionDisposalVO query = new ExceptionDisposalVO(); + query.setWaybillId(WAYBILL_ID); + + LambdaQueryWrapper wrapper = capturedQuery(query); + + assertTrue(wrapper.getTargetSql().contains("waybill_id ="), + "按运单过滤要用等值:" + wrapper.getTargetSql()); + assertTrue(wrapper.getParamNameValuePairs().values().stream() + .anyMatch(value -> WAYBILL_ID.equals(value) + || (value instanceof Collection values && values.contains(WAYBILL_ID))), + "过滤条件要带上运单ID:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getTargetSql().contains("waybill_no"), + "运单号是 like,会把前缀相同的运单一起捞回来:" + wrapper.getTargetSql()); + } + + @Test + @DisplayName("不传运单ID时不加运单条件,列表原有入口行为不变") + void withoutWaybillIdNoWaybillCondition() { + LambdaQueryWrapper wrapper = capturedQuery(new ExceptionDisposalVO()); + + assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql()); + } + + // ------------------------------------------------------------ 夹具 + + private LambdaQueryWrapper capturedQuery(ExceptionDisposalVO query) { + IPage page = service.selectPage(new Page<>(1, 10), query); + assertTrue(page.getRecords().isEmpty()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectPage(any(), captor.capture()); + return captor.getValue(); + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java new file mode 100644 index 00000000..be77a57c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -0,0 +1,379 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常上报门禁:已打卡的节点、已有未了结异常的节点都不能再上报。 + *

+ * 这里的口径决定「这个节点还能不能再报一条异常」,与打卡门禁是两回事: + * 一个管还能不能报,一个管还能不能打卡。两者共用同一份未了结判定, + * 但解除条件互不相同,所以断言分开下。 + */ +class ExceptionDisposalReportGateTest { + + private static final Long WAYBILL_ID = 5101L; + private static final Long USER_ID = 7001L; + + private ExceptionDisposalMapper disposalMapper; + private IWaybillService waybillService; + private IDriverWaybillService driverWaybillService; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + waybillService = mock(IWaybillService.class); + driverWaybillService = mock(IDriverWaybillService.class); + service = new ExceptionDisposalServiceImpl( + mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService); + inject("baseMapper", disposalMapper); + + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); + when(disposalMapper.selectCount(any())).thenReturn(0L); + when(disposalMapper.insert(any(ExceptionDisposal.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 已打卡 + + @Test + @DisplayName("已打卡的普通节点不能再上报异常") + void punchedNodeRejectsReport() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已打卡,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("在途已打卡仍可上报:它可重复打卡、没有完成含义,打卡与否不说明环节已经过去") + void punchedTransitNodeStillAcceptsReport() { + stubNode("transit", "在途", "transit"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "transit")).thenReturn(true); + + submitOk("transit"); + + assertEquals("transit", savedDisposal().getNodeCode()); + } + + @Test + @DisplayName("未打卡的普通节点照常可上报") + void unpunchedNodeAcceptsReport() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + + submitOk("load"); + + assertEquals("load", savedDisposal().getNodeCode()); + assertEquals("装货", savedDisposal().getNodeName()); + } + + // ------------------------------------------------------------ 已有未了结异常 + + @Test + @DisplayName("节点已有待处理异常时拒绝再报") + void pendingNodeExceptionRejectsReport() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(1L); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + } + + @Test + @DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁") + void processingNodeExceptionRejectsReport() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(1L); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + List params = capturedUnresolvedParams(); + assertTrue(params.contains("completed") && params.contains("revoked"), + "未了结 = 处置状态不在这两个终态里,故「处理中」也占位:" + params); + } + + @Test + @DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报") + void settledNodeExceptionAllowsReportAgain() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(0L); + + submitOk("load"); + + assertEquals("load", savedDisposal().getNodeCode()); + assertTrue(capturedUnresolvedParams().containsAll(List.of("completed", "revoked")), + "只有已完成、已撤销解除门禁,SQL 必须把它们排除在未了结之外"); + } + + @Test + @DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上") + void otherNodesExceptionDoesNotBlockReport() { + stubNode("unload", "卸货", "cargo"); + stubUnresolvedCount(0L); + + submitOk("unload"); + + assertEquals("unload", savedDisposal().getNodeCode()); + assertTrue(capturedUnresolvedParams().contains("unload"), + "节点归属条件落在 SQL 上,查的是本次上报的节点:" + capturedUnresolvedParams()); + } + + @Test + @DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点") + void nodeCodeComparisonIgnoresCase() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(1L); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(TRIM(node_code)) ="), + "忽略大小写要落在 SQL 上:" + capturedUnresolvedQuery().getTargetSql()); + assertTrue(capturedUnresolvedParams().contains("load"), + "参数取节点 key 的小写形式:" + capturedUnresolvedParams()); + } + + @Test + @DisplayName("门禁拿过程配置里的节点 key 去比对,而不是请求体里那串写法") + void gateUsesNodeKeyResolvedFromProcessConfig() { + // 请求体写的是大写,过程配置里的 key 是小写;打卡流水按配置的 key 落库 + when(driverWaybillService.findPunchNode(WAYBILL_ID, "LOAD")).thenReturn(node("load", "装货", "cargo")); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("LOAD"); + + assertEquals("该节点已打卡,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("未了结异常的查询按运单收敛,并排除已删除的行") + void unresolvedQueryIsScopedToWaybillAndSkipsDeletedRows() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + + submitOk("load"); + + String sql = capturedUnresolvedQuery().getTargetSql(); + + assertTrue(sql.contains("waybill_id"), + "未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql); + assertTrue(sql.contains("is_deleted"), + "已删除的异常不算未了结:" + sql); + assertTrue(capturedUnresolvedParams().contains(WAYBILL_ID), + "运单条件取本次上报的运单:" + capturedUnresolvedParams()); + } + + // ------------------------------------------------------------ 整单异常不受门禁 + + @Test + @DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报") + void waybillLevelReportIsNotGated() { + stubUnresolvedCount(1L); + + submitOk(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).insert(captor.capture()); + assertNull(captor.getValue().getNodeCode(), "整单异常没有节点,不得凭空补一个"); + verify(driverWaybillService, never()).findPunchNode(any(), any()); + verify(driverWaybillService, never()).isNodePunched(any(), any()); + verify(disposalMapper, never()).selectCount(any()); + } + + @Test + @DisplayName("上报人取登录态,请求体里的上报人一律被忽略") + void reporterComesFromLoginState() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + ExceptionDisposal request = request("load"); + request.setReporterId(999999L); + request.setReporterName("冒充的人"); + + runSubmit(request); + + assertEquals(USER_ID, savedDisposal().getReporterId()); + assertEquals("韦司机", savedDisposal().getReporterName()); + } + + // ------------------------------------------------------------ 上报人归属 + + @Test + @DisplayName("上报人不属于该运单时在落库前就被拒") + void reportIsRejectedWhenReporterDoesNotOwnWaybill() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + doThrow(new ServiceException("运单不存在或无权操作")) + .when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("整单异常同样校验上报人:没有节点不等于没有上报人") + void waybillLevelReportAlsoChecksReporterOwnership() { + doThrow(new ServiceException("未找到当前登录司机档案")) + .when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID); + + ServiceException exception = submitExpectRejected(null); + + assertEquals("未找到当前登录司机档案", exception.getMessage()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubNode(String key, String name, String type) { + when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type)); + when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false); + } + + private void stubUnresolvedCount(long count) { + when(disposalMapper.selectCount(any())).thenReturn(count); + } + + /** 上报门禁查未了结异常的那条 count 查询 */ + private LambdaQueryWrapper capturedUnresolvedQuery() { + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectCount(captor.capture()); + return captor.getValue(); + } + + /** 上述查询的参数值,集合型参数摊平成元素 */ + private List capturedUnresolvedParams() { + LambdaQueryWrapper query = capturedUnresolvedQuery(); + // MyBatis-Plus 的条件参数是惰性的:取过 SQL 片段之后 paramNameValuePairs 里才有值 + query.getTargetSql(); + Collection values = query.getParamNameValuePairs().values(); + List params = new ArrayList<>(); + for (Object value : values) { + if (value instanceof Collection nested) { + params.addAll(nested); + } else { + params.add(value); + } + } + return params; + } + + private ServiceException submitExpectRejected(String nodeCode) { + ServiceException exception = assertThrows(ServiceException.class, () -> runSubmit(request(nodeCode))); + // 门禁在落库之前:被拒时库里不能多一行 + verify(disposalMapper, never()).insert(any(ExceptionDisposal.class)); + return exception; + } + + private void submitOk(String nodeCode) { + runSubmit(request(nodeCode)); + } + + private void runSubmit(ExceptionDisposal request) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class); + MockedStatic userCache = mockStatic(UserDisplayCache.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦司机"); + service.submitReport(request); + } + } + + private ExceptionDisposal savedDisposal() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).insert(captor.capture()); + return captor.getValue(); + } + + private ExceptionDisposal request(String nodeCode) { + ExceptionDisposal request = new ExceptionDisposal(); + request.setWaybillId(WAYBILL_ID); + request.setExceptionType("cargo_damage"); + request.setReportDescription("装货时发现外包装破损"); + request.setNodeCode(nodeCode); + return request; + } + + private Map node(String key, String name, String type) { + Map cfg = new HashMap<>(); + cfg.put("key", key); + cfg.put("name", name); + cfg.put("type", type); + cfg.put("enabled", true); + cfg.put("punch", true); + return cfg; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setVehicleNo("桂AAW3255"); + waybill.setIsDeleted(0); + return waybill; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java new file mode 100644 index 00000000..1d27f177 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java @@ -0,0 +1,198 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.AdminTodoItemVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.IWaybillService; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 调度端首页待处理事项:异常待处置 + 重新派单待处理。 + *

+ * 拒单条目的判据是「司机已拒单且运单未取消未完成」,必须与待处理运单列表的 needReassign + * 口径一致,否则两个页面的条数对不上;卡片上「多久之前」取拒单时刻,不取最后修改时间 + * (后者会被任何无关改动刷新)。 + *

+ * 这里直接验归并方法本身,不走 {@code home()}:那个入口会解析当前用户名、依赖 Spring 缓存, + * 在单测里没有上下文。 + */ +class ManageAdminHomeFeedTest { + + private static final Long EXCEPTION_ID = 8801L; + private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001"; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + private IExceptionDisposalService exceptionService; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + exceptionService = mock(IExceptionDisposalService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionService, + mock(IRiskDisposalService.class), + mock(IDriverWaybillService.class)); + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + } + + @Test + @DisplayName("拒单运单产出一条重新派单待处理,字段与跳转参数对齐小程序约定") + void rejectedWaybillAppearsAsReassignTodo() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7201L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + AdminTodoItemVO item = feed.get(0); + assertEquals("reassign", item.getType()); + assertEquals("重新派单待处理", item.getTitle()); + assertEquals("重新派单", item.getActionLabel()); + assertEquals(REJECTED_WAYBILL_NO, item.getWaybillNo()); + assertEquals(Long.valueOf(7201L), item.getId(), "重派条目应当带运单主键"); + assertEquals("运单已指派黄大富,被拒绝,请重新派单:时间冲突", item.getDesc()); + assertEquals("/pages/waybill/reassign?id=7201&buyerPaid=0", item.getTargetUrl(), + "跳转参数必须是运单主键:该页按主键回显运单,填运单号读不出数据"); + assertFalse(item.getTimeAgo().isEmpty(), "拒单时刻应换算成相对时间"); + } + + @Test + @DisplayName("拒单原因为空时正文不带空冒号") + void withoutRejectReasonKeepsDescClean() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7202L, "黄宇富", null))); + + AdminTodoItemVO item = service.buildAdminFeed().get(0); + + assertEquals("运单已指派黄宇富,被拒绝,请重新派单", item.getDesc()); + assertFalse(item.getDesc().contains(":"), "原因缺失时不得留下空的「:」"); + } + + @Test + @DisplayName("没有拒单时只出异常条目,不凭空造重派条目") + void withoutRejectedWaybillNoReassignTodo() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + assertEquals("exception", feed.get(0).getType()); + assertEquals("异常待处置", feed.get(0).getTitle()); + } + + @Test + @DisplayName("两类条目按各自业务发生时间倒序混排") + void feedIsMergedByBusinessTime() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7203L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(2, feed.size()); + assertEquals(List.of("reassign", "exception"), + feed.stream().map(AdminTodoItemVO::getType).collect(Collectors.toList()), + "拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前"); + } + + @Test + @DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与") + void reassignQueryExcludesTerminalStatus() { + service.buildAdminFeed(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); + String sql = wrapper.getTargetSql(); + + assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql); + assertTrue(sql.contains("business_status"), + "已取消/已完成的运单不该再催重新派单:" + sql); + + Set values = new HashSet<>(wrapper.getParamNameValuePairs().values()); + assertTrue(values.contains("rejected"), "要带上已拒绝:" + values); + assertTrue(values.contains("pending") && values.contains("running"), + "业务状态只应放待执行与进行中:" + values); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill rejectedWaybill(Long id, String driverName, String reason) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(REJECTED_WAYBILL_NO); + waybill.setDriverName(driverName); + waybill.setDriverAcceptStatus("rejected"); + waybill.setBusinessStatus("pending"); + waybill.setDriverRejectReason(reason); + waybill.setDriverRejectTime(yesterdayAt22()); + return waybill; + } + + private ExceptionDisposal exceptionDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(EXCEPTION_ID); + disposal.setWaybillNo("YD-20261006-0001"); + disposal.setReporterName("黄宇富"); + disposal.setExceptionType("交通异常"); + disposal.setDisposalStatus("pending"); + disposal.setReportTime(LocalDateTime.now().minusHours(26)); + return disposal; + } + + private Date yesterdayAt22() { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.add(java.util.Calendar.DAY_OF_MONTH, -1); + calendar.set(java.util.Calendar.HOUR_OF_DAY, 22); + calendar.set(java.util.Calendar.MINUTE, 0); + calendar.set(java.util.Calendar.SECOND, 0); + calendar.set(java.util.Calendar.MILLISECOND, 0); + return calendar.getTime(); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java new file mode 100644 index 00000000..e4639e36 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -0,0 +1,378 @@ +package org.springblade.transport.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.service.IDriverAppService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 个人认证的「提交 → 生效值不变 → 审核通过 → 生效值更新」闭环测试。 + *

+ * 这是司机认证最关键的一条业务闭环,也是单看提交侧或审核侧都验证不到的: + * 提交之后司机侧查到的必须仍是**旧生效值**(否则等于提交即自动认证), + * 审核通过之后才变成**新值**(否则认证永远不生效)。 + *

+ * 用一个内存版的记录存储把两侧接起来,避免两边各自 mock 成「恰好一致」。 + */ +class PersonCertRoundTripTest { + + private static final Long DRIVER_ID = 9001L; + + private IDriverAppService driverAppService; + private IDriverService driverService; + private ITransportCertRecordService certRecordService; + private TransportCertRecordMapper certRecordMapper; + private DriverCertServiceImpl driverCertService; + private CertAuditServiceImpl certAuditService; + + /** + * 内存记录表:Id → 记录 + */ + private final Map recordStore = new HashMap<>(); + private Driver driver; + private long idSequence = 100L; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + + @BeforeEach + void setUp() { + recordStore.clear(); + idSequence = 100L; + + driverAppService = mock(IDriverAppService.class); + driverService = mock(IDriverService.class); + certRecordService = mock(ITransportCertRecordService.class); + certRecordMapper = mock(TransportCertRecordMapper.class); + ITransportVehicleService transportVehicleService = mock(ITransportVehicleService.class); + + driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setDriverName("张三"); + when(driverAppService.currentDriverEntity()).thenAnswer(invocation -> driver); + // 司机表只持有指针,指针是被审核链路改的,这里让桩跟着内存状态走; + // 证件字段也要跟着落地,否则「审核通过写回档案」这条链路在测试里验不到 + when(driverService.updateById(any(Driver.class))).thenAnswer(invocation -> { + Driver patch = invocation.getArgument(0); + if (patch.getEffectiveCertRecordId() != null) { + driver.setEffectiveCertRecordId(patch.getEffectiveCertRecordId()); + } + copyCertFields(patch, driver); + return true; + }); + + stubRecordStore(); + + driverCertService = new DriverCertServiceImpl(driverAppService, driverService, + transportVehicleService, certRecordService); + certAuditService = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService); + } + + @Test + @DisplayName("提交后仍是旧生效值,审核通过后才变新值") + void submitThenApprove() { + // 1) 先走一遍完整流程,让该司机有一个生效值 + Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + certAuditService.audit(firstRecordId, true, null); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "审核通过后应能查到生效值"); + + // 2) 提交新内容:生效值必须**不变** + Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "提交只产生待审记录,不得改变生效值——否则等于提交即自动认证"); + assertEquals(firstRecordId, driver.getEffectiveCertRecordId(), + "指针应仍指向原来那条已通过记录"); + + // 3) 审核通过:生效值才变成新值 + certAuditService.audit(secondRecordId, true, null); + assertEquals("张三改名", driverCertService.getEffectivePersonCert().getRealName(), + "审核通过后生效值应切到新记录"); + assertEquals(secondRecordId, driver.getEffectiveCertRecordId()); + } + + @Test + @DisplayName("驳回后生效值不变(已认证对象不会被驳回下线)") + void rejectKeepsEffectiveValue() { + Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + certAuditService.audit(firstRecordId, true, null); + + Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); + certAuditService.audit(secondRecordId, false, "证件照片模糊"); + + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "驳回只终结该次提交,既得认证必须保持有效"); + assertEquals(firstRecordId, driver.getEffectiveCertRecordId()); + } + + @Test + @DisplayName("从未认证过时,审核通过前查不到生效值") + void noEffectiveBeforeFirstApproval() { + assertNull(driverCertService.getEffectivePersonCert()); + + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + assertNull(driverCertService.getEffectivePersonCert(), + "待审期间不应有生效值"); + + certAuditService.audit(recordId, true, null); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName()); + } + + @Test + @DisplayName("连续提交两次,不存在两条待审,且旧待审被作废") + void consecutiveSubmitsLeaveSinglePending() { + Long first = driverCertService.submitPersonCert(personCert("第一次", "110101199001011234")); + Long second = driverCertService.submitPersonCert(personCert("第二次", "110101199001011234")); + + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, recordStore.get(first).getStatus(), + "前一条待审应被作废"); + assertEquals(TransportCertRecord.STATUS_PENDING, recordStore.get(second).getStatus()); + + long pendingCount = recordStore.values().stream() + .filter(record -> TransportCertRecord.STATUS_PENDING.equals(record.getStatus())) + .count(); + assertEquals(1L, pendingCount, "同一司机至多一条待审"); + + // 被作废的那条不能再被审核(否则会把已作废的记录变成生效值) + org.junit.jupiter.api.Assertions.assertThrows( + org.springblade.core.log.exception.ServiceException.class, + () -> certAuditService.audit(first, true, null)); + } + + @Test + @DisplayName("已通过的记录不可再被审核(防止把生效快照改坏)") + void approvedRecordCannotBeReaudited() { + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + certAuditService.audit(recordId, true, null); + + org.junit.jupiter.api.Assertions.assertThrows( + org.springblade.core.log.exception.ServiceException.class, + () -> certAuditService.audit(recordId, false, "事后反悔")); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "重复审核被拒后,生效值不受影响"); + } + + // ------------------------------------------------------------ 内存记录存储 + + /** + * 用内存 Map 承接记录读写,让两个服务真实地共享同一份状态 + */ + private void stubRecordStore() { + when(certRecordService.save(any(TransportCertRecord.class))).thenAnswer(invocation -> { + TransportCertRecord record = invocation.getArgument(0); + record.setId(++idSequence); + recordStore.put(record.getId(), copyOf(record)); + return true; + }); + when(certRecordService.updateById(any(TransportCertRecord.class))).thenAnswer(invocation -> { + TransportCertRecord patch = invocation.getArgument(0); + TransportCertRecord stored = recordStore.get(patch.getId()); + if (stored != null) { + if (patch.getStatus() != null) { + stored.setStatus(patch.getStatus()); + } + stored.setRejectReason(patch.getRejectReason()); + stored.setAuditTime(patch.getAuditTime()); + stored.setAuditUser(patch.getAuditUser()); + } + return true; + }); + when(certRecordService.getById(anyLong())) + .thenAnswer(invocation -> recordStore.get(invocation.getArgument(0, Long.class))); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenAnswer(invocation -> { + String bizType = invocation.getArgument(0); + Long bizId = invocation.getArgument(1); + return recordStore.values().stream() + .filter(record -> bizType.equals(record.getBizType()) + && bizId.equals(record.getBizId()) + && TransportCertRecord.STATUS_PENDING.equals(record.getStatus())) + .findFirst() + .orElse(null); + }); + } + + private TransportCertRecord copyOf(TransportCertRecord source) { + return JSON.parseObject(JSON.toJSONString(source), TransportCertRecord.class); + } + + /** + * 模拟框架的 not_null 更新策略:patch 里为 null 的列不写,非 null 才覆盖 + *

+ * 单测里直接整对象覆盖会丢掉这个语义,从而验不出「管理端填过、司机端没填的字段被清空」 + * 这类只有真实更新策略才暴露的问题。 + */ + private void copyCertFields(Driver patch, Driver target) { + if (patch.getDriverName() != null) { + target.setDriverName(patch.getDriverName()); + } + if (patch.getIdCardNo() != null) { + target.setIdCardNo(patch.getIdCardNo()); + } + if (patch.getIdCardFront() != null) { + target.setIdCardFront(patch.getIdCardFront()); + } + if (patch.getIdCardBack() != null) { + target.setIdCardBack(patch.getIdCardBack()); + } + if (patch.getHeadPhoto() != null) { + target.setHeadPhoto(patch.getHeadPhoto()); + } + if (patch.getDrivingType() != null) { + target.setDrivingType(patch.getDrivingType()); + } + if (patch.getDrivingLicenseNo() != null) { + target.setDrivingLicenseNo(patch.getDrivingLicenseNo()); + } + if (patch.getDrivingLicenseFront() != null) { + target.setDrivingLicenseFront(patch.getDrivingLicenseFront()); + } + if (patch.getDrivingLicenseBack() != null) { + target.setDrivingLicenseBack(patch.getDrivingLicenseBack()); + } + if (patch.getQualificationType() != null) { + target.setQualificationType(patch.getQualificationType()); + } + if (patch.getQualificationNo() != null) { + target.setQualificationNo(patch.getQualificationNo()); + } + if (patch.getQualificationFront() != null) { + target.setQualificationFront(patch.getQualificationFront()); + } + if (patch.getQualificationBack() != null) { + target.setQualificationBack(patch.getQualificationBack()); + } + } + + private PersonCertSubmitRequest personCert(String realName, String idCardNo) { + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName(realName); + request.setIdCardNo(idCardNo); + request.setIdCardFrontUrl("http://example.com/front.jpg"); + request.setIdCardBackUrl("http://example.com/back.jpg"); + return request; + } + + @Test + @DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致") + void approveWritesCertContentBackToDriver() { + PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); + request.setHeadPhoto("http://example.com/head.jpg"); + request.setDriverClass("A2"); + request.setDriverFileNo("450100769875"); + request.setDriverLicenseFrontUrl("http://example.com/dl-front.jpg"); + request.setDriverLicenseBackUrl("http://example.com/dl-back.jpg"); + request.setQualificationType("道路旅客运输"); + request.setQualificationNo("Q0001"); + request.setQualificationFront("http://example.com/q-front.jpg"); + request.setQualificationBack("http://example.com/q-back.jpg"); + + Long recordId = driverCertService.submitPersonCert(request); + certAuditService.audit(recordId, true, null); + + // 档案里必须是这次提交的内容:只切指针、不写内容的话, + // 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的 + assertEquals("http://example.com/head.jpg", driver.getHeadPhoto()); + assertEquals("A2", driver.getDrivingType()); + assertEquals("450100769875", driver.getDrivingLicenseNo()); + assertEquals("http://example.com/dl-front.jpg", driver.getDrivingLicenseFront()); + assertEquals("http://example.com/dl-back.jpg", driver.getDrivingLicenseBack()); + assertEquals("道路旅客运输", driver.getQualificationType()); + assertEquals("Q0001", driver.getQualificationNo()); + assertEquals("http://example.com/q-front.jpg", driver.getQualificationFront()); + assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack()); + } + + @Test + @DisplayName("审核通过:姓名与身份证号跟着认证资料走,档案与生效值不再各说各话") + void approveOverwritesIdentity() { + driver.setDriverName("张三(档案名)"); + driver.setIdCardNo("110101199001011234"); + + Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); + certAuditService.audit(recordId, true, null); + + // 审核通过即认可这次申报:档案里必须也是申报的姓名, + // 否则司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字 + assertEquals("张三改名", driver.getDriverName()); + assertEquals("110101199001011234", driver.getIdCardNo()); + } + + @Test + @DisplayName("审核通过:申报的身份证号已属于另一名司机时阻断审核,不得产生两条同号档案") + void approveRejectsIdCardTakenByAnotherDriver() { + Driver other = new Driver(); + other.setId(8888L); + other.setIdCardNo("110101199001011999"); + when(driverService.getOne(any())).thenReturn(other); + + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011999")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> certAuditService.audit(recordId, true, null)); + assertTrue(exception.getMessage().contains("另一名司机档案"), exception.getMessage()); + // 真实环境下这次审核由 @Transactional 整体回滚(记录仍是待审); + // 单测没有事务,故这里校验更直接的一点:拦截发生在写档案之前,别人的证件号没被写进来 + assertNull(driver.getIdCardNo(), "不得把属于别人的证件号写进当前档案"); + } + + @Test + @DisplayName("驳回:证件内容一个字都不写回档案") + void rejectWritesNothingBack() { + driver.setHeadPhoto("http://example.com/original-head.jpg"); + + PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); + request.setHeadPhoto("http://example.com/new-head.jpg"); + Long recordId = driverCertService.submitPersonCert(request); + certAuditService.audit(recordId, false, "证件照片模糊"); + + assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(), + "驳回只终结该次提交,档案内容不得被改动"); + } + + @Test + @DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过") + void legacySnapshotSkipsWriteBackWithoutFailingAudit() { + // 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null; + // 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败 + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + TransportCertRecord record = recordStore.get(recordId); + record.setSnapshotJson("{\"legacyField\":\"早期格式的旧内容\"}"); + + certAuditService.audit(recordId, true, null); + + assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(), + "快照读不懂不该阻断审核"); + assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换"); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java new file mode 100644 index 00000000..c61dd1ec --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java @@ -0,0 +1,225 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO; + +import java.lang.reflect.Field; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 管理端打卡时间轴的接单节点判定测试。 + *

+ * 接单节点不落打卡流水,是否完成由运单的接单事实决定:司机确认接单即为已打卡; + * 项目「无需确认接单」时运单越过接单阶段即为已打卡,但没有完成时刻。 + * 这条口径若被改成「看打卡表」,时间轴上的接单会退回恒显示未打卡。 + */ +class WaybillPunchRecordListTest { + + private static final Long WAYBILL_ID = 6101L; + private static final String WAYBILL_NO = "YD202610060002"; + + /** 需确认接单:接单节点 + 一个普通打卡节点 */ + private static final String CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"yes\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + /** 无需确认接单 */ + private static final String NO_CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"no_confirm_accept\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", enroutePunchMapper); + } + + @Test + @DisplayName("司机确认接单:接单节点显示已打卡,时间取接单时间") + void acceptNodePunchedWhenDriverAccepted() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + waybill.setDriverAcceptTime(parseTime("2026-10-06 10:04:07")); + stubWaybill(waybill); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("2026-10-06 10:04:07", accept.getPunchTime()); + assertNull(accept.getId(), "派生记录没有打卡流水,不能带流水 id"); + } + + @Test + @DisplayName("待接单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenPending() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "pending")); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertFalse(accept.getPunched()); + assertEquals("未打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime()); + } + + @Test + @DisplayName("已拒单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenRejected() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "rejected")); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单且已进入进行中:接单节点已打卡,但没有完成时刻") + void acceptNodePunchedWithoutTimeWhenNoConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "running", null)); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime(), "没有接单动作就没有完成时刻,不得拿别的字段编一个"); + } + + @Test + @DisplayName("无需确认接单但仍是草稿:接单节点未打卡") + void acceptNodeUnpunchedWhenDraft() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "draft", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单但停在待执行:接单节点未打卡") + void acceptNodeUnpunchedWhenPendingWithoutConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "pending", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("已取消的无需确认接单运单:判为已打卡——状态本身不带是否曾进入进行中的历史") + void acceptNodePunchedWhenCancelled() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "cancelled", null)); + + assertTrue(acceptRecord().getPunched()); + } + + @Test + @DisplayName("接单节点的派生不影响普通节点:有流水的普通节点照旧已打卡并带流水 id") + void otherNodesStillReadPunchTable() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "running", "accepted")); + WaybillNodePunch loadPunch = new WaybillNodePunch(); + loadPunch.setId(7001L); + loadPunch.setWaybillId(WAYBILL_ID); + loadPunch.setNodeCode("load"); + loadPunch.setNodeName("装货"); + loadPunch.setPunchTime(parseTime("2026-10-06 09:30:00")); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(loadPunch)); + + WaybillPunchRecordItemVO load = service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "load".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + assertTrue(load.getPunched()); + assertEquals("已打卡", load.getStatusName()); + assertEquals("2026-10-06 09:30:00", load.getPunchTime()); + assertEquals(7001L, load.getId()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubWaybill(Waybill waybill) { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + } + + private WaybillPunchRecordItemVO acceptRecord() { + return service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "accept".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有接单节点")); + } + + private Waybill waybill(String processJson, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } + + private static Date parseTime(String text) { + try { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text); + } catch (ParseException exception) { + throw new IllegalArgumentException(text, exception); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java new file mode 100644 index 00000000..6b41a1ef --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -0,0 +1,380 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 管理端打卡记录编辑测试。 + *

+ * 守住几件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来 + * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段; + * 订正的照片只限「每类各自最多 9 张」、不校验必填——订正的是已经发生的事实,不是重新走一遍流程。 + */ +class WaybillPunchRecordSaveTest { + + private static final Long WAYBILL_ID = 6001L; + private static final String WAYBILL_NO = "YD202610060001"; + private static final String PROCESS_JSON = "[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true," + + "\"punch\":true,\"location\":true,\"uploadCargo\":true,\"cargoTypes\":[\"重量\",\"数量\"]," + + "\"uploadVoucher\":true,\"voucherTypes\":[\"委托单\"]}]"; + + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", enroutePunchMapper); + } + + // ------------------------------------------------------------ 过程节点打卡 + + @Test + @DisplayName("补录未打卡节点:新建流水,节点取过程配置,不记司机、异常标记为否") + void insertNodePunchWhenNoneExists() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + WaybillPunchRecordSaveRequest request = request("node", "LOAD", "装货"); + request.setPunched(true); + request.setPunchTime("2026-10-06 09:30:00"); + request.setWeight("12.5"); + request.setRemark("补录"); + request.setPhotos(List.of(photo("委托单", "https://oss/a.jpg"))); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + WaybillNodePunch saved = captor.getValue(); + // 大小写不一致也应按过程配置回写节点 key,否则司机端按 key 查不到这条打卡 + assertEquals("load", saved.getNodeCode()); + assertEquals("装货", saved.getNodeName()); + assertEquals(WAYBILL_ID, saved.getWaybillId()); + assertEquals(WAYBILL_NO, saved.getWaybillNo()); + assertNotNull(saved.getPunchTime()); + assertEquals("12.5", saved.getWeight()); + assertEquals(0, saved.getExceptionFlag()); + assertNull(saved.getDriverId(), "管理端补录不能记到司机头上"); + assertNotNull(saved.getPhotos()); + assertTrue(saved.getPhotos().contains("https://oss/a.jpg"), "照片URL应写入 photos"); + assertTrue(saved.getPhotos().contains("委托单"), "凭证类型应随照片一起写入"); + verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("编辑已打卡节点:改最新一条,不新建,也不改写异常标记") + @SuppressWarnings("unchecked") + void updateLatestNodePunch() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillNodePunch existed = nodePunch(7001L, "load", "装货"); + existed.setExceptionFlag(1); + existed.setWeight("10"); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(existed)); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPunchTime("2026-10-06 11:00:00"); + request.setWeight("20"); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(nodePunchMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains("20"), "重量应写进更新语句"); + // updateById 会跳过 null 字段:清空地址/货量/照片必须由显式 set 写 null 才生效 + assertTrue(values.contains(null), "被清空的字段必须显式写 null"); + assertFalse(wrapper.getSqlSet().contains("exception_flag"), + "异常标记与异常处置表绑定,编辑打卡不得动它"); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("取消打卡:清空该节点全部流水,历史流水不得顶上来") + @SuppressWarnings("unchecked") + void deleteAllNodePunchesWhenUnpunched() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())) + .thenReturn(List.of(nodePunch(7001L, "load", "装货"), nodePunch(7002L, "load", "装货"))); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(nodePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(7001L, 7002L), new HashSet<>(captor.getValue())); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定") + void rejectAcceptNodePunchEdit() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + + WaybillPunchRecordSaveRequest punched = request("node", "accept", "接单"); + punched.setPunched(true); + assertThrows(ServiceException.class, () -> service.savePunchRecord(punched)); + + WaybillPunchRecordSaveRequest unpunched = request("node", "accept", "接单"); + unpunched.setPunched(false); + assertThrows(ServiceException.class, () -> service.savePunchRecord(unpunched)); + + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(nodePunchMapper, never()).deleteBatchIds(any()); + } + + // ------------------------------------------------------------ 打卡照片张数 + + @Test + @DisplayName("同一类型第 9 张可保存:上限按类型各自计数") + void acceptNinthPhotoOfSameType() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(sameTypePhotos("委托单", 9)); + + assertTrue(service.savePunchRecord(request)); + } + + @Test + @DisplayName("同一类型第 10 张被拒且不落库") + void rejectTenthPhotoOfSameType() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(sameTypePhotos("委托单", 10)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.savePunchRecord(request)); + + assertTrue(exception.getMessage().contains("委托单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("订正不校验照片必填:一张不传也能保存") + void missingPhotosDoNotBlockPunchRecordEdit() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(List.of()); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + assertNull(captor.getValue().getPhotos()); + } + + // ------------------------------------------------------------ 在途打卡 + + @Test + @DisplayName("编辑在途打卡:改最新一条,单张照片字段只留第一张") + @SuppressWarnings("unchecked") + void updateEnroutePunchKeepsFirstPhoto() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillEnroutePunch existed = new WaybillEnroutePunch(); + existed.setId(8001L); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of(existed)); + + WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途"); + request.setPunched(true); + request.setPunchTime("2026-10-06 12:00:00"); + request.setAddress("沪昆高速"); + request.setPhotos(List.of( + photo("货物照片", "https://oss/a.jpg"), + photo("货物照片", "https://oss/b.jpg"))); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(enroutePunchMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains("https://oss/a.jpg"), "只保留第一张照片"); + assertFalse(values.contains("https://oss/b.jpg"), "第二张不应写入"); + assertTrue(values.contains("沪昆高速")); + assertTrue(wrapper.getSqlSet().contains("photo"), "照片字段必须显式更新"); + verify(enroutePunchMapper, never()).insert(any(WaybillEnroutePunch.class)); + } + + @Test + @DisplayName("取消在途打卡:清空该运单全部在途流水") + @SuppressWarnings("unchecked") + void deleteAllEnroutePunchesWhenUnpunched() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillEnroutePunch first = new WaybillEnroutePunch(); + first.setId(8001L); + WaybillEnroutePunch second = new WaybillEnroutePunch(); + second.setId(8002L); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of(first, second)); + + WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途"); + request.setPunched(false); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(enroutePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(8001L, 8002L), new HashSet<>(captor.getValue())); + } + + // ------------------------------------------------------------ 入参校验 + + @Test + @DisplayName("运单不存在:拒绝保存") + void rejectMissingWaybill() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(null); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + } + + @Test + @DisplayName("打卡时间格式非法:报错而不是静默改成当前时间") + void rejectInvalidPunchTime() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPunchTime("2026/10/06 09:30"); + + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("经纬度非法:报错且不写库") + void rejectInvalidCoordinate() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setLongitude("东经120度"); + + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setProcessJson(PROCESS_JSON); + waybill.setIsDeleted(0); + return waybill; + } + + private WaybillNodePunch nodePunch(Long id, String nodeCode, String nodeName) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(id); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + punch.setNodeName(nodeName); + return punch; + } + + private WaybillPunchRecordSaveRequest request(String type, String nodeCode, String nodeName) { + WaybillPunchRecordSaveRequest request = new WaybillPunchRecordSaveRequest(); + request.setWaybillId(WAYBILL_ID); + request.setType(type); + request.setNodeCode(nodeCode); + request.setNodeName(nodeName); + return request; + } + + private WaybillPunchRecordSaveRequest.Photo photo(String type, String url) { + WaybillPunchRecordSaveRequest.Photo photo = new WaybillPunchRecordSaveRequest.Photo(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private List sameTypePhotos(String type, int count) { + List photos = new ArrayList<>(); + for (int i = 0; i < count; i++) { + photos.add(photo(type, "https://oss/" + type + i + ".jpg")); + } + return photos; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java new file mode 100644 index 00000000..f2f7e9f9 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java @@ -0,0 +1,114 @@ +package org.springblade.transport.support; + +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 审核列表自定义 SQL 的语句级验证。 + *

+ * 这条 SQL 有两个容易写坏、写坏了又不会报错的地方: + *

    + *
  • 车型侧要按提交司机定位司机档案。车辆表没有司机列, + * 定位靠 {@code dr.user_id = r.submit_user}。少了这个条件, + * 车辆记录的司机列会全部取不到值,而页面只是「空着」——不像故障。
  • + *
  • 司机侧不能跟着走 submit_user。存量订正记录没有提交人, + * 若司机侧也用 submit_user 定位,23 条有司机的记录反而会全部变空。
  • + *
+ * 这里直接编译 Mapper XML 并检查生成出来的语句,把「靠约定」变成「靠语句」。 + */ +class CertAuditPageSqlTest { + + private static Configuration configuration; + + @BeforeAll + static void loadMapperXml() { + configuration = new Configuration(); + String resource = "org/springblade/transport/mapper/TransportCertRecordMapper.xml"; + try (InputStream input = CertAuditPageSqlTest.class.getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input, "找不到 Mapper XML:" + resource + "(它必须与 Mapper 接口同包放在 src/main/java 下)"); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } catch (Exception exception) { + throw new IllegalStateException("Mapper XML 解析失败:" + exception.getMessage(), exception); + } + } + + @Test + @DisplayName("无搜索条件:仍按状态与对象类型过滤,并按提交时间倒序") + void baseConditions() { + String sql = sql(Map.of()); + + assertTrue(sql.contains("r.status = ?"), "必须按审核状态过滤"); + assertTrue(sql.contains("r.biz_type = ?"), "必须按认证对象类型过滤"); + assertTrue(sql.contains("r.is_deleted = 0"), "逻辑删除的记录不得出现在审核列表"); + assertTrue(sql.contains("ORDER BY") && sql.contains("submit_time DESC"), "必须按提交时间倒序"); + } + + @Test + @DisplayName("司机档案定位:司机侧按对象主键,车辆侧按提交司机(user_id)") + void driverResolution() { + String sql = sql(Map.of()); + + assertTrue(sql.contains("WHEN r.biz_type = 'driver' THEN r.biz_id"), + "司机认证记录的司机就是认证对象本身"); + assertTrue(sql.contains("dr.user_id = r.submit_user"), + "车辆认证记录没有司机列,只能靠提交司机定位——少了它车辆页的司机列会整列取不到值"); + } + + @Test + @DisplayName("司机档案收敛为一条:子查询必须带 ORDER BY ... LIMIT 1") + void driverResolutionCollapsesToOneRow() { + String sql = sql(Map.of()); + + assertTrue(sql.contains("ORDER BY dr.update_time DESC") && sql.contains("LIMIT 1"), + "user_id 上没有唯一索引,两条司机档案指向同一用户时会让一条记录在列表里出现多行、总数虚高"); + } + + @Test + @DisplayName("空搜索条件不产生任何 LIKE(不能把「没填」当成筛选条件)") + void blankConditionsAddNoFilter() { + String sql = sql(Map.of()); + + assertFalse(sql.contains("LIKE"), "没传搜索项时不应出现 LIKE:空串会被拼成 LIKE '%%'"); + } + + @Test + @DisplayName("姓名搜索:同时匹配档案当前姓名与提交时的姓名") + void nameSearchCoversBothCurrentAndHistorical() { + String sql = sql(Map.of("driverName", "黄")); + + assertTrue(sql.contains("d.driver_name LIKE"), "必须能按档案当前姓名搜到"); + assertTrue(sql.contains("r.biz_key LIKE"), "改名后按旧名也要能搜到,否则审核员得两头试"); + } + + @Test + @DisplayName("手机号搜索:只按司机档案的手机号匹配(快照里从来没有手机号)") + void mobileSearchUsesProfileOnly() { + String sql = sql(Map.of("driverMobile", "1208")); + + assertTrue(sql.contains("d.mobile LIKE"), "手机号只有司机档案一个来源"); + } + + private String sql(Map params) { + MappedStatement statement = configuration.getMappedStatement( + "org.springblade.transport.mapper.TransportCertRecordMapper.selectAuditPage"); + Map parameters = new HashMap<>(params); + parameters.put("bizType", "driver"); + parameters.put("status", 0); + BoundSql boundSql = statement.getBoundSql(parameters); + return boundSql.getSql(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java new file mode 100644 index 00000000..4422d9cd --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java @@ -0,0 +1,99 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.secure.auth.AuthFun; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 认证审核权限表达式的可求值性测试。 + *

+ * 这些字符串写在 {@code @PreAuth} 注解里,由框架在**请求时**交给 SpEL 求值, + * root object 是 {@code AuthFun}。名字写错不会在编译期或启动期暴露, + * 只会在真实调用时抛异常(表现为接口 500)——用测试把它提前到构建期。 + *

+ * 这里用同一个 {@link SpelExpressionParser} 与同一套 root object 语义求值, + * 与框架 {@code AuthAspect} 的做法一致。 + */ +class CertAuditPermissionsTest { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + @Test + @DisplayName("查看权限:拥有司机审核菜单即可通过") + void auditViewWithDriverMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true); + + assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun), + "只授予司机审核菜单的用户也应能查看待审列表"); + } + + @Test + @DisplayName("查看权限:拥有车辆审核菜单即可通过") + void auditViewWithVehicleMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("cert_certification_audit_vehicle")).thenReturn(true); + + assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun)); + } + + @Test + @DisplayName("查看权限:两个菜单都没有则拒绝") + void auditViewWithoutAnyMenu() { + assertFalse(evaluate(CertAuditPermissions.AUDIT_VIEW, mock(AuthFun.class)), + "无任何审核菜单的用户不得查看待审列表"); + } + + @Test + @DisplayName("审核动作:只有查看权限、没有审核按钮权限时必须拒绝") + void auditActionRequiresButtonPermission() { + AuthFun authFun = mock(AuthFun.class); + // 只给了列表菜单,没给「审核」按钮 + when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true); + + assertFalse(evaluate(CertAuditPermissions.AUDIT_ACTION, authFun), + "能看待审列表不等于能执行审核"); + } + + @Test + @DisplayName("审核动作:任一审核按钮权限即可通过") + void auditActionWithEitherButton() { + AuthFun driverAuditor = mock(AuthFun.class); + when(driverAuditor.hasMenu("cert_certification_audit_driver_audit")).thenReturn(true); + assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, driverAuditor)); + + AuthFun vehicleAuditor = mock(AuthFun.class); + when(vehicleAuditor.hasMenu("cert_certification_audit_vehicle_audit")).thenReturn(true); + assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, vehicleAuditor)); + } + + @Test + @DisplayName("表达式必须引用真实存在的菜单码(与票 01 建的一致)") + void expressionsReferenceRealMenuCodes() { + assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_driver")); + assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_vehicle")); + assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_driver_audit")); + assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_vehicle_audit")); + // 不应再出现角色名判断:库里不存在 admin 角色,且会漏掉「运管」岗位 + assertFalse(CertAuditPermissions.AUDIT_VIEW.contains("hasAnyRole")); + assertFalse(CertAuditPermissions.AUDIT_ACTION.contains("hasAnyRole")); + } + + /** + * 用与框架相同的方式求值:AuthFun 作为 root object + */ + private boolean evaluate(String expression, AuthFun authFun) { + Expression parsed = parser.parseExpression(expression); + StandardEvaluationContext context = new StandardEvaluationContext(authFun); + return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class)); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java new file mode 100644 index 00000000..8a9bbb0f --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java @@ -0,0 +1,120 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 认证可用性门槛测试。 + *

+ * 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照, + * 而重新提交会把「最新提交状态」变成「审核中」。 + */ +class CertificationGateTest { + + private CertificationGate gate; + + @BeforeEach + void setUp() { + gate = new CertificationGate(); + } + + // ------------------------------------------------------------ 开关 + + @Test + @DisplayName("门槛默认开启") + void enabledByDefault() { + assertTrue(gate.isVehicleGateEnabled()); + assertTrue(gate.isDriverGateEnabled()); + } + + @Test + @DisplayName("关闭后不因未认证而拦截(便于快速回退)") + void disabledLetsEveryoneThrough() { + gate.setVehicleGateEnabled(false); + gate.setDriverGateEnabled(false); + + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null)); + assertTrue(gate.isDriverAvailable(null)); + } + + // ------------------------------------------------------------ 车辆 + + @Test + @DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)") + void vehicleWithEffectiveSnapshotIsAvailable() { + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L)); + } + + @Test + @DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用") + void resubmittedVehicleStaysAvailable() { + // 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。 + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L), + "已认证的车重新提交后必须仍可接单,否则司机一提交就停工"); + } + + @Test + @DisplayName("车辆:从未认证(无生效快照)被拒绝") + void vehicleWithoutEffectiveSnapshotIsRejected() { + assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null)); + } + + @Test + @DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用") + void disabledVehicleIsRejectedEvenIfCertified() { + assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L), + "启用/停用是运营状态,认证门槛只叠加不覆盖"); + assertFalse(gate.isVehicleAvailable(null, 7001L)); + } + + // ------------------------------------------------------------ 司机 + + @Test + @DisplayName("司机:有生效快照即可用") + void driverWithEffectiveSnapshotIsAvailable() { + assertTrue(gate.isDriverAvailable(9001L)); + } + + @Test + @DisplayName("司机:从未认证被拒绝") + void driverWithoutEffectiveSnapshotIsRejected() { + assertFalse(gate.isDriverAvailable(null)); + } + + @Test + @DisplayName("司机门槛可单独关闭") + void driverGateCanBeDisabledIndependently() { + gate.setDriverGateEnabled(false); + assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截"); + assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响"); + } + + // ------------------------------------------------------------ 提示文案 + + @Test + @DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证") + void unavailableReasonsDistinguishDisabledFromUncertified() { + String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L); + assertTrue(disabled.contains("停用"), disabled); + assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过"); + + String uncertified = gate.vehicleUnavailableReason(Boolean.TRUE, null); + assertTrue(uncertified.contains("认证"), uncertified); + assertFalse(uncertified.contains("停用"), uncertified); + } + + @Test + @DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名") + void availableHasNoReasonAndTextIsReadable() { + assertNull(gate.vehicleUnavailableReason(Boolean.TRUE, 7001L)); + assertFalse(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("effective_cert_record_id")); + assertFalse(gate.vehicleUnavailableReason(Boolean.FALSE, null).contains("effective_cert_record_id")); + assertTrue(gate.driverUnavailableReason().contains("认证")); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java new file mode 100644 index 00000000..ebfad643 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java @@ -0,0 +1,123 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.TransportCertRecord; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 认证记录状态机规则测试。 + *

+ * 这几条规则是整个认证改造的地基,一旦被改坏,症状是「司机重新提交认证后车辆掉出可用池」 + * 或「同一辆车出现两条待审」,都属线上事故,故集中在一处并锁住行为。 + */ +class CertificationRuleSupportTest { + + @Test + @DisplayName("提交:前一条待审必须转已作废,而不是被删除") + void supersedePending() { + TransportCertRecord previousPending = record(TransportCertRecord.STATUS_PENDING); + + TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(previousPending); + + // 返回的正是那条旧记录,且只改状态 + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, superseded.getStatus()); + assertEquals(previousPending.getId(), superseded.getId()); + } + + @Test + @DisplayName("提交:前一条不是待审时,不得被改动(已通过/已驳回都是终态)") + void doesNotTouchNonPending() { + assertNull(CertificationRuleSupport.supersedeIfPending(null)); + assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_APPROVED))); + assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_REJECTED))); + assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_SUPERSEDED))); + } + + @Test + @DisplayName("提交:新记录一律是待审,且清空驳回原因(避免继承上一条的驳回文案)") + void newRecordIsPending() { + TransportCertRecord incoming = record(null); + incoming.setStatus(TransportCertRecord.STATUS_REJECTED); + incoming.setRejectReason("上一次的驳回原因"); + + CertificationRuleSupport.prepareForSubmit(incoming); + + assertEquals(TransportCertRecord.STATUS_PENDING, incoming.getStatus()); + assertNull(incoming.getRejectReason()); + } + + @Test + @DisplayName("生效快照判定:只看指针,与记录表里任何一条的状态都无关") + void effectiveIsDecidedByPointerOnly() { + assertFalse(CertificationRuleSupport.isCertified(null)); + assertTrue(CertificationRuleSupport.isCertified(123456789L)); + } + + @Test + @DisplayName("可审核判定:只有待审记录可被审核,终态一律拒绝(防止把生效快照改坏)") + void onlyPendingIsAuditable() { + assertTrue(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_PENDING))); + assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_APPROVED))); + assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_REJECTED))); + assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_SUPERSEDED))); + assertFalse(CertificationRuleSupport.isAuditable(null)); + } + + @Test + @DisplayName("审核入参:驳回必须给原因,通过时原因无意义需清空") + void auditPayload() { + // 驳回无原因 -> 拒绝 + assertFalse(CertificationRuleSupport.isRejectReasonValid(false, null)); + assertFalse(CertificationRuleSupport.isRejectReasonValid(false, " ")); + assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "证件照片模糊")); + + // 通过时不需要原因 + assertTrue(CertificationRuleSupport.isRejectReasonValid(true, null)); + + // 通过必须把原因清空,否则会残留上一次驳回文案 + assertNull(CertificationRuleSupport.reasonForApprove()); + assertEquals("证件照片模糊", CertificationRuleSupport.reasonForReject(" 证件照片模糊 ")); + } + + @Test + @DisplayName("驳回原因长度:超过 200 字拒绝(与列宽一致)") + void rejectReasonLength() { + assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "短".repeat(200))); + assertFalse(CertificationRuleSupport.isRejectReasonValid(false, "长".repeat(201))); + } + + @Test + @DisplayName("状态文案:四种状态各一个中文说法,未知状态不静默给空") + void statusText() { + assertEquals("审核中", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_PENDING)); + assertEquals("已通过", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_APPROVED)); + assertEquals("已驳回", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_REJECTED)); + assertEquals("已作废", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_SUPERSEDED)); + assertEquals("未知状态", CertificationRuleSupport.statusText(99)); + assertEquals("未知状态", CertificationRuleSupport.statusText(null)); + } + + @Test + @DisplayName("认证对象类型:只认 driver / vehicle,其它一律拒绝") + void bizTypeValidation() { + assertTrue(CertificationRuleSupport.isValidBizType("driver")); + assertTrue(CertificationRuleSupport.isValidBizType("vehicle")); + assertFalse(CertificationRuleSupport.isValidBizType("DRIVER")); + assertFalse(CertificationRuleSupport.isValidBizType("truck")); + assertFalse(CertificationRuleSupport.isValidBizType(null)); + assertFalse(CertificationRuleSupport.isValidBizType("")); + } + + private TransportCertRecord record(Integer status) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(1000L); + record.setStatus(status); + return record; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java new file mode 100644 index 00000000..ff45d30a --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java @@ -0,0 +1,60 @@ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportVehicle; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 生效快照指针的 UPDATE 语句级验证。 + *

+ * 「驳回不得改变生效快照」这条不变式最终取决于:驳回时那次 UPDATE 会不会带上 + * {@code effective_cert_record_id} 列。审核服务传的是 null,因此只要该列被 + * 「值非 null 才写入」的 if 包住,驳回就物理上碰不到指针。 + *

+ * 这里直接检查 MyBatis-Plus 生成的 SET 片段,把「靠约定」变成「靠语句」—— + * 若哪天有人给该字段加了 {@code updateStrategy = ALWAYS},这个测试会立刻变红。 + */ +class EffectivePointerUpdateSqlTest { + + @Test + @DisplayName("车辆:指针列进入 UPDATE 片段,但被 null 判断包住(通过能写、驳回不碰)") + void vehiclePointerGuardedByNullCheck() { + assertPointerGuarded(TransportVehicle.class); + } + + @Test + @DisplayName("司机:同上") + void driverPointerGuardedByNullCheck() { + assertPointerGuarded(Driver.class); + } + + private void assertPointerGuarded(Class clazz) { + String updateSql = tableInfo(clazz).getAllSqlSet(true, "et"); + + assertTrue(updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId}"), + clazz.getSimpleName() + ":通过审核必须能写指针,否则生效快照永远建立不起来"); + + // 必须是「被 包住」的形式;若变成无条件的 SET, + // 驳回时(传 null)就会把已认证对象的指针清空,等于把车/司机无辜下线。 + assertTrue( + updateSql.contains("") + && updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId},"), + clazz.getSimpleName() + ":指针列必须是「值非 null 才写」;" + + "无条件 SET 会让驳回清空已认证对象的指针"); + } + + private TableInfo tableInfo(Class clazz) { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + return TableInfoHelper.initTableInfo(assistant, clazz); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java new file mode 100644 index 00000000..f082dc22 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java @@ -0,0 +1,73 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.secure.auth.AuthFun; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 异常处置权限表达式的可求值性测试。 + *

+ * 表达式写在 {@code @PreAuth} 注解里,由框架在请求时交给 SpEL 求值, + * root object 是 {@code AuthFun}。写成裸字符串(如 {@code "exception_disposal_edit"}) + * 会被当作属性访问而抛异常,表现为接口 500 而不是 403——用测试把它提前到构建期。 + * + * @author Chill + */ +class ExceptionDisposalPermissionsTest { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + @Test + @DisplayName("编辑权限:拥有编辑菜单即通过") + void editWithMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_edit")).thenReturn(true); + + assertTrue(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "授予编辑菜单的用户应能编辑"); + } + + @Test + @DisplayName("编辑权限:没有编辑菜单则拒绝") + void editWithoutMenu() { + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, mock(AuthFun.class)), + "未授予编辑菜单的用户不得编辑"); + } + + @Test + @DisplayName("编辑权限:只有查看/跟进菜单不能编辑") + void editRequiresDedicatedMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_view")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_follow")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_complete")).thenReturn(true); + + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "能跟进、能完成不等于能编辑"); + } + + @Test + @DisplayName("表达式必须引用真实存在的菜单码") + void expressionReferencesRealMenuCode() { + assertTrue(ExceptionDisposalPermissions.EDIT.contains("exception_disposal_edit")); + assertFalse(ExceptionDisposalPermissions.EDIT.contains("hasAnyRole")); + } + + /** + * 用与框架相同的方式求值:AuthFun 作为 root object + */ + private boolean evaluate(String expression, AuthFun authFun) { + Expression parsed = parser.parseExpression(expression); + StandardEvaluationContext context = new StandardEvaluationContext(authFun); + return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class)); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java new file mode 100644 index 00000000..52530a56 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java @@ -0,0 +1,223 @@ +package org.springblade.transport.support; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 百度 OCR 结果归一化测试。 + *

+ * 这一层挡的是「识别成功但什么都没填」——百度返回的是中文键,写死键名的代码在键名 + * 对不上的那天会静默失效:不抛异常、不报错,用户只看到识别完表单还是空的。 + * 故这里用真实形状的返回把别名容错与值归一化都钉住。 + */ +class OcrFieldSupportTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + @DisplayName("行驶证主页:官方字段名逐个归一化,外廓尺寸拆成三个") + void vehicleLicenseFront() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result_num": 12, + "words_result": { + "号牌号码": {"words": "桂A32523"}, + "车辆类型": {"words": "重型厢式货车"}, + "所有人": {"words": "桂物物流集团"}, + "车辆识别代码": {"words": "LGAX2A130J1001234"}, + "档案编号": {"words": "450100123456"}, + "核定载质量": {"words": "18000kg"}, + "准牵引总质量": {"words": "35000"}, + "外廓尺寸": {"words": "5990×2300×3100"}, + "强制报废期止": {"words": "2030-05-20"}, + "能源种类": {"words": "柴油"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertEquals("桂A32523", fields.get("plateNo")); + assertEquals("重型厢式货车", fields.get("vehicleType")); + assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("LGAX2A130J1001234", fields.get("vin")); + assertEquals("450100123456", fields.get("drivingLicenseNo")); + assertEquals(18000, fields.get("approvedLoadKg"), "带 kg 单位也要能取到数字"); + assertEquals(35000, fields.get("tractionMassKg")); + assertEquals(5990, fields.get("outerLength")); + assertEquals(2300, fields.get("outerWidth")); + assertEquals(3100, fields.get("outerHeight")); + assertEquals("2030-05-20", fields.get("compulsoryScrapDate")); + assertEquals("柴油", fields.get("energyType")); + } + + @Test + @DisplayName("键名写法差异:车牌号码 / 车辆识别代号 / 带单位后缀的键都要认") + void aliasTolerance() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "车牌号码": {"words": "桂B11111"}, + "车辆识别代号": {"words": "LVSHCAMB7HF123456"}, + "核定载质量(kg)": {"words": "12000"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertEquals("桂B11111", fields.get("plateNo")); + assertEquals("LVSHCAMB7HF123456", fields.get("vin")); + assertEquals(12000, fields.get("approvedLoadKg"), "带后缀的键名要靠「包含」兜底命中"); + } + + @Test + @DisplayName("日期写法:年月日 / 点分 / 纯数字都归一化成 yyyy-MM-dd") + void dateNormalization() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "强制报废期止": {"words": "2030年05月20日"}, + "注册日期": {"words": "2018.3.9"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertEquals("2030-05-20", fields.get("compulsoryScrapDate")); + assertEquals("2018-03-09", fields.get("drivingLicenseStartDate"), "月日个位数要补零"); + } + + @Test + @DisplayName("「长期」不产出日期,而是把对应的长期有效标志置 1") + void longTermBecomesFlag() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "强制报废期止": {"words": "长期"}, + "检验有效期止": {"words": "长期有效"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertFalse(fields.containsKey("compulsoryScrapDate"), "长期不能当成日期塞进日期框"); + assertEquals(1, fields.get("compulsoryScrapLongTerm")); + assertEquals(1, fields.get("drivingLicenseLongTerm")); + } + + @Test + @DisplayName("身份证与驾驶证:键名归一化到个人认证的字段名") + void idCardAndDrivingLicense() throws Exception { + JsonNode idCard = MAPPER.readTree(""" + { + "words_result": { + "姓名": {"words": "黄宇富"}, + "公民身份号码": {"words": "452730197911172318"}, + "签发日期": {"words": "2015-03-12"}, + "失效日期": {"words": "2035-03-12"} + } + } + """); + Map person = OcrFieldSupport.extract(BaiduOcrType.ID_CARD, "front", idCard); + assertEquals("黄宇富", person.get("realName")); + assertEquals("452730197911172318", person.get("idCardNo")); + assertEquals("2015-03-12", person.get("idCardValidStart")); + assertEquals("2035-03-12", person.get("idCardValidEnd")); + + JsonNode driving = MAPPER.readTree(""" + { + "words_result": { + "档案编号": {"words": "451200976793"}, + "准驾车型": {"words": "B2D"}, + "有效起始日期": {"words": "2016-08-01"}, + "有效期限": {"words": "2026-08-01"} + } + } + """); + Map license = OcrFieldSupport.extract(BaiduOcrType.DRIVING_LICENSE, "front", driving); + assertEquals("451200976793", license.get("driverFileNo")); + assertEquals("B2D", license.get("driverClass")); + assertEquals("2016-08-01", license.get("driverValidStart")); + assertEquals("2026-08-01", license.get("driverValidEnd")); + } + + @Test + @DisplayName("道路运输证:证号与业户名称") + void roadTransport() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "道路运输证号": {"words": "桂交运管桂字450100123456号"}, + "业户名称": {"words": "桂物物流集团"}, + "有效期": {"words": "2020-01-01 至 2030-01-01"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, null, result); + + assertEquals("桂交运管桂字450100123456号", fields.get("roadTransportCertNo")); + assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("2030-01-01", fields.get("roadTransportCertEndDate"), "止期字段取区间的最后一段,取第一段会把有效期起填成止"); + assertEquals("2020-01-01", fields.get("roadTransportCertStartDate"), "区间要同时带出起止,否则用户得照图再敲一遍"); + } + + @Test + @DisplayName("识别不到任何字段时返回空 Map,而不是一堆空串") + void emptyResult() throws Exception { + JsonNode result = MAPPER.readTree(""" + {"words_result_num": 0, "words_result": {}} + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertTrue(fields.isEmpty(), "空结果必须是空 Map:给空串会把用户已填内容覆盖掉"); + } + + @Test + @DisplayName("数值荒谬时不填:位数过多的串不可能是载质量,宁可留空也不填错") + void absurdNumberIsSkipped() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "核定载质量": {"words": "识别串行123456789012"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertFalse(fields.containsKey("approvedLoadKg")); + } + + @Test + @DisplayName("外廓尺寸只识别到两段时不写回:硬填会让宽高错位") + void outerSizeNeedsThreeParts() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "外廓尺寸": {"words": "5990×2300"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertFalse(fields.containsKey("outerLength")); + assertFalse(fields.containsKey("outerWidth")); + assertFalse(fields.containsKey("outerHeight")); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java new file mode 100644 index 00000000..698d21df --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java @@ -0,0 +1,122 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 小程序证件类型 → 百度 OCR 证件类型的映射测试。 + *

+ * 两侧用的是两套命名,且**都不太直观**: + * 小程序把「行驶证」叫 {@code vehicle}、把「驾驶证」叫 {@code driver} + * (按业务直觉命名),而后端 OCR 沿用百度接口的 {@code vehicle_license} + * (行驶证)与 {@code driving_license}(驾驶证)。 + * 这两个词一一对应关系相反,映射写错会让「行驶证」被当成「驾驶证」识别, + * 结果是司机传了行驶证却拿到一堆驾驶证字段——而且不会报错。 + * 故此处逐一锁住。 + */ +class OcrTypeMappingTest { + + @Test + @DisplayName("身份证:小程序 idcard → 后端 ID_CARD") + void idCard() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("idcard")); + } + + @Test + @DisplayName("驾驶证:小程序 driver → 后端 DRIVING_LICENSE(不是 vehicle_license)") + void drivingLicense() { + assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driver")); + } + + @Test + @DisplayName("行驶证:小程序 vehicle → 后端 VEHICLE_LICENSE(不是 driving_license)") + void vehicleLicense() { + assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle")); + } + + @Test + @DisplayName("道路运输证:小程序 road_transport → 后端 ROAD_TRANSPORT_CERTIFICATE") + void roadTransport() { + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("road_transport")); + } + + @Test + @DisplayName("从业资格证:后端无对应 OCR 能力,必须明确报错而不是退化成通用识别") + void qualificationIsNotSilentlyDowngraded() { + // 百度没有「从业资格证」专用接口。若这里悄悄退化成 GENERAL, + // 前端会拿到一堆通用文本却以为识别成功了,故要求明确失败。 + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> OcrTypeMapping.toBaiduType("qualification")); + assertTrue(ex.getMessage().contains("从业资格证"), + "错误信息要指明是哪个证件类型不支持,便于前端提示用户手动填写"); + } + + @Test + @DisplayName("后端既有命名也接受(前端或其它调用方可能直接传后端名)") + void acceptsBackendNames() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id_card")); + assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driving_license")); + assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle_license")); + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("road_transport_certificate")); + } + + @Test + @DisplayName("大小写与分隔符不敏感") + void caseAndSeparatorInsensitive() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("IDCard")); + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType(" ID_CARD ")); + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id-card")); + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("Road-Transport")); + } + + @Test + @DisplayName("未知类型:明确报错,不静默降级为通用识别") + void unknownTypeFails() { + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("truck")); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("general")); + } + + @Test + @DisplayName("空值:明确报错") + void emptyTypeFails() { + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(null)); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("")); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(" ")); + } + + @Test + @DisplayName("正反面:小程序不传 side 时返回 null,交由知晓证件类型的一侧决定默认值") + void unsetSideStaysNull() { + // 这里恒返回 front 会让道路运输证恒失败:它没有正副面参数, + // 上游先填成 front,下游就变成「传了 side 但该证件不支持」—— + // 小程序不传 side 也会中招(实测三种传法全部报错)。 + assertNull(OcrTypeMapping.normalizeSide(null)); + assertNull(OcrTypeMapping.normalizeSide("")); + assertNull(OcrTypeMapping.normalizeSide(" ")); + assertEquals("front", OcrTypeMapping.normalizeSide("front")); + assertEquals("back", OcrTypeMapping.normalizeSide("back")); + assertEquals("back", OcrTypeMapping.normalizeSide("BACK")); + } + + @Test + @DisplayName("正反面:道路运输证不带正副面参数,其余证件默认正面") + void roadTransportHasNoSide() { + // 下游据此区分「默认取正面」与「该证件不该带这个参数」,故这条契约必须有测试守 + assertFalse(OcrTypeMapping.toBaiduType("road_transport").supportsSide(), + "道路运输证没有正副面参数,上游不得替它补 front"); + assertTrue(OcrTypeMapping.toBaiduType("vehicle").supportsSide()); + assertTrue(OcrTypeMapping.toBaiduType("driver").supportsSide()); + assertTrue(OcrTypeMapping.toBaiduType("idcard").supportsSide()); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java new file mode 100644 index 00000000..445e0419 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java @@ -0,0 +1,196 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.BeforeAll; +import org.springblade.core.log.exception.ServiceException; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import org.springblade.transport.pojo.entity.TransportVehicle; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 车牌匹配逻辑测试。 + *

+ * 司机档案的 {@code driving_vehicle} 与车辆表的 {@code plate_no} 是**文本软关联**, + * 无外键,且库里已知存在间隔符/横线/全角等写法差异(例如「沪AE23411」在车辆表中不存在, + * 而「桂A·32523」这类带间隔符的写法要靠规范化才能匹配上)。 + * 认证提交的越权校验依赖这套匹配,匹配漏判会让司机操作不了自己的车, + * 误判则会让司机改到别人的车,故用测试锁住行为。 + */ +class VehiclePlateSupportTest { + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @Test + @DisplayName("单个车牌:原样返回规范化结果") + void singlePlate() { + assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523")); + } + + @Test + @DisplayName("多个车牌:中英文逗号、顿号、分号、斜杠、竖线都算分隔符") + void multipleSeparators() { + assertEquals( + List.of("桂A32523", "桂B11111", "桂C22222"), + VehiclePlateSupport.splitPlates("桂A32523,桂B11111、桂C22222")); + assertEquals( + List.of("桂A32523", "桂B11111"), + VehiclePlateSupport.splitPlates("桂A32523;桂B11111")); + assertEquals( + List.of("桂A32523", "桂B11111"), + VehiclePlateSupport.splitPlates("桂A32523/桂B11111")); + assertEquals( + List.of("桂A32523", "桂B11111"), + VehiclePlateSupport.splitPlates("桂A32523|桂B11111")); + } + + @Test + @DisplayName("去重:同一车牌写两次只保留一条") + void dedupe() { + assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523,桂A32523")); + } + + @Test + @DisplayName("规范化:去空格/横线/间隔符并转大写") + void normalize() { + assertEquals("桂A32523", VehiclePlateSupport.normalize(" 桂a32523 ")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A-32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A·32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A•32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A.32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A 32523")); + } + + @Test + @DisplayName("空值与脏数据:不抛异常,返回空串或空列表") + void emptyAndDirty() { + assertEquals("", VehiclePlateSupport.normalize(null)); + assertEquals("", VehiclePlateSupport.normalize(" ")); + assertTrue(VehiclePlateSupport.splitPlates(null).isEmpty()); + assertTrue(VehiclePlateSupport.splitPlates("").isEmpty()); + assertTrue(VehiclePlateSupport.splitPlates(",,、;;").isEmpty()); + } + + @Test + @DisplayName("绑定判定:写法差异不影响匹配(越权校验的关键)") + void isBound() { + // 司机档案里带间隔符,车辆表里是干净写法 + assertTrue(VehiclePlateSupport.isBound("桂A·32523", "桂A32523")); + // 大小写差异 + assertTrue(VehiclePlateSupport.isBound("桂a32523", "桂A32523")); + // 多车:命中其中一辆即可 + assertTrue(VehiclePlateSupport.isBound("桂A32523,桂B11111", "桂B11111")); + // 未绑定 + assertFalse(VehiclePlateSupport.isBound("桂A32523", "桂B11111")); + // 空值一律不绑定(不能因为双方都为空就放行——那等于越权) + assertFalse(VehiclePlateSupport.isBound(null, "桂A32523")); + assertFalse(VehiclePlateSupport.isBound("桂A32523", null)); + assertFalse(VehiclePlateSupport.isBound(null, null)); + assertFalse(VehiclePlateSupport.isBound("", "")); + } + + @Test + @DisplayName("SQL 规范化表达式必须覆盖 Java 侧剔除的全部字符(否则两边会悄悄分叉)") + void sqlNormalizationMirrorsJava() { + String sql = VehiclePlateSupport.NORMALIZED_PLATE_SQL; + // Java 的 normalize 会剔除这些排版字符并转大写,SQL 必须一一对应 + for (String noise : List.of("·", "•", ".", ".", "-", " ")) { + assertTrue(sql.contains("'" + noise + "',''"), + "SQL 规范化漏掉了字符 [" + noise + "],会导致该写法差异的车牌匹配不到"); + } + assertTrue(sql.contains("UPPER(plate_no)"), "SQL 必须先转大写再比对"); + assertTrue(sql.contains("plate_no"), "表达式作用的列应为 plate_no"); + } + + @Test + @DisplayName("车牌匹配条件是「精确 IN 或 规范化相等」,不能只有精确 IN") + void plateMatchIncludesNormalizedFallback() { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(wrapper, List.of("桂A32523")); + + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("plate_no IN"), "必须保留精确匹配,避免全表函数扫描"); + assertTrue(sql.contains("REPLACE"), "必须带规范化兜底,否则脏车牌匹配不到"); + assertTrue(sql.contains("OR"), "两者应是 OR 关系"); + } + + @Test + @DisplayName("绑定数上限:5 辆放行,第 6 辆拒绝") + void boundVehicleCountLimit() { + String five = "桂A32523,桂B11111,桂C22222,桂D33333,桂E44444"; + VehiclePlateSupport.validateBoundVehicles(five); + assertEquals(5, VehiclePlateSupport.splitPlates(five).size()); + + // 不抛异常就是通过;第 6 辆必须拦下,否则字段放不下会被静默截断 + ServiceException exception = assertThrows(ServiceException.class, + () -> VehiclePlateSupport.validateBoundVehicles(five + ",桂F55555")); + assertTrue(exception.getMessage().contains("最多绑定5辆车"), exception.getMessage()); + } + + @Test + @DisplayName("绑定校验:空值与单辆车都放行") + void boundVehicleEmptyAndSingle() { + VehiclePlateSupport.validateBoundVehicles(null); + VehiclePlateSupport.validateBoundVehicles(""); + VehiclePlateSupport.validateBoundVehicles("桂A32523"); + } + + @Test + @DisplayName("绑定校验:车牌格式不合法时拒绝,且报出是哪个车牌") + void boundVehicleRejectsBadPlate() { + ServiceException exception = assertThrows(ServiceException.class, + () -> VehiclePlateSupport.validateBoundVehicles("桂A32523,乱写的车牌")); + assertTrue(exception.getMessage().contains("车牌号格式不正确"), exception.getMessage()); + } + + @Test + @DisplayName("车牌格式:普通、新能源(8位)、挂车/学车/警车都合法") + void validPlateFormats() { + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A32523")); + assertTrue(VehiclePlateSupport.isValidPlateNo("沪AE31312")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234挂")); + assertTrue(VehiclePlateSupport.isValidPlateNo("京A1234学")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234警")); + // 写法差异不影响判定:小写与带间隔符的车牌先规范化再比对 + assertTrue(VehiclePlateSupport.isValidPlateNo("桂a32523")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A·32523")); + } + + @Test + @DisplayName("车牌格式:空值、乱码、位数不对一律不合法") + void invalidPlateFormats() { + assertFalse(VehiclePlateSupport.isValidPlateNo(null)); + assertFalse(VehiclePlateSupport.isValidPlateNo("")); + assertFalse(VehiclePlateSupport.isValidPlateNo("乱写的车牌")); + assertFalse(VehiclePlateSupport.isValidPlateNo("A32523")); + assertFalse(VehiclePlateSupport.isValidPlateNo("桂A325")); + } + + @Test + @DisplayName("空车牌列表不产生任何条件(避免 IN () 语法错误)") + void emptyPlateListAddsNoCondition() { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(wrapper, List.of()); + assertEquals("", wrapper.getTargetSql().trim()); + VehiclePlateSupport.applyPlateMatch(wrapper, null); + assertEquals("", wrapper.getTargetSql().trim()); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java new file mode 100644 index 00000000..fa3334c2 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -0,0 +1,315 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 打卡门禁:异常门禁(节点区间 + 在途运单级)与顺序门禁(在途豁免)。 + *

+ * 这里的口径会同时决定司机端卡片的禁用态与后端提交接口的校验, + * 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与 + * {@link WaybillProcessSupport#unfinishedPrerequisiteKeys}, + * 所以断言下在它们身上就覆盖了两条链路。 + *

+ * 同域还放着异常标签的组装规则:哪个节点该挂「整单异常」、哪些异常算与某节点相关。 + */ +class WaybillProcessSupportTest { + + /** 与线上「石灰石包头运输项目」实际下发的过程配置同序:在途排在发货之后、到货之前 */ + private static final String TRANSPORT_JSON = """ + [ + {"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"arrive_scene","name":"到场","type":"punch","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","type":"cargo","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true} + ] + """; + + private static Set keys(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } + + // ------------------------------------------------------------ 异常门禁 + + @Test + @DisplayName("无未了结异常时谁都不阻断") + void withoutUnresolvedExceptionNothingIsBlocked() { + assertTrue(WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Collections.emptyList()).isEmpty()); + } + + @Test + @DisplayName("普通节点:异常所在节点及其后节点阻断,之前的不受影响") + void unresolvedExceptionBlocksNodeAndItsSuccessors() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("arrival")); + + assertTrue(blocked.containsAll(keys("arrival", "unload", "sign")), "异常所在节点及其后节点都要锁"); + assertFalse(blocked.contains("arrive_scene"), "异常之前的节点不该被锁"); + assertFalse(blocked.contains("load")); + assertFalse(blocked.contains("dispatch")); + // 在途是运单级的例外:异常落在它之后也锁,见 transitIsBlockedByExceptionOnLaterNode + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("在途:异常落在它之后的「卸货」时同样阻断——门禁是运单级,不看异常在哪个节点") + void transitIsBlockedByExceptionOnLaterNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload")); + + assertTrue(blocked.contains("transit"), + "运单还有未了结异常时,在途就不能打卡;按区间算会漏掉「异常在在途之后」这种最常见的情形"); + assertEquals(keys("transit", "unload", "sign"), blocked); + } + + @Test + @DisplayName("在途:异常落在它之前的「装货」时也阻断") + void transitIsBlockedByExceptionOnEarlierNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("load")); + + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("未指明节点的异常(全局入口上报)阻断全部打卡节点,含在途") + void exceptionWithoutNodeBlocksEverything() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Arrays.asList("")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("多节点上报异常时取最早的那个起算,在途仍被阻断") + void earliestExceptionDecidesTheRange() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload", "arrive_scene")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("配置里没有在途节点时不凭空造一个 key") + void withoutTransitNodeNothingIsInvented() { + String noTransit = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + assertEquals(keys("load", "unload"), WaybillProcessSupport.blockedNodeKeys(noTransit, List.of("load"))); + } + + // ------------------------------------------------------------ 顺序门禁 + + @Test + @DisplayName("顺序门禁:前面还有打卡节点没打时返回它们,在途不作为前置") + void unfinishedPrerequisitesIgnoreTransit() { + Set punched = keys("arrive_scene", "load", "dispatch"); + + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, punched, "unload"); + + assertEquals(keys("arrival"), unfinished, + "「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)"); + } + + @Test + @DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集") + void transitIsExemptFromOrderGate() { + assertTrue( + WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, Collections.emptySet(), "transit").isEmpty(), + "在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了"); + } + + // ------------------------------------------------------------ 异常标签与相关异常 + + @Test + @DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点") + void relatesToNodeCoversWaybillLevelForEveryNode() { + assertTrue(WaybillProcessSupport.relatesToNode("load", null), + "整单异常不归属任何节点,对每个节点都算相关"); + assertTrue(WaybillProcessSupport.relatesToNode("load", "")); + assertTrue(WaybillProcessSupport.relatesToNode("load", "load")); + assertFalse(WaybillProcessSupport.relatesToNode("sign", "load"), + "装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事"); + } + + @Test + @DisplayName("节点编码忽略大小写,两侧空白不影响判定") + void relatesToNodeIgnoresCaseAndBlank() { + assertTrue(WaybillProcessSupport.relatesToNode("load", "LOAD")); + assertTrue(WaybillProcessSupport.relatesToNode(" load ", " LOAD ")); + assertFalse(WaybillProcessSupport.relatesToNode("load", "unload")); + } + + @Test + @DisplayName("整单异常的判定只看节点编码是否为空") + void waybillLevelExceptionFlag() { + assertTrue(WaybillProcessSupport.isWaybillLevelException(null)); + assertTrue(WaybillProcessSupport.isWaybillLevelException("")); + assertTrue(WaybillProcessSupport.isWaybillLevelException(" ")); + assertFalse(WaybillProcessSupport.isWaybillLevelException("load")); + } + + @Test + @DisplayName("节点编码相同判定:忽略大小写与两侧空白,空与空不算相等") + void sameNodeCodeIgnoresCaseAndBlank() { + assertTrue(WaybillProcessSupport.sameNodeCode("load", "LOAD")); + assertTrue(WaybillProcessSupport.sameNodeCode(" load ", "load")); + assertFalse(WaybillProcessSupport.sameNodeCode("load", "unload")); + assertFalse(WaybillProcessSupport.sameNodeCode("load", ""), "节点编码为空表示整单,不等于任何节点"); + assertFalse(WaybillProcessSupport.sameNodeCode("", ""), "空与空不算相等"); + assertFalse(WaybillProcessSupport.sameNodeCode(null, null)); + } + + // ------------------------------------------------------------ 到货/卸货/签收 + + @Test + @DisplayName("到货/卸货/签收:key 或中文名任一命中即可,两者都为空不算") + void deliveryNodeMatchesKeyOrName() { + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", ""), "只有 key 也要认,别把中文名当必要条件"); + assertTrue(WaybillProcessSupport.isDeliveryNode("", "签收"), "只有中文名也要认——key 可能被改成自定义编码"); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货")); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途")); + assertFalse(WaybillProcessSupport.isDeliveryNode("", "")); + assertFalse(WaybillProcessSupport.isDeliveryNode(null, null)); + } + + // ------------------------------------------------------------ 末位司机打卡节点 + + /** 末位是「到货」,其后没有打卡节点 */ + private static final String ARRIVAL_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","enabled":true,"punch":true}] + """; + + /** 末位是「卸货」 */ + private static final String UNLOAD_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + /** 末位是「在途」 */ + private static final String TRANSIT_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}] + """; + + /** 末位是「发货」 */ + private static final String DISPATCH_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("末位打卡节点取「司机可打卡节点」的末位:接单与回单就算启用也不算") + void lastDriverPunchNodeSkipsAcceptAndReturn() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign"), + "回单启用在配置里排最末,但回单不打卡,末位打卡节点仍是签收"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "unload")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "arrival")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "return")); + } + + @Test + @DisplayName("末位是签收 / 到货 / 卸货才同时成立「末位」与「到货卸货签收」;末位是在途 / 发货只满足前者") + void lastNodeIsDeliveryOnlyForDeliveryTypes() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign") + && WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(ARRIVAL_LAST_JSON, "arrival") + && WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(UNLOAD_LAST_JSON, "unload") + && WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSIT_LAST_JSON, "transit"), + "在途排在末位时它确实是末位打卡节点"); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"), "在途不是可完成节点"); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "dispatch")); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"), "发货不是可完成节点"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "unload"), + "该配置里没有卸货节点,不能因为编码眼熟就认下"); + } + + @Test + @DisplayName("没有可打卡节点(或没有过程配置)时,谁都不是末位") + void lastDriverPunchNodeWithoutPunchNodes() { + String onlyAcceptAndReturn = """ + [{"key":"accept","name":"接单","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true}] + """; + + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "accept")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "return")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode("", "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(null, "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, null)); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "")); + } + + // ------------------------------------------------------------ 回单是否无需确认完成 + + /** 回单启用且配置为「无需确认完成」 */ + private static final String RETURN_NO_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}] + """; + + /** 回单启用且配置为「需确认完成」 */ + private static final String RETURN_NEED_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 回单未启用,但 confirmMode 写着「需确认完成」 */ + private static final String RETURN_DISABLED_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":false,"punch":true,"confirmMode":"yes"}] + """; + + /** 配置里根本没有回单节点 */ + private static final String NO_RETURN_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("回单启用时要看 confirmMode:只有「无需确认完成」才成立") + void enabledReturnNeedsConfirmMode() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NO_CONFIRM_JSON)); + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NEED_CONFIRM_JSON), + "回单需要确认完成时,打完签收不能算整单完成"); + } + + @Test + @DisplayName("回单未启用、或配置里没有回单节点,一律视为无需确认完成") + void absentOrDisabledReturnNeedsNoConfirmation() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_DISABLED_JSON), + "回单未启用时 confirmMode 写着需确认也不算数:没启用的环节不会有人来确认"); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(NO_RETURN_JSON)); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation("")); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(null)); + } + + @Test + @DisplayName("回单启用但 confirmMode 缺失时按「需确认完成」算") + void enabledReturnWithoutConfirmModeNeedsConfirmation() { + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(TRANSPORT_JSON), + "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); + } + +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java index a730f952..535935c7 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java @@ -27,7 +27,12 @@ package org.springblade.thirdparty.bfi.config; import feign.Request; import feign.RequestInterceptor; +import feign.codec.Encoder; +import feign.form.spring.SpringFormEncoder; import org.springblade.thirdparty.bfi.interceptor.BfiRequestInterceptor; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.support.SpringEncoder; import org.springframework.context.annotation.Bean; import java.util.concurrent.TimeUnit; @@ -61,4 +66,9 @@ public class BfiFeignClientConfig { public Request.Options options() { return new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true); } + + @Bean + public Encoder feignFormEncoder(ObjectFactory messageConverters) { + return new SpringFormEncoder(new SpringEncoder(messageConverters)); + } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java index f8744c62..608c39b2 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java @@ -84,6 +84,11 @@ public class BfiConstant { */ public static final long TOKEN_EXPIRE_BUFFER_MILLIS = 120_000L; + /** + * A801-5 黑名单查询页大小上限,取值范围[1, 2000]的正整数 + */ + public static final int BLACKLIST_QUERY_PAGE_SIZE = 2000; + /** * A303-5 开票申请发票查询 接口编码,固定值 */ diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java index c2e5c1ff..fea916a7 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java @@ -30,17 +30,18 @@ import org.springblade.thirdparty.bfi.config.BfiFeignClientConfig; import org.springblade.thirdparty.bfi.constant.BfiConstant; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBlacklistQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiExchangeRateQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceDownloadDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushBodyDTO; -import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveBodyDTO; -import org.springblade.thirdparty.bfi.pojo.dto.BfiExchangeRateQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO; @@ -48,7 +49,10 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO; @@ -58,18 +62,21 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; -import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.SpringQueryMap; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -106,6 +113,9 @@ public interface IBfiClient { String CLAIM_BILL_PUSH_URL = "${thirdParty.bfi.claimBillPushUrl:/ierp/kapi/v2/awrp/cas/cas/cas_claimbill/claimbillPush}"; String INVOICE_POOL_QUERY_URL = "${thirdParty.bfi.invoicePoolQueryUrl:/ierp/kapi/v2/awrp/rim/rim_invoice/query}"; String INVOICE_DOWNLOAD_URL = "${thirdParty.bfi.invoiceDownloadUrl:/ierp/kapi/v2/awrp/bosext/attachment/download}"; + String ATTACHMENT_UPLOAD_URL = "${thirdParty.bfi.attachmentUploadUrl:/ierp/kapi/v2/frame/attachment/uploadFile}"; + String BLACKLIST_QUERY_URL = "${thirdParty.bfi.blacklistQueryUrl:/ierp/kapi/v2/awrp/bdm/bdm_blacklist_config/blacklistquery}"; + String LOCK_BILL_URL = "${thirdParty.bfi.billLockUrl:/API_lockAction_Router}"; /** * A100-1 获取令牌 @@ -144,6 +154,16 @@ public interface IBfiClient { @GetMapping(FOREX_QUERY_URL) BfiResultVO> forexQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @SpringQueryMap BfiExchangeRateQueryDTO param); + /** + * A801-5 黑名单查询(分页,pageSize 取值范围[1, 2000]的正整数) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 黑名单分页数据 + */ + @GetMapping(BLACKLIST_QUERY_URL) + BfiResultVO> blacklistQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @SpringQueryMap BfiBlacklistQueryDTO param); + /** * A103-1 付款申请保存(根据星瀚应付下推付款申请) * @@ -410,15 +430,21 @@ public interface IBfiClient { /** * A301-2 收款认领公告查询 * - * @param accessToken 访问令牌,通过 getToken 获取 - * @param claimusers 参与认领用户名 - * @param pageNo 页码,从1开始 - * @param pageSize 页数 + * @param accessToken 访问令牌,通过 getToken 获取 + * @param claimusers 参与认领用户名 + * @param billstatus 单据状态(0-未认领,1-已认领) + * @param modifytimeS 修改时间-开始时间,格式:yyyy-MM-dd + * @param modifytimeE 修改时间-结束时间,格式:yyyy-MM-dd + * @param pageNo 页码,从1开始 + * @param pageSize 页数 * @return 收款认领公告分页数据 */ @GetMapping(CLAIM_ANNOUNCE_QUERY_URL) BfiResultVO> claimAnnounceQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestParam("claimusers") String claimusers, + @RequestParam(value = "billstatus", required = false) String billstatus, + @RequestParam("modifytimeS") String modifytimeS, + @RequestParam("modifytimeE") String modifytimeE, @RequestParam("pageNo") Integer pageNo, @RequestParam("pageSize") Integer pageSize); @@ -451,4 +477,30 @@ public interface IBfiClient { */ @PostMapping(INVOICE_DOWNLOAD_URL) Response invoiceDownload(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiInvoiceDownloadDTO param); + + /** + * A902 附件上传到指定单据(multipart/form-data) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param file 附件文件 + * @param attachmentUploadFileArgs 上传参数 JSON 字符串:{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"} + * @return 上传结果,data.path 即金蝶侧文件路径 + */ + @PostMapping(value = ATTACHMENT_UPLOAD_URL, consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + BfiResultVO uploadAttachment(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestPart("file") MultipartFile file, + @RequestPart("attachmentUploadFileArgs") String attachmentUploadFileArgs); + + /** + * 单据锁定(lockBusiData):推送金蝶成功后 WLTMS 调用锁定目标单据; + * 接口 1对1:单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo), + * 应答为 {updateCount, updateResultList} 裸结构(非 BfiResultVO 包装); + * 解锁结果由金蝶经 unLockNotifyBusiData 回调 WLTMS 通知(见 transport 侧公开端点) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 锁单参数 + * @return 锁单处理结果 + */ + @PostMapping(LOCK_BILL_URL) + BfiBillLockResultVO lockBusiData(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiBillLockDTO param); } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBillLockDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBillLockDTO.java new file mode 100644 index 00000000..e38b4ef5 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBillLockDTO.java @@ -0,0 +1,90 @@ +/** + * 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 + * of this software for such purposes. Copyright of this software + * remains with BladeX. + *

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; + +/** + * 业财融合(BFI)单据锁定/解锁通知参数(lockBusiData 与 unLockNotifyBusiData 出入参共用)。 + *

lockBusiData:WLTMS 调金蝶,sourceSystem=WLTMS、targetSystem=XH、lockAction=LOCK; + * unLockNotifyBusiData:金蝶调 WLTMS,sourceSystem=XH、targetSystem=WLTMS、lockAction=UNLOCK, + * operationType=DELETE 时 WLTMS 侧需废除源单并还原关联数据。 + * 传参约定:接口 1对1,单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo), + * time/operator 按接口文档为选传字段(示例报文未携带),非空时才送

+ * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBillLockDTO extends BfiBaseDTO { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】操作类型:LOCK-锁定,UNLOCK-解锁 + */ + private String lockAction; + /** + * 【必填】源系统标识,即接口调用方:WLTMS / XH + */ + private String sourceSystem; + /** + * 【必填】目标系统标识,即接口目标接收方:WLTMS / XH + */ + private String targetSystem; + /** + * 【必填】来源操作类型:SAVE-单据新增,DELETE-单据删除 + */ + private String operationType; + /** + * 【必填】源单据类型:调用方单据类型(WLTMS 自定义:SETTLEMENT/INVOICE_APPLY,金蝶固定:ApFin_other_BT_S 等) + */ + private String sourceBillType; + /** + * 【必填】源单据编号,与 targetBillNo 一对一 + */ + private String sourceBillNo; + /** + * 【必填】目标单据类型:接收方单据类型(金蝶固定:ApFin_other_BT_S 等,WLTMS 自定义:SETTLEMENT 等) + */ + private String targetBillType; + /** + * 【必填】目标单据编号,与 sourceBillNo 一对一 + */ + private String targetBillNo; + /** + * 操作时间,格式:yyyy-MM-dd HH:mm:ss(非空才送) + */ + private String time; + /** + * 操作人名字:推单用发起人,删除用操作人(非空才送) + */ + private String operator; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBlacklistQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBlacklistQueryDTO.java new file mode 100644 index 00000000..21947099 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBlacklistQueryDTO.java @@ -0,0 +1,69 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A801-5 黑名单查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBlacklistQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 使用状态[1:可用, 0:禁用] + */ + private String enable; + + /** + * 企业税号,数组 + */ + private List number; + /** + * 企业名称,数组 + */ + private List name; + + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页大小 + */ + private Integer pageSize; + +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimAnnounceQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimAnnounceQueryDTO.java index cd265ad9..dbd2b2ff 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimAnnounceQueryDTO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimAnnounceQueryDTO.java @@ -36,7 +36,7 @@ import java.io.Serializable; *

* 请求示例: *

{@code
- * GET /ierp/kapi/v2/awrp/cas/cas_claimannounce/query?claimusers=苏浩晖&pageSize=10&pageNo=1
+ * GET /ierp/kapi/v2/awrp/cas/cas_claimannounce/query?claimusers=苏浩晖&billstatus=0&modifytimeS=2024-01-01&modifytimeE=2024-06-30&pageNo=1&pageSize=10
  * }
* * @author Chill @@ -50,6 +50,18 @@ public class BfiClaimAnnounceQueryDTO implements Serializable { * 【必填】参与认领用户名 */ private String claimusers; + /** + * 单据状态(0-未认领,1-已认领) + */ + private String billstatus; + /** + * 【必填】修改时间-开始时间,格式:yyyy-MM-dd + */ + private String modifytimeS; + /** + * 【必填】修改时间-结束时间,格式:yyyy-MM-dd + */ + private String modifytimeE; /** * 【必填】页码,从1开始 */ diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushEntryDTO.java index fa214635..fdd221db 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushEntryDTO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushEntryDTO.java @@ -25,6 +25,7 @@ */ package org.springblade.thirdparty.bfi.pojo.dto; +import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @@ -40,6 +41,9 @@ import java.math.BigDecimal; */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) public class BfiClaimPushEntryDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; @@ -61,7 +65,7 @@ public class BfiClaimPushEntryDTO implements Serializable { /** * 分录行号 */ - private Integer seq; + private Long seq; /** * 收款类型.编码 */ diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveDTO.java index d96b7beb..7b1ec53a 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveDTO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveDTO.java @@ -307,4 +307,16 @@ public class BfiInvoiceApplySaveDTO implements Serializable { */ @JsonProperty("sim_original_bill_item") private List simOriginalBillItem; + /** + * 货物运输单据体(特殊票种 E04 货物运输时填写) + */ + private List freights; + /** + * 旅客运输单据体(特殊票种 E09 旅客运输时填写) + */ + private List travelers; + /** + * 代收车船税单据体(特殊票种 E07 代收车船税时填写) + */ + private List vehichevesselships; } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveFreightDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveFreightDTO.java new file mode 100644 index 00000000..390c49f3 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveFreightDTO.java @@ -0,0 +1,73 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A303-1 开票申请单新增 货物运输单据体(特殊票种 E04 货物运输时填写) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveFreightDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 星瀚内码 + */ + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 运输工具种类[铁路运输:铁路运输, 公路运输:公路运输, 水路运输:水路运输, 航空运输:航空运输, 管道运输:管道运输, 其它运输工具:其它运输工具] + */ + private String transporttype; + /** + * 运输工具牌号 + */ + private String licenseplate; + /** + * 运输货物名称 + */ + private String transportgoods; + /** + * 起运地 + */ + private String startplace; + /** + * 到达地 + */ + private String endplace; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveTravelerDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveTravelerDTO.java new file mode 100644 index 00000000..a0d2cd48 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveTravelerDTO.java @@ -0,0 +1,88 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A303-1 开票申请单新增 旅客运输单据体(特殊票种 E09 旅客运输时填写) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveTravelerDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 星瀚内码 + */ + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 出行人 + */ + private String traveler; + /** + * 出行人证件类型 + */ + private String travelercardtype; + /** + * 有效身份证件号 + */ + private String travelercardno; + /** + * 出行日期,格式:yyyy-MM-dd + */ + private String traveldate; + /** + * 旅客出发地 + */ + private String travelerstartplace; + /** + * 旅客到达地 + */ + private String travelerendplace; + /** + * 交通工具类型[1:飞机, 2:火车, 3:长途汽车, 4:公共交通, 5:出租车, 6:汽车, 7:船舶, 9:其他] + */ + private String travelertransporttype; + /** + * 等级[公务舱:飞机-公务舱, 头等舱:飞机-头等舱, 经济舱:飞机-经济舱, 一等舱:船舶-一等舱, 二等舱:船舶-二等舱, 三等舱:船舶-三等舱, 一等座:火车-一等座, 二等座:火车-二等座, 软席(软座、软卧):火车-软席(软座、软卧), 硬席(硬座、硬卧):火车-硬席(硬座、硬卧)] + */ + private String travelerseatclass; + /** + * 等级(自定义文本) + */ + private String travelerseatclassstr; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveVehicheVesselShipDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveVehicheVesselShipDTO.java new file mode 100644 index 00000000..e1114bae --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveVehicheVesselShipDTO.java @@ -0,0 +1,89 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A303-1 开票申请单新增 代收车船税单据体(特殊票种 E07 代收车船税时填写) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveVehicheVesselShipDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 星瀚内码 + */ + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 保险单号 + */ + private String policyno; + /** + * 车牌号/船舶登记号 + */ + private String shipsno; + /** + * 税款所属期 + */ + private String perioddate; + /** + * 车辆识别代码/车架号码 + */ + private String vehiclecode; + /** + * 代收车船税金额 + */ + private BigDecimal vehiclevesselamount; + /** + * 滞纳金金额 + */ + private BigDecimal vehiclelateamount; + /** + * 金额合计 + */ + private BigDecimal vehicletotalamount; + /** + * 税款所属期起,格式:yyyy-MM-dd + */ + private String periodstartdate; + /** + * 税款所属期止,格式:yyyy-MM-dd + */ + private String periodenddate; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java new file mode 100644 index 00000000..0e3437ea --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java @@ -0,0 +1,57 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A902 附件上传返回数据 + *

+ * 响应示例: + *

{@code
+ * {
+ *   "data": {"path": "211e6ce5c9805c00"},
+ *   "errorCode": "0",
+ *   "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BfiAttachmentUploadVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 金蝶侧文件路径(附件面板回显/排查用) + */ + private String path; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillLockResultVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillLockResultVO.java new file mode 100644 index 00000000..6a470ada --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillLockResultVO.java @@ -0,0 +1,78 @@ +/** + * 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 + * of this software for such purposes. Copyright of this software + * remains with BladeX. + *

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)单据锁定/解锁通知处理结果。 + *

lockBusiData(金蝶返回)与 unLockNotifyBusiData(WLTMS 回调应答)同构: + * updateCount 为处理单据数,updateResultList 为逐单处理结果(1-成功,0-失败/未匹配), + * 批量传参时单据编号逗号拼接、结果列表与传参顺序对应

+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBillLockResultVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 处理单据数量 + */ + private Integer updateCount; + /** + * 逐单处理结果:1-成功,0-失败/未匹配 + */ + private List updateResultList; + + /** + * 组装成功应答 + * + * @param count 处理单据数 + * @return 应答结果 + */ + public static BfiBillLockResultVO of(int count) { + BfiBillLockResultVO result = new BfiBillLockResultVO(); + result.setUpdateCount(count); + result.setUpdateResultList(count > 0 ? List.of(1) : List.of(0)); + return result; + } + + /** + * 是否全部处理成功 + * + * @return true-更新数量大于0 + */ + public boolean isSuccess() { + return updateCount != null && updateCount > 0; + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java new file mode 100644 index 00000000..e8fe9d91 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java @@ -0,0 +1,108 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-5 黑名单查询返回结果 + * + * @author Chill + */ +@Data +public class BfiBlacklistVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 企业税号 + */ + private String number; + /** + * 企业名称 + */ + private String name; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 数据状态标题 + */ + @JsonProperty("status_title") + private String statusTitle; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 使用状态标题 + */ + @JsonProperty("enable_title") + private String enableTitle; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; + /** + * 主数据内码 + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long masterid; + /** + * 上报单位(部门) + */ + @JsonProperty("awrp_textfield1") + private String awrpTextfield1; + /** + * 失信类别[A:禁止交易类, B:风险警示类] + */ + private String awrpCombofield; + /** + * 失信类别标题 + */ + @JsonProperty("awrp_combofield_title") + private String awrpCombofieldTitle; + /** + * 列入事由 + */ + @JsonProperty("awrp_textareafield") + private String awrpTextareafield; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiInvoiceVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiInvoiceVO.java index 35ace40b..e58d5a08 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiInvoiceVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiInvoiceVO.java @@ -217,6 +217,14 @@ public class BfiInvoiceApplyOpenApiInvoiceVO implements Serializable { * 发票图片URL */ private String invoiceImageUrl; + /** + * 数电PDF下载地址(发票云5.0.025版本返回) + */ + private String invoicePdfFileUrl; + /** + * 数电XML下载地址(发票云5.0.025版本返回) + */ + private String invoiceXmlFileUrl; /** * 开具状态 */ diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolVO.java index 1139590b..6b9867f9 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolVO.java @@ -518,10 +518,10 @@ public class BfiInvoicePoolVO implements Serializable { @JsonProperty("taxXmlUrl") private String taxXmlUrl; /** - * 出行人列表,客运票 + * 出行人列表,客运票;票易通多数场景返回字符串数组,部分数据源(如发票助手)返回对象数组,宽松承载防解析失败 */ @JsonProperty("travelerList") - private List travelerList; + private List travelerList; /** * 发票抬头 */ diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java index 0faceef1..50a26ba2 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java @@ -30,6 +30,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; @@ -49,7 +50,9 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO; @@ -123,6 +126,15 @@ public interface IBfiService { */ List queryExchangeRate(LocalDate effectdate, LocalDate effectdateE); + /** + * A801-5 黑名单查询 + * + * @param enable 使用状态[1:可用, 0:禁用] + * @param number 企业税号数组,按税号过滤时传 + * @return 黑名单列表 + */ + List queryBlacklist(String enable, List number); + /** * A103-1 付款申请保存(根据星瀚应付下推付款申请) * @@ -263,12 +275,10 @@ public interface IBfiService { /** * A301-2 收款认领公告查询 * - * @param claimusers 参与认领用户名 - * @param pageNo 页码,从1开始 - * @param pageSize 页数 + * @param param 查询参数,claimusers/modifytimeS/modifytimeE 必填,页码从1开始 * @return 收款认领公告分页数据 */ - BfiPageVO queryClaimAnnounce(String claimusers, Integer pageNo, Integer pageSize); + BfiPageVO queryClaimAnnounce(BfiClaimAnnounceQueryDTO param); /** * A301-3 收款认领结果推送 @@ -293,4 +303,25 @@ public interface IBfiService { * @return 文件流响应,调用方负责关闭 */ Response downloadInvoice(String path); + + /** + * A902 附件上传到指定单据 + * + * @param entityNumber 星瀚单据标识(如 ap_payapply) + * @param billPkId 星瀚单据内码(保存/查询响应返回的 id) + * @param controlKey 附件面板标识,统一 attachmentpanel + * @param fileName 附件名称(含扩展名) + * @param fileContent 附件内容 + * @return 上传结果,path 即金蝶侧文件路径 + */ + BfiAttachmentUploadVO uploadAttachment(String entityNumber, String billPkId, String controlKey, String fileName, byte[] fileContent); + + /** + * 单据锁定(lockBusiData):推送金蝶成功后锁定目标单据,接口 1对1(单次一个源单号对一个金蝶单号), + * 解锁结果由金蝶回调 unLockNotifyBusiData 通知 + * + * @param param 锁单参数,操作类型固定 LOCK,源系统 WLTMS、目标系统 XH + * @return true-锁定成功(updateCount 大于 0) + */ + boolean lockBusiData(BfiBillLockDTO param); } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java index a2d84e33..0cc4b18c 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java @@ -31,6 +31,7 @@ import feign.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.redis.cache.BladeRedis; +import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.thirdparty.bfi.config.BfiProperties; import org.springblade.thirdparty.bfi.constant.BfiConstant; @@ -42,6 +43,9 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBlacklistQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushBodyDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; @@ -73,7 +77,10 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO; @@ -91,6 +98,8 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.thirdparty.bfi.util.InMemoryMultipartFile; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; @@ -215,6 +224,24 @@ public class BfiServiceImpl implements IBfiService { return queryExchangeRate(effectdate.format(DATE_FORMATTER), effectdateE.format(DATE_FORMATTER)); } + @Override + public List queryBlacklist(String enable, List number) { + if (StringUtil.isBlank(enable)) { + throw new BfiException("查询BFI黑名单失败,使用状态不能为空"); + } + BfiBlacklistQueryDTO param = new BfiBlacklistQueryDTO(); + param.setEnable(enable); + if (Func.isNotEmpty(number)) { + param.setNumber(number); + } + String accessToken = getAccessToken(); + return BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(BfiConstant.BLACKLIST_QUERY_PAGE_SIZE); + return commonCall("黑名单查询", () -> client.blacklistQuery(accessToken, param)).getData(); + }); + } + @Override public BfiBillSaveResultVO pushPayApply(BfiPayApplyPushDTO param) { if (param == null || StringUtil.isBlank(param.getAwrpSrcCode()) || StringUtil.isBlank(param.getAwrpSrcSys())) { @@ -345,6 +372,33 @@ public class BfiServiceImpl implements IBfiService { return result.getData(); } + @Override + public boolean lockBusiData(BfiBillLockDTO param) { + if (param == null || StringUtil.isBlank(param.getLockAction()) || StringUtil.isBlank(param.getSourceSystem()) + || StringUtil.isBlank(param.getTargetSystem()) || StringUtil.isBlank(param.getOperationType()) + || StringUtil.isBlank(param.getSourceBillType()) || StringUtil.isBlank(param.getSourceBillNo()) + || StringUtil.isBlank(param.getTargetBillType()) || StringUtil.isBlank(param.getTargetBillNo())) { + throw new BfiException("BFI单据锁定失败,操作类型/源目标系统/操作类型/单据类型与编号不能为空"); + } + // 锁单应答为 {updateCount, updateResultList} 裸结构(非 BfiResultVO 包装),独立校验 + try { + String accessToken = getAccessToken(); + BfiBillLockResultVO result = client.lockBusiData(accessToken, param); + if (result != null && result.isSuccess()) { + return true; + } + log.error("调用BFI接口单据锁定异常响应参数:{}", JSONUtil.toJsonStr(result)); + throw new BfiException("调用BFI接口单据锁定异常,应答缺少updateCount字段(金蝶侧可能返回了请求回显、未实际处理锁定) updateCount=" + + (result == null ? "无响应" : result.getUpdateCount())); + } catch (BfiException e) { + log.error("调用BFI接口单据锁定,BfiException异常", e); + throw e; + } catch (Exception e) { + log.error("调用BFI接口单据锁定异常", e); + throw new BfiException("调用BFI接口单据锁定异常", e); + } + } + @Override public BfiPageVO queryArBill(BfiArBillQueryDTO param) { if (param == null || param.getPageNo() == null || param.getPageSize() == null) { @@ -470,15 +524,19 @@ public class BfiServiceImpl implements IBfiService { } @Override - public BfiPageVO queryClaimAnnounce(String claimusers, Integer pageNo, Integer pageSize) { - if (StringUtil.isBlank(claimusers)) { + public BfiPageVO queryClaimAnnounce(BfiClaimAnnounceQueryDTO param) { + if (param == null || StringUtil.isBlank(param.getClaimusers())) { throw new BfiException("查询BFI收款认领公告失败,参与认领用户名不能为空"); } - if (pageNo == null || pageSize == null) { + if (StringUtil.isBlank(param.getModifytimeS()) || StringUtil.isBlank(param.getModifytimeE())) { + throw new BfiException("查询BFI收款认领公告失败,修改时间范围不能为空"); + } + if (param.getPageNo() == null || param.getPageSize() == null) { throw new BfiException("查询BFI收款认领公告失败,分页参数不能为空"); } String accessToken = getAccessToken(); - BfiResultVO> result = commonCall("收款认领公告查询", () -> client.claimAnnounceQuery(accessToken, claimusers, pageNo, pageSize)); + BfiResultVO> result = commonCall("收款认领公告查询", () -> client.claimAnnounceQuery(accessToken, + param.getClaimusers(), param.getBillstatus(), param.getModifytimeS(), param.getModifytimeE(), param.getPageNo(), param.getPageSize())); return result.getData(); } @@ -533,12 +591,15 @@ public class BfiServiceImpl implements IBfiService { BfiInvoiceDownloadDTO param = new BfiInvoiceDownloadDTO(); param.setPath(path); String accessToken = getAccessToken(); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 进项发票下载开始 path:{}", path); try { Response response = client.invoiceDownload(accessToken, param); if (response == null || response.status() != 200) { log.error("调用BFI接口 进项发票下载异常响应状态:{}", response == null ? "无响应" : response.status()); throw new BfiException("调用BFI接口进项发票下载异常,响应状态:" + (response == null ? "无响应" : response.status())); } + log.info("调用BFI接口 进项发票下载结束 耗时:{}ms", System.currentTimeMillis() - start); return response; } catch (BfiException e) { throw e; @@ -548,6 +609,34 @@ public class BfiServiceImpl implements IBfiService { } } + @Override + public BfiAttachmentUploadVO uploadAttachment(String entityNumber, String billPkId, String controlKey, String fileName, byte[] fileContent) { + if (StringUtil.isBlank(entityNumber) || StringUtil.isBlank(billPkId)) { + throw new BfiException("上传BFI附件失败,entityNumber与billPkId不能为空"); + } + if (StringUtil.isBlank(fileName) || fileContent == null || fileContent.length == 0) { + throw new BfiException("上传BFI附件失败,附件名称与内容不能为空"); + } + String accessToken = getAccessToken(); + // A902 参数原文 JSON:{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"} + JSONObject args = new JSONObject(); + args.set("entityNumber", entityNumber); + args.set("billPkId", billPkId); + args.set("controlKey", StringUtil.isBlank(controlKey) ? "attachmentpanel" : controlKey); + InMemoryMultipartFile file = new InMemoryMultipartFile("file", fileName, MediaType.APPLICATION_OCTET_STREAM_VALUE, fileContent); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 附件上传开始 entityNumber:{},billPkId:{},文件:{},大小:{}字节", + entityNumber, billPkId, fileName, fileContent.length); + BfiResultVO result = commonCall("附件上传", () -> client.uploadAttachment(accessToken, file, args.toString())); + BfiAttachmentUploadVO uploadVO = result.getData(); + if (uploadVO == null || StringUtil.isBlank(uploadVO.getPath())) { + log.error("调用BFI接口 附件上传返回数据为空:{}", JSONUtil.toJsonStr(result)); + throw new BfiException("调用BFI接口附件上传异常,返回数据为空"); + } + log.info("调用BFI接口 附件上传结束 耗时:{}ms,kingdeePath:{}", System.currentTimeMillis() - start, uploadVO.getPath()); + return uploadVO; + } + /** * 公共调用方法,统一校验响应结果 * diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java new file mode 100644 index 00000000..37b09524 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java @@ -0,0 +1,101 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.util; + +import org.springframework.lang.NonNull; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * 内存字节数组版 MultipartFile:A902 附件上传经 Feign multipart 发送时使用, + * 文件内容已整体读入内存(单文件软限制 20MB,见 KingdeeAttachmentConstant) + * + * @author Chill + */ +public class InMemoryMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + public InMemoryMultipartFile(String name, String originalFilename, String contentType, byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content == null ? new byte[0] : content; + } + + @Override + @NonNull + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + @NonNull + public byte[] getBytes() { + return content; + } + + @Override + @NonNull + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(@NonNull File destination) throws IOException { + try (FileOutputStream outputStream = new FileOutputStream(destination)) { + outputStream.write(content); + } + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java b/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java index 9205574a..5794196b 100644 --- a/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java +++ b/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java @@ -46,11 +46,12 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerGroupEntryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveDTO; -import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerGroupEntryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO; @@ -60,10 +61,10 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignRefDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO; -import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; @@ -74,31 +75,21 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; -import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; -import org.springframework.data.redis.core.ValueOperations; - import org.springblade.thirdparty.bfi.service.impl.BfiServiceImpl; +import org.springframework.data.redis.core.ValueOperations; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; /** * 业财融合(BFI)服务实现单元测试 @@ -480,16 +471,28 @@ class BfiServiceImplTest { @Test void queryClaimAnnounceShouldValidateParams() { - assertThrows(BfiException.class, () -> service.queryClaimAnnounce(null, 1, 10)); - assertThrows(BfiException.class, () -> service.queryClaimAnnounce("张三", null, 10)); + BfiClaimAnnounceQueryDTO nullParam = null; + assertThrows(BfiException.class, () -> service.queryClaimAnnounce(nullParam)); + + BfiClaimAnnounceQueryDTO noTimeRange = new BfiClaimAnnounceQueryDTO(); + noTimeRange.setClaimusers("张三"); + noTimeRange.setPageNo(1); + noTimeRange.setPageSize(10); + assertThrows(BfiException.class, () -> service.queryClaimAnnounce(noTimeRange)); } @Test void queryClaimAnnounceShouldReturnPage() { mockAccessToken(); BfiPageVO page = new BfiPageVO<>(); - when(client.claimAnnounceQuery("token-1", "张三", 1, 10)).thenReturn(success(page)); - assertEquals(page, service.queryClaimAnnounce("张三", 1, 10)); + when(client.claimAnnounceQuery("token-1", "张三", null, "2026-01-01", "2026-06-30", 1, 10)).thenReturn(success(page)); + BfiClaimAnnounceQueryDTO param = new BfiClaimAnnounceQueryDTO(); + param.setClaimusers("张三"); + param.setModifytimeS("2026-01-01"); + param.setModifytimeE("2026-06-30"); + param.setPageNo(1); + param.setPageSize(10); + assertEquals(page, service.queryClaimAnnounce(param)); } @Test diff --git a/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/pojo/dto/MKProcessCreateDTO.java b/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/pojo/dto/MKProcessCreateDTO.java index f0a254d4..d902ed2c 100644 --- a/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/pojo/dto/MKProcessCreateDTO.java +++ b/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/pojo/dto/MKProcessCreateDTO.java @@ -17,9 +17,14 @@ public class MKProcessCreateDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; /** - * 表单实例id,业务id + * 表单实例id(传给 MK;由后端生成,前端勿传) */ private String formInstanceId; + /** + * 业务主键,仅 ERP 内部使用,不传给 MK + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String bizId; /** * 标题 */ @@ -50,4 +55,9 @@ public class MKProcessCreateDTO implements Serializable { */ @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String bizType; + /** + * 强制生成新表单实例(审核通过后再次提交),仅 ERP 内部使用,不传给 MK + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private Boolean forceNewFormInstance; } diff --git a/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/service/impl/MKServiceImpl.java b/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/service/impl/MKServiceImpl.java index e4b05985..eb5b1877 100644 --- a/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/service/impl/MKServiceImpl.java +++ b/blade-third-party-api/blade-mk-api/src/main/java/org/springblade/thirdparty/mk/service/impl/MKServiceImpl.java @@ -323,10 +323,13 @@ public class MKServiceImpl implements IMKService { return null; } String code = codeVO.getCode(); + // 跳转域名使用 thirdParty.mk.baseUrl(loginUrl 在部分环境未配置会导致拼出相对路径 null/...) + AssertUtils.notBlank(properties.getBaseUrl(), "未配置 thirdParty.mk.baseUrl"); + String mkBaseUrl = StringUtil.removeSuffix(properties.getBaseUrl().trim(), StringPool.SLASH); // 审批页面地址 - String approvalUrl = String.format(APPROVAL_URL_FORMAT, properties.getLoginUrl(), processInstanceId, processInstanceId); + String approvalUrl = String.format(APPROVAL_URL_FORMAT, mkBaseUrl, processInstanceId, processInstanceId); // sso回调地址 - return String.format(SSO_CALLBACK_URL_FORMAT, properties.getLoginUrl(), code, UrlUtil.encode(approvalUrl)); + return String.format(SSO_CALLBACK_URL_FORMAT, mkBaseUrl, code, UrlUtil.encode(approvalUrl)); } @Override diff --git a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java index 4f6027c0..e157641b 100644 --- a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java +++ b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java @@ -31,7 +31,35 @@ public class OAConvertConstant { */ public static final Long ROOT_PARENT_ID = 0L; /** - * 同步人员时挂载的顶级组织名称 + * OA 同步挂载的本地顶级组织主键(集团) */ - public static final String ROOT_COMPANY_NAME = "桂物物流集团"; + public static final Long OA_SYNC_ROOT_DEPT_ID = 135L; + /** + * OA 同步挂载的本地顶级组织默认名称(集团;已存在时不覆盖原名称,除非误写成外部组织) + */ + public static final String OA_SYNC_ROOT_DEPT_NAME = "桂物物流集团"; + /** + * OA 同步挂载的本地顶级组织编码(集团) + */ + public static final String OA_SYNC_ROOT_DEPT_CODE = "GWWL"; + /** + * 承运商等业务使用的外部组织顶级名称(与集团顶级并存) + */ + public static final String EXTERNAL_ORG_NAME = "外部组织"; + /** + * 组织编码分段数字位数(不足补零) + */ + public static final int DEPT_CODE_SEGMENT_WIDTH = 3; + /** + * 历史人员同步创建的伪顶级编码,同步时需清理 + */ + public static final String LEGACY_ROOT_DEPT_CODE = "OACROOT"; + /** + * 历史人员同步创建的伪顶级名称(与集团同名时仅清理非 id=135 的记录) + */ + public static final String LEGACY_ROOT_DEPT_NAME = "桂物物流集团"; + /** + * 同步人员时挂载的顶级组织名称(兼容旧引用) + */ + public static final String ROOT_COMPANY_NAME = OA_SYNC_ROOT_DEPT_NAME; } diff --git a/doc/docs/业务流程/2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md b/doc/docs/业务流程/2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md index b0a24a2b..9a5b7792 100644 --- a/doc/docs/业务流程/2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md +++ b/doc/docs/业务流程/2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md @@ -4,7 +4,7 @@ > 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) > 覆盖接口:A302-2 财务应收单保存、A302-4 财务应收单查询、A303-1 开票申请单新增、A303-4 开票申请单查询、A303-5 开票申请发票查询 > 关联文档:`2026-09-20-应收结算与开票申请同步金蝶设计.md`(设计口径与遗留清单)、`2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md`(应付侧对称链路) -> 最后更新:2026-09-21(按当前代码梳理;销方名称/销方税号/税收分类编码已改为随单据取数,不再使用 Nacos 配置) +> 最后更新:2026-10-08(联调纠偏:A303-1 awrp_src_con_no 改传首张结算单金蝶应收单防重键 awrp_src_code,传单据编号会报「源单编号不存在」;2026-10-01 上游来源单据放开传值 awrp_src_bt=ar_finarbill;2026-09-21 按当前代码梳理,销方名称/销方税号/税收分类编码随单据取数) --- @@ -156,8 +156,8 @@ kingdeeSyncStatus:unsynced ──A302-2成功──> synced(终态) | customnameid_number | 客户.编码 | 是 | 受票方客商档案客商编码(未维护报错) | 客商档案「客商编号」 | | awrp_project_number | 项目.项目编码 | 是 | 联调期 999 | — | | billsource | 数据来源 | 是 | 固定 3 | 无 | - | awrp_src_bt / awrp_src_con_no | 上游来源单据/单号 | 是 | ar_finarbill / 关联结算单金蝶应收单号(多张取第一张,拼接规则联调确认) | 环节一回写的「金蝶单据编号」 | - | systemsource | 系统来源 | 否 | 暂不传(联调校准项) | — | + | awrp_src_bt / awrp_src_con_no | 上游来源单据/单号 | 是 | ar_finarbill / 首张关联结算单的金蝶应收单**防重键 awrp_src_code**(联调确认金蝶按防重键匹配源单,2026-10-08 纠偏:传单据编号报「源单编号不存在」;多张取第一张) | `结算单号_结算明细单据号拼接`(与 A302-2 防重键同源) | + | systemsource | 系统来源 | 否 | thirdParty.bfi.srcSys(默认 WLTMS) | 无(后端配置) | 分录(sim_original_bill_item,逐「开票内容」商品行一条,按发票张次+张内行号平铺):取数口径即商品行落库值,**不回查开票项目主数据、不按开票明细→费用行展开分摊**(实现见 `InvoiceApplicationServiceImpl.resolveInvoiceApplyEntries`,仅查张次与商品行两张表): @@ -223,7 +223,7 @@ kingdeeStatus:unsynced ──A303-1成功──> synced ──A303-5查到发 | 2 | Nacos 配置 | 仅需已有 `thirdParty.bfi.srcSys`(默认 WLTMS)与任务开关/`cron`;`invoiceApply.salerName/salerTaxNo/taxratecode` **已取消**,`invoiceApply.srcType` 有默认值无需配置 | | 3 | 客商档案 | 我方开票单位(结算单收款方)与受票方客户均须入客商档案:我方须维护**统一社会信用代码**(销方税号),受票方须维护**客商编码**;受票方另需维护客商开票信息(税号、邮箱等) | | 4 | 主数据 | 开票项目须维护税收分类编码(新增开票内容选择即快照);货物类型档案货物编码;部门编码 | -| 5 | 联调校准项 | A302-2:单据类型/款项性质 1001/CASH/src_type 文案、物料 SP0324+计量 TON+项目 999 临时值(`pushArBill` 调试块);A303-1:rowtype、systemsource、多结算单上游单号拼接;A303-5:原文字段名收敛 | +| 5 | 联调校准项 | A302-2:单据类型/款项性质 1001/CASH/src_type 文案、物料 SP0324+计量 TON+项目 999 临时值(`pushArBill` 调试块);A303-1:rowtype(systemsource 已传 srcSys、上游单据号已放开传首张结算单金蝶应收单编号);A303-5:原文字段名收敛 | | 6 | 联调顺序 | 应收结算单同步(①)→ 开票申请同步(②)→ 手动触发 `sync-kingdee-ar-result` / `sync-kingdee-invoice-result` 验证回写 | --- diff --git a/doc/docs/业务流程/2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md b/doc/docs/业务流程/2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md index 839e5107..e7869c41 100644 --- a/doc/docs/业务流程/2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md +++ b/doc/docs/业务流程/2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md @@ -5,6 +5,8 @@ > 关联文档:`2026-09-20-收票管理同步金蝶全票池发票(A801-1查询+批量同步+定时绑定关联)业务流程.md`(同步链已整体退役,仅存推送/回写侧仍有效的键与端点见该文档退役声明)、`2026-09-22-收票单推送金蝶全链路业务流程(A303-2保存-A302-6指定应付-A801-3预检回写).md`(图05 推送链路,镜像字段 invoice_type_code/items_json 由其取数消费) > 设计依据:`docs/superpowers/specs/2026-09-22-invoice-pool-remote-reversal-design.md` > 最后更新:2026-09-23(选票可用过滤 + 有界循环补页游标分页 + 单据使用状态收敛为后端固定查未用;2026-09-22 本文原为「金蝶票池选票统一查镜像」,同日二次决策改回实时查询后整篇重写;文件名保留以免断链) +> +> **⚠️ 退役声明(2026-10-05)**:本文描述的**实时查询链路(游标分页、可用过滤拉取后执行、claimed/registered 纯展示装饰)已被第三次定稿回切为镜像读**,终态口径见 `2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md`。**仍然有效的部分**:保存重查闸门 `persistMirrorFromRemote`(含 isUsablePoolRow 复核,原样保留)、镜像 `(tenant_id, invoice_no)` 唯一与存根写入语义、A801-2 附件下载镜像优先/回源链路。下文其余叙述作历史口径。 --- diff --git a/doc/docs/业务流程/2026-09-24-kingdee-attachment-sync-design.md b/doc/docs/业务流程/2026-09-24-kingdee-attachment-sync-design.md new file mode 100644 index 00000000..977df069 --- /dev/null +++ b/doc/docs/业务流程/2026-09-24-kingdee-attachment-sync-design.md @@ -0,0 +1,219 @@ +# 金蝶附件同步(A902)设计文档 + +- 日期:2026-09-24 +- 范围:后端同步链路联带 A902 附件上传(付款申请、正式结算应付/应收、收票登记、开票申请)+ 前端状态列/提示/超时调整 +- 背景:单据推送金蝶成功后,TMS 侧附件(attachmentsJson)不随单上行,金蝶单据缺影像。星瀚 A902「附件上传到指定单据」接口已具备(见《业财融合系统接口文档.xlsx》同名页签),此前未在 BFI 客户端实现 +- 前置:原计划复用 2026-09-24-kingdee-push-timeout-anticlick 的 loading+守卫+try/finally 模式;经评审确认该前置未落地,本期**不实施防连点(后补)**,本设计仍不新增前端状态机 + +## 一、目标 + +1. 五类单据推送金蝶成功后,后端联带逐个上传附件至金蝶对应单据附件面板(A902) +2. 收票登记含发票文件:票池镜像附件 path → A801-2 下载 → A902 回传收票单 +3. 附件同步独立状态、失败可补传(重推自动补);前端列表列 + message 可见 +4. 客商档案预留 entityNumber 映射配置,二期接入(开票申请 entityNumber 已确认为 `sim_original_bill`,提入本期) + +## 二、A902 / A801-2 契约 + +### A902 附件上传到指定单据 + +- `POST /ierp/kapi/v2/frame/attachment/uploadFile`,multipart/form-data +- Header:`access_token`(复用现有 getToken/token 缓存) +- Body:`file`(文件)+ `attachmentUploadFileArgs`(JSON):`{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"}` +- 返回:`{"data":{"path":"211e6ce5c9805c00"},"errorCode":"0","status":true}`,`data.path` 即金蝶侧文件路径 + +### A801-2 文件下载(收票发票文件用) + +- `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `path`(镜像 attachmentsJson 的 path)→ 返回文件流 + +### entityNumber / controlKey 映射 + +| 业务单据 | entityNumber | controlKey | 期次 | +| --- | --- | --- | --- | +| 付款申请单 | `ap_payapply` | `attachmentpanel` | 本期 | +| 财务应付单(正式结算应付面板) | `ap_finapbill` | `attachmentpanel` | 本期 | +| 财务应收单(正式结算应收面板) | `ar_finarbill` | `attachmentpanel` | 本期 | +| 收票单 | `ap_invoice` | `attachmentpanel` | 本期 | +| 开票申请单 | `sim_original_bill` | `attachmentpanel` | 本期 | +| 客商(资质附件) | 待金蝶确认 | 待确认 | 二期 | + +映射以常量类维护:`KingdeeAttachmentConstant.ENTITY_NUMBERS`(biz_type → entityNumber),controlKey 统一 `attachmentpanel`;二期客商补条目不改架构。 + +## 三、领域模型 + +### 实体 + +**业务附件(不动)**:各单据 `attachmentsJson` JSON 列,是「单据有哪些附件」的业务事实,随表单编辑、随审批快照。本设计不读写其结构(只读 url/name)。 + +**附件同步表 `blade_kingdee_attachment_sync`(新增)**: + +| 字段 | 说明 | +| --- | --- | +| id / tenant_id / 审计字段 | 标准结构 | +| biz_type | `pay_application` / `formal_settlement_ap` / `formal_settlement_ar` / `invoice_receipt` / `invoice_application` | +| biz_id | 单据 id | +| attachment_key | 幂等键:用户附件=附件 url;发票文件=A801-2 的 path(path 变化视为新文件可重传) | +| attachment_name | 附件名(展示/排查用) | +| kingdee_path | A902 返回的 data.path | +| sync_status | 1 成功 / 0 失败 | +| fail_reason | 失败原因(超限/下载失败/金蝶报错) | +| sync_time | 上传时间 | + +- 唯一键 `uk_(biz_type, biz_id, attachment_key)`;同步前按 `(biz_type, biz_id)` 载入成功集合,命中即跳过(幂等) +- 同步流水落点见「四、同步流水落点(评审定案)」 + +**单据表加列**:`kingdee_bill_id`(星瀚单据内码,取自保存响应 `BfiBillSaveResultItemVO.id`)。付款申请已有该列;本期新增三列(SQL 见 `doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`): + +| 表 | 列 | 说明 | +| --- | --- | --- | +| blade_formal_settlement | kingdee_bill_id | A102-2/A302-2 保存响应返回;幂等跳过/存量缺失经 A102-4/A302-4 按防重键反查回填 | +| blade_invoice_receipt | kingdee_received_invoice_id | A303-2 保存响应返回;A801-3 幂等预检命中/定时回写时一并回填 | +| blade_invoice_application | kingdee_bill_id | A303-1 保存响应返回;A303-4 勾稽回填一并带回 | + +### 附件同步状态机(单附件行级) + +``` +(无记录) ──上传成功──> 成功(终态,幂等跳过) + └────上传失败──> 失败(记 fail_reason;重推时重新尝试,成功则覆盖为成功) +``` + +列表聚合展示态(运行时聚合,非存储字段,评审定案):`无附件` / `未上传` / `部分未传` / `已上传`。 +判定:单据 attachmentsJson 无可上传附件→无附件;同步表成功数=0→未上传;0<成功数<附件数→部分未传;成功数≥附件数→已上传。 + +### 不变式 + +1. 附件上传必须晚于主单推送成功、拿到并落库 billPkId 之后 +2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch) +3. 同 `(biz_type, biz_id, attachment_key)` 最多一条成功记录 +4. 不自动重试;重推主单时自动补传(主单被幂等预检跳过不影响附件补传分支) +5. 单文件软限制 20MB(常量,待金蝶确认后调整);超限/下载失败记原因跳过,不阻塞同单其余附件,更不阻塞同批其他单据 + +## 四、后端设计 + +### BFI 客户端(blade-bfi-api) + +`IBfiClient` 新增一个方法,复用现有 token 配置(`BfiFeignClientConfig`): + +- `uploadAttachment`(A902):`produces = MULTIPART_FORM_DATA_VALUE`,`@RequestPart("file")`(内容为内存字节数组,经 `InMemoryMultipartFile` 适配,单文件软限制 20MB)+ `@RequestPart("attachmentUploadFileArgs")`(JSON 字符串),返回 `BfiResultVO`,`data.path` 即金蝶侧文件路径 +- A801-2 下载**复用已有** `IBfiClient.invoiceDownload` / `IBfiService.downloadInvoice`(`feign.Response` 流式读取),本期仅在附件链路启用,无新增接口 + +### 附件同步支持类(blade-transport) + +新增 `KingdeeAttachmentSyncSupport`(`org.springblade.transport.support`,与 `KingdeeApBillSyncSupport` 等同包同风格): + +``` +syncAttachments(bizType, bizId, billPkId, List) // 主流程 +syncUserAttachments(bizType, bizId, billPkId, attachmentsJson) // 用户附件快捷入口 +parseUserAttachments(json) / parseInvoicePoolFile(json) // 解析:数组项容忍 url/link/fileUrl/downloadUrl/domain/src;镜像对象取 path +countAttachments(json) / successCountByBizId(bizType, bizIds) // 列表聚合(一次 GROUP BY,防 N+1) +aggregateStatus(attachmentCount, successCount) // 四态判定 +``` + +流程:载入已成功集合 → 过滤已传 → 过滤超限(记 fail_reason)→ 逐个(http(s) 下载 / A801-2 下载,流式封顶读取)→ A902 上传 → 落记录。单附件 try/catch,异常记失败继续下一个;结果汇总(`AttachmentSyncSummary`:total/success/failed/skipCount/failReasons)供接入点写操作流水与前端提示。同步表既有失败记录重试成功时覆盖为成功(复用主键 update)。 + +### 各模块接入点(主单成功、billPkId 落库之后,均在主单事务提交后执行) + +| 模块 | 接入点 | entityNumber | 附件来源 | +| --- | --- | --- | --- | +| 付款申请 | `PaymentApplicationServiceImpl.syncKingdee(id, context)` 包装层(`doSyncKingdee` 事务提交后) | ap_payapply | 单据 attachmentsJson | +| 正式结算应付/应收 | `FormalSettlementServiceImpl.syncKingdee`(无事务包装)→ `doSyncKingdee`(@Transactional)提交后 | ap_finapbill / ar_finarbill | 结算单 attachmentsJson | +| 收票登记 | `pushOneReceivedInvoice`:A303-2 落 pushed 后、A302-6 指定应付前 | ap_invoice | 登记附件 + 镜像发票文件(A801-2 下载,key=path) | +| 开票申请 | `InvoiceApplicationServiceImpl.syncKingdee`(无事务包装)→ `doSyncKingdee` 提交后 | sim_original_bill | 单据 attachmentsJson | + +事务边界(ADR-7):附件上传是慢远程调用,统一放在主单事务**之外**——正式结算/开票申请原 `syncKingdee` 事务体下移为 `doSyncKingdee`(经自代理调用),付款申请沿用既有包装层模式;附件失败仅在附件链路内消化,绝不回滚主单、不拉长主单持锁时间。 + +星瀚内码(billPkId)回填路径: + +- 推送路径:保存/下推响应 `BfiBillSaveResultItemVO.id` 随单落库(`BfiApBillPushOutcome`/`ReceivedInvoicePushOutcome` 扩展携带 billId) +- 幂等/存量路径:本地 kingdee_bill_id 缺失时反查——正式结算 A102-4/A302-4(`queryApBillExistId`/`queryArBillExistId`)、收票 A801-3 预检行/定时回写行自带 id、开票申请 A303-4 勾稽行自带 id;反查命中即回填并记 info 日志,仍未命中则跳过附件待重推补传 + +重推补传:现有同步入口无改动——主单被幂等预检判已存在时,仍进入附件补传分支(billPkId 取本地 kingdee_bill_id,缺失则按上述路径反查回填)。 + +### 同步流水落点(评审定案) + +附件明细以 `blade_kingdee_attachment_sync` 自身为准(sync_time/fail_reason 已是完整流水);仅在**有 actionType 流水表的单据**追加 `attachment_upload` 操作记录:收票登记(`InvoiceReceiptRecord`)、开票申请(`InvoiceApplicationRecord`);付款申请/正式结算无流水表,不追加。 + +### 列表状态聚合 + +各列表 VO(PaymentApplicationVO/InvoiceApplicationVO/FormalSettlementVO/InvoiceReceiptVO)新增三个展示字段:`attachmentCount`、`attachmentSyncStatus`(none/pending/partial/done)、`attachmentSyncStatusName`(无附件/未上传/部分未传/已上传)。各 `selectPage` 转换后按本页 biz_ids 一次 `GROUP BY biz_id`(`selectMaps`)查同步表成功计数,与解析 attachmentsJson 的附件数对比得出聚合态,避免 N+1;正式结算按应付/应收拆两组业务类型各查一次。 + +## 五、前端设计 + +| 项 | 内容 | +| --- | --- | +| 超时 | 实施勘误:paymentApplication/invoiceApplication/formalSettlement 的 syncKingdee 实际已是 120s(批量 300s),无需调整;唯一风险点是 `invoiceReceipt.js pushKingdee` 未设 timeout(走全局 10s 默认),已显式放宽为 120000 | +| 列表列 | payment-application.vue、invoice-application.vue、formal-settlement.vue(option `formalSettlementTable.js`)、invoice-receipt.vue 增「附件同步」列(`attachmentSyncStatusName`):无附件/未上传/部分未传/已上传 | +| message | 同步成功后 `await loadTable()` 刷新列表,按本次同步 ids 过滤刷新行中 `attachmentSyncStatus ∈ {pending, partial}` 的数量,追加 warning 提示「其中 N 单附件未全部上传,可重推补传」;收票页追加进推送结果弹窗文案 | +| 交互 | 无新增按钮(重推即补传);loading/守卫/try-finally 防连点模式本期未实施(见「不做的事」) | + +## 六、不做的事 + +- 前端同步按钮防连点模式(loading+守卫+try/finally):本期评审确认不实施,后补 +- 客商档案附件推送(二期,等金蝶确认 entityNumber/controlKey;本期仅预留映射配置 `KingdeeAttachmentConstant.ENTITY_NUMBERS`) +- 附件自动重试、定时补传任务 +- attachmentsJson 结构改造(去 JSON 化属独立重构,见 ADR-3) +- OFD/PDF 发票文件在金蝶侧的可预览性验证 + +## 七、待确认(金蝶侧) + +1. A902 单文件大小上限、允许格式(当前后端软限制 20MB 常量) +2. 客商的 entityNumber/controlKey +3. A902 调用频率限制(批量串行是否需间隔) +4. OFD 数电票文件上传收票单附件面板是否可用(主文档 641 行注明「金蝶不支持直接推送数电票 OFD 附件」,需验证 A902 通道例外与否) + +## 八、验证 + +1. 后端:`mvn clean compile -DskipTests -pl`(改动模块)——✅ 已通过(bfi-api / transport-api / blade-transport) +2. 前端:`pnpm run typecheck`——⚠️ 仓库既有问题无法运行(vue-tsc 3.3.11 与 typescript@7.0.2 不兼容,TS7 移除 `./lib/tsc` 导出,与本特性无关);已用 @vue/compiler-sfc 解析 + `node --check` 校验全部改动 .vue/.js 语法通过 +3. 手动(用户执行): + - 含附件单据(付款申请/开票申请/正式结算/收票登记)单条/批量同步 → 金蝶对应单据附件面板可见文件 + - 同步成功但附件失败(断网/超限模拟)→ 主单状态正常、列表「附件同步」列示未传/部分未传、重推自动补传 + - 重推已全部上传的单据 → A902 零调用(幂等跳过,日志「历史已成功 N 个」) + - 收票登记:镜像发票文件经 A801-2 下载后出现在收票单附件面板 + - 关键日志链路:`调用BFI接口 附件上传开始/结束` → `金蝶附件上传成功` → `金蝶附件同步完成` → 各接入点「同步完成」日志 + +## 九、ADR 摘要(本次评审定案) + +| # | 决策 | 备选与理由 | +| --- | --- | --- | +| ADR-1 | 后端联带自动传附件 | 前端两段式/独立按钮被否:状态机复杂、易漏传 | +| ADR-2 | 附件独立状态+失败可补传,不自动重试 | 主单状态机不被污染;重推即补传零新增交互 | +| ADR-3 | 幂等落独立同步表;**保留 attachmentsJson** | 附件业务事实(编辑/审批快照)与集成状态(幂等/path/原因)职责分离,url 幂等键是必要冗余而非数据冗余;推送期间单据锁定,无并发写 JSON 风险;去 JSON 化若要做属独立重构 | +| ADR-4 | 本期 5 单据(开票申请 entityNumber 已确认 `sim_original_bill` 提入本期),客商预留配置 | entityNumber 是硬约束,猜错即传不上 | +| ADR-5 | 同步接口超时 60s + 后端软限制跳过 | 大文件不拖死整批;不引入异步任务复杂度 | +| ADR-6 | 收票含发票文件:A801-2 下载→A902 回传,key=path | path 变化(红冲重开)自动触发重传 | +| ADR-7 | 附件上传在主单事务提交后执行(正式结算/开票申请拆 `doSyncKingdee` 事务体) | 附件是慢远程调用:放事务内会拉长持锁时间;放事务外失败只消化在附件链路,主单状态不被污染 | + +## 十、实施记录(2026-09-24 as-built) + +评审定案与设计的差异,均已按本节落地: + +| # | 事项 | 设计原假设 | 实施定案 | +| --- | --- | --- | --- | +| 1 | 前端防连点模式 | 前置已落地、直接复用 | 未落地,本期不实施(后补) | +| 2 | 前端超时 | 30s→60s 全量放宽 | 其余已 120s/300s 不动;仅 `invoiceReceipt.pushKingdee` 显式 120s(原全局 10s 默认) | +| 3 | 同步流水落点 | 「现有同步流水」统一新增 action | 无统一流水表:新表即明细事实源;收票/开票申请有 actionType 流水表追加 `attachment_upload`,付款申请/正式结算不追加 | +| 4 | 聚合态术语 | 正文与术语表两版冲突 | 定案:无附件/未上传/部分未传/已上传(CONTEXT.md 已落档) | +| 5 | A801-2 客户端 | 需新增 downloadFile | 已有 `invoiceDownload`,直接复用 | +| 6 | 应付/应收/收票推送返回 | 未明确内码获取方式 | `BfiApBillPushOutcome`/`ReceivedInvoicePushOutcome` 扩展 billId;幂等/存量经查询接口反查回填(`queryApBillExistId`/`queryArBillExistId`、A801-3/A303-4 查询行自带 id) | + +关键产物: + +- 后端:`IBfiClient.uploadAttachment`、`BfiAttachmentUploadVO`、`InMemoryMultipartFile`、`KingdeeAttachmentConstant`(biz_type/entityNumber/controlKey/20MB/聚合态)、`KingdeeAttachmentSync` 实体 + Mapper/Service、`KingdeeAttachmentSyncSupport` +- 接线:PaymentApplicationServiceImpl、FormalSettlementServiceImpl、InvoiceReceiptServiceImpl、InvoiceApplicationServiceImpl、KingdeeApBillSyncSupport、KingdeeArBillSyncSupport、KingdeeReceivedInvoiceSyncSupport +- 前端:4 option 列 + 4 页面 warning 提示 + invoiceReceipt.js 超时 +- SQL:`doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`(建表 + 3 加列,可重复执行) +- 提交:tms-api `dee649d`(docs) → `79aaa9b`(feat) → `9f95c75`(log);tms-erp-web `aabe67e` + +## 术语表 + +| 术语 | 含义 | +| --- | --- | +| A902 | 星瀚「附件上传到指定单据」接口(注意与客商接口 A902-1/A902-2 是两套编号) | +| A801-2 | 星瀚「文件下载」接口(票池发票影像) | +| billPkId | 星瀚单据内码,保存响应 `BfiBillSaveResultItemVO.id`,A902 定位目标单据 | +| entityNumber | 星瀚单据标识(如 ap_payapply) | +| controlKey | 星瀚附件面板/附件字段标识,统一 attachmentpanel | +| 附件幂等键 | 判定「同一附件已传过」的键:用户附件=附件 url;发票文件=A801-2 的 path(path 变化视为新文件) | +| 镜像发票文件 | 票池镜像 attachmentsJson 保存的发票文件 {source,path,name},上传收票单时经 A801-2 取回回传 | +| 附件同步聚合态 | 无附件/未上传/部分未传/已上传(列表展示用,运行时聚合) | +| 重推补传 | 主单重复推送(含幂等跳过)时自动补传未成功附件,无新增交互 | diff --git a/doc/docs/业务流程/2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md b/doc/docs/业务流程/2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md index 4274aab9..d7db7410 100644 --- a/doc/docs/业务流程/2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md +++ b/doc/docs/业务流程/2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md @@ -59,7 +59,7 @@ ### 3.1 编排 1. **确定参与认领用户**:`UserCache.getUserRealName(AuthUtil.getUserId())`,为空拦截「当前用户无姓名,无法同步认领公告」。 -2. **全量翻页**:`BfiUtil.queryAllPages(pageNo -> bfiService.queryClaimAnnounce(claimusers, pageNo, 200))`,受 `BfiUtil.DEFAULT_MAX_PAGES`(50 页)保护。 +2. **全量翻页**:`BfiUtil.queryAllPages(pageNo -> bfiService.queryClaimAnnounce(queryDTO))`,受 `BfiUtil.DEFAULT_MAX_PAGES`(50 页)保护。时间窗口从 Nacos 配置 `thirdParty.bfi.claimAnnounce.months` 读取(默认 6 个月)。 3. **幂等落库**:按 `detail_serial_no`(取 `detailid`,缺省回退 `tradedetailno`/`bankdetailno`)`FOR UPDATE` 查重后 insert/update 收款流水表;重复明细流水号抛「明细流水号重复」拦截。 4. **操作留痕**:`blade_receipt_flow_record` 记 `sync` 操作(新增/更新逐行记录,operator=当前用户)。 diff --git a/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md b/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md new file mode 100644 index 00000000..611fa719 --- /dev/null +++ b/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md @@ -0,0 +1,171 @@ +# 金蝶附件下载(A801-2 前端下载通道)设计文档 + +- 日期:2026-09-27 +- 范围:票池镜像发票文件面向前端的查看/下载通道(后端流式代理端点 + 前端三处入口改造);附件同步(A902)链路零改动 +- 背景:2026-09-24 金蝶附件同步设计核验时确认缺口——票池镜像发票文件的 attachmentsJson 为 `{source:"piaozone", path, name}`,其中 `path` 是金蝶侧文件地址(A801-1 的 downloadUrl,非 http 直链),前端 `invoiceAttachmentUrl`/`attachmentFileUrl` 只识别 `url/link/fileUrl`,因此正式结算认领发票行、付款申请发票附件行的「查看/下载」按钮对这类文件弹「暂无可查看/下载附件」;A801-2 后端能力已具备(`IBfiClient.invoiceDownload`),但仅用于附件同步链路(A801-2 下载 → A902 回传),无面向前端的下载端点。 + +## 一、目标 + +1. 前端可在票池弹窗、正式结算认领发票行、付款申请发票附件行三处查看/下载票池发票文件,文件经后端代理 A801-2 流式返回 +2. http 型附件(OSS 直链)的现有查看/下载行为完全不变,两类附件按字段特征自动分流 +3. 不提供「任意 path」下载能力,防止开放代理与越权读取金蝶文件 + +## 二、现状与缺口(核验结论) + +| 入口 | 现状 | 缺口 | +| --- | --- | --- | +| 票池弹窗 `kingdee-invoice-pool-dialog` | 仅选择发票,无附件操作列 | 无查看/下载入口 | +| 正式结算 `formal-settlement-editor.vue` 认领发票行 | 有 查看/下载 按钮,`invoiceAttachmentUrl` 只取 `url/link/fileUrl`(:2288-2305) | path 型附件取不到 url,按钮弹「暂无可查看/下载附件」 | +| 付款申请 `payment-application-form.vue` 发票附件行 | 同上(:1385-1411) | 同上 | +| 收票登记 `invoice-receipt-form.vue` | 登记附件(OSS 直链)正常 | 镜像发票文件入口由票池弹窗覆盖,本表单不改 | + +行数据具备条件:票池弹窗选中行与认领行均 `...row` 展开(formal-settlement-editor.vue:2195-2216),自带 `invoiceNo` 与 `attachmentJson`,满足「按发票号定位」的入参要求(勘误说明见「实施记录」1)。 + +## 三、契约 + +### A801-2 文件下载(已有,零改动) + +- `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `{"path": "<票池 downloadUrl>"}`,Header `access_token`(复用现有 getToken/token 缓存) +- 返回文件流;客户端 `IBfiClient.invoiceDownload` / `BfiServiceImpl.downloadInvoice` 已实现(status 校验、日志齐全),本期直接复用 + +### 契约核验(2026-09-27,源:《业财融合系统接口文档.xlsx》页签「文件下载」「查询全票池发票数据」「附件上传到指定单据接口」) + +1. **A801-2 失败响应是 JSON 而非流**:`{"data":null,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。","status":false}`;成功响应文档未给出字段定义(即原始文件流)。后端代理必须先判 Content-Type 再写流——失败时解析 `message`/`errorCode` 透传给前端,不能把 JSON 错误体当文件下发。 +2. **path 形态三方一致**:A801-2 请求示例 `234e244b2c803400`、A801-1 票池响应 `downloadUrl:"234e244b2c803400"`(另有 `imageUrl`/`pdfurl`/`snapshotUrl` 同形态;`originalFileName` 为 `serialNo.pdf`)、A902 返回 `data.path:"211e6ce5c9805c00"`——均为 16 位 hex 文件 id。镜像 path 可直接作为 A801-2 入参(与现状一致),且 A902 返回的 kingdee_path 与 A801-2 的 path 大概率同命名空间(见「待确认」1)。 +3. **entityNumber 映射获接口文档印证**:「附件上传到指定单据接口」页签内嵌映射表——付款申请单 `ap_payapply`、财务应付单 `ap_finapbill`、财务应收单 `ar_finarbill`、收票单 `ap_invoice`、开票申请单 `sim_original_bill`(controlKey 统一 `attachmentpanel`),与上期 `KingdeeAttachmentConstant` 完全一致,无需调整。 +4. A801-1 上游为票易通(queryParam 见 open.piaozone.com),`downloadUrl` 的有效期文档未说明;A902 页签未载明单文件大小上限与频率限制——「待确认」项维持。 + +### 新增 TMS 下载端点 + +`GET /blade-transport/invoice-receipt/invoice-pool/attachment/download?invoiceNo=<发票号>` + +- 鉴权:BladeX 登录态(网关 + Secure 框架),与同控制器 `/invoice-pool` 查询一致,不额外加 `@PreAuth` +- 租户隔离:`KingdeeInvoicePool extends TenantEntity`,`getById` 经 MP 租户插件自动过滤,跨租户 id 等同不存在 +- 成功响应:文件流。Header: + - `Content-Type`:透传 BFI 响应头,缺失时兜底 `application/pdf`(票池发票文件均为 PDF) + - `Content-Length`:BFI 响应头携带时透传 + - `Content-Disposition`:`attachment; filename=""; filename*=UTF-8''`,文件名取镜像 attachmentsJson 的 `name`(发票号.pdf) +- 失败响应(均在写响应头**之前**失败,走全局异常处理返回 `R` JSON): + +| 场景 | 行为 | +| --- | --- | +| 镜像未命中且 A801-1 实时回源未命中(含跨租户票号,租户隔离下等同不存在) | `ServiceException("该发票没有金蝶侧文件")` | +| 镜像/实时行均无 downloadUrl(如 OFD-only 票) | 同上,`ServiceException("该发票没有金蝶侧文件")` | +| BFI 返回非 200,或响应体为 JSON 错误(如 `FILE_IS_NOT_EXIST`) | 解析 BFI `message`/`errorCode` 抛 `ServiceException`(如「调用BFI文件下载失败:附件不存在。」),错误信息透传前端 | +| 传输中断 | 已写头后无法回退,流中断即连接断开,前端 reject | + +## 四、后端设计 + +接线(均在既有文件内新增,无新文件): + +- `InvoiceReceiptController`:新增 `downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response)`,与 `/invoice-pool` 同域同风格(`@GetMapping` + `@ApiOperationSupport` + `@Operation`) +- `IInvoiceReceiptService` / `InvoiceReceiptServiceImpl`:新增 `downloadPoolAttachment(String invoiceNo, HttpServletResponse response)` + +流程: + +``` +取镜像(自动租户过滤)→ 解析 attachmentsJson 取 path/name +→ bfiService.downloadInvoice(path) ← 在写任何响应字节之前 +→ 校验 status==200 且 Content-Type 非 JSON(防御 BFI 以 200 返回错误体) +→ 写响应头(Content-Type / Content-Length / Content-Disposition) +→ 8KB 缓冲流式拷贝 feign Response → ServletOutputStream,flush,finally 关闭 Response +``` + +设计要点: + +1. **path 一律服务端解析,接口不收 path 参数(ADR-1)**:按发票号定位,解析顺序「镜像快照优先 → A801-1 实时回源兜底」——镜像快照与上传链路(A902 镜像文件取回)同源同口径(同取 `blade_kingdee_invoice_pool.attachmentsJson`);但票池弹窗行是 A801-1 实时行、未必已入镜像,故下载必须有实时回源兜底(上传只发生在已绑定镜像的收票单上,无此场景)。镜像快照过期(红冲重开)时报「附件不存在。」不自动刷新,与上传取快照口径一致,刷新机制见「待确认」2 +2. **先校验后写流**:所有可预知错误发生在写头之前,保证前端拿到的是可识别的 JSON 错误而非损坏文件(ADR-3)。A801-2 失败时返回 JSON(`{"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}`,HTTP 状态文档未标注,按 200 + `status:false` 防御),解析 `message`/`errorCode` 透传给前端;成功时按 Content-Type 判定为文件流再写响应 +3. **全程流式**:不落盘、不整文件进堆,与同步链路的 20MB `readCapped` 无关——下载通道**不设 20MB 上限**(ADR-4) +4. **复用 BFI 配置**:token 缓存与 Feign 超时(连 10s / 读 60s,`BfiFeignClientConfig`)不动;无事务、不落附件同步表、不记操作流水 +5. **日志**:BFI 侧沿用「调用BFI接口 进项发票下载开始/结束」;业务侧补一条 info(操作人、发票号、字节数),便于审计 +6. 文件名编码:RFC 5987 `filename*` + ASCII 兜底,中文发票号不乱码 + +## 五、前端设计 + +### API 与公共工具 + +- `src/api/payment/invoiceReceipt.js` 新增: + +```js +// 金蝶票池发票文件下载(后端代理 A801-2),耗时较普通接口长,放宽到 120s +export const downloadInvoicePoolAttachment = invoiceNo => + request({ url: `${baseUrl}/invoice-pool/attachment/download`, method: 'get', params: { invoiceNo }, responseType: 'blob', timeout: 120000 }); +``` + +- 新增公共工具 `src/utils/kingdee-attachment.js`(三入口共用,避免复制三份): + - `isKingdeePathAttachment(attachment)`:`source === 'piaozone'`,或「有 `path` 且非 http(s) 开头」——判定为金蝶侧文件 + - `downloadKingdeeAttachment(row)`:金蝶型 → 调下载 API;若响应 blob 的 `type` 含 `application/json` 则读取文本解析出 msg 后 `message.error`(兼容全局异常处理器以 200 返回 R 的情况);否则 `downloadFile(blob, name)`;URL 型 → 回退既有 `downloadFileByUrl` + - `viewKingdeeAttachment(row)`:金蝶型 → 拉取 blob 后 `URL.createObjectURL` 新窗口打开(PDF 可预览;OFD 等浏览器不支持的格式提示改用下载);URL 型 → 维持 `window.open(url)` + - 两个动作均带行级 loading 守卫(单行按钮级即可,沿用「防连点全局模式本期不实施」的口径) + +### 入口改造(三处) + +| 文件 | 改造点 | +| --- | --- | +| `src/components/kingdee-invoice-pool-dialog/main.vue` | 表格新增「附件」操作列:查看 / 下载(行自带 invoiceNo + attachmentJson) | +| `src/views/settlement/components/formal-settlement-editor.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:2296-2305)增加金蝶型分支,走公共工具;URL 型行为不变 | +| `src/views/payment/payment-application-form.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:1393-1411)同上 | + +收票登记表单不改(镜像文件入口由票池弹窗覆盖)。 + +## 六、不做的事 + +- **已上传金蝶附件(`blade_kingdee_attachment_sync.kingdee_path`)的回源下载**:A801-2 对 A902 返回 path(如 `211e6ce5c9805c00`)的兼容性未验证,待金蝶确认后二期评估 +- 批量发票文件打包 zip 下载 +- 后端转存 OSS 再发直链的方案(ADR-2 否决,理由见下) +- 附件同步(A902)链路与 `KingdeeAttachmentSyncSupport` 的任何改动 + +## 七、待确认(金蝶侧) + +1. A801-2 是否可下载 A902 返回的 `data.path`(决定二期「从金蝶恢复已上传附件」可行性)。契约核验补充依据:A902 返回 path(`211e6ce5c9805c00`)与 A801-2 path 参数示例(`234e244b2c803400`)同为 16 位 hex 文件 id,疑似同一文件命名空间,大概率兼容,需实测确认 +2. 票池 `downloadUrl` 的有效期与失效表现:镜像落库后 path 是否会过期、失效时 A801-2 返回什么(已知失效类错误如 `FILE_IS_NOT_EXIST`「附件不存在。」);接口文档未载明有效期,上游为票易通(queryParam 见 open.piaozone.com),若会过期,需明确经 A801-1 刷新镜像的机制(影响下载失败时的用户提示与补救路径) +3. A801-2 频率限制(本场景为用户点击型、低频,预期无压力,确认即可);A902 页签亦未载明单文件大小上限与频率限制,上期 20MB 软限制待一并确认 + +## 八、验证 + +1. 后端:`mvn clean compile -DskipTests -pl blade-service/blade-transport -am` +2. 前端:`pnpm run typecheck` 因仓库既有问题不可用(vue-tsc 3.3.11 与 typescript@7.0.2 不兼容,与上期同因),沿用 @vue/compiler-sfc 解析 + `node --check` 校验全部改动 .vue/.js +3. 手动(用户执行): + - 票池弹窗行 查看/下载 → 得到 `发票号.pdf`,内容可打开 + - 正式结算认领发票行、付款申请发票附件行:金蝶型附件 查看/下载 成功;OSS 型附件行为与改造前完全一致 + - 镜像缺失 path / 发票未同步票池 → 友好提示,不产生损坏文件 + - 不存在的发票号 → 「该发票没有金蝶侧文件」(镜像与 A801-1 回源均未命中;跨租户票号在租户隔离下等同不存在) + - BFI 停机/超时模拟 → 前端 JSON 错误识别并提示,浏览器不落损坏文件 + - path 失效模拟(BFI 返回 `FILE_IS_NOT_EXIST`)→ 前端提示透传的「附件不存在。」,不落损坏文件 + - 日志链路:`调用BFI接口 进项发票下载` → 业务侧下载 info + +## 九、ADR 摘要 + +| # | 决策 | 备选与理由 | +| --- | --- | --- | +| ADR-1 | 按发票号定位,path 服务端解析(镜像优先 → A801-1 实时兜底) | 前端传 path 被否:等同开放代理,可遍历下载金蝶任意文件;只信镜像不够——票池弹窗是 A801-1 实时行、未必已入镜像;发票号在镜像表同租户唯一(A801-1 upsert 匹配键),天然带租户与数据边界 | +| ADR-2 | 后端流式代理,不转存 OSS | 转存需处理双存储一致性、清理策略与额外延时;票池文件为点击型低频小文件(PDF),代理足够;若日后高频/大文件再评估 | +| ADR-3 | 先校验后写流,错误返回 JSON | 先写头后失败会导致前端拿到损坏文件且无法提示;前端按响应 Content-Type 是否为 JSON 统一识别成败,不依赖 HTTP 状态码细节 | +| ADR-4 | 下载通道不设 20MB 上限 | 20MB 是 A902 上传软限制(金蝶契约),A801-2 下载无此约束,流式透传不占堆内存 | +| ADR-5 | 查看走 blob objectURL 新窗口,作为增强项 | 后端不区分 inline/attachment,减少契约面;OFD 等不可预览格式由前端降级为下载提示 | + +## 十、实施记录(2026-09-27 as-built) + +实施中发现的现状与设计的两处偏差,已按实际落地(勘误同上期 as-built 风格): + +| # | 事项 | 设计原假设 | 实施定案 | +| --- | --- | --- | --- | +| 1 | 下载定位键 | 按票池镜像 id(`GET /invoice-pool/{poolId}/attachment/download`) | **按发票号**(`GET /invoice-pool/attachment/download?invoiceNo=`)。原因:票池弹窗行是 A801-1 实时行、`id` 恒空(`toPoolViewVO` 注释即注明);正式结算/付款详情行是关系行(`FormalSettlementInvoice`/`PaymentApplicationInvoice` 的 id 为关系行 id 而非镜像 id)。三处入口统一仅可靠携带 `invoiceNo`;票号在镜像表同租户唯一(A801-1 upsert 匹配键),按票号取**实时镜像**还自动跟随 path 变化(红冲重开)。定位顺序:镜像优先(0 远程调用)→ 镜像未落库/无文件时实时回源 A801-1 按票号取 `downloadUrl`(远程模糊匹配命中行做全等防御) | +| 2 | 失败返回方式 | 走全局异常处理返回 R JSON | 写流前的业务失败(票号空/无金蝶侧文件/BFI JSON 错误体)在服务内捕获后经 `WebUtil.renderJson(response, R.fail(msg))` 直接写 HTTP 200 + JSON。原因:blade-transport 无本地异常处理器,全局处理器行为在 jar 内不可控(可能非 200,axios 拦截器对 blob 错误体无法解析出 msg);`WebUtil.renderJson` 为本仓库既有惯例(运单批量导入校验同款)。前端 `kingdee-attachment.js` 统一按响应 Content-Type 含 `application/json` 识别失败并弹出 msg | + +实施产物: + +- 后端:`InvoiceReceiptController.downloadPoolAttachment`(`/invoice-receipt/invoice-pool/attachment/download`,GET,继承类级 `@PreAuth(menu="invoice_receipt")`)、`IInvoiceReceiptService.downloadPoolAttachment`、`InvoiceReceiptServiceImpl` 实现(`resolvePoolAttachment` 定位 + `doDownloadPoolAttachment` A801-2 流式透传 + `readBfiJsonMessage` 错误体解析,`bfiResponse.close()` finally 兜底;IOException(客户端断开)仅记日志) +- 前端:`api/payment/invoiceReceipt.js` 新增 `downloadInvoicePoolAttachment`(blob + 120s);新增 `utils/kingdee-attachment.js`(`isKingdeePathAttachment` 判定 + 下载/预览 + JSON 错误识别 + 静默标记防双 toast);三处入口——票池弹窗新增「附件」列(行级 `attachmentBusy` 守卫)、正式结算编辑器与付款申请表单的 查看/下载 按钮按附件类型分流(`kingdeeAttachmentBusy` 守卫,OSS 直链行为不变) +- 验证:后端 `mvn clean compile -DskipTests -pl blade-service/blade-transport -am` ✅;前端 @vue/compiler-sfc 解析 + `node --check` 全部改动文件 ✅(`pnpm run typecheck` 因仓库既有 vue-tsc/TS7 不兼容问题不可用,与上期同因) + +## 术语表 + +| 术语 | 含义 | +| --- | --- | +| A801-2 | 星瀚「文件下载」接口,按 path 取回文件流(本期对前端暴露的下载通道) | +| 票池镜像 | `blade_kingdee_invoice_pool`,A801-1 全票池发票的本地镜像,attachmentsJson 存 `{source:"piaozone", path, name}` | +| path 型附件 | 金蝶侧文件:attachmentsJson 项有 `path` 无 http url,只能经 A801-2 取回 | +| URL 型附件 | OSS 直链附件:attachmentsJson 项有 `url/link/fileUrl`,前端可直连下载(现有行为) | +| billPkId | 星瀚单据内码(上期 A902 使用,本特性不涉及,仅口径区分) | +| 开放代理 | 接口接受任意目标地址参数并代为取回内容的安全缺陷,本设计以 id 定位规避 | diff --git a/doc/docs/业务流程/2026-09-27-金蝶附件上传下载链路梳理.md b/doc/docs/业务流程/2026-09-27-金蝶附件上传下载链路梳理.md new file mode 100644 index 00000000..bd716f97 --- /dev/null +++ b/doc/docs/业务流程/2026-09-27-金蝶附件上传下载链路梳理.md @@ -0,0 +1,213 @@ +# TMS ↔ 金蝶 上传/下载链路梳理 + +- 日期:2026-09-27 +- 依据:代码真实实现(blade-third-party-api/blade-bfi-api、blade-service/blade-transport)+ 《2026-09-24-kingdee-attachment-sync-design.md》《2026-09-27-kingdee-attachment-download-design.md》 +- 回答三个问题:**系统需要从金蝶下载哪些东西**、**上传和下载的逻辑是什么**、**上传与下载能否对应上(闭环)** + +--- + +## 一、结论摘要 + +1. **文件级下载目前只有一种:票池发票文件(发票影像 PDF)**。判断正确——全代码库中唯一返回文件流的金蝶接口是 A801-2「文件下载」(`IBfiClient.invoiceDownload`),它的消费方只有两处,都围绕票池发票文件:① 附件同步链路内「下载回传」收票单;② 面向前端的票池发票文件下载端点(2026-09-27 新增,当前未提交)。销项开票发票(A303-5 回写)不走文件流接口:A303-5 响应自带发票云文件直链 URL,落库后前端直连查看/下载(2026-09-30 补充,见 3.4)。 +2. 除文件外,系统还从金蝶「下载」**数据**(JSON 查询结果):票池发票元数据(A801-1)、收票单结果(A801-3)、付款申请/付款处理结果(A103-2/A103-4)、应付/应收单结果(A102-4/A302-4)、开票申请单与开票结果(A303-4/A303-5)、客商(A902-1)、汇率(A801-4)。这些用于幂等预检、星瀚内码反查、状态/结果定时回写。 +3. **上传与下载基本能对应上,但有三处缺口**:① 已上传金蝶的附件(`blade_kingdee_attachment_sync.kingdee_path`)没有回源下载通道(A801-2 是否兼容 A902 返回的 path 未验证,二期待金蝶确认);② 收款认领链路(A301-1/2/3)客户端已实现、业务零接线;③ 客商资质附件上传(A902)二期预留,entityNumber 未确认——客商附件目前既无上传也无下载。 +4. 每一个「推送/保存」类接口都有对应的「查询」类接口做幂等预检或结果回写,数据面上传下载成对;文件面只有「下载发票→上传回传」一条闭环。 + +--- + +## 二、接口总清单与业务接线状态 + +`IBfiClient`(blade-bfi-api)共 25 个接口,按业务接线情况分三类: + +### 2.1 业务已接线(上传/推送类) + +| 编号 | 接口 | IBfiService 方法 | 业务调用点 | +| --- | --- | --- | --- | +| A103-1 | 应付下推付款申请 | `pushPayApply` | PaymentApplicationServiceImpl:1045 | +| A103-3 | 预付款申请保存 | `savePrePayApply` | PaymentApplicationServiceImpl:949 | +| A102-2 | 财务应付单保存 | `saveApBill` | KingdeeApBillSyncSupport:318 | +| A302-2 | 财务应收单保存 | `saveArBill` | KingdeeArBillSyncSupport:258 | +| A303-2 | 收票单新增(批量) | `saveReceivedInvoice` | KingdeeReceivedInvoiceSyncSupport:218 | +| A302-6 | 收票指定应付 | `invoiceAssign` | KingdeeReceivedInvoiceSyncSupport:288 | +| A303-1 | 开票申请单新增 | `saveInvoiceApply` | InvoiceApplicationServiceImpl:702 | +| A902-2 | 客户保存 | `saveCustomer` | CustomerArchiveServiceImpl:527 | +| **A902** | **附件上传到指定单据** | `uploadAttachment` | KingdeeAttachmentSyncSupport:164(唯一入口) | + +### 2.2 业务已接线(下载/查询类) + +| 编号 | 接口 | IBfiService 方法 | 业务调用点 | 用途 | +| --- | --- | --- | --- | --- | +| A801-1 | 查询全票池发票数据 | `queryInvoicePool` | InvoiceReceiptServiceImpl:285、1110 | 选票弹窗实时查询;保存收票登记落镜像;下载回源兜底 | +| A801-3 | 收票单查询 | `queryReceivedInvoice` | KingdeeReceivedInvoiceSyncSupport:115 | 推送幂等预检;定时回写 | +| **A801-2** | **文件下载(发票文件)** | `downloadInvoice` | InvoiceReceiptServiceImpl:1248、KingdeeAttachmentSyncSupport:367 | **见第三节** | +| A102-4 | 财务应付单查询 | `queryApBill` | KingdeeApBillSyncSupport:155、182 | 幂等预检;正式结算内码反查 | +| A302-4 | 财务应收单查询 | `queryArBill` | KingdeeArBillSyncSupport:106、133 | 幂等预检;应收结果回写、开票前置校验、内码反查 | +| A103-2 | 付款申请单查询 | `queryPayApply` | PaymentApplicationServiceImpl:743 | 付款结果定时回写;单条状态回查 | +| A103-4 | 付款处理查询 | `queryPayBill` | PaymentApplicationServiceImpl:833 | 付款处理结果勾稽回写 | +| A303-4 | 开票申请单查询 | `queryInvoiceApply` | InvoiceApplicationServiceImpl:581 | 推送幂等预检;附件内码反查 | +| A303-5 | 开票申请发票查询(OpenApi) | `queryInvoiceApplyInvoice` | InvoiceApplicationServiceImpl.executeInvoiceResultSync | 开票结果回写(发票号 + 板式文件/数电PDF/XML文件地址落库,2026-09-30 扩展) | +| A902-1 | 客户查询 | `queryCustomer` | CustomerArchiveServiceImpl:554、821;BfiCustomerController:70 | 推送前置幂等复查;定时同步复查;客商列表穿透查询 | +| A801-4 | 汇率查询 | `queryExchangeRate` | BfiExchangeRateController:63(GET /bfi/exchange-rate) | 前端汇率展示 | + +### 2.3 客户端已实现、业务零调用(仅测试覆盖) + +| 编号 | 接口 | 说明 | +| --- | --- | --- | +| A301-1 | 收款单查询(`queryRecBill`) | 收款认领链路未启用 | +| A301-2 | 收款认领公告查询(`queryClaimAnnounce`) | 同上 | +| A301-3 | 收款认领结果推送(`pushClaimResult`) | 同上(上传方向) | +| A100-2/3 | 令牌校验/撤回 | 基础设施预留,token 管理由 `BfiRequestInterceptor`/缓存承担 | + +> 结论:**收款认领(收款单从金蝶下载 → TMS 认领 → 结果回推)是设计上预留、尚未接线的下载场景**,是目前除发票文件外唯一「未来可能需要从金蝶下载」的业务,与《业财融合方案》中收款认领规划对应。 + +--- + +## 三、文件下载链路(金蝶 → TMS)——「下载什么」的完整答案 + +### 3.1 唯一被下载的文件:票池发票文件 + +文件从哪来:A801-1 票池查询响应的 `downloadUrl`(16 位 hex 文件 id,另有 `imageUrl`/`pdfurl`/`snapshotUrl` 同形态),保存收票登记落镜像时序列化为 `blade_kingdee_invoice_pool.attachmentsJson`:`{"source":"piaozone","path":"","name":"<发票号>.pdf"}`(InvoiceReceiptServiceImpl `buildAttachmentJson`:1206-1215)。 + +文件怎么取:A801-2 `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `{"path":"..."}`,返回文件流。BFI 层实现 `BfiServiceImpl.downloadInvoice`:532-555(校验 path 非空、HTTP 200、日志齐全),失败时金蝶返回 JSON 错误体(如 `FILE_IS_NOT_EXIST`「附件不存在。」)。 + +### 3.2 用途一:附件同步链路内下载回传(后端自动,无前端交互) + +`KingdeeAttachmentSyncSupport.fetchContent`:365-385:当附件项 `kingdeePathSource=true`(镜像发票文件)时,调 `bfiService.downloadInvoice(path)` → `readCapped` 流式读取(64KB 缓冲,**20MB 封顶**,超限中断并记失败原因)→ 内存字节数组 → A902 上传至收票单附件面板。 + +特点:同步链路内的下载**必须**经 20MB 软限制(A902 上传契约约束),下载的文件即传即走、不落盘不存 OSS。 + +### 3.3 用途二:前端下载通道(用户点击,2026-09-27 新增,当前未提交) + +端点:`GET /blade-transport/invoice-receipt/invoice-pool/attachment/download?invoiceNo=<发票号>`(InvoiceReceiptController:90-96,类级 `@PreAuth(menu="invoice_receipt")`)。 + +实现(InvoiceReceiptServiceImpl `downloadPoolAttachment`:1219-1312): + +``` +按发票号定位 path(resolvePoolAttachment) + ① 本地镜像优先:blade_kingdee_invoice_pool 按票号查(MP 租户插件自动隔离),解析 attachmentsJson 取 path/name + ② 镜像未落库或无文件 → A801-1 实时回源(queryPoolByInvoiceNo,模糊匹配结果做票号全等防御) +→ bfiService.downloadInvoice(path) ← 在写任何响应字节之前 +→ 校验:HTTP 200(BFI 层)+ Content-Type 非 application/json(防 JSON 错误体当文件下发) +→ 写响应头(Content-Type 缺省兜底 application/pdf / Content-Length 透传 / Content-Disposition RFC5987 文件名) +→ 8KB 缓冲流式透传 feign Response → ServletOutputStream,不落盘不进堆,finally 关闭 Response +``` + +错误处理:写流前的业务失败(票号空/无金蝶侧文件/BFI JSON 错误体)经 `WebUtil.renderJson(response, R.fail(msg))` 写 HTTP 200 JSON,前端按响应 Content-Type 是否含 `application/json` 统一识别;传输中断(IOException,客户端断开)仅记日志。**此通道不设 20MB 上限**(20MB 是 A902 上传契约,A801-2 下载无此约束)。 + +前端三处入口(票池弹窗附件列、正式结算认领发票行、付款申请发票附件行)经 `kingdee-attachment.js` 分流:`source === 'piaozone'` 或「有 path 且非 http(s) 开头」判为金蝶型走该端点;OSS 直链附件维持现有 `downloadFileByUrl` 行为不变。 + +### 3.4 用途三:销项开票发票文件直链查看/下载(2026-09-30 新增) + +开票申请链路的销项发票文件不走 A801-2:A303-5 开票结果查询响应自带发票云文件直链——`invoiceFileUrl`(板式文件:数电票 OFD、航信百望税盘普通电票 PDF、电子专票/UKey 电票 OFD)、`invoiceImageUrl`(PDF 图片预览地址)、`invoicePdfFileUrl`/`invoiceXmlFileUrl`(数电 PDF/XML,发票云 5.0.025+ 返回)。 + +- **落库**:`executeInvoiceResultSync` 回写时将四个地址独立落 `blade_invoice_application_invoice`(补丁 `blade_invoice_application_invoice_urls_20260930.sql`),原文 JSON 照旧留档;已落库但缺地址的历史记录由同任务字段级补空(只补空不覆盖已有值)。 +- **展示**:正式结算单详情 `FormalSettlementVO.issuedInvoices`(`FormalSettlementServiceImpl.listIssuedInvoices`:结算单 → 开票申请关联 → 发票结果,仅保留非作废未删除申请),前端 `formal-settlement-editor.vue` 发票信息卡片内只读表格「开票发票」提供查看/下载。该列表只读、不落 `blade_formal_settlement_invoice`、不参与匹配金额计算,避免被 `rebuildInvoices` 全删重建逻辑误持久化。 +- **下载通道**:URL 为 http(s) 直链,按 3.3 前端分流约定归入「OSS 直链」一类——前端 `window.open`(查看,数电 PDF 优先)/ `downloadFileByUrl`(下载,按直链扩展名命名)直连,不经 A801-2 代理,不新增文件流接口。 +- **时效性**:直链有效期未知,与 5.2 缺口 #4(票池文件时效性)同源待金蝶确认;回写补齐策略为仅补空不刷新,若金蝶确认直链会过期需另行设计刷新机制。 + +### 3.5 数据下载(查询回写)的触发机制 + +| 定时任务(PaymentKingdeeResultTask / CustomerKingdeeSyncTask) | cron 默认 | 拉取内容 | 回写目标 | +| --- | --- | --- | --- | +| payResult | 每 10 分钟 | A103-2 付款申请单状态 + A103-4 付款处理结果 | 付款申请 kingdee_status | +| receivedInvoiceResult | 每 10 分钟 | A801-3 收票单结果 | 收票登记 kingdee_push_status、内码 | +| formalSettlement 应收回写(`syncKingdeeArResult`:631,随 payResult 任务调度) | 同上 | A302-4 应收单结果 | 正式结算应收面板状态 | +| invoiceApplication 开票结果(`syncKingdeeInvoiceResult`:803) | 同上 | A303-4 申请单 + A303-5 发票明细 | 开票申请状态 + 发票号落库 | +| customer(CustomerKingdeeSyncTask) | 每天凌晨 2 点 | A902-1 客商(按信用代码批量复查) | 客商档案协同状态 | + +另有手动触发点:各单据「同步金蝶结果」按钮(如 InvoiceReceiptController `/sync-kingdee-result`)、推送前的幂等预检(A102-4/A302-4/A801-3/A303-4/A103-2)、附件内码反查、票池选票弹窗实时查询(A801-1,游标分页 + 可用性过滤)。 + +--- + +## 四、文件上传链路(TMS → 金蝶)——A902 附件同步 + +### 4.1 唯一上传通道 + +`KingdeeAttachmentSyncSupport.syncAttachments`:125-189,经 `BfiServiceImpl.uploadAttachment`:558-583 调 A902 `POST /ierp/kapi/v2/frame/attachment/uploadFile`(multipart,`file` + `attachmentUploadFileArgs` JSON:entityNumber/billPkId/controlKey),返回 `data.path` 落 `blade_kingdee_attachment_sync.kingdee_path`。 + +核心机制: + +- **幂等**:按 `(biz_type, biz_id, attachment_key)` 唯一键;同步前载入已成功集合,命中即跳过(重推 A902 零调用);失败记录重试成功时覆盖更新 +- **幂等键**:用户附件 = 附件 url;发票文件 = A801-2 的 path(path 变化即红冲重开,自动视为新文件重传) +- **限制**:单文件 20MB 软限制(下载前按声明大小拦截 + 下载后按实际字节校验) +- **隔离**:逐附件独立 try/catch,失败记 `fail_reason` 继续下一个;附件失败不回滚主单、不阻塞同批其他单据 +- **补传**:不自动重试;重推主单时自动补传(主单被幂等预检跳过不影响附件分支) + +### 4.2 五类单据接入点(均已落地,主单事务提交后执行) + +| 单据 | biz_type → entityNumber | 接入点(代码位置) | 附件来源 | billPkId 缺失时的反查 | +| --- | --- | --- | --- | --- | +| 付款申请 | `pay_application` → `ap_payapply` | PaymentApplicationServiceImpl.syncKingdee:387 → doSyncKingdee 事务提交后 :403 → syncAttachmentsAfterPush:414 | 单据 attachmentsJson(用户附件) | 无(本地 kingdeeBillId 缺失即跳过待重推) | +| 正式结算-应付 | `formal_settlement_ap` → `ap_finapbill` | FormalSettlementServiceImpl.syncKingdee:510 → syncAttachmentsAfterPush:525 | 结算单 attachmentsJson | A102-4 按防重键反查回填(`resolveKingdeeBillId`) | +| 正式结算-应收 | `formal_settlement_ar` → `ar_finarbill` | 同上(按 settlementType 分流 :547-548) | 同上 | A302-4 反查回填 | +| 收票登记 | `invoice_receipt` → `ap_invoice` | InvoiceReceiptServiceImpl.pushOneReceivedInvoice:720,A303-2 落 pushed 后、A302-6 指定应付前 :786 → syncAttachmentsAfterPush:809 | 登记附件(用户附件)+ 镜像发票文件(**A801-2 下载**) | A801-3 预检行自带 id 回填 :752-754 | +| 开票申请 | `invoice_application` → `sim_original_bill` | InvoiceApplicationServiceImpl.syncKingdee:497 → syncAttachmentsAfterPush:511 | 单据 attachmentsJson | A303-4 勾稽回填带回 | + +流水落点:收票登记(`InvoiceReceiptRecord`)与开票申请(`InvoiceApplicationRecord`)有 actionType 流水表,追加 `attachment_upload` 记录;付款申请/正式结算无流水表不追加。列表聚合态(无附件/未上传/部分未传/已上传)由 `successCountByBizId`(一次 GROUP BY)+ `aggregateStatus` 运行时聚合。 + +### 4.3 数据上传(单据推送)不在本档展开 + +付款申请(A103-1/A103-3)、财务应付/应收单(A102-2/A302-2)、收票单+指定应付(A303-2/A302-6)、开票申请(A303-1)、客商(A902-2)的报文组装与状态机,见各 Support 类与《业财融合方案》主文档。 + +--- + +## 五、上传 ↔ 下载对应关系(能否对上) + +### 5.1 已闭环的对应 + +| 对应项 | 上传侧 | 下载侧 | 状态 | +| --- | --- | --- | --- | +| **票池发票文件** | A801-2 下载(20MB 封顶)→ A902 回传收票单附件面板,key=path | A801-2 前端下载端点(不设上限),镜像优先 + A801-1 回源 | ✅ 完整闭环:同一份文件既能自动回传金蝶,又能给前端人工查看/下载 | +| **用户附件(OSS 直链)** | http(s) 下载(60s 超时、20MB 封顶)→ A902 上传 5 类单据 | 不经金蝶:前端直连 OSS(`downloadFileByUrl` 现有行为) | ✅ 各自闭环,两类附件按字段特征分流互不干扰 | +| **单据数据面** | A103-1/3、A102-2、A302-2、A303-2/6、A303-1、A902-2 | A103-2/4、A102-4、A302-4、A801-3、A303-4/5、A902-1(幂等预检 + 内码反查 + 结果回写) | ✅ 每个推送接口都有查询接口配套,成对出现 | +| **票池元数据** | (消费方)收票登记落镜像、A303-2 分录取数 | A801-1 实时查询(选票弹窗 + 回源) | ✅ 闭环 | +| **销项开票发票文件** | (数据面)A303-1 开票申请推送 | A303-5 回写发票结果含文件直链,落库后前端直连查看/下载(不经 A801-2) | ✅ 闭环(2026-09-30 补充,见 3.4) | + +### 5.2 对不上的缺口(单向或未启用) + +| # | 缺口 | 现状 | 影响 | +| --- | --- | --- | --- | +| 1 | **已上传附件的金蝶侧回源下载** | A902 上传成功后 `kingdee_path` 已落 `blade_kingdee_attachment_sync`,但没有任何代码用它发起 A801-2 下载 | TMS 侧附件若 OSS 丢失/清理,无法从金蝶恢复。A902 返回 path(`211e6ce5c9805c00`)与 A801-2 path(`234e244b2c803400`)同为 16 位 hex,疑似同命名空间,但兼容性**未验证**——2026-09-27 设计「不做的事」第 1 条明确列为二期待确认项 | +| 2 | **收款认领链路** | A301-1 收款单查询、A301-2 认领公告查询、A301-3 结果推送在 `IBfiClient`/`BfiServiceImpl` 已完整实现,业务层零调用 | 金蝶收款数据下载 → TMS 认领 → 结果回推整条链路待启用;启用前 A301 系列不产生任何下载 | +| 3 | **客商资质附件** | `KingdeeAttachmentConstant.ENTITY_NUMBERS` 仅预留注释(CustomerArchiveServiceImpl 侧无任何附件上传接线);客商的 entityNumber/controlKey 待金蝶确认 | 客商附件目前既不上传也不下载,二期接入时补一个 biz_type 条目即可复用现有架构 | +| 4 | 票池文件时效性 | 镜像 `attachmentsJson` 落库后 path 是否会过期未知;下载时镜像快照过期(红冲重开)报「附件不存在」**不自动刷新**,与上传取快照口径一致。2026-09-30 落库的 A303-5 销项发票云直链(见 3.4)同样受时效性约束,回写仅补空不刷新 | 需金蝶确认 `downloadUrl`/发票直链有效期与失效表现,决定是否要补定时刷新镜像机制 | +| 5 | 附件面板反向查询 | 金蝶无「查询单据附件列表」接口接入 | 附件同步状态只能以本地同步表为准,无法与金蝶侧附件面板对账 | + +--- + +## 六、验证核对表(文档 vs 代码一致性) + +| 设计文档断言 | 代码核实结果 | +| --- | --- | +| A902 五类单据接入、entityNumber 映射 | ✅ `KingdeeAttachmentConstant.ENTITY_NUMBERS` 五条映射与文档完全一致,未配置类型抛 ServiceException | +| A801-2 复用 `invoiceDownload`,附件链路无新增接口 | ✅ `KingdeeAttachmentSyncSupport.fetchContent` 直接调 `bfiService.downloadInvoice` | +| 附件上传在主单事务提交后执行(ADR-7) | ✅ 付款申请经 `syncKingdee` 包装层(doSyncKingdee 事务外 :403);正式结算/开票申请经 `SpringUtil.getBean(...).doSyncKingdee` 自代理(:512/:499);收票在推送编排内但独立 try/catch(:786) | +| 幂等键 url/path、失败覆盖更新、20MB 双重校验 | ✅ syncAttachments:144-177(跳过/超限拦截/覆盖更新),readCapped:388-403 | +| 下载按发票号定位、镜像优先 + 实时回源、先校验后写流 | ✅ resolvePoolAttachment:1292-1312、doDownloadPoolAttachment:1242-1285,JSON 错误体解析 readBfiJsonMessage:1315-1332 | +| 下载不设 20MB 上限、8KB 流式透传 | ✅ DOWNLOAD_BUFFER_SIZE 缓冲拷贝,无 readCapped 调用 | +| 失败经 `WebUtil.renderJson` 返回 200 JSON(勘误 #2) | ✅ downloadPoolAttachment:1222-1235 | +| 收票/开票申请追加 `attachment_upload` 流水,付款/结算不追加 | ✅ InvoiceReceiptServiceImpl:823、InvoiceApplicationServiceImpl:522;付款/结算接入点无 record 调用 | + +--- + +## 七、顺带发现的风险点(与本次梳理主题相关,建议确认) + +1. **客商定时同步被硬编码过滤到单一客商**:`CustomerArchiveServiceImpl.syncKingdeeCustomerData`:625-628 的查询条件含 `.eq(CustomerArchive::getCustomerCode, "KS000067")`,即每天凌晨 2 点的客商同步任务只会处理这一个客商编码,疑似联调/灰度遗留。上线前需移除,否则其余客商的定时推送与状态复查全部不生效。 +2. **2026-09-27 下载功能代码未提交**:`InvoiceReceiptController`、`IInvoiceReceiptService`、`InvoiceReceiptServiceImpl` 的下载端点改动仍在工作区(git status modified),设计文档已写 as-built,注意随下次提交入库。 +3. 镜像定位 `resolvePoolAttachment`:1293-1294 按票号 selectList 后取 `mirrors.get(0)`:依赖「同租户票号唯一」(A801-1 upsert 匹配键保证),若上游契约变化出现重复票号将静默取第一条,可考虑加 `last("limit 1")` 或唯一索引兜底(当前表无票号唯一索引约束见 `blade_kingdee_invoice_pool_a8011_20260920.sql`)。 + +--- + +## 八、术语与代码索引 + +| 术语 | 代码落点 | +| --- | --- | +| A902 附件上传 | `IBfiClient.uploadAttachment`(blade-bfi-api feign)/ `BfiServiceImpl.uploadAttachment` | +| A801-2 文件下载 | `IBfiClient.invoiceDownload` / `BfiServiceImpl.downloadInvoice` | +| 附件同步主流程 | `org.springblade.transport.support.KingdeeAttachmentSyncSupport` | +| 附件同步表 | `blade_kingdee_attachment_sync`(实体 `KingdeeAttachmentSync`,建表 `doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`) | +| 票池镜像 | `blade_kingdee_invoice_pool`(`KingdeeInvoicePool`,`InvoiceReceiptServiceImpl` 内 upsert) | +| 开票回写发票结果 | `blade_invoice_application_invoice`(实体 `InvoiceApplicationInvoice`,建表 `blade_invoice_application_invoice_20260920.sql` + 文件地址补丁 `blade_invoice_application_invoice_urls_20260930.sql`) | +| 前端下载端点 | `InvoiceReceiptController.downloadPoolAttachment`(GET /invoice-receipt/invoice-pool/attachment/download) | +| entityNumber 映射 | `org.springblade.transport.constant.KingdeeAttachmentConstant` | +| 定时回写任务 | `org.springblade.transport.task.PaymentKingdeeResultTask`、`CustomerKingdeeSyncTask` | diff --git a/doc/docs/业务流程/2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md b/doc/docs/业务流程/2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md new file mode 100644 index 00000000..5a26cea4 --- /dev/null +++ b/doc/docs/业务流程/2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md @@ -0,0 +1,648 @@ +# 金蝶(星瀚)BFI 接口调用全链路梳理(按业务域汇总·更新版) + +> 整理日期:2026-09-27 +> 依据:当前代码(`tms-api/blade-third-party-api/blade-bfi-api` 客户端 + `tms-api/blade-service/blade-transport` 业务实现,git 基线 `50573e7` 2026-09-24)与三份材料逐条核对: +> ① `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(2026-09-14,6 张集成流程图 OCR 文字版); +> ② `doc/docs/TMS业财融合方案.docx`(V1.2,2026-08-10,8 大集成场景方案); +> ③ `doc/docs/业务流程/2026-09-23-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总).md`(上一版总纲)。 +> 本文是 09-23 总纲的**全面更新版**:自 09-23 以来代码有 12 个提交(09-23 19:00 收票防重键修复 + 09-24 两批重构/新增),接口清单、分录口径、勾稽方式、联调残留与缺陷清单均有实质变化,全文按当前代码重新核实。 +> 每个接口仍按 **触发场景 → 调用顺序 → 参数来源 → 响应存储 → 依赖与幂等** 五个维度说明;文末附全景依赖、状态机、定时任务与配置开关、差异/风险清单。 + +--- + +## 目录 + +- [0. 阅读须知与本次更新要点](#0-阅读须知与本次更新要点) +- [1. 集成总览与公共机制](#1-集成总览与公共机制) +- [2. 业务域一:客商档案(A902-1 客户查询 / A902-2 客户保存)](#2-业务域一客商档案a902-1-客户查询--a902-2-客户保存) +- [3. 业务域二:财务应付与付款(A102-4 财务应付单查询 / A102-2 财务应付单保存 / A103-1 应付下推生成付款申请 / A103-3 预付款申请保存 / A103-2 付款申请单查询 / A103-4 付款处理查询)](#3-业务域二财务应付与付款) +- [4. 业务域三:财务应收与开票(A302-4 财务应收单查询 / A302-2 财务应收单保存 / A303-4 开票申请单查询 / A303-1 开票申请单新增 / A303-5 开票发票查询)](#4-业务域三财务应收与开票) +- [5. 业务域四:收票与金蝶票池(A801-1 全票池发票查询 / A303-2 收票单批量保存 / A302-6 收票指定应付 / A801-3 收票单查询)](#5-业务域四收票与金蝶票池) +- [6. 业务域五:收款认领(A301-2 收款认领公告查询 / A301-3 收款认领结果推送 / A301-1 收款单查询)★新增](#6-业务域五收款认领a301-2-收款认领公告查询--a301-3-收款认领结果推送--a301-1-收款单查询新增) +- [7. 业务域六:公共取数(A801-4 汇率)★新启用](#7-业务域六公共取数a801-4-汇率新启用) +- [8. 全景:接口先后顺序与依赖关系](#8-全景接口先后顺序与依赖关系) +- [9. 状态机汇总](#9-状态机汇总) +- [10. 定时任务与配置开关汇总](#10-定时任务与配置开关汇总) +- [11. 代码与三份材料差异 / 风险清单(更新版)](#11-代码与三份材料差异--风险清单更新版) + +--- + +## 0. 阅读须知与本次更新要点 + +1. **接口编号口径**:金蝶星瀚侧开放接口以 `Axxx-x` 操作码标识,代码常量、文档标题、日志均沿用该口径。 +2. **权威顺序**:以**当前代码**为准;与 09-23 总纲及单链路文档冲突处一律按本文第 11 节结论。 +3. **本次(09-23 → 09-27)核心变化一览**: + +| # | 变化 | 说明 | +|---|---|---| +| 1 | **收款认领链路接线(新增业务域五)** | A301-2 公告同步 / A301-3 认领推送 / A301-1 收款单回写由「客户端已实现无调用」变为「业务侧已接线(全部手动触发,无定时)」 | +| 2 | **A801-4 汇率查询启用(新增业务域六)** | 新增 `GET /bfi/exchange-rate`,结算页面按币种+日期取金蝶汇率;A102-2/A302-2 推送时传 `exchangerate/exratedate`(结算单页面维护值) | +| 3 | **应付/应收/收票分录口径重构** | 分录 = **结算明细行(每行一条)+ 手工结算合计费用项(每行一条)**(新表 `blade_formal_settlement_summary_fee`,仅 manualFlag=1);分录新增「运输货物 / 车号」字段(仅明细行传) | +| 4 | **A103-1 勾稽方式重大变化** | **A102-4 反查付款计划分录 `awrp_entry_code_p` 的步骤已移除**;`awrp_finapbill_entryid` 改为**本地组装的主结算单应付单防重键**(`settlementApBillSrcCode`),分录由多条费用行改为**单条**(付款申请号-1) | +| 5 | **A103-1 结算方式与往来账户完善** | `e_settlementtype_number` 按付款方式显式映射(汇票→012-02 应付票据电子银行承兑汇票,其余→02 银行转账);`e_bebank_number` 改取客商收款账户(`blade_customer_receipt_account`)**联行号**,未维护即拦截 | +| 6 | **开票申请按发票张次拆分** | 新表 `blade_invoice_application_sheet`:**每张发票张次推一张金蝶开票申请单**;billno 单张=申请单号、多张=申请单号-张次序号;A303-4 预检改为**按全部候选 billno 批量幂等预检**(已生成张次回填跳过、未生成张次重推) | +| 7 | **收票推送防重键修复** | A302-6 指定应付与 A801-3 **推送预检**统一改用收票登记 id(原误用发票号,导致指定应付报「收票单不存在」、预检永不命中);⚠️ **A801-3 定时回写仍按发票号匹配,缺陷残留**(见 11.2) | +| 8 | **物料/计量单位真实映射链路建成(调试覆盖仍在)** | 物料按「装卸大类 / 国际业务 / 国内+运输方式」映射名称 → `BFI_MATERIAL_NUMBER_MAP` 映射金蝶编码;计量单位按合同计费方案计费单位 → 计量单位档案金蝶编码(`blade_measurement_unit` 已初始化、新表 `blade_kingdee_material`);但三处 `if(true)` 调试组合块仍做**最终覆盖**(兜底 物流配送 Item-00001080+TON;收票侧未命中还**随机取组合**) | +| 9 | **客商分组 1102 问题修复** | `customerKind` 已落库(`CustomerArchive.customerKind`),internal→1101 / 其余→1102 可正确分支 | +| 10 | **主数据初始化 SQL** | `doc/sql/transport/kingdee_master_data_init_20260924.sql`:计量单位金蝶编码(TON/CUM/PCS/KG/KM/25/04/sou/014/013)+ 金蝶服务类物料档案 `blade_kingdee_material` | + +4. **两条推送主线 + 两条数据主线 + 一条认领主线**: + - 应付主线:财务应付单(A102-2)→ 付款申请下推(A103-1,预付款走 A103-3)→ 状态回写(A103-2/A103-4); + - 应收主线:财务应收单(A302-2)→ 开票申请(A303-1,按张次拆分)→ 开票结果(A303-5); + - 数据主线:客商档案(A902-1/A902-2)、金蝶发票池(A801-1)、汇率(A801-4); + - 认领主线:收款认领公告(A301-2)→ 认领结果推送(A301-3)→ 收款单回写(A301-1)。 + +--- + +## 1. 集成总览与公共机制 + +### 1.1 调用架构 + +``` +blade-transport 业务服务 + │ 调用 IBfiService(参数校验 + 自动取令牌) + ▼ +blade-bfi-api(Feign 客户端 IBfiClient,@FeignClient url=${thirdParty.bfi.baseUrl}) + │ 请求头:access_token(业务令牌) + Authorization(iPaaS 网关认证,BfiRequestInterceptor 统一注入) + ▼ +iPaaS 网关(/gwzh/BFI,不直连金蝶) + ▼ +金蝶星瀚 /ierp/kapi/... +``` + +- 客户端模块:`blade-third-party-api/blade-bfi-api`(`org.springblade.thirdparty.bfi`)。 +- 业务编排模块:`blade-service/blade-transport`(`support/` 三个推送组件、`service/impl/` 各业务实现、`task/` 定时任务)。 +- **无重试**:Feign 未配 Retryer(NEVER_RETRY)、无 fallback;失败抛 `BfiException`,由调用方决定(批量循环逐条 catch,单条失败不中断整批)。 +- 配置前缀两套并存(读同一批 nacos 键的不同子集): + - bfi-api `BfiProperties`(prefix `third-party.bfi`):baseUrl / authorization / clientId / clientSecret / username / accountId / language / mockEnabled; + - transport `BfiProperties`(prefix `thirdparty.bfi`):srcSys(默认 WLTMS)/ payApply.*(billtype=`ap_payapply_oth_BT_S`、exratetable=`01`、srcType=`0KzXs`)/ invoiceApply.*(srcType=`0KzXs`)/ receivedInvoice.*; + - `payResult.*`、`receivedInvoiceResult.*`、`customer.*` 由定时任务以 `@Value` 消费。 + - 配置样例见 `doc/nacos/third-party-api.yaml`(默认 baseUrl `http://172.16.203.134:8000/gwzh/BFI`)。 + +### 1.2 接口清单总表(含在用状态,2026-09-27) + +来源:`blade-bfi-api/.../feign/IBfiClient.java`(URL 可被 `thirdParty.bfi.*Url` 配置覆盖)。 + +| 操作码 | 星瀚端点 | Feign 方法 | 用途 | 业务侧是否在用 | +|---|---|---|---|---| +| A100-1 | POST /ierp/kapi/oauth2/getToken | getToken | 获取令牌 | ✅ 自动(每次调用前) | +| A100-2 | POST /ierp/kapi/oauth2/verifyToken | verifyToken | 校验令牌 | ❌ 已实现无调用 | +| A100-3 | POST /ierp/kapi/oauth2/withdrawToken | withdrawToken | 撤回令牌 | ❌ 已实现无调用 | +| A102-2 | POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save | apBillSave | 财务应付单保存 | ✅ 正式结算单(应付)推送 | +| A102-4 | GET /ierp/kapi/v2/awrp/ap/ap_finapbill/query | apBillQuery | 财务应付单查询 | ✅ 预检/硬校验/批量预热(**反查付款计划分录已移除**) | +| A103-1 | POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push | payApplyPush | 应付下推生成付款申请 | ✅ 付款申请推送(单分录) | +| A103-2 | GET /ierp/kapi/v2/awrp/ap/ap_payapply/query | payApplyQuery | 付款申请单查询 | ✅ 推送预检 + 定时回写 | +| A103-3 | POST /ierp/kapi/v2/awrp/ap/ap/ap_payapply/save | savePrePayApply | 预付款申请保存 | ✅ 预付款(项目垫资/进度垫资)直推 | +| A103-4 | GET /ierp/kapi/v2/awrp/cas/cas_paybill/query | payBillQuery | 付款处理查询 | ✅ 定时回写付款记录 | +| A301-1 | GET /ierp/kapi/v2/awrp/cas/cas_recbill/queryRecBill | recBillQuery | 收款单查询(收款处理查询) | ✅ **认领结果回写(手动)** ★新启用 | +| A301-2 | GET /ierp/kapi/v2/awrp/cas/cas_claimannounce/query | claimAnnounceQuery | 收款认领公告查询 | ✅ **收款流水同步(手动)** ★新启用 | +| A301-3 | POST /ierp/kapi/v2/awrp/cas/cas/cas_claimbill/claimbillPush | claimBillPush | 收款认领结果推送 | ✅ **认领推送(认领事务内)** ★新启用 | +| A302-2 | POST /ierp/kapi/v2/awrp/ar/ar/ar_finarbill/save | arBillSave | 财务应收单保存 | ✅ 正式结算单(应收)推送 | +| A302-4 | GET /ierp/kapi/v2/awrp/ar/ar_finarbill/query | arBillQuery | 财务应收单查询 | ✅ 预检/硬校验/定时勾稽 | +| A302-6 | POST /ierp/kapi/v2/awrp/ap/ap/ap_invoice/assign | invoiceAssign | 收票指定应付 | ✅ 收票推送第二步 | +| A303-1 | POST /ierp/kapi/v2/awrp/sim/sim_original_bill/save | invoiceApplySave | 开票申请单新增 | ✅ **按发票张次逐张推送** | +| A303-2 | POST /ierp/kapi/v2/awrp/ap/ap_invoice/cusBatchSave | receivedInvoiceSave | 收票单批量保存 | ✅ 收票登记推送 | +| A303-4 | GET /ierp/kapi/v2/awrp/sim/sim_original_bill/query | invoiceApplyQuery | 开票申请单查询 | ✅ **批量 billno 幂等预检** | +| A303-5 | POST /ierp/kapi/app/sim/openApi | invoiceApplyOpenApi | 开票发票查询(票易通 openApi,Base64 包裹) | ✅ 定时回写开票结果 | +| A801-1 | POST /ierp/kapi/v2/awrp/rim/rim_invoice/query | invoicePoolQuery | 全票池发票查询(rim 契约) | ✅ 选票/保存重查/补票种(实时,无定时) | +| A801-2 | POST /ierp/kapi/v2/awrp/bosext/attachment/download | invoiceDownload | 进项发票文件下载 | ❌ 已实现无调用(备用) | +| A801-3 | GET /ierp/kapi/v2/awrp/ap/ap_invoice/apinvoicequery | receivedInvoiceQuery | 收票单查询 | ✅ 推送预检 + 定时回写 | +| A801-4 | GET /ierp/kapi/v2/awrp/base/bd_exrate_tree/Forexquery | forexQuery | 汇率查询 | ✅ **结算取汇率(手动,页面触发)** ★新启用 | +| A902-1 | GET /ierp/kapi/v2/awrp/basedata/bd_customer/query | customerQuery | 客户查询 | ✅ 档案回填/推送前复查/定时复查 | +| A902-2 | POST /ierp/kapi/v2/awrp/basedata/bd_customer/save | customerSave | 客户保存 | ✅ 档案推送 | + +> 与基础业务文档《TMS业财融合-集成流程图文字版与业务流程梳理》第 10 节「接口速查」的差异:文档清单中的 **A901-1 供应商查询、A801-5 黑名单、A913 项目保存、A101-1/2 合同保存、A102-1/A302-1 暂估单、A902 附件上传** 均未在客户端实现(合同/项目同步走合同合规系统,非金蝶 A 类接口;A902 附件上传各推送链路均未接,方案文档中的「审批单据/磅单等作为附件推送金蝶」暂未实现)。 + +**查询类接口时间窗约束**(IBfiService Javadoc):A103-2 付款申请单查询 / A103-4 付款处理查询 / A102-4 财务应付单查询 / A302-4 财务应收单查询 / A303-4 开票申请单查询 / **A301-1 收款单查询** 查询区间 **≤ 1 个月**;A902-1 客户查询 **≤ 6 个月**;A301-2 收款认领公告查询 无时间窗参数(按认领用户+分页)。 + +### 1.3 认证与令牌(A100-1 获取令牌) + +- **取令牌**:`BfiServiceImpl.getToken()`。请求体 `BfiTokenDTO`:`client_id / client_secret / username / accountId(数据中心) / language`,另由 `BfiUtil.fillRandomParam` 填充 `nonce`(UUID) 与 `timestamp`(服务端校验 ±5 分钟)。 +- **缓存**:Redis,key=`blade:third:bfi:token`;TTL = `expires_in` − 120 秒缓冲;`getAccessToken()` 先读缓存,未命中才调 A100-1 获取令牌。无 refresh 机制;写入用 `setIfAbsent`,已有未过期缓存时新令牌不会覆盖(排障需知)。 +- **注入**:双头——业务方法 `@RequestHeader("access_token")` 显式携带;`BfiRequestInterceptor` 统一加 `Authorization` 网关头。`BfiFeignClientConfig` 用空拦截器覆盖全局 BladeFeignRequestInterceptor;超时 10s/60s。 + +### 1.4 响应封装、分页与异常处理 + +- **统一响应** `BfiResultVO`:`data / errorCode("0"=成功) / message / status(Boolean)`;`isSuccess() = status && errorCode=="0"`。三类封装:`commonCall`(标准)、`commonRimCall`(rim 契约,兼容 "0"/"0000",A801-1 全票池发票查询用)、`commonOpenApiCall`(A303-5 开票发票查询,只看 `success`;请求/响应 data 均 Base64(JSON))。 +- **保存类响应** `BfiBillSaveResultVO`:`successCount/failCount`(String)、`result[]`(`billIndex/billStatus/entryIds/errors/id/number/type: Add/Push/Update`)。业务侧统一 `requireBfiSaveSuccess` 校验 `result[0].billStatus`,失败抛异常。 +- **分页**:`BfiPageVO{filter, lastPage, pageNo, pageSize, totalCount, rows}`;通用翻页 `BfiUtil.queryAllPages`(页码递增,终止:页空 / `lastPage=true` / rows 不满页 / 超 50 页)适用于 9 个 BfiPageVO 类查询接口。**不适用**:A801-1 全票池发票查询(rim 契约无 lastPage/totalCount,游标分页 `PoolScanCursor`,单请求上限 3 次远程调用)与 A801-4 汇率查询(单页固定 pageSize=10)。 +- **异常转换**:`BfiExceptionTranslator`(@RestControllerAdvice)→ `R.fail(500)`。 + +### 1.5 批量同步上下文 KingdeeSyncContext(09-24 重构) + +`transport/service/KingdeeSyncContext.java`:Java record,8 个可写缓存: + +| 缓存 | 内容 | 变化 | +|---|---|---| +| `measureUnitCodeByName` | 计量单位名称→金蝶编码 | ★新增(配合主数据初始化 SQL) | +| `contractCache` | 合同 id→ContractManage | 保留 | +| `customerCodeCache` | 名称→客商编码 | 保留 | +| `projectCache` | 项目 id→ProjectApply | 保留 | +| `apBillNoCache` | 应付单防重键→金蝶单号 | 保留(含「未生成」空串标记) | +| `settlementDetailCache` | 结算单 id→结算明细列表 | 保留 | +| `settlementCache` | 结算单 id→FormalSettlement | ★新增(结算单维度传参) | +| `receiptAccountCache` | 客商 id→收款账户 | ★新增(付款联行号取数) | +| ~~`cargoCodeByName`~~ / ~~`apBillPlanCodeCache`~~ | — | **已删除**(货物编码映射被物料映射替代;付款计划分录反查已移除) | + +- 生命周期 = 一次批量同步方法调用(`empty()` 创建,方法结束即弃),非 Spring Bean;缓存未命中自动回退逐单查询。 +- 使用方:`PaymentApplicationServiceImpl`(批量前 `preloadApBillExistNos` 整批预热)、`FormalSettlementServiceImpl`、`InvoiceReceiptServiceImpl`。 + +### 1.6 防重键(候选键)总表(2026-09-27 口径) + +金蝶侧按「来源系统 + 来源单据号」幂等(`awrp_src_sys` + `awrp_src_code`,分录级 `awrp_entry_code`)。组装工具 `ExternalSrcCodeSupport.buildSrcCode`(下划线拼接、去空白、去重保序)。 + +| 金蝶单据 | awrp_src_code | awrp_src_sys / srcType | 分录 awrp_entry_code | 预检查重接口 | +|---|---|---|---|---| +| 财务应付单 A102-2 | `正式结算单号_结算明细单据号1_…` | WLTMS;`TMS财务应付单` | 明细行=**结算明细单据号**;手工合计费用项行=**结算单号-费用类型-费用项**拼接 | A102-4 财务应付单查询 | +| 财务应收单 A302-2 | 同应付口径 | WLTMS;`TMS财务应收单` | 同应付口径 | A302-4 财务应收单查询 | +| 付款申请 A103-1 | `paymentNo` | WLTMS;srcType=配置 `0KzXs` | **固定 `paymentNo-1`(单分录)** | A103-2 付款申请单查询 | +| 预付款申请 A103-3 | 付款申请号口径 | WLTMS;`0KzXs` | `paymentNo-1`(单分录) | A103-2 付款申请单查询 预检共用 | +| 开票申请 A303-1 | `billno = application_no`(单张)或 `application_no-张次序号`(多张)★变化 | systemsource=WLTMS;srcType=`0KzXs` | 开票商品行 id(blade_invoice_application_line.id) | A303-4 开票申请单查询(**批量 billno**) | +| 收票单 A303-2 | `收票登记 id` | WLTMS;`TMS收票登记` | 与 A102-2 分录同源同值(明细行/手工合计行) | A801-3 收票单查询 | +| 收款认领 A301-3 | `SKCL+yyyyMMddHHmmss+认领id尾号6位`(blade_receipt_claim.awrp_src_code)★新增 | WLTMS | 结算单分摊行(seq 序号) | A301-1 收款单查询(sourcebillnumber 匹配) | +| 客户 A902-2 | `number = TMS客商编号`(KS+6位自增),`societycreditcode` 为金蝶侧候选键 | awrp_datasourcesys=WLTMS | — | A902-1 客户查询 | + +### 1.7 公共档案取数(应付/应收/收票三侧复用 `KingdeeApBillSyncSupport`) + +| 取数项 | 规则 | 变化 | +|---|---|---| +| 组织 org/payorg(应收 recorg) | 结算单 `contract_id` → `blade_contract_manage.organization_id` → `SysCache.getDept().getDeptCode()` | 取数主体由合同改为结算单(结算单维度传参) | +| 币种 | 合同 `settlement_currency`,`RMB→CNY` | 不变 | +| **汇率 exchangerate / exratedate** | **正式结算单页面维护值**(来源:结算页面经 `GET /bfi/exchange-rate` 调金蝶 A801-4 汇率查询回填);未维护不送 | ★新增 | +| 往来户 asstact_number | 名称(合同乙方 / 结算单付款方 / 开票单位)→ 本地 `blade_customer_archive` 按 `full_name/short_name` 匹配取 `customer_code`;查不到即报错拦截 | 不变 | +| 往来类型 asstacttype | 合同类别=`客户合同` → `bd_customer`,否则 `bd_supplier` | 不变 | +| **物料编码** | 真实映射链路:装卸大类费用行→`装卸`;国际业务模式/项目业务类型→`国际货运代理服务费`;国内→`国内道路货物运输服务-`+运单运输方式;再经 `BFI_MATERIAL_NUMBER_MAP` 映射金蝶编码(`Item-00001081` / `XDWL11010001` 等);⚠️ 三处 `if(true)` 调试组合块仍做最终覆盖(兜底 物流配送 Item-00001080+TON;**收票侧未命中随机取**) | ★重构 | +| **计量单位** | 费用类型/费用项匹配合同计费方案计费单位 → `blade_measurement_unit` 金蝶编码(`context.measureUnitCodeByName`);⚠️ 同被调试组合块覆盖 | ★重构 | +| 金额(含税) | 结算明细行=结算金额(含税);手工合计费用项行=合计行结算金额;原三级取数兜底保留 | 调整 | +| 金额(不含税) | 结算明细行取结算金额(不含税);**手工合计费用项行无不含税金额不传** | 调整 | +| **运输货物 / 车号** | 结算明细行传货物名称 / 车号(金蝶接口新字段);合计费用项行不传 | ★新增 | +| 日期文本 | 统一 `yyyy-MM-dd HH:mm:ss` | 不变 | + +--- + +## 2. 业务域一:客商档案(A902-1 客户查询 / A902-2 客户保存) + +**定位**:全局数据前提。其他链路的往来户编码(`customer_code`)来自 `blade_customer_archive`,与金蝶客户的对应关系由本链路建立(**金蝶客户编码 = TMS 客商编号**,无独立存储)。 + +![客商档案页面(2026-09-29):新增弹窗按统一社会信用代码满 18 位防抖触发 A902-1 查询回填;审批通过档案的 A902-2 推送为行内「同步金蝶」链接(仅 approved 且 kingdeeStatus=failed 时显示,人工重推入口),另有定时复查/重推任务](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-01-customer-archive.png) + +### 2.1 A902-1 客户查询(回填 / 复查) + +**触发场景(3 个入口)** + +| 入口 | 触发 | 代码 | +|---|---|---| +| ① 前端新增回填 | 录入统一社会信用代码满 18 位防抖查询 → `GET /bfi/customer/query-by-credit-code`(pageNo=1/pageSize=1) | BfiCustomerController | +| ② 单条推送前复查 | `/customer-archive/sync-kingdee` 或审批通过内部推送前,`syncKingdee(id)` 先查一次 | CustomerArchiveServiceImpl | +| ③ 定时批量复查 | `CustomerKingdeeSyncTask`(cron `thirdParty.bfi.customer.cron` 默认 `0 0 2 * * ?`,开关默认 false) | CustomerKingdeeSyncTask | + +**调用顺序**:无固定前置;定时路径按 50 个信用代码/批、`BfiUtil.queryAllPages` 自动翻页取全,键=大写信用代码,单批异常不中断。 + +**参数来源**:`societycreditcode` = `blade_customer_archive.unified_credit_code`;分页参数固定。 + +**响应存储**: +- 前端回填:`status=C` 回填名称/法人/地址/经营范围/注册资金/经营期限等表单字段(锁定客商名称/法人/注册资金);`A/B` 前端阻断保存。原始报文不存储。 +- 后端复查:`C`→`kingdee_status=exist`(终态);`A/B`→`failed`(「金蝶侧该客商审核中,请稍后重推」)。 +- 定时附加职责:synced/exist 且启用客商复查 `C`→exist;`enable=0` → 联动停用 TMS 档案 `status=2`(单向,金蝶恢复不自动启用)。 +- 落库字段:`kingdee_status / kingdee_fail_reason / kingdee_sync_time`。 + +### 2.2 A902-2 客户保存(推送) + +**触发场景(3 入口,收敛于 `pushCustomerToKingdee`)**:① 审批通过自动推送(`POST /customer-archive/approve`,失败不影响审批);② 列表人工重推(`POST /customer-archive/sync-kingdee`,仅 approved,synced/exist 幂等返回);③ 定时重推(pending = approved 且 none/failed)。 + +**调用顺序**:先 A902-1 复查(金蝶已有 `C` 直接置 exist 跳过)→ 组装报文(`CustomerBfiConverter.buildSaveDTO`)→ A902-2 保存。**只推新增建档**:审批后的档案变更不重推(无更新链路);`synced`(金蝶受理 B)不重推,由定时复查升级 exist。 + +**参数要点**: +- `number`=`customer_code`(submit 时生成 `KS`+6 位自增);`name/societycreditcode/artificialperson` = full_name/unified_credit_code/legal_person; +- `type`=customer_nature→字典 dictKey(解析失败抛异常,不兜底);`bizfunction` 固定 `1,2,3,4`; +- `entry_groupstandard`:**★已修复** —— `customerKind` 已落库(`CustomerArchive.customerKind`),internal→1101(内部组织)、其余→1102(外部客商); +- `entry_bank`:`blade_customer_receipt_account`(账号/开户行/CNY/isdefault);⚠️ `bankNumber` 仍硬编码 "001110012009"(CustomerBfiConverter:141,todo); +- `entry_linkman`:principal + contact_phone;`status` 不传。 + +**响应存储**:`result[0].billStatus=true` → `synced`;`false` → `failed` + errors.rowMsg(截 500 字);异常 → `failed`。返回的 number/id 不落库。 + +**依赖与幂等**:无前置;被应付/应收/收票/开票链路取 `customer_code`;⚠️ 其他链路**不校验客商 `kingdee_status`**(软依赖,见 11.2-#3)。幂等:本地 synced/exist 直接返回;金蝶按 `societycreditcode` 防重。 + +--- + +## 3. 业务域二:财务应付与付款 + +**链路总览(三环节接力,★ 标注本次变化)** + +``` +环节一 正式结算单页面「同步金蝶」(POST /formal-settlement/sync-kingdee) + A102-4 预检 ──▶ A102-2 保存应付单(明细行+手工合计费用项分录,汇率随单)──▶ 结算单回写 kingdee_bill_no + synced +环节二 付款管理「同步金蝶 / 批量同步」(POST /payment-application/sync-kingdee[-batch]) + A102-4 硬校验(应付单已生成,逐关联结算单) ★不再反查付款计划分录 + ──▶ A103-2 幂等预检 ──▶ A103-1 下推(单分录,勾稽键=主结算单应付防重键;预付款走 A103-3)──▶ 回写 kingdee_bill_id/no + synced +环节三 PaymentKingdeeResultTask 定时(每10分钟,默认关) + A103-2 查状态(synced→paying→paid/closed) ──▶ A103-4 查付款处理单 ──▶ 付款记录落库(含 voucher_json) +``` + +### 3.1 A102-2 财务应付单保存 + +![正式结算单页面(2026-09-29):应付 / 应收 Tab 同页,勾选一条单据后点工具栏「同步金蝶」——payable 路由 A102-2、receivable 路由 A302-2(4.1),成功后回写结算单 kingdee_bill_no 并置 synced](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-02-formal-settlement.png) + +- **触发**:`POST /formal-settlement/sync-kingdee`(FormalSettlementController:209),结算类型=`payable` 路由(`receivable` 路由 A302-2 财务应收单保存,同一入口)。无定时。 +- **顺序**:校验 approved+应付类型 → synced 幂等返回 → 组防重键 → **A102-4 预检** → 未生成才 A102-2 保存(`KingdeeApBillSyncSupport.pushApBill`)→ 回写。单笔单事务。 +- **参数来源**: + - 防重键 `正式结算单号_结算明细单据号…`;srcType=`TMS财务应付单`;billtype=`服务采购应付`→`ApFin_service_BT_S`; + - org/payorg/币种/往来户/往来类型:见 1.7;**exchangerate/exratedate=结算单页面维护值(未维护不送)** ★新增; + - purmode=CASH、款项性质=2001、isincludetax/ispricetotal=true;**settlementtype_number(结算方式)不在应付单送**(仅 A103-1 分录需要); + - **分录(★新口径)**:`resolveEntryGroups` = 结算明细行(每行一条)+ **手工结算合计费用项**(`blade_formal_settlement_summary_fee.manualFlag=1`,每行一条;系统自动汇总行不送,防与明细重复); + - `awrp_entry_code`:明细行=结算明细单据号;合计费用项行=`结算单号-费用类型-费用项`拼接; + - 明细行带运输货物(货物名称)/车号;数量=运输量(兜底 1,合计行默认 1);单价=结算金额/数量反算; + - ⚠️ 调试组合块强制物料/计量单位(见 1.7);⚠️ 项目编码仍传测试值 `999`; + - **planentity 仍不传** → 金蝶按分录自动汇总生成付款计划。 +- **响应存储**:`result[0].number` → `blade_formal_settlement.kingdee_bill_no`、`kingdee_sync_status='synced'`;id 不落库。失败抛异常整体回滚保持 unsynced(**不落 failed**)。 +- **依赖**:本地数据齐备即可;**是 A103-1 应付下推生成付款申请与 A303-2 收票单批量保存的硬前置**。 +- **幂等**:A102-4 按防重键预检,已存在直接返回已有 billno(alreadyExist=true)。 + +### 3.2 A102-4 财务应付单查询(★用途缩减) + +- **触发**:纯内部步骤,无独立入口。**三个用途**(原四个,反查已移除):① A102-2 推送前幂等预检;② A103-1 推送前「应付单已生成」硬校验(逐关联结算单,未生成报「请先在正式付款单页面同步金蝶」);③ 批量同步整批预热(`preloadApBillExistNos`,未命中编码补空串标记避免重复远程查询)。 +- ~~原用途④「反查付款计划分录 awrp_entry_code_p」已随 09-24 重构移除~~:A103-1 的勾稽键改为本地组装(见 3.3)。 +- **参数/存储**:`awrp_src_code`(多值,50 键/批、页大小 200)+ `awrp_src_sys`;结果不落库,仅入 `apBillNoCache`。 +- **幂等**:本身即防重手段。 + +### 3.3 A103-1 付款申请保存(应付下推)/ A103-3 预付款直推(★大改) + +![付款管理页面(2026-09-29):「批量同步」= /payment-application/sync-kingdee[-batch](A102-4 硬校验 → A103-2 幂等预检 → A103-1 下推,预付款走 A103-3);「同步付款结果」手动触发 A103-2 / A103-4 状态与付款记录回写](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-03-payment-application.png) + +- **触发**:`POST /payment-application/sync-kingdee`(单笔)与 `/sync-kingdee-batch`(批量)。**预付款**(project_advance/progress_advance)走 A103-3 直推。 +- **调用顺序**(`doSyncKingdee`,单据级事务): + 1. 锁单 + approved 校验 → 状态门禁(synced/paying/paid/closed 幂等跳过); + 2. 结算类型路由 → **A102-4 硬校验**(关联的**每张**正式结算单应付单必须已生成,按本地组装的防重键逐单预检); + 3. **A103-2 幂等预检**(`reconcileExistPayApply`:正式路径统一先预检;预付款路径仅 failed 恢复时预检——查到即回填跳过,防「远端已落单本地回滚」重推); + 4. **A103-1 下推**(单分录)/ **A103-3 保存** → 回写。批处理:整批一次 A102-4 预热 → 逐单自代理独立事务 → 单条失败仅回滚自身、落 `failed`、不中断整批。 +- **A103-1 付款申请保存(应付下推)参数(★新口径)**: + - 防重键 `awrp_src_code`=`paymentNo`;`applydate`=申请日期(空取当天);`exratetable_number`=配置 `01`(注释保留,实际不送——按应付单自动带出);`awrp_textfield1`=付款方式字典标签(非必传); + - **分录固定一条**:`e_paymenttype_number`=201(正式付款);**`e_settlementtype_number` 按付款方式映射(★新增):汇票付款→`012-02`(应付票据电子银行承兑汇票),其余→`02`(银行转账)**; + - **`awrp_finapbill_entryid` = 主结算单应付单防重键(★本地组装 `settlementApBillSrcCode`,不再 A102-4 反查 `awrp_entry_code_p`)**——主结算单=付款申请 `settlementId`,聚合多结算单取关联结算明细首个结算单 id;⚠️ 支撑类注释仍写「A102-4 反查、禁止自行拼接」,**注释与代码不一致,金蝶侧对该值的校验口径需联调确认**(见 11.2-#4); + - `awrp_entry_code`=`paymentNo-1`;`e_assacct`=付款申请 `bankAccount`(收款账号);**`e_bebank_number`=客商收款账户联行号(★`blade_customer_receipt_account`,未维护报错拦截)**; + - `e_applyamount`=申请付款金额(2 位 HALF_UP);project 仍传 `999`。 +- **A103-3 预付款申请保存参数**:单分录 `paymentNo-1`;`e_paymenttype_number` 按付款类型(正式=201/预付);`e_assacct`=bankAccount;**`e_bebank_number`=`entity.getBankName()`**(⚠️ 语义存疑:往来银行.编码取的是名称字段,见 11.2-#5);`awrp_textfield1`=付款方式标签;汇率表/汇率日期=申请日期;**project=`entity.projectId`→项目档案 `projectCode`(真实编码,无则 999)** ★改善。 +- **响应存储**:`requireBfiBillIdAndFillNo`:`result[0].id` → `kingdee_bill_id`;`result[0].number` → `kingdee_bill_no`(推送即时值,后续被 A103-2 回写覆盖校准);`kingdee_status='synced'`。异常:事务回滚后独立事务 `markKingdeeSyncFailed` → `failed`。 +- **依赖**:硬前置 = A102-2 已生成(A102-4 校验)。失败重推断点续传:A102-4 命中跳过 → A103-2 命中直接回填。 +- **幂等**:三层——本地状态门禁、A102-4 应付预检、A103-2 按 paymentNo 查重。 + +### 3.4 A103-2 付款申请单查询(状态回写 + 推送预检) + +- **触发**:① 定时 `PaymentKingdeeResultTask.syncKingdeePaymentResult`(默认 `0 */10 * * * ?`,开关默认 false);② 手动 `POST /payment-application/sync-kingdee-result`;③ A103-1/A103-3 推送链内部幂等预检。 +- **顺序**:查待回写(approved 且 status ∈ {synced, paying, **failed**})→ paymentNo 去重按 50/批查询 → 同一 srcCode 取 `modifytime` 最新 → 逐单独立事务回写。 +- **响应存储**:`billno`→kingdee_bill_no、`id`→kingdee_bill_id;状态映射:`Alreadypay`→**paid**(终)> `billstatus=D`→**closed**(终)> `Inpayment`→**paying** > 其余→synced;`entry[].e_paidamt` 汇总→`paid_amount`(缺失且 paid 时兜底=申请金额);paid 联动履约(写 `blade_pre_settlement_advance` 与 `blade_formal_settlement_payment`);**failed 纳入勾稽纠偏**:查到即视为上次推送实际成功,纠正为 synced。 +- **依赖/幂等**:按防重键查询,无硬前置;主键更新天然幂等;终态不再轮询;查无此单状态不变。 + +### 3.5 A103-4 付款处理查询(付款记录回写) + +- **触发**:定时子流程 + 手动端点。**仅查询,不推送**。 +- **顺序**:候选 = 本轮 A103-2 查到 `paystatus≠Unpaid` 的单 + 已 paid 但无真实付款记录的单(补偿);按付款组织编码分组逐组查询;按源单勾稽匹配(`sourcebillid`==kingdee_bill_id 优先,`sourcebillnumber`==kingdee_bill_no 兜底)。 +- **参数**:`org_number` 优先取 A103-2 返回 `payorg_number`,兜底按合同所属组织解析;业务日期窗口=最近 1 个月;页大小 200 翻页。 +- **响应存储**:upsert `blade_payment_application_record`——`billno`→payment_no+kingdee_bill_no;`actpayamt`→paid_amount;`paydate`→paid_date;**整单 JSON→`voucher_json`**(凭证号/银行流水/电子回单留档);清理 MOCK-PAY- 历史模拟记录。 +- **依赖/幂等**:勾稽需已知 kingdee_bill_id → 必然晚于推送链;付款组织解析失败仅 warn 跳过下轮重试;按付款处理单号判重更新。 + +--- + +## 4. 业务域三:财务应收与开票 + +**链路总览** + +``` +环节一 正式结算单页面「同步金蝶」(应收类型) + A302-4 预检 ──▶ A302-2 保存应收单(明细行+手工合计费用项分录,汇率随单)──▶ 结算单回写 kingdee_bill_no + synced +环节二 开票申请「同步金蝶」 + A302-4 硬校验(应收单已生成) ──▶ A303-4 批量 billno 幂等预检 ──▶ A303-1 按发票张次逐张新增 ──▶ 张次/申请单回写单号 + synced +环节三 定时回写(PaymentKingdeeResultTask 串行第 2/3 步) + A302-4 勾稽纠偏(failed→synced / 补单号) + A303-5 开票发票查询(serialNo 按 application.kingdee_bill_no 逗号拆分)──▶ 发票落库 ──▶ invoiced(终态) +``` + +> 推送链路**无批量端点**;查询侧为批量(防重键 50/批 + 自动翻页 200/页)。 + +### 4.1 A302-2 财务应收单保存 + +- **触发**:`POST /formal-settlement/sync-kingdee`,`settlementType=receivable` 路由 → `KingdeeArBillSyncSupport.pushArBill`。用户动作,无定时。 +- **顺序**:防重键组装 → **A302-4 幂等预检**(命中即返回已有单号跳过保存)→ 结算单维度取数(合同/组织/币种/客商)→ 组装 `BfiArBillSaveDTO` → 保存 → 回写。 +- **参数**:防重键同应付口径;srcType=`TMS财务应收单`;billtype=`服务销售应收`→`arfin_sersal_BT_S`;`bizdate`=同步当日;**exchangerate/exratedate=结算单页面维护值** ★新增;`asstacttype` 固定 `bd_customer`;`asstact_number`=结算单付款方 payer_name→客商编码;paymode=CASH、payproperty=1001; + **分录(★新口径,与 A102-2 完全同源)**:结算明细行 + 手工合计费用项各一条,`awrp_entry_code` 同应付口径;调试组合块同在;项目 `999`。 +- **响应存储**:`result[0].number` → `blade_formal_settlement.kingdee_bill_no`、`synced`;id 不落库;失败回滚保持 unsynced(**不落 failed**)。 +- **依赖/幂等**:是 A303-1 开票申请单新增的硬前置;A302-4 预检防重推。 + +### 4.2 A302-4 财务应收单查询 + +- **触发**:① A302-2 推送前幂等预检;② **定时勾稽纠偏**(`syncKingdeeArResult`:轮询 approved+receivable+status ∈ {synced, **failed**},批量 50 键/批:查到补缺失 kingdee_bill_no、failed 纠偏为 synced;查不到不变);③ **A303-1 推送前置硬校验**(任一关联结算单应收单未生成即拦截)。 +- **参数/存储**:`awrp_src_code`(多值)+ `awrp_src_sys`;落库仅限勾稽纠偏补号/状态纠正。 +- **依赖/幂等**:A302-2 财务应收单保存 →(A302-4 财务应收单查询)→ A303-1 开票申请单新增 硬顺序。 + +### 4.3 A303-4 开票申请单查询(★批量幂等预检) + +- **触发**:仅一处——A303-1 推送前预检。 +- **★变化**:按**全部候选 billno 列表**(单张=application_no;多张=application_no-1/2/3…)一次性查询(pageSize=max(张数,10)),命中的张次幂等回填跳过,未命中的张次重新推送——支持「部分张次已生成」的断链重推。 +- **参数/存储**:查询键 `billno`(多值);命中结果仅用于跳过与回填 sheet.kingdee_bill_no。 + +### 4.4 A303-1 开票申请单新增(★按张次拆分重写) + +![开票管理页面(2026-09-29):工具栏「批量同步」= /invoice-application/sync-kingdee,A302-4 硬校验 → A303-4 按全部候选 billno 批量幂等预检 → A303-1 按发票张次逐张新增;列表支持按金蝶单据状态筛选](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-04-invoice-application.png) + +- **触发**:用户动作 `POST /invoice-application/sync-kingdee` → `syncKingdee`。单申请一次。 +- **调用顺序**:校验 approved → synced/invoiced 幂等返回 → 关联结算单非空 → **A302-4 硬校验** → **A303-4 批量预检** → 销/购方取数校验 → **逐张发票张次**组装保存(`requireBfiSaveSuccess` 校验每张)→ 批量回写张次与申请单。 +- **参数**: + - **张次(★新表 `blade_invoice_application_sheet`)**:每张金蝶开票申请单对应一个张次;`billno`=单张取 `application_no`(兼容存量)、多张取 `application_no-张次序号`;价税合计=本张 `invoice_amount`;分录=本张商品行(`blade_invoice_application_line` 按 `invoice_sheet_id` 分组、lineNo 升序),`awrp_entry_code`=商品行 id; + - 头字段(沿用):`orgid_number`=承办部门 dept_code;`billdate`=application_date;`invoicetype`:数电专票 `08xdp` / 数电普票 `10xdp`;`billproperties=1`、`taxationstyle=0`、`buyeremail`=department_emails;购方=申请单受票方快照列;销方=salername/开票单位税号(客商档案)/地址电话/银行账号;`customnameid_number`=receiver_customer_id→客商编码;`hsbz`:普票=1 否则 0;`awrp_project_number`=999;systemsource=WLTMS。 +- **响应存储(★)**:每张 `sheet.kingdee_bill_no`=金蝶返回 number(幂等命中的张次回填候选 billno);**`entity.kingdee_bill_no`=全部张次单号逗号拼接**;`kingdee_status='synced'`;记操作历史。 +- **依赖/幂等**:硬前置 = A302-2 应收单已生成;幂等 = billno 候选键 + A303-4 批量预检回填(**未生成张次可断点续推**)。 + +### 4.5 A303-5 开票发票查询(结果回写) + +- **触发**:定时(PaymentKingdeeResultTask 第 3 步)+ 手动 `POST /invoice-application/sync-kingdee-invoice-result`。 +- **顺序**:待回写 = approved + synced + 有 kingdee_bill_no → **按 `application.kingdee_bill_no` 逗号拆分逐个 serialNo**(★适配张次多单号)→ 按销方税号分组(开票单位→客商档案税号,解析不到跳过 warn)→ 组内 ≤50/批调 A303-5 开票发票查询(票易通 openApi,Base64 包裹)。 +- **响应存储**:`billNo + invoiceList[]` → `blade_invoice_application_invoice`(serial_no、invoice_no、invoice_date、invoice_amount、raw_json);幂等 = 批量预查已有发票号 + 唯一键 `uk_application_invoice(tenant_id, application_id, invoice_no)`;有新增发票 → `invoiced` 终态。**发票结果不反写结算单**。 +- **依赖/幂等**:必须先有 A303-1 开票申请单新增产生的单号;唯一键防重;批次失败仅计数不中断。 + +--- + +## 5. 业务域四:收票与金蝶票池 + +**架构现状**:票池浏览**实时回源 A801-1 全票池发票查询**(全量定时同步任务已退役),镜像表 `blade_kingdee_invoice_pool` 降级为**登记存根**;推送链 A303-2 收票单批量保存 → A302-6 收票指定应付 人工批量按钮触发;A801-3 收票单查询 承担推送预检 + 定时回写。 + +### 5.1 A801-1 全票池发票查询(选票 / 保存重查 / 补票种) + +- **触发**:① 选票弹窗:收票登记 `GET /invoice-receipt/invoice-pool`、结算单认领 `GET /formal-settlement/invoice-pool`;② 收票登记**保存重查**(`persistMirrorFromRemote`,按票号精确单查,作为「保存重查闸门」);③ 推送时镜像缺票种编码的按票号窄查补填。**无定时任务**。 +- **顺序**:游标分页(`PoolScanCursor`)→ 首拉 size+1 探测 → 不足按契约上限 500 行/次补拉,单请求最多 3 次远程调用 → 短页即耗尽。 +- **参数**:`invoiceStatus`="0"(正常票)、`expenseStatus`="1"(未用,后端固定下推);票号/销购方税号/票种/日期来自前端 DTO(日期强校验)。 +- **行过滤**:票号非空 + `amountUseStatus="0"` + `availableAmount>0`;按票号查询时 trim 全等防御(防远程模糊匹配假命中)。本地打标 registered/claimed 纯展示。 +- **响应存储**:浏览**不落库**;落库仅保存收票登记(按票号精确单查→可用性复核→`(tenant_id, invoice_no)` FOR UPDATE + upsert 存根)与推送补查。镜像列含 `invoice_type_code`(票易通原始编码,A303-2 取数)、税率(小数→百分数)、票面四要素、`check_status`、`attachments_json`、`items_json`(仅存档,**不作分录取数**)。 +- **依赖/幂等**:仅 BFI 网关;**金蝶不可用则选票与收票保存整体不可用**(无镜像读降级);保存重查闸门拦截红冲/作废/被占用竞态;一票一登记(`assertInvoiceUnused`)。 + +### 5.2 A303-2 收票单批量保存 + A302-6 指定应付(推送链) + +![收票管理页面(2026-09-29):工具栏「批量同步」= /invoice-receipt/push-kingdee,链路 A102-4 应付预检 → A801-3 幂等预检(防重键=收票登记 id)→ A303-2 收票单保存 → A302-6 指定应付;选票 / 保存重查经 A801-1 实时回源票池](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-05-invoice-receipt.png) + +- **触发**:`POST /invoice-receipt/push-kingdee`。单笔失败不中断整批,逐单报因。 +- **调用顺序**(`pushOneReceivedInvoice`): + 1. 批量取数防 N+1(分摊/镜像/结算单/明细一次取回); + 2. 组装推送计划:approved、状态≠assigned、已绑定镜像、`checkStatus=2`(查验通过)、**恰好分摊 1 张审批通过应付结算单(一票一应付)**、有结算明细;不满足写 `blockedReason` 不改状态; + 3. **A102-4 预检**:关联结算单应付单必须已生成,缺则拦截「请先同步金蝶应付单」; + 4. **A801-3 幂等预检**:按镜像购方名称分组、窗口 30 天翻页,本地按 `awrpSrcSys+awrpSrcCode`(=**收票登记 id**,★243d5be 修复)匹配;命中且状态正常 → 跳过 A303-2 直接续推指定(断点恢复);命中但发票状态异常(作废 2/红冲 3/部分红冲 7/全部红冲 8)→ 置 failed 报人工; + 5. 镜像缺票种 → A801-1 按票号窄查回填; + 6. **A303-2 保存** → 成功先落单号 + `pushed`; + 7. **A302-6 指定应付**(★入参改 receipt.getId())→ 成功 `assigned`(终态)+ 记流水;失败 `failed` + 原因。unassign 不自动执行。 +- **参数**: + - A303-2 收票单批量保存:`awrp_src_code`=收票登记 id;srcType=`TMS收票登记`;`invoiceno`=receipt.invoice_no;`receivedate`=推送当日;`issuedate`=镜像 invoice_date;`invoicetypef7_number`=镜像 invoice_type_code(缺则报错);销购方四要素=镜像票面;org/币种/`receivablessupp_number`(合同乙方→客商编码)复用公共取数;**分录与 A102-2 同源同口径(★结算明细行+手工合计费用项)**;⚠️ 调试组合块**含随机兜底**(未命中组合随机选一个物料,见 11.2-#6); + - A302-6 收票指定应付:`ap_invoice` 引用 `awrp_src_code`=收票登记 id(★修复);`ap_finapbill` 引用 `apBillSrcCode`=正式结算单号_明细单据号(与 A102-2 同源)。 +- **响应存储**:`result[0].number` → `blade_invoice_receipt.kingdee_received_invoice_no`;状态机 `unsynced→pushed→assigned/failed` → `kingdee_push_status`;失败原因→`kingdee_push_msg`(截 500 字);终态/失败写 `blade_invoice_receipt_record` 流水。`blade_invoice_receipt.kingdee_bill_no / kingdee_status` 两列已删除(20260923),金蝶状态唯一来源=`kingdee_push_status`。 +- **依赖/幂等**:A302-6 依赖 A303-2 同键收票单已生成、且依赖 A102-2 应付单(硬预检);A801-3 预检命中跳过保存;pushed 断点由定时补偿重发 A302-6;assigned 拒绝重推;pushed/assigned 状态**禁止本地作废**(voidBill)。 + +### 5.3 A801-3 收票单查询(预检 + 定时回写) + +- **触发**:① 推送链内部预检(上);② 定时 `PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult`(独立开关默认 false,cron 默认每 10 分钟);③ 手动 `POST /invoice-receipt/sync-kingdee-result`。 +- **回写顺序**:轮询 `kingdee_push_status ∈ (pushed, assigned)` 且非作废、已绑定镜像 → 按 `receiver_name`(接口必填购方名称)分组 → 窗口 30 天翻页 → 逐单处置:发票状态异常→`failed`+「已回收推送状态」;`pushed`→**补偿重发 A302-6**(★防重键已修复为登记 id)→ assigned;`assigned` 缺单号→仅补登单号;**窗口内查不到不改状态**。 +- **⚠️ 疑似缺陷残留(★未修复)**:定时回写**仍按发票号码**组装查询集与匹配(`entry.getValue().stream().map(InvoiceReceipt::getInvoiceNo)` + `matched.get(receipt.getInvoiceNo())`),而 A303-2 落库的 `awrp_src_code` 是**收票登记 id** → 回写将**永远匹配不到 TMS 自推单据**(静默跳过,异常回收/指定补偿/单号补登全部失效)。09-23 的 243d5be 提交只修复了**推送预检与 A302-6** 路径,回写路径漏改。见 11.2-#1。 + +### 5.4 选票 / 认领流(是否调金蝶对照) + +| 流 | 入口 | 金蝶调用 | 本地存储 | +|---|---|---|---| +| 收票登记 | `POST /invoice-receipt/save` | 保存重查 A801-1(闸门) | 镜像存根 upsert + 绑定 + 分摊 + 流水 | +| 结算单认领(按票) | `POST /formal-settlement/claim-invoices`;查票 `GET /formal-settlement/invoice-pool` | 查票实时 A801-1;**确认认领时不调金蝶** | `blade_formal_settlement_invoice` + 联动付款申请 matched;⚠️ **仍无保存重查闸门**(前端传票面直接落库,09-23 提案至今未实现) | +| 结算单收款认领(收票流水) | ReceiptFlowController / ReceiptClaimRecordController | **不调金蝶**(镜像流水由外部 `sync` 推入) | `blade_kingdee_receipt_flow`、`blade_receipt_claim(_settlement)`、`blade_receipt_flow_record` | + +--- + +## 6. 业务域五:收款认领(A301-2 收款认领公告查询 / A301-3 收款认领结果推送 / A301-1 收款单查询)★新增 + +**定位**:图06「回款认领」采用**业务先行认领(场景二)**:金蝶司库收到银行流水完成第一轮自动认领(到客户维度),TMS 二次认领到结算单(应收单)维度后回推金蝶生成收款单。**三个接口本期全部接线,均为手动触发,无定时任务**(定时开关 `thirdParty.bfi.receiptClaimResult.*` 预留未接)。专项文档:`2026-09-24-收款认领金蝶全链路业务流程….md`。 + +``` +阶段一 POST /receipt-flow/sync(手动同步流水按钮) + A301-2 认领公告查询(claimusers=当前登录用户真实姓名) + → BfiUtil.queryAllPages 全量翻页 → 按 detail_serial_no 幂等 upsert blade_kingdee_receipt_flow +阶段二 POST /receipt-flow/claim(页面认领) + 本地认领落库(blade_receipt_claim + 分摊表)+ A301-3 认领结果推送 同一事务 + → 成功回写金蝶收款单号 + kingdee_bill_status=pushed;失败抛异常整体回滚 +阶段三 POST /receipt-claim-record/query-kingdee-result(手动回写) + A301-1 收款单查询(近 30 天窗口全量翻页)→ sourcebillnumber==awrp_src_code 匹配 → 回写单号与状态 +``` + +### 6.1 A301-2 收款认领公告查询(公告同步) + +![收款流水页面(2026-09-29):「手动同步流水」= POST /receipt-flow/sync(A301-2 公告查询,claimusers=当前用户真实姓名,全量翻页幂等落库);勾选流水后「批量认领」在认领事务内同步推送 A301-3](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-06-receipt-flow.png) + +- **触发**:仅手动 `POST /receipt-flow/sync`(按钮权限 `receipt_flow_sync`)。前端已去 mock,直接调后端。 +- **顺序**:确定参与认领用户(`UserCache.getUserRealName`,为空拦截)→ 全量翻页(pageSize 200,50 页保护,时间窗口从 Nacos 配置 `thirdParty.bfi.claimAnnounce.months` 读取,默认 6 个月)→ 幂等落库 → 操作留痕(`blade_receipt_flow_record` 记 sync)。 +- **参数**:`claimusers`=当前用户真实姓名(**前提:金蝶公告的 claimusers 须与 TMS 用户姓名一致**,否则查不到公告)。 +- **响应存储**:upsert `blade_kingdee_receipt_flow`,幂等键 `detail_serial_no`(取 detailid,缺省回退 tradedetailno/bankdetailno;重复明细流水号拦截);落库 `bill_id`(**认领公告 id,A301-3 收款认领结果推送必填**)、billno→receipt_notice_no、reamount、对方户名/账号/开户行、摘要、交易时间、unclaimed_amount、fee、payee_name、claimstatus→kingdee_claim_status、claimme。**公告侧已认领金额/状态与本地 claimed_amount/claim_status 互不覆盖**。 +- **依赖/幂等**:独立;唯一索引 `(tenant_id, detail_serial_no)`。 + +### 6.2 A301-3 收款认领结果推送 + +- **触发**:页面认领 `POST /receipt-flow/claim`(流水详情/批量认领页选择审批通过的应收正式结算单按比例分摊)。 +- **顺序**:本地校验(锁行、分摊金额上限、同一项目/组织/收付款方)→ 本地落库(claim + 分摊 + 流水 claimed_amount/claim_status 刷新 + 结算单收款汇总)→ 生成 `awrp_src_code = SKCL+yyyyMMddHHmmss+认领id尾号6位` → **A301-3 推送**(同事务)→ 成功回写 `kingdee_bill_no`(result[0].number)+ `kingdee_bill_status=pushed`;失败抛 `ServiceException` 整体回滚(无残留)。 +- **参数**:`billid`=流水 `bill_id`(**必填**,缺失拦截「认领公告id缺失,请先同步金蝶收款流水」);`awrp_src_sys`=WLTMS;`awrp_src_code`=SKCL 码;`recpaytype_number`=常量(示例 101,**待金蝶确认**);`paymenttype`=bd_customer(**待确认**);`recpayer`=流水 payer_id(**待确认**:金蝶付款人 master id,联调若强校验需按名称反查 A902-1 补齐);分录 `entryentity[]`=各结算单分摊行:`e_receivableamt`=分摊金额、`e_corebilltype`=`ar_finarbill`、`e_corebillno`=分摊结算单 `kingdee_bill_no`(缺失不传)、`seq`=行号、`e_receivingtype_number` 同 recpaytype_number(待确认)。 +- **依赖**:分摊结算单须审批通过、未作废、且已完成 A302-2 同步(有 kingdee_bill_no)以便分录携带应收单号。 +- **幂等**:`awrp_src_sys+awrp_src_code` 为金蝶候选键;⚠️ 推送成功但本地回滚后重推会**重新生成 awrp_src_code** 产生新单(联调期以推送失败回滚为主路径规避)。 + +### 6.3 A301-1 收款单查询(认领结果回写) + +![认领记录页面(2026-09-29):「查询金蝶认领结果」= POST /receipt-claim-record/query-kingdee-result,手动调 A301-1 收款单查询(今天回溯 30 天),按 sourcebillnumber == awrp_src_code(SKCL 码)匹配回写单号与状态](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-07-receipt-claim-record.png) + +- **触发**:仅手动 `POST /receipt-claim-record/query-kingdee-result`。**仅查询,不推送**。 +- **顺序**:轮询 `claim_status=claimed` 且 `awrp_src_code` 非空且 `kingdee_bill_status ∈ (pushed, approved)` → A301-1 按业务日期**今天回溯 30 天**(≤1 个月约束)全量翻页(pageSize 200)→ 本地匹配 `sourcebillnumber == awrp_src_code` → 逐条回写。 +- **响应存储**:`kingdee_bill_no`=收款单 billno(首条命中后仅推进状态);`kingdee_bill_status` 映射:A 暂存/B 已提交→pushed;C 已审核→approved;**D 已收款→received(终态)**;其他/G 退单→failed。窗口内查不到不改状态(金蝶回单延迟兜底)。**不改本地认领金额/分摊**。 +- **依赖/幂等**:依赖 A301-3 产生的 awrp_src_code;主键更新幂等。 + +--- + +## 7. 业务域六:公共取数(A801-4 汇率)★新启用 + +- **触发**:前端结算页面按「结算币种 + 汇率日期」调用 `GET /bfi/exchange-rate?currencyCode=&effectdate=`(BfiExchangeRateController),用于正式结算单页面维护汇率/汇率日期。手动、单次。 +- **顺序**:按日期查 A801-4 全量汇率(当日默认,页大小固定 10)→ 本地按 `orgcur_number` 匹配币种 → 返回汇率 VO。 +- **响应**:不落库(汇率/汇率日期随结算单提交落 `blade_formal_settlement.exchange_rate / exchange_rate_date`);查不到币种/日期返回 R.fail。 +- **下游消费**:A102-2 / A302-2 保存时传 `exchangerate`(结算汇率)与 `exratedate`(汇率日期),未维护不传;A103-1 下推汇率相关字段不传(按应付单自动带出)。 +- **与方案文档对照**:方案 V1.2「汇率在金蝶维护,TMS 结算时通过接口获取并记录在结算单据上,期末调汇全部在金蝶处理」——**代码已落地**。 + +--- + +## 8. 全景:接口先后顺序与依赖关系 + +### 8.1 链路依赖图 + +``` + ┌────────────────── 客商档案(全局前提,软依赖) ──────────────────┐ + │ A902-2 客户保存(审批通过自动/人工/定时重推) │ + │ A902-1 客户查询(前端回填/推送前复查/定时复查) │ + │ ⇒ 本地 customer_code(其他链路只查本地表,不校验金蝶侧状态) │ + └───────────────────────────┬───────────────────────────────────┘ + ▼ +【应付主线】 + 正式结算单(应付) ─A102-4 预检─▶ A102-2 保存应付单 ─▶ 结算单.kingdee_bill_no + synced + │ (汇率 exchangerate/exratedate ← A801-4 页面取数) + │ A102-4 硬校验(每张关联结算单) │ A102-4 预检(收票推送硬前置) + ▼ ▼ + 付款申请 ─A103-2 预检─▶ A103-1 下推(单分录;勾稽键=主结算单应付防重键,本地组装) + (预付款→A103-3 直推) ─▶ 付款申请.kingdee_bill_id/no + synced + │ + 定时:A103-2 状态回写(synced→paying→paid/closed, failed勾稽纠偏) + ▼ + A103-4 付款处理查询 ─▶ blade_payment_application_record(+voucher_json) + +【应收主线】 + 正式结算单(应收) ─A302-4 预检─▶ A302-2 保存应收单 ─▶ 结算单.kingdee_bill_no + synced + │ A302-4 硬校验 + ▼ + 开票申请 ─A303-4 批量billno预检─▶ A303-1 逐张次新增 ─▶ 张次.kingdee_bill_no + 申请单.kingdee_bill_no(逗号拼接) + synced + │ + 定时:A302-4 勾稽纠偏 + A303-5(serialNo 按逗号拆分)─▶ 发票落库 ─▶ invoiced(终态) + +【收票主线】(与应收主线无调用耦合,与应付主线强耦合) + 金蝶票池 A801-1(实时选票 / 保存重查闸门 / 补票种) ─▶ 镜像存根 blade_kingdee_invoice_pool + │ 保存收票登记(一票一登记) + ▼ + 收票登记 ─A102-4 预检(应付已生成) + A801-3 预检(防重键=登记id)─▶ A303-2 收票单保存 ─▶ pushed + ▼ + A302-6 指定应付(引用=登记id)─▶ assigned(终态) + │ + 定时:A801-3 回写(⚠️ 仍按发票号匹配,疑缺陷残留) + +【收款认领主线】★新增(全部手动触发,无定时) + 金蝶第一轮自动认领(到客户)─▶ A301-2 公告同步(claimusers=用户姓名)─▶ blade_kingdee_receipt_flow + │ 页面二次认领(分摊到应收结算单) + ▼ + 认领落库 + A301-3 推送(billid=公告id;src_code=SKCL码;分录=e_corebillno=结算单应收单号)─▶ pushed + ▼ + 手动:A301-1 收款单查询(sourcebillnumber==SKCL码)─▶ approved/received(终) + +【公共取数】 + 结算页面 ─▶ A801-4 汇率查询 ─▶ 结算单.exchange_rate/exchange_rate_date ─▶ 随 A102-2/A302-2 推送 +``` + +### 8.2 硬顺序规则(代码强制) + +| # | 顺序规则 | 强制方式 | +|---|---|---| +| 1 | 客商档案先于一切推送:本地 `customer_code` 必须存在 | 查不到直接报错拦截;**金蝶侧是否已审核不校验**(软依赖) | +| 2 | A102-2(应付单)先于 A103-1(付款下推) | A102-4 硬校验(逐关联结算单);~~A102-4 反查 awrp_entry_code_p~~ **已移除** | +| 3 | A102-2(应付单)先于 A303-2/A302-6(收票推送) | A102-4 硬预检 | +| 4 | A302-2(应收单)先于 A303-1(开票申请) | A302-4 硬校验 | +| 5 | A303-2 收票单批量保存 先于 A302-6 收票指定应付 | 同一次推送内串行;assigned 依赖 pushed | +| 6 | A302-2(应收单)先于 A301-3(认领推送分录携带应收单号) | 分录 e_corebillno 缺失不传(软依赖),联调前提建议先同步应收 | +| 7 | 推送类先于回写类:A103-2 付款申请单查询/A103-4 付款处理查询、A302-4 财务应收单查询 勾稽、A303-5 开票发票查询、A801-3 收票单查询、A301-1 收款单查询 都依赖推送产生的键/单号 | 查询键(paymentNo / srcCode / billNo / serialNo / SKCL码)由推送链写入本地 | +| 8 | A801-4 汇率查询先于 A102-2 财务应付单保存/A302-2 财务应收单保存(如需携带汇率) | 页面维护动作,非代码强制(未维护不传) | + +### 8.3 断链恢复语义(各链路通用模式) + +- **本地失败、远端未落单**:状态保持 unsynced(应付/应收/开票)或 failed(付款申请/收票),可重推。 +- **远端成功、本地回滚**:重推时预检命中幂等回填——应付/应收走 A102-4 财务应付单查询/A302-4 财务应收单查询 防重键、付款申请走 A103-2 付款申请单查询 按 paymentNo、开票走 A303-4 开票申请单查询 按候选 billno(张次级)、收票走 A801-3 收票单查询 按 srcCode(登记 id)、认领走 A301-1 收款单查询 按 sourcebillnumber(⚠️ A301-3 重推会换新码,见 6.2)。 +- **推送成功后状态滞后**:定时任务回写(A103-2 付款申请单查询→paid/closed、A303-5 开票发票查询→invoiced、A801-3 收票单查询→assigned 补偿;A301-1 收款单查询 暂为手动)。 +- **批量语义**:整批预热 → 逐单独立事务 → 单条失败不中断整批(付款批量、收票推送、定时回写均如此)。 + +--- + +## 9. 状态机汇总 + +| 表.字段 | 状态集 | 迁移 | 写入方 | +|---|---|---|---| +| `blade_formal_settlement.kingdee_sync_status` | unsynced / synced | unsynced→(A102-2 财务应付单保存 或 A302-2 财务应收单保存 成功)→synced;失败回滚保持 unsynced;定时勾稽可纠偏 failed→synced(防御性,代码无 failed 写入) | FormalSettlementServiceImpl | +| `blade_payment_application.kingdee_status` | unsynced / synced / paying / paid(终) / closed(终) / failed | unsynced→(A103-1 应付下推生成付款申请/A103-3 预付款申请保存)→synced→(Inpayment)→paying→(Alreadypay)→paid;billstatus=D→closed;推送异常→failed;failed 可被 A103-2 付款申请单查询 勾稽纠偏回 synced | PaymentApplicationServiceImpl + PaymentKingdeeResultTask | +| `blade_invoice_application.kingdee_status` | unsynced / synced / invoiced(终) | unsynced→(A303-1 开票申请单新增 全部张次成功)→synced→(A303-5 开票发票查询 有新发票)→invoiced;失败回滚保持 unsynced(**无 failed 写入**) | InvoiceApplicationServiceImpl + 定时 | +| `blade_invoice_application_sheet.kingdee_bill_no` | 空 / 金蝶单号 | 张次级单号,A303-1 推送或 A303-4 幂等命中时回填 | InvoiceApplicationServiceImpl | +| `blade_invoice_receipt.kingdee_push_status` | unsynced / pushed / assigned(终) / failed | unsynced→(A303-2 收票单批量保存)→pushed→(A302-6 收票指定应付)→assigned;A302-6 失败/发票异常回收→failed;pushed 由定时补偿重发 A302-6(⚠️ 回写匹配缺陷,见 11.2) | InvoiceReceiptServiceImpl + 定时 | +| `blade_customer_archive.kingdee_status` | none / synced / failed / exist(终) | 审批通过→(A902-2 客户保存)→synced(金蝶受理 B);金蝶审核 C 后定时复查→exist;推送失败/A/B→failed | CustomerArchiveServiceImpl + CustomerKingdeeSyncTask | +| `blade_receipt_claim.kingdee_bill_status` ★新增 | none / pushed / approved / received(终) / failed / voided | none→(A301-3 收款认领结果推送)→pushed→(A301-1 收款单查询: C)→approved→(D)→received;A/B→pushed 维持;G/其他→failed;作废→voided+KDCX 冲单号(本地) | ReceiptFlowServiceImpl + ReceiptClaimRecordServiceImpl | +| `blade_kingdee_receipt_flow.claim_status` ★新增 | unclaimed / partial / claimed | 公告同步不覆盖;认领分摊→partial/claimed;全部作废→unclaimed | ReceiptFlowServiceImpl | + +--- + +## 10. 定时任务与配置开关汇总 + +| 任务类 | 方法 | 覆盖接口 | cron(默认) | 开关(默认) | 手动端点 | +|---|---|---|---|---|---| +| PaymentKingdeeResultTask | syncKingdeePaymentResult | ① A103-2 付款申请单查询 + A103-4 付款处理查询 ② A302-4 财务应收单查询 勾稽 ③ A303-5 开票发票查询 | `thirdParty.bfi.payResult.cron` = `0 */10 * * * ?` | `thirdParty.bfi.payResult.syncEnabled` = **false** | `POST /payment-application/sync-kingdee-result`、`POST /formal-settlement/sync-kingdee-ar-result`、`POST /invoice-application/sync-kingdee-invoice-result` | +| PaymentKingdeeResultTask | syncKingdeeReceivedInvoiceResult | A801-3 收票单查询 回写 | `thirdParty.bfi.receivedInvoiceResult.cron` = `0 */10 * * * ?` | `thirdParty.bfi.receivedInvoiceResult.syncEnabled` = **false** | `POST /invoice-receipt/sync-kingdee-result` | +| CustomerKingdeeSyncTask | syncKingdeeCustomerData | A902-1 客户查询 复查 + A902-2 客户保存 重推 | `thirdParty.bfi.customer.cron` = `0 0 2 * * ?` | `thirdParty.bfi.customer.syncEnabled` = **false** | `POST /customer-archive/sync-kingdee` | +| (预留未接) | — | A301-1 收款单查询 认领回写 | — | `thirdParty.bfi.receiptClaimResult.*`(代码未消费) | `POST /receipt-claim-record/query-kingdee-result`(手动) | + +> 所有已接线的定时开关**代码默认关闭**,需 Nacos 显式开启。⚠️ 客商定时任务仍有联调残留过滤(只处理 KS000067,见 11.1-#3),开启前必须移除。收款认领三接口**全部手动触发**(A301-2 sync、A301-3 认领、A301-1 回写),与方案文档「每天定时获取金蝶收款流水」不一致(见 11.3)。 + +--- + +## 11. 代码与三份材料差异 / 风险清单(更新版) + +### 11.1 联调残留(上线前必须处理;对照 09-23 清单标注状态) + +| # | 位置 | 问题 | 状态 vs 09-23 | +|---|---|---|---| +| 1 | KingdeeApBillSyncSupport:299、KingdeeArBillSyncSupport:234、KingdeeReceivedInvoiceSyncSupport:244 | `if(true)` 调试组合块强制物料/计量单位(固定 4 组合,兜底 物流配送 Item-00001080+TON) | ⚠️ **部分解决**:真实映射链路已建成(装卸/国际/国内+运输方式 → 金蝶编码;计量单位档案编码),但调试块仍做**最终覆盖** | +| 2 | KingdeeReceivedInvoiceSyncSupport 调试块 | 收票侧映射表与应付侧不一致,且**未命中组合时随机取一个物料**(`new Random().nextInt`)——同一票每次推送物料可能不同 | ⚠️ **新增风险** | +| 3 | CustomerArchiveServiceImpl:629 | 定时客商同步硬编码 `.eq(customerCode, "KS000067")`,只处理一条 | ❌ **仍在** | +| 4 | CustomerBfiConverter:141 | 客商银行分录 `bankNumber` 硬编码 "001110012009"(todo) | ❌ **仍在**(付款申请侧已改为取客商收款账户真实联行号,仅客商推送残留) | +| 5 | KingdeeApBillSyncSupport / KingdeeArBillSyncSupport / A103-1 分录 / 开票申请 | 项目编码传测试值 `999` | ⚠️ **部分解决**:A103-3 预付款已按项目档案真实编码(无项目才 999);其余仍 999 | +| 6 | KingdeeBfiConstant 遗留常量 | `BFI_AP_BILL_MATERIAL_NUMBER_TEST(SP0324)`、`BFI_AP_BILL_BANKACCOUNT_NUMBER_TEST` 等联调常量仍在(BfiProperties.receivedInvoice.materialNumber 默认值亦为 SP0324) | ⚠️ 无调用方或被覆盖,建议清理 | + +### 11.2 疑似缺陷(建议人工确认) + +| # | 问题 | 证据 | 状态 vs 09-23 | +|---|---|---|---| +| 1 | **A801-3 定时回写按发票号码匹配防重键,而 A303-2 落库 awrp_src_code=收票登记 id** → 回写(异常回收/指定补偿/单号补登)永远匹配不到 TMS 自推单据,静默跳过。09-23 的 243d5be 只修复了推送预检与 A302-6 路径 | InvoiceReceiptServiceImpl(syncKingdeeReceivedInvoiceResult:`map(InvoiceReceipt::getInvoiceNo)` + `matched.get(receipt.getInvoiceNo())`,而预检路径已改 `String.valueOf(receipt.getId())`) | ⚠️ **部分修复(回写路径残留)** | +| 2 | A103-1(应付下推生成付款申请)`awrp_finapbill_entryid` 实际传**主结算单应付单防重键(本地组装)**,而 PaymentApplicationServiceImpl 与 KingdeeApBillSyncSupport 注释仍写「A102-4 反查 awrp_entry_code_p,禁止各接口自行拼接」——注释与代码矛盾;金蝶侧按单据头 srcCode 勾稽付款计划是否成立需联调确认 | PaymentApplicationServiceImpl:962-963 vs :1129 注释;KingdeeApBillSyncSupport:81/:359-361 注释 | ⚠️ **新增待确认** | +| 3 | 应收/开票链路 `failed` 状态无写入路径(回滚后停留 unsynced;轮询集合中的 failed 只能人工改库产生) | FormalSettlementServiceImpl、InvoiceApplicationServiceImpl | ❌ **仍在** | +| 4 | 其他链路推送前不校验客商 `kingdee_status`,客商未推送/未审核也会带编码推单 | KingdeeApBillSyncSupport.queryCustomerCode | ❌ **仍在** | +| 5 | A103-3 预付款分录 `e_bebank_number`(往来银行.编码)取 `entity.getBankName()`(名称字段)——与 A103-1 取联行号的口径不一致,疑似取错字段 | PaymentApplicationServiceImpl:885 vs :969 | ⚠️ **新增待确认** | +| 6 | 收款认领 A301-3:推送成功但本地回滚后重推会**重新生成 awrp_src_code** 产生金蝶重复收款单(联调期以推送失败回滚规避);且 `recpaytype_number/paymenttype/recpayer/e_receivingtype_number` 等字段取值待金蝶侧确认 | ReceiptFlowServiceImpl(09-24 专项文档 §4.1/§7) | ⚠️ **新增待确认** | + +### 11.3 代码与三份材料的口径差异 + +**vs《TMS业财融合方案.docx》(V1.2 方案)** + +| 方案描述 | 代码现状 | +|---|---| +| SC-04「付款申请可分多次下推」 | A103-1 当前**单分录单次下推**(一申请一单,`paymentNo-1`);多次下推未实现 | +| SC-04 预付/尾款两模式、实付=全额−预付抵扣−已付 | 本地金额口径已实现;金蝶侧预付冲应付由金蝶自动处理(代码不传冲抵明细) | +| 附件推送(审批单据/磅单/合同签章随单推金蝶 A902 附件上传) | **未实现**:A902 未在客户端实现,各推送链路均不传附件 | +| SC-05「结算审批时同步录入进项发票…同步生成收票单并自动绑定」 | 流程拆分:收票登记独立审批 → 人工批量 `push-kingdee` 推送(非结算审批时自动) | +| SC-07「每天定时获取金蝶收款流水」 | A301-2 当前**手动按钮触发**,无定时(`receiptClaimResult.*` 开关预留未接) | +| SC-08「按发票张数生成多个金蝶开票申请单,按票面行拆分明细」 | ✅ **已实现**(张次拆分) | +| 汇率「TMS 结算时通过接口获取金蝶汇率记录在结算单据上」 | ✅ **已实现**(A801-4 汇率查询 → 结算单页面维护 → 随 A102-2 财务应付单保存/A302-2 财务应收单保存 推送) | +| 客商「TMS 按机构类别转换伙伴类型再推送」 | `customer_nature` 字典映射;分组 1101/1102 已按落库 customerKind 区分 | + +**vs《TMS业财融合-集成流程图文字版与业务流程梳理》(09-14)** + +| 文字版描述 | 代码现状 | +|---|---| +| 图05/第6节 票池「定时/手动拉取全票池 → 本地镜像 → 查本地镜像选票」 | 已演进:**实时回源 A801-1 全票池发票查询**(无定时拉取),镜像降级为登记存根(09-23 已按此定稿) | +| 图05 状态一览「A303-2/A302-6/A801-3 已编码未联调」 | 已联调推进:A801-3 预检与 A302-6 防重键 bug 已修(243d5be),回写仍带缺陷(11.2-#1) | +| 图06/第7节 收款认领 A301-1/A301-2/A301-3 | ✅ **已接线**(手动触发;公告 claimusers 须与 TMS 用户姓名匹配;多个 A301-3 字段待金蝶确认) | +| 第9节 汇率 A801-4「TMS 结算时获取」 | ✅ 已实现 | +| 第9节 黑名单 A801-5、A901-1 供应商查询、A913 项目、A101-1/2 合同 | ❌ 均未实现(合同/项目/风险走合同合规系统 C 类接口,本期不在 BFI 范围) | +| 第9节 附件 A902 上传 | ❌ 未实现(方案中随单附件推送金蝶暂缺) | + +**vs《2026-09-23 全链路梳理》(上一版总纲)** + +| 09-23 结论 | 当前状态 | +|---|---| +| A301-1/2/3「业务侧无调用」 | ❌ 已过时:收款认领链路接线(业务域五) | +| A801-4「业务侧无调用」 | ❌ 已过时:结算取汇率(业务域七) | +| A102-4 用途④「反查 awrp_entry_code_p,是 A103-1 硬前置」 | ❌ 已过时:反查移除,勾稽键本地组装(11.2-#2) | +| A103-1 分录「逐费用行一条,e_bebank_number=测试常量」 | ❌ 已过时:单分录、联行号取客商收款账户、结算方式显式映射 | +| 开票申请「billno=application_no,一张申请一张金蝶单」 | ❌ 已过时:按张次拆分多单,billno 加张次后缀,A303-4 批量预检 | +| 9.1-#5 customerKind 恒推 1102 | ✅ 已修复(字段落库) | +| 9.2-#1 A801-3 回写匹配缺陷 | ⚠️ 部分修复(预检/A302-6 已修,回写残留) | +| 9.1-#1 物料 SP0324/TON 强制、#2 项目 999、#3 银行硬编码 | ⚠️ 演进(真实映射+调试覆盖并存;见 11.1) | +| 9.1-#3 付款 e_bebank_number 测试值 | ✅ 已修复(联行号);客商 converter 残留 | + +### 11.4 其他已知注意点 + +- 客户端常量两处并存(`bfi/constant/BfiConstant` 与 `transport/constant/KingdeeBfiConstant`),语义重复,改动需双侧核对。 +- `BfiBillSaveResultVO.successCount/failCount` 为 String 类型。 +- token 缓存 `setIfAbsent`:已有缓存时强制取的新令牌不会覆盖旧缓存。 +- 操作码与 URL 资源域不完全对应(A303-2 收票单批量保存/A801-3 收票单查询 挂 `/ap/ap_invoice/`,A303-1 开票申请单新增/A303-4 开票申请单查询 挂 `/sim/`,A301 系列收款认领接口挂 `/cas/`),检索时勿按 URL 前缀猜操作码。 +- 结算单认领(claim-invoices)**仍无保存重查闸门**(前端传票面直接落库,09-23 提案未实现)。 +- 主数据初始化 SQL `kingdee_master_data_init_20260924.sql`(计量单位金蝶编码 + `blade_kingdee_material`)须先执行,物料/单位映射才有正确档案值;认领链路须先执行 `blade_receipt_flow_claim_20260924.sql`。 + +--- + +## 附:本文档与单链路文档的关系 + +- 本文是**总纲更新版**:跨链路顺序、依赖、公共机制、状态机、差异风险汇总,基线为 2026-09-24 代码(`50573e7`)。 +- 单链路细节仍以 `doc/docs/业务流程/` 对应文档为深入参考;其中与代码冲突处一律以本文第 11 节结论 + 当前代码为准。 +- 09-23 版总纲中被本文覆盖的过时结论:A301 系列收款认领接口/A801-4 汇率查询 未调用、A102-4 财务应付单查询 反查、付款申请分录口径、开票单张口径、客商 1102 问题——请以本文为准。 diff --git a/doc/docs/业务流程/2026-09-28-收款认领推送金蝶重构设计(认领本地化-A301-3定时+手动推送-按流水归集).md b/doc/docs/业务流程/2026-09-28-收款认领推送金蝶重构设计(认领本地化-A301-3定时+手动推送-按流水归集).md new file mode 100644 index 00000000..ef4b0bdb --- /dev/null +++ b/doc/docs/业务流程/2026-09-28-收款认领推送金蝶重构设计(认领本地化-A301-3定时+手动推送-按流水归集).md @@ -0,0 +1,258 @@ +# 收款认领推送金蝶重构设计(认领本地化 → A301-3 定时 + 手动推送 → 按流水归集) + +> 适用系统:TMS(blade-transport) +> 对接系统:金蝶云·星瀚(BFI,经 iPaaS 网关直连) +> 覆盖接口:A301-2 收款认领公告查询(不变)、A301-3 收款认领结果推送(**触发时机重构**)、A301-1 收款单查询回写(**轮询对象改造**) +> 前置文档:`2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md`(下称「旧文档」,描述的是重构前现状) +> 最后更新:2026-09-28(设计稿,未实施) + +--- + +## 1. 为什么要重构 + +旧文档描述的现状(对应代码 `ReceiptFlowServiceImpl.claim()` L353):**每次认领落库后立即在事务内推送 A301-3**。带来三个问题: + +1. **部分认领立即推**:一笔流水分两次认领(40 万 + 60 万),金蝶侧生成两张收款单,司库对账时一笔到账裂成多单。 +2. **来源编码撞码**:`awrp_src_code` 用结算单号拼接,两次认领若关联同一批结算单,编码重复,A301-1 回写按 `sourcebillnumber` 匹配存在歧义(旧文档 §4.1 幂等说明已指出)。 +3. **金额口径混乱**:本地认领上限按 `receipt_amount`(收款金额)控制,而金蝶公告侧有第一轮自动认领后的 `unclaimed_amount`(未认领金额),两个口径互不参照,可能出现本地认领金额与金蝶侧待认领金额对不上的脏数据。 + +## 2. 新设计原则(对旧文档原则的修订) + +| # | 旧原则(旧文档 §1) | 新原则 | +| --- | --- | --- | +| 1 | 公告为准(A301-2 落库流水) | **不变** | +| 2 | 推送先于结果(认领与推送同事务,失败整体回滚) | **废弃**。改为:**认领本地先行,推送异步解耦**——认领动作只落本地库,永不直接调金蝶;推送由定时任务 + 前端手动按钮承担 | +| 3 | 认领公告 id 必填 | **不变**,并扩展:`unclaimed_amount` 同样必填,缺失拦截 | +| 4 | 以金蝶侧实际生成为准 | **不变**,扩展为:**推送前实时对账**——推送前重查 A301-2 拿最新 `unclaimed_amount`,校验通过才推 | +| 5 | 异常回收人工处置(作废不推金蝶) | **不变**,并强化:**已推送流水禁止二次推送**,作废后的金蝶侧冲销一律人工处置 | + +新增两条核心规则(本次重构的业务闸门): + +- **认领完成 = 累计有效认领金额 == 公告未认领金额**(`unclaimed_amount`,即 A301-2 的 `unclaimamount`;作废分摊不计入累计)。只有认领完成的流水才满足推送条件——**不认领完就不推**。 +- **推送单元 = 收款流水**:一条流水只产生一张金蝶收款单,**单条分录、金额 = 全部未推送金额**(累计有效认领,闸门保证等于公告未认领金额);`awrp_src_code` 按分摊结算单号下划线拼接(A301-3 契约口径),一条流水只推送一次,编码不会撞码。 + +## 3. 领域模型与职责划分 + +### 3.1 三张表的分工 + +``` +blade_kingdee_receipt_flow(收款流水) ← 推送的权威载体 + 认领状态 claim_status / 累计认领 claimed_amount(展示缓存) + 公告侧 unclaimed_amount / bill_id / fee ... + 推送状态 push_status / 推送失败原因 push_fail_reason / 推送时间 pushed_time + 推送编码 awrp_src_code / 金蝶收款单号 kingdee_bill_no / 回写状态 kingdee_bill_status + +blade_receipt_claim(认领主单) ← 认领动作的留痕 + 认领人/部门/日期/附件/备注、claim_amount(本次认领金额) + kingdee_bill_no / kingdee_bill_status 仅作冗余展示(由流水级联刷新) + 【去掉】awrp_src_code(移至流水表) + +blade_receipt_claim_settlement(分摊行) ← 累计认领的权威来源 + 累计认领 = Σ(status=1 行的 allocated_receipt_amount) ← 作废行 status=0,天然不计入 +``` + +**权威口径**:累计认领永远以分摊表实时聚合为准(`flowClaimedAmount()`,现有实现),流水上的 `claimed_amount` 只是展示缓存,在认领/作废/同步/推送前校验四个时机重算维护。 + +### 3.2 流水新增字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `push_status` | varchar(20) | 推送状态:`none` 未推送(默认)/ `pushed` 已推送 / `failed` 推送失败 | +| `push_fail_reason` | varchar(1000) | 最近一次推送失败原因(成功后清空) | +| `pushed_time` | datetime | 首次推送成功时间 | +| `awrp_src_code` | varchar(64) | 推送来源编码 = **分摊结算单号下划线拼接**(A301-3 契约口径);推送时生成并落库,分摊不变则重试编码不变(依赖金蝶候选键 `awrp_src_sys+awrp_src_code` 幂等,防重试双单) | + +认领状态 `claim_status` **口径变更**(字段沿用,含义改): + +``` +unclaimed 累计有效认领 = 0 +partial 0 < 累计有效认领 < unclaimed_amount +claimed 累计有效认领 == unclaimed_amount(且 > 0) ← 「认领完成」,唯一可推送状态 +``` + +不再与 `receipt_amount` 比较。前端「剩余可认领」= `unclaimed_amount − 累计有效认领`。 + +`kingdee_bill_status`(回写生命周期)从认领主单上移到流水:`none → pushed →(A301-1 回写)approved / received / failed`;认领主单保留同名冗余字段仅作展示。 + +### 3.3 状态机 + +``` +流水推送状态: + none ──推送成功──▶ pushed ──A301-1 回写──▶ approved ──▶ received(终态) + │ │ └──(A/B/退单)──▶ failed(回写失败) + └──推送失败──▶ failed ──重试(同码)──▶ pushed / 维持 failed + ※ pushed 为终态闸门:任何路径不允许二次推送(作废→重认领→再满足等式也不推) + +流水认领状态(对照 unclaimed_amount,随认领/作废/同步刷新重算): + unclaimed ⇄ partial ⇄ claimed +``` + +## 4. 流程编排 + +### 4.1 认领(POST /receipt-flow/claim,纯本地化) + +改造 `ReceiptFlowServiceImpl.claim()`: + +1. 锁流水后校验:`bill_id` 非空(沿用)+ **`unclaimed_amount` 非空**(缺失拦截「公告未认领金额缺失,请重新同步金蝶收款流水」)。 +2. 结算单侧校验全部不变(审批通过/应收类型/未作废/同项目组织收付款方/单张结算单累计 ≤ 结算金额)。 +3. **流水维度上限改为公告口径**:`累计有效认领 + 本次分摊 ≤ unclaimed_amount`(原为 ≤ receipt_amount),报错文案「累计认领金额不能超过公告未认领金额」。 +4. 本地落库:认领主单(`kingdee_bill_status=none`)+ 分摊行 + 刷新结算单收款汇总 + 更新流水 `claimed_amount/claim_status`(按 3.2 新口径)+ 认领留痕。 +5. **删除 `pushClaimResultToKingdee(...)` 调用**(L353)——认领事务到此结束,不产生任何 BFI 调用。 + +### 4.2 推送(统一入口,定时任务与手动按钮共用) + +新服务方法 `pushClaimToKingdee(Long flowId)`,**方法级事务,推送异常在事务内消化(不外抛),以返回值向调用方报告成败与原因**——保证失败路径的状态落库不被回滚: + +``` +① FOR UPDATE 锁流水 +② 静态闸门(硬校验,不满足直接返回失败): + - bill_id 非空 + - push_status != pushed ←「已推送禁止二次推送」终态闸门 + - 累计有效认领(分摊表实时聚合)> 0 且 == unclaimed_amount(compareTo==0,严格相等) +③ 实时对账:重查 A301-2 拿最新公告 + - 手动按钮:claimusers = 当前登录用户真实姓名 + - 定时任务:按候选流水的认领人姓名(认领主单 claimer_name 去重)逐人查询,§4.3 + - 按 bill_id 匹配本流水 → 刷新 unclaimed_amount / kingdee_claim_status / claim_me + - 公告查不到 → 失败:「金蝶公告不存在或已不在认领范围,请重新同步」 + - 重验:累计有效认领 == 最新 unclaimed_amount,不等 → 失败并提示两侧金额差额 + (不等说明金蝶侧有人认领过或金额有变,需人工核对后作废/补认领) +④ awrp_src_code:按分摊结算单号下划线拼接生成并落库(分摊不变则重试编码不变) +⑤ 组装 A301-3:billid=流水.bill_id,**单条分录**: + e_receivableamt = 全部未推送金额(累计有效认领); + seq / e_corebillno = 第一张结算单id(分摊按结算单id升序,多张取最小) +⑥ 调 pushClaimResult: + 成功 → push_status=pushed、push_fail_reason 清空、pushed_time=now、 + kingdee_bill_no=响应 number(**A301-3 成功响应 number 为空,取回执 keys.awrp_src_code 即来源编码占位**)、 + kingdee_bill_status=pushed; + 级联刷新该流水名下全部未作废认领主单的 kingdee_bill_no/kingdee_bill_status + 失败 → push_status=failed、push_fail_reason=金蝶错误文本汇总(认领数据不回滚) +⑦ 推送动作留痕(blade_receipt_flow_record,operation=push) +``` + +**事务边界说明**:BFI HTTP 调用在事务内,流水行锁覆盖整个推送过程——天然防「定时任务与手动按钮并发双推」(后到者会看到 `push_status=pushed` 或等锁后走静态闸门被拦)。锁粒度为单流水行锁,持锁时长为一次接口调用,可接受。 + +### 4.3 定时任务 ReceiptClaimPushTask + +沿用 `PaymentKingdeeResultTask` 编排模式(task 类只做开关与调度,逻辑收敛在服务方法,返回 summary): + +```java +@Value("${thirdParty.bfi.receiptClaimPush.syncEnabled:false}") // 默认关闭,联调稳定后开启 +@Scheduled(cron = "${thirdParty.bfi.receiptClaimPush.cron:0 */10 * * * ?}") +``` + +扫描对象:`bill_id` 非空 AND `push_status IN (none, failed)` AND `claim_status = claimed`(缓存口径预筛缩小集合)。 + +**A301-2 重查的认领人问题**:A301-2 的 `claimusers` 必填且为单个用户姓名(`BfiClaimAnnounceQueryDTO` L50-52),定时任务没有登录用户上下文。处理: + +1. 取候选流水的有效认领主单 `claimer_name` 去重集合; +2. **按认领人串行处理**:先调 `BfiUtil.queryAllPages(queryClaimAnnounce)` 查该认领人的公告(时间窗沿用 `thirdParty.bfi.claimAnnounce.months`,默认 6 个月),推完其名下全部流水后再处理下一个认领人; +3. 该认领人名下流水按公告 `id == 流水.bill_id` 匹配刷新,再走 §4.2 ③-⑦; +4. 单流水推送失败 try/catch 独立消化,不影响同批其他流水;同一流水被多个认领人认领时只在首个覆盖它的认领人处推送一次。 + +> 联调确认项:`claimusers` 若支持逗号分隔多人,可合并为一次查询(见 §8)。 + +### 4.4 手动推送按钮(收款流水页) + +- 行按钮「推送金蝶」:`POST /receipt-flow/push-kingdee`(body: flowId),按钮权限 `receipt_flow_push`。 +- 前端可用条件(展示层控制,后端硬校验为准):`claim_status=claimed` 且 `push_status != pushed` 且 `bill_id` 非空。 +- 后端走 §4.2 同一入口,用当前用户姓名重查公告;若当前用户不在公告认领范围内(查不到),提示「当前用户不在该公告认领范围内,请由认领人操作或重新同步后重试」。 +- 列表新增「推送状态」「推送失败原因」列;详情弹窗展示推送留痕。 +- 本期不做批量推送按钮(定时任务已覆盖批量场景)。 + +### 4.5 A301-1 回写改造(POST /receipt-claim-record/query-kingdee-result) + +`ReceiptClaimRecordServiceImpl.refreshKingdeeResult()` 轮询对象由认领主单改为**流水**: + +- 扫描:`push_status = pushed` 且 `kingdee_bill_status IN (pushed, approved)` 的流水; +- 匹配:金蝶收款单 `sourcebillnumber == 流水.awrp_src_code`(一流水一码,无歧义); +- 回写:`flow.kingdee_bill_status` 按状态映射推进(A/B→pushed、C→approved、D→received、其他→failed);`kingdee_bill_no` 以金蝶真实收款单号**覆盖**推送时的来源编码占位,并级联刷新名下未作废认领主单的冗余字段; +- 定时接入复用现有任务风格(`thirdParty.bfi.receiptClaimResult.*`,本期仍可手动触发)。 + +### 4.6 作废(不变 + 两点明确) + +`voidClaim()` 现有逻辑保留: + +1. **累计认领不考虑作废**:作废行 `status=0`,分摊聚合天然排除——与「累计认领不考虑作废的」要求一致,无需改代码;作废后重算流水 `claimed_amount/claim_status`(口径按 §3.2)。 +2. **已推送的认领不允许作废**:流水 `push_status=pushed` 时作废直接拦截(后端硬校验 + 前端按钮隐藏),金蝶侧冲销/调整由人工处置;作废仅对未推送流水生效,永不触发推送、不生成冲单(金蝶单号/状态保留原值)。终态闸门继续兜底:即使数据漂移使等式再次满足,`push_status=pushed` 也阻止二次推送。 + +### 4.7 重新同步(A301-2 sync 补一处) + +同步 upsert 刷新 `unclaimed_amount` 后,**对值发生变化的流水重算 `claim_status`**(对照分摊聚合),保证公告漂移后认领状态不失真。 + +## 5. 边界场景与防重汇总 + +| 场景 | 行为 | +| --- | --- | +| 定时任务与手动按钮并发推同一流水 | 流水行锁串行化 + `push_status != pushed` 静态闸门,不会双推 | +| 推送超时但金蝶实际已生成收款单 | 本地记 failed;重试时分摊不变则 `awrp_src_code` 不变,金蝶候选键幂等,不产生重复收款单 | +| 推送成功后作废一笔认领、再补认领到等式满足 | 终态闸门拦截,不二次推送;金蝶侧冲销人工处置 | +| 公告侧 unclaimed_amount 变小(他人在金蝶认领) | 推送前实时对账发现差额 → 拦截并提示差额,人工作废多余本地认领后重推 | +| 公告侧 unclaimed_amount 变大 | 用户补认领到等式满足后推送(认领上限随新值放开) | +| unclaimamount = 0 的公告 | 认领上限为 0,不可认领;累计认领 > 0 的推送前置条件天然排除空推 | +| 手动推送者不在公告认领范围 | A301-2 按其姓名查不到公告 → 拦截提示,改由认领人操作 | + +防重键沿用旧文档 §6.3,变更点:`awrp_src_code` 由「认领主单、每次认领各自拼接」改为「流水、按该流水全部分摊结算单号拼接、仅推送一次」,A301-1 匹配键随之移到流水上。 + +## 6. 前端改动汇总(tms-erp-web) + +| 页面 | 改动 | +| --- | --- | +| `receipt-flow.vue` | ① 认领按钮可用条件:`剩余可认领 = unclaimed_amount − 累计认领 > 0`(原按 receipt_amount);② 新增「推送金蝶」行按钮(条件 §4.4);③ 列表加「推送状态/推送失败原因」列;④ 详情展示推送留痕 | +| `receipt-flow-form.vue` | 分摊上限与提示文案改为公告未认领金额口径 | +| `receipt-claim-record.vue` | 不变(金蝶状态列继续展示冗余字段;「查询金蝶认领结果」按钮保留) | +| `api/payment/receiptFlow.js` | 新增 `pushKingdee` | + +## 7. SQL 变更 + +新增幂等脚本 `doc/sql/transport/blade_receipt_flow_push_20260928.sql`,沿用仓库惯例(`information_schema` 探测 + `PREPARE/EXECUTE` 动态 DDL),**支持重复执行**:0924 脚本执行过与否均可跑,任意顺序、任意次数,已存在的列/索引自动跳过、已不存在的列/索引自动跳过删除。 + +| # | 对象 | 变更 | 说明 | +| --- | --- | --- | --- | +| 1-6 | `blade_kingdee_receipt_flow` | ADD COLUMN `push_status`(默认 none)、`push_fail_reason`、`pushed_time`、`awrp_src_code`、`kingdee_bill_no`、`kingdee_bill_status`(默认 none) | 推送域字段上收至流水 | +| 7-8 | `blade_receipt_claim` | DROP KEY `idx_receipt_claim_src_code`、DROP COLUMN `awrp_src_code` | 推送编码上移至流水;0924 未执行过则本就无此列,探测后自动跳过 | +| 9-10 | `blade_kingdee_receipt_flow` | ADD KEY `idx_kingdee_receipt_flow_push_scan` (tenant_id, push_status, claim_status)、`idx_kingdee_receipt_flow_src_code` (tenant_id, awrp_src_code) | 定时任务扫描 + A301-1 回写匹配 | +| 11 | `blade_menu` | INSERT 按钮权限 `receipt_flow_push`(父级 2090000000000001260 收款流水,id 2090000000000001264,ON DUPLICATE 幂等) | 手动推送按钮 | + +0924 脚本(`blade_receipt_flow_claim_20260924.sql`)保持原样不动,两个脚本无执行顺序依赖。上线顺序:先执行 0924(若未执行)→ 执行 0928 → 启动服务。 + +## 8. 待联调确认项 + +| # | 事项 | +| --- | --- | +| 1 | `claimusers` 是否支持逗号分隔多人(支持则定时任务合并为一次 A301-2 查询) | +| 2 | A301-3 字段沿用旧文档 §7 待确认清单(recpaytype_number / paymenttype / recpayer / e_corebilltype / e_corebillno) | +| 3 | `unclaimamount` 是否已扣手续费(fee)口径——本方案等式直接对照 `unclaimamount` 本身,无论是否含费均自洽,但需确认推送后金蝶侧 `unclaimamount` 归零的时效,用于判断重查窗口 | +| 4 | 同一公告在金蝶侧被人工认领部分金额后,A301-2 返回的 `unclaimamount` 更新时效(影响实时对账的准确性预期) | + +## 9. 与旧文档的差异对照 + +| 环节 | 旧(2026-09-24) | 新(本设计) | +| --- | --- | --- | +| 认领事务 | 落库后同事务立即推 A301-3,失败整体回滚 | 纯本地落库,不调 BFI | +| 推送触发 | 每次认领动作 | 定时任务(`receiptClaimPush.*` 开关)+ 手动按钮,双入口同一服务方法 | +| 推送条件 | 无(认领即推) | 累计有效认领 == 公告未认领金额(推送前重查 A301-2 实时对账) | +| 推送粒度 | 每次认领一张金蝶收款单 | 一条流水一张金蝶收款单(单条分录,金额=全部未推送金额) | +| awrp_src_code | 认领主单上,每次认领各自按结算单号拼接(多流水分摊到同一批结算单时撞码) | 流水上,按该流水全部分摊结算单号拼接,一条流水仅推送一次,不再撞码 | +| 认领上限/认领完成口径 | receipt_amount | unclaimed_amount | +| 推送状态/失败原因 | 无(失败即回滚无痕) | 流水上 push_status / push_fail_reason / pushed_time,失败可重试可追溯 | +| A301-1 轮询对象 | 认领主单 | 流水(级联刷新认领主单冗余字段) | +| 二次推送 | 可能(作废重认领再推) | 禁止(终态闸门) | + +## 10. 代码改动索引(已实施,2026-09-28) + +| 环节 | 位置 | 改动 | +| --- | --- | --- | +| 认领 | `ReceiptFlowServiceImpl.claim()` | 上限口径改公告未认领金额(缺失/为0拦截);删除同步推送调用,事务内只落本地 | +| 推送 | `IReceiptFlowService.pushClaimToKingdee(flowId)` / `pushClaimByTask()` | 新增。共用 `pushClaimLocked`(`TransactionTemplate` 编程式事务,流水行锁覆盖闸门→对账→推送→落库,防并发双推);失败不抛异常跨事务边界,以 `ReceiptClaimPushResult` 返回 | +| 定时任务 | `task/ReceiptClaimPushTask.java`(新增) | Nacos 开关 `thirdParty.bfi.receiptClaimPush.syncEnabled/cron`(默认关闭、每10分钟);候选=认领完成且 push_status ∈ (none, failed),按认领人姓名去重逐人重查 A301-2 | +| 推送报文 | `buildClaimPushDTO` | billid=流水.bill_id;**单条分录**:e_receivableamt=全部未推送金额(累计有效认领),seq/e_corebillno=第一张结算单id;awrp_src_code=分摊结算单号下划线拼接 | +| 手动接口 | `ReceiptFlowController.pushKingdee`(`POST /receipt-flow/push-kingdee`) | 成功返回金蝶收款单号,失败 R.fail(原因);按钮权限 `receipt_flow_push` | +| 回写 | `ReceiptClaimRecordServiceImpl.refreshKingdeeResult()` | 轮询对象改流水(push_status=pushed 且回写状态 pushed/approved),按流水 awrp_src_code 匹配,级联刷新认领主单冗余字段 | +| 作废 | `ReceiptClaimRecordServiceImpl.voidClaim()` | claim_status 重算口径改对照公告未认领金额;**不再生成本地 KDCX 冲单号**(金蝶单号/状态保留原值);**已推送流水(push_status=pushed)作废拦截** | +| 同步 | `ReceiptFlowServiceImpl.sync()` + 共享 `queryClaimAnnounces` | unclaimed_amount 刷新后按新口径重算 claim_status;公告查询方法供 sync/手动推送/定时任务共用 | +| 常量 | `KingdeeBfiConstant` | 新增 RECEIPT_CLAIM_PUSH_NONE/PUSHED/FAILED、RECEIPT_CLAIM_BILL_NONE | +| 实体/VO | `KingdeeReceiptFlow`(推送域6字段)、`ReceiptClaim`(删 awrpSrcCode)、`ReceiptClaimMapper.xml`(删列)、`ReceiptFlowVO/Wrapper`(pushStatusName/kingdeeBillStatusName、剩余可认领改公告口径)、`ReceiptClaimPushResult`(新增 DTO) | — | +| 详情留痕 | `ReceiptFlowServiceImpl.detail()` | claimRecords 含 claim + push 两类动作记录 | +| 批量化 | `FormalSettlementServiceImpl.refreshPaymentSummaries(Collection)`(新增)+ `calculatePaymentSummary` 拆纯计算重载 | 固定数次批量查询完成 N 张结算单收款汇总重算(关系/付款申请/申请状态/预收来源/认领分摊各一次 IN 查询 + updateBatchById);单笔 `refreshPaymentSummary` 与 `refreshPaymentSummariesForPreSettlement` 复用同一口径 | +| 批量化 | `claim()` / `voidClaim()` / `settlementCandidates()` / `sync()` | ① 结算单锁行改一次 `IN ... ORDER BY id FOR UPDATE`(与逐单加锁顺序一致防死锁);② 结算单累计认领额一次聚合查询(候选单列表页原先每页最多 200 次查询);③ 作废分摊行改单条 UPDATE(实体携带 status 保留审计字段填充);④ 同步留痕改 `Db.saveBatch` 批量插入 | +| 前端 | `receipt-flow.vue`(推送金蝶按钮+canPush、推送状态/失败原因/金蝶单号/金蝶状态列、详情推送字段)、`receipt-flow-form.vue`(剩余可认领金额字段、上限口径)、`receipt-flow-batch-form.vue`(批量口径)、`option/payment/receiptFlow.js`(新列)、`api/payment/receiptFlow.js`(pushKingdee) | — | diff --git a/doc/docs/业务流程/2026-09-28-收款认领流程(业务版).md b/doc/docs/业务流程/2026-09-28-收款认领流程(业务版).md new file mode 100644 index 00000000..0c51b815 --- /dev/null +++ b/doc/docs/业务流程/2026-09-28-收款认领流程(业务版).md @@ -0,0 +1,110 @@ +# 收款认领流程(业务版) + +> 业务域:图06「收款查询及认领(回款认领)」的 TMS 二次认领环节 +> 核心规则:**认领只在本系统登记,不同步金蝶;一笔流水认领完成后,才推送金蝶生成收款单——不认领完就不推。** +> 最后更新:2026-09-28 + +--- + +## 一、认领的对象:收款流水从哪来 + +金蝶司库收到银行收款流水后,先自动认领到客户维度,剩余未落到合同/结算单的部分形成**未认领金额**。业务人员在 TMS「收款流水」页面点「手动同步流水」,把这些公告同步成本系统的收款流水——每条流水带一个**公告未认领金额**,这就是后续认领的额度基准(重复同步不会产生重复流水,金额有变化会刷新)。 + +--- + +## 二、认领操作流程 + +**入口**:收款流水列表/详情的「认领」按钮——流水还有剩余可认领额度(公告未认领金额 − 累计认领)时可操作。 + +**操作步骤**: + +| 步骤 | 操作 | 规则 | +| --- | --- | --- | +| 1. 选结算单 | 选择一张或多张应收正式结算单 | 须审批通过、未作废;多张须同一项目、同一组织、同一收付款方 | +| 2. 录分摊金额 | 逐张结算单录入本次认领的收款金额 | ① 单张结算单:累计被认领金额 ≤ 其结算金额;② 整条流水:累计认领金额 ≤ 公告未认领金额(只认领金蝶侧缺口) | +| 3. 确认 | 提交认领 | 生成一条认领记录(含分摊明细),刷新流水认领进度 | + +**关键规则**: + +1. 确认后认领只在本系统登记,**不与金蝶发生任何交互**。 +2. 一条流水**允许多次认领**(部分认领):今天认 40 万给 A 结算单,明天再认 60 万给 B 结算单,直到累计认领等于公告未认领金额。 + +--- + +## 三、认领状态 + +### 流水认领状态(认领进度) + +| 状态 | 含义 | +| --- | --- | +| 未认领 | 还没有任何有效认领 | +| 部分认领 | 累计认领 < 公告未认领金额 | +| **认领完成** | 累计认领 = 公告未认领金额(正好补齐金蝶侧缺口) | + +- 累计认领金额 = 该流水下所有**有效**认领分摊之和,**作废的认领不计入**; +- 剩余可认领金额 = 公告未认领金额 − 累计认领金额。 + +### 认领记录状态 + +| 状态 | 含义 | +| --- | --- | +| 已认领 | 有效认领,金额计入流水累计 | +| 已作废 | 不再计入累计,流水认领进度相应回退 | + +--- + +## 四、认领完成后的推送(认领流程的出口) + +**推送条件(闸门)**: + +1. 流水**认领完成**——累计认领 = 公告未认领金额,**不认领完就不推**; +2. 该流水**尚未推送过**——已推送的流水不允许二次推送。 + +**推送方式(两个入口,同一套规则)**: + +- 定时任务自动推送:系统定期扫描满足条件的流水自动推; +- 手动推送:流水页面「推送金蝶」按钮,条件不满足时置灰。 + +**推送前对账**:推送前先到金蝶重查该公告的**最新未认领金额**,与本地累计认领核对——一致才推;不一致(如他人在金蝶侧手工认领过)则拦截并提示差额,人工核对作废或补认领后再推。 + +**推送结果**: + +| 结果 | 表现 | +| --- | --- | +| 成功 | 金蝶生成**一张收款单**(一笔流水一张单,多张结算单是该单下的多条分摊明细);流水推送状态置"已推送",回填金蝶收款单号 | +| 失败 | 推送状态置"推送失败"并记录失败原因;**本地认领保留不回滚**;排除原因后可重新推送,重试不会在金蝶产生重复单据 | + +### 流水推送状态 + +| 状态 | 含义 | 后续 | +| --- | --- | --- | +| 未推送 | 未满足推送条件或尚未轮到推送 | 继续认领 / 等待定时任务或手动推送 | +| 已推送 | 金蝶已生成收款单 | 等待金蝶处理结果回写(审核/收款状态由回写推进);不再接受二次推送 | +| 推送失败 | 上次推送被金蝶拒绝或异常 | 按失败原因整改后重新推送 | + +> 认领记录页可随时手动「查询金蝶认领结果」,回写金蝶收款单号与处理状态(已推送 → 审核通过 → 已收款)。 + +--- + +## 五、作废认领 + +1. 仅认领人本人可作废自己的认领记录;作废后该笔分摊不再计入累计,流水认领状态相应回退(认领完成 → 部分认领/未认领)。 +2. **已推送金蝶的认领不允许作废**(按钮不显示,后端拦截)——金蝶侧收款单已生成,冲销/调整由人工在金蝶处置。 +3. **作废永远不会触发推送**:作废仅对未推送流水生效,纯本地生效,无金蝶影响;补认领到认领完成后仍可正常推送。 + +--- + +## 六、典型场景 + +1. **一次认领完成**:未认领金额 100 万,一次认领到 2 张结算单(60 万 + 40 万)→ 认领完成 → 推送,金蝶生成一张 100 万收款单。 +2. **分两次认领完成**:先认 40 万(部分认领,不推金蝶),再认 60 万补齐 → 认领完成 → 推送,仍是一张收款单含两笔分摊。 +3. **金额不一致被拦截**:本地累计 100 万,推送前金蝶重查最新未认领只剩 80 万 → 拦截提示差额 20 万 → 作废多余认领后再推。 +4. **推送失败重试**:因结算单尚未同步金蝶应收被拒 → 记录失败原因 → 补齐前置后重推,不产生重复收款单。 + +--- + +## 七、注意事项 + +1. **结算单前提**:认领的结算单须已完成金蝶应收侧同步(有金蝶应收单号),推送时金蝶才能按应收单核销。 +2. **认领人前提**:金蝶公告的认领人姓名须与 TMS 用户真实姓名一致,否则同步不到流水;推送前对账也要求操作者在公告认领范围内(定时任务按各认领人自动查询,不受单个人限制)。 +3. **两个口径不要混**:「公告未认领金额」是金蝶侧待认领缺口(额度与认领完成的判定基准);「累计认领金额」是本系统登记的有效认领合计。认领完成 = 两者相等。 diff --git a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md new file mode 100644 index 00000000..130d952c --- /dev/null +++ b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md @@ -0,0 +1,169 @@ +# 预付款申请(A103-3)金蝶链路补齐方案:e_bebank_number 统一、A103-2 消失解锁、回写默认开启 + +> 日期:2026-09-29 +> 关联文档: +> - `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(图03 财务应付及付款,步骤4 推送金蝶 / 步骤13 异常回收) +> - `doc/adr/0005-B901事件驱动双表锁单模型.md`(UNLOCK 置失败态不自动重推;A103-2 连续两轮查询消失兜底复用 `unlockBill()`) +> - `doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义) +> - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` +> - `doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md` + +## 1. 背景与范围 + +对照方案文档图03 对 A103-3 预付款路径做金蝶侧缺口盘点,本方案补齐三项(合同合规系统相关事项另期处理): + +| # | 缺口 | 原状 | 本方案 | +|---|---|---|---| +| 1 | A103-3 分录 `e_bebank_number`(往来银行.编码)取 `bankName`(银行名称字段) | 与 A103-1 取客商收款账户联行号的口径不一致,疑似取错字段 | 统一取联行号,未维护即拦截 | +| 2 | 金蝶侧反审核删除付款申请单后,TMS 无感知、无恢复路径 | A103-2 查不到仅 `continue`,本地停留 synced/paying;状态门禁禁止重推 → 单据永久卡死 | 连续两轮查询消失 → `unlockBill()` 解锁置失败,交人工重推 | +| 3 | 定时回写任务默认关闭 | 代码 `@Value` 默认 false(注释却写"默认开启"),三套 Nacos 配置默认 false,上线必须手工开启 | 代码与 Nacos 默认均改为开启,注释纠正 | + +不改动范围:A103-3 推送报文其余字段、三层幂等门禁、A902 附件链路、A103-4 付款记录回写、履约数据联动(均已就绪)。 + +## 2. 改造一:A103-3 `e_bebank_number` 与 A103-1 统一口径 + +### 2.1 变更内容 + +`PaymentApplicationServiceImpl#pushBfiPayApply`: + +| 项 | 变更前 | 变更后 | +|---|---|---| +| 取值 | `entity.getBankName()`(付款申请"收款银行名称"文本) | `bfiBebankNumber(entity, context)` | +| 实际含义 | 银行名称(非编码,金蝶档案无法匹配) | 付款申请所选客商收款账户(`blade_customer_receipt_account`)**联行号 `cnapsCode`** | +| 缺失行为 | 不校验,直接传名称 | 收款账户不存在 / 未维护联行号 → `ServiceException` 拦截推送(与 A103-1 同口径) | + +```java +// 往来银行.编码:与 A103-1 统一口径,取付款申请所选客商收款账户联行号(未维护即拦截) +entry.setEBebankNumber(this.bfiBebankNumber(entity, context)); +``` + +### 2.2 取值链路(与 A103-1 完全一致) + +1. 付款申请选择了收款账户(`receiptAccountId`)→ 按账户 id 直查,批量同步经 `KingdeeSyncContext.receiptAccountCache()` 复用; +2. 未选账户 → 按合同乙方客商 + 收款账号兜底匹配(默认账户优先); +3. `cnapsCode` 为空 → 拦截:"收款账户未维护联行号,请先在客商档案维护收款信息联行号"。 + +### 2.3 影响 + +- **新增推送前置条件**:预付款推送前必须维护收款账户联行号。存量已 synced 单据不受影响(状态门禁不会重推);failed 待重推单据需先补联行号。 +- 解锁重推(改造二)产生的"新单"同样走该口径,与金蝶侧往来银行档案编码匹配。 + +## 3. 改造二:A103-2 消失解锁兜底(ADR-0005) + +### 3.1 业务问题 + +图03 异常回收分支:财务共享驳回后,金蝶侧对该付款申请单**反审核 + 删除**,TMS 需解锁并允许重新推送。此前 TMS 只有正向回写:A103-2 查不到该单仅跳过,本地停留 synced/paying,且 `doSyncKingdee` 状态门禁禁止 synced/paying 重推——单据卡死,只能改库解救。 + +B901 UNLOCK 事件通知属合同系统对接域,本期不建设;按 ADR-0005 约定,A103-2 轮询兜底与 B901 共用同一 `unlockBill()` 方法,B901 后续接入时零改动复用。 + +### 3.2 判定规则:"连续两轮查询消失" + +| 要素 | 规则 | +|---|---| +| 判定对象 | 本轮 A103-2 批量查询中未查得的、本地状态为 **synced / paying** 的单据 | +| 健康前提 | 同批次其余付款申请号正常返回(A103-2 服务整体故障时整轮抛异常,不做消失判定) | +| 排除对象 | failed(金蝶查不到属正常态,等待人工重推)、终态 paid/closed(不参与轮询)、paymentNo 为空(防御) | +| 计数存储 | Redis `transport:payment-application:kingdee-pay-miss:{付款申请id}`,每轮未查得 `INCR`,TTL 2100s(覆盖 3 个 10 分钟回写周期;任务中断超时后计数自然过期,需重新累计,方向保守) | +| 查得即清零 | A103-2 查得该单 → `DEL` 计数键 | +| 解锁阈值 | 连续计数 ≥ 2 轮 → 调用 `unlockBill(id, "A103-2 连续N轮查询消失")` | +| Redis 异常 | 放弃本轮判定(解锁会清空金蝶单号,属破坏性动作,宁可延迟不可误判) | + +### 3.3 `unlockBill()` 语义(ADR-0005,接口方法,B901 可直接复用) + +前置:仅 synced / paying 生效;failed 与终态单据自动跳过。 + +动作(单据级事务,条件更新防并发): + +1. 清除消失计数键; +2. `kingdee_status`:synced/paying → **failed(生成失败)**; +3. `kingdee_bill_id`、`kingdee_bill_no` **清空**(原值 warn 日志留档;金蝶侧单据已删除,原内码对 A902 附件、A103-4 勾稽均已失效); +4. **不自动重推**——金蝶反审核删除大概率伴随驳回意见,原样重推等于无视驳回(ADR-0005),置 failed 回人工。 + +### 3.4 重推闭环与误判安全网 + +解锁后单据进入 failed,与"推送异常"恢复路径完全合并: + +```mermaid +sequenceDiagram + participant T as TMS 定时回写(A103-2) + participant R as Redis 计数 + participant K as 金蝶(星瀚) + participant U as 人工 + + T->>K: A103-2 按 paymentNo 批量查询 + K-->>T: 某单未查得(同批其余正常) + T->>R: INCR 消失计数(第1轮,仅告警) + T->>K: 下一轮再查仍未见 + T->>R: INCR 计数 ≥ 2 + T->>T: unlockBill():status=failed、清单号/内码(日志留档) + U->>U: 核实金蝶确已删除(驳回原因) + U->>T: 「同步金蝶」重新推送 + T->>K: A103-2 勾稽预检(failed 恢复场景) + alt 金蝶侧单据实际存在(误判) + K-->>T: 命中 → 回填单据id/编号,跳过推送(不产生重复单据) + else 确已删除 + K-->>T: 未命中 → A103-3 重新保存,生成新单 + T->>K: A902 附件上传到新单 + end +``` + +要点:**即使误判解锁,重推链的 A103-2 勾稽预检也能兜底**——预付款路径 failed 恢复场景必经 `reconcileExistPayApply`,正式路径推送前统一预检,命中即回填跳过,不会产生重复单据。 + +### 3.5 涉及代码 + +| 位置 | 内容 | +|---|---| +| `IPaymentApplicationService#unlockBill(Long id, String source)` | 新增接口方法(B901 接入时复用) | +| `PaymentApplicationServiceImpl#unlockBill` | 解锁实现(事务 + 条件更新) | +| `PaymentApplicationServiceImpl#handlePayApplyMissed` | 消失轮次判定(回写主流程接入) | +| `executePaymentResultSync` ① 循环 | `payApply == null` → `handlePayApplyMissed`;查得 → `clearPayApplyMissKey` | + +## 4. 改造三:定时回写默认开启 + +| 位置 | 变更 | +|---|---| +| `PaymentKingdeeResultTask` | 注释纠正为"代码默认关闭(安全兜底),由 Nacos 按环境显式开启"(`@Value` 兜底默认保持 `false`) | +| `doc/nacos/blade-dev.yaml` / `blade-prod.yaml` / `third-party-api.yaml` | `syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true}`——**环境配置默认开启**,未部署 Nacos 配置时代码兜底关闭 | + +- 调度频率不变:`0 */10 * * * ?`(每 10 分钟)。 +- 环境变量 `BFI_PAY_RESULT_SYNC_ENABLED=false` 仍可显式关闭(灰度/演练用)。 +- 消失解锁依赖该任务运行,开关关闭期间兜底判定同步停摆(预期行为)。 + +## 5. 状态机全景(变更后) + +``` +unsynced ─A103-1/A103-3─▶ synced ─Inpayment─▶ paying ─Alreadypay─▶ paid(终) + │ ▲ │ + │ └─ A103-2 勾稽纠偏 ──┘ + ├─ 连续两轮查询消失 / B901 UNLOCK ─▶ failed ─人工重推─▶ synced +推送异常 ─────────────────────────────────────────────────────▶ failed +billstatus=D ──────────────────────────────────────────────────▶ closed(终) +``` + +- 解锁后 `kingdee_bill_id/no` 清空 → A103-2/A103-4 后续轮询按 paymentNo 重新勾稷新单; +- failed 纳入轮询的既有纠偏逻辑与解锁路径互不冲突(failed 查不到不计数、查到回填 synced)。 + +## 6. 配置项汇总 + +| 配置 | 默认 | 说明 | +|---|---|---| +| `thirdParty.bfi.payResult.syncEnabled` | Nacos 配置 `true`;代码兜底 `false` | 回写任务总开关,关闭则状态回写与消失解锁同时停摆 | +| `thirdParty.bfi.payResult.cron` | `0 */10 * * * ?` | 回写周期,同时决定消失解锁的轮次间隔 | +| `thirdParty.bfi.payResult.lockLeaseSeconds` | `1800` | 回写 single-flight 锁租约 | +| Redis 键 `transport:payment-application:kingdee-pay-miss:{id}` | TTL 2100s | 消失轮次计数,无配置项(常量) | + +## 7. 验证要点 + +1. **联行号拦截**:收款账户未维护联行号的预付款点击"同步金蝶" → 报"收款账户未维护联行号"且不产生金蝶单据;维护后重推成功,A103-2 查回的 `e_bebank_number` 为联行号。 +2. **消失解锁**:推送成功(synced)→ 金蝶侧反审核并删除该付款申请单 → 等待 ≥2 个回写周期 → 日志出现"第1轮/连续2轮",单据变 failed、`kingdee_bill_id/no` 清空。 +3. **误判回填**:解锁后金蝶侧单据实际存在(构造 A103-2 两次查询异常)→ 人工重推 → 勾稽命中回填,金蝶侧不出现重复单据。 +4. **开关回归**:`BFI_PAY_RESULT_SYNC_ENABLED=false` 环境变量能覆盖默认值关闭任务。 + +## 8. 遗留事项(不在本方案范围) + +| # | 事项 | 状态 | +|---|---|---| +| 1 | B901 UNLOCK 回调接入(解锁延迟从 ≤2 个回写周期缩短为事件即时) | 待建设,复用 `unlockBill()` 零改动 | +| 2 | closed 终态(金蝶驳回/关闭)票据额度释放与待处理提醒 | 未实现 | +| 3 | A103-3 预付款不传 `awrpSrcConNo`(合同号)是否符合金蝶勾稽要求 | 待联调确认 | +| 4 | A103-2 查询未传 modifytime 时间窗(接口约束 ≤1 个月)是否放行 | 待联调确认 | diff --git a/doc/docs/业务流程/2026-09-30-金蝶(星瀚)BFI定时任务逻辑全梳理(付款回写-应收勾稽-开票回写-收票回写-客商同步-收款认领推送).md b/doc/docs/业务流程/2026-09-30-金蝶(星瀚)BFI定时任务逻辑全梳理(付款回写-应收勾稽-开票回写-收票回写-客商同步-收款认领推送).md new file mode 100644 index 00000000..f3045260 --- /dev/null +++ b/doc/docs/业务流程/2026-09-30-金蝶(星瀚)BFI定时任务逻辑全梳理(付款回写-应收勾稽-开票回写-收票回写-客商同步-收款认领推送).md @@ -0,0 +1,209 @@ +# 金蝶(星瀚)BFI 定时任务逻辑全梳理(付款回写 + 应收勾稽 + 开票回写 + 收票回写 + 客商同步 + 收款认领推送) + +> 整理日期:2026-09-30 +> 依据:当前代码(git 基线 `2e91e11` 收款认领本地化提交),代码位置 `blade-service/blade-transport/src/main/java/org/springblade/transport/task/` 与各服务实现。 +> 关联文档:`2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md`(总纲)、`2026-09-27-blade-transport定时任务全梳理(金蝶客商同步-金蝶结果回写-收票回写).md`(上一版,**未收录收款认领推送任务,行号已过时,以本文为准**)。 +> 接口名称口径与总纲一致:编号后标注金蝶侧接口名称。 + +--- + +## 1. 任务总览 + +task 目录共 **3 个任务类、4 个调度方法**,覆盖 6 条金蝶(星瀚)链路的回写/同步/推送。调度由 `TransportApplication` 的 `@EnableScheduling` 开启;每个方法 = Nacos 开关 + cron 表达式,业务逻辑全部收敛在服务层方法(任务类只做开关判断、异常捕获与中文摘要日志),定时与手动端点共用同一服务方法。 + +| 调度方法 | 开关(thirdParty.bfi.*,代码默认) | 默认 cron | 服务方法(代码位置) | 覆盖接口 | 手动端点 | +|---|---|---|---|---|---| +| `PaymentKingdeeResultTask.syncKingdeePaymentResult` | `payResult.syncEnabled`(false) | `0 */10 * * * ?` | `PaymentApplicationServiceImpl.syncKingdeePaymentResult`(:644,编排 3 个子流程) | ① A103-2 付款申请单查询 + A103-4 付款处理查询 ② A302-4 财务应收单查询 勾稽 ③ A303-5 开票发票查询 | `POST /payment-application/sync-kingdee-result`、`POST /formal-settlement/sync-kingdee-ar-result`、`POST /invoice-application/sync-kingdee-invoice-result` | +| `PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult` | `receivedInvoiceResult.syncEnabled`(false) | `0 */10 * * * ?` | `InvoiceReceiptServiceImpl.syncKingdeeReceivedInvoiceResult`(:968) | A801-3 收票单查询(回写) | `POST /invoice-receipt/sync-kingdee-result` | +| `CustomerKingdeeSyncTask.syncKingdeeCustomerData` | `customer.syncEnabled`(false) | `0 0 2 * * ?` 每天 02:00 | `CustomerArchiveServiceImpl.syncKingdeeCustomerData`(:637) | A902-1 客户查询 复查 + A902-2 客户保存 推送 | `POST /customer-archive/sync-kingdee` | +| `ReceiptClaimPushTask.pushClaimToKingdee` ★新增 | `receiptClaimPush.syncEnabled`(false) | `0 */10 * * * ?` | `ReceiptFlowServiceImpl.pushClaimByTask`(:714) | A301-3 收款认领结果推送 | `POST /receipt-flow/push-kingdee`(单条手动) | + +> 认领链路另两个接口仍为**纯手动、无定时**:A301-2 收款认领公告查询(`POST /receipt-flow/sync`)、A301-1 收款单查询(`POST /receipt-claim-record/query-kingdee-result`)。旧总纲「收款认领三接口全部手动触发」的说法已被本任务打破:**A301-3 自 `2e91e11` 起为定时 + 手动双入口**。 +> ⚠️ 代码开关默认值均为 `false`;`doc/nacos/third-party-api.yaml` 样例中 `payResult.syncEnabled` 的环境变量兜底值为 `true`(`BFI_PAY_RESULT_SYNC_ENABLED:true`),以 Nacos 实际下发为准。 + +--- + +## 2. 公共机制 + +### 2.1 single-flight 分布式锁(KingdeeSyncLockSupport,★09-27 后新建) + +6 个服务方法入口统一经 `KingdeeSyncLockSupport.runSingleFlight(锁前缀, 租约秒数, 任务名, action)`(`support/KingdeeSyncLockSupport.java:61`): + +- **零等待抢锁**:`tryLock(0, leaseSeconds)`,已有轮次在跑立刻放弃本轮并返回「XX进行中,本轮跳过」摘要(定时下轮幂等重试,手动稍后再试);多副本部署的定时任务与手动触发共用一道闸,同一轮全局最多一份在跑。 +- **锁键 = 前缀 + 租户**:定时任务无登录上下文时按 `default` 租户加锁,锁粒度与多租户数据隔离边界一致。 +- **租约兜底**:租约(各任务默认 1800 秒)须大于最坏一轮耗时(含翻页远程查询),否则租约到期另一副本会并发起跑;进程异常退出时锁到期自动释放。 +- **降级**:`blade.lock.enabled=false` 时不加锁直接执行(多副本各自起跑),打 warn 日志——生产必须开启分布式锁。 + +各任务锁前缀与租约配置键: + +| 任务 | 锁键前缀(transport:…) | 租约配置键(默认 1800) | +|---|---|---| +| 付款结果回写 | `payment-application:kingdee-pay-result-sync:` | `thirdParty.bfi.payResult.lockLeaseSeconds` | +| 应收勾稽 | `formal-settlement:…`(AR_RESULT) | `thirdParty.bfi.arResult.lockLeaseSeconds` | +| 开票结果回写 | `invoice-application:…`(INVOICE_RESULT) | `thirdParty.bfi.invoiceResult.lockLeaseSeconds` | +| 收票结果回写 | `invoice-receipt:…`(RECEIVED_RESULT) | ⚠️ `thirdParty.bfi.receivedInvoice.lockLeaseSeconds`(前缀少「Result」,与其余开关键不同名) | +| 客商同步 | `customer-archive:…`(CUSTOMER_SYNC) | `thirdParty.bfi.customer.lockLeaseSeconds` | +| 收款认领推送 | `receipt-flow:…`(RECEIPT_CLAIM_PUSH) | `thirdParty.bfi.receiptClaimPush.lockLeaseSeconds` | + +### 2.2 批量与时间窗口径(IBfiService 契约) + +- 批量查询统一 **50 键/批**(付款 `PAY_RESULT_QUERY_BATCH=50`、开票 `INVOICE_RESULT_QUERY_BATCH=50`、客商信用代码 `KINGDEE_CUSTOMER_QUERY_BATCH=50`),页大小 200,`BfiUtil.queryAllPages` 自动翻页(页空/`lastPage`/不满页/超 50 页终止)。 +- 查询类接口时间窗:A103-2 付款申请单查询 / A103-4 付款处理查询 / A302-4 财务应收单查询 / A303-4 开票申请单查询 / A301-1 收款单查询 **≤ 1 个月**;A902-1 客户查询 **≤ 6 个月**;A301-2 收款认领公告查询 无接口级窗口(代码按 `thirdParty.bfi.claimAnnounce.months`,默认近 6 个月,逐认领人查询)。 +- 事务边界:任务方法与 single-flight 主体**无事务**;单据级写操作经自代理/`TransactionTemplate` 走**独立事务**(如 `applyPayApplyResult`、`applyPayBillRecord`、`pushClaimLocked`),单条失败只回滚自身并计数,不中断整批。 +- 可观测:每个子流程返回中文执行摘要(扫描数、成功/失败/纠偏/跳过计数),任务类 `log.info` 落盘。 + +--- + +## 3. 任务一:付款结果回写(PaymentKingdeeResultTask.syncKingdeePaymentResult) + +编排 **3 个互相独立的子流程**(各自 try/catch,一个异常不影响其余两个): + +### 3.1 子流程①:A103-2 付款申请单查询 + A103-4 付款处理查询 + +实现:`PaymentApplicationServiceImpl.executePaymentResultSync`(:655)。 + +``` +待回写付款申请(approved + kingdeeStatus IN (synced, paying, failed),paid/closed 终态不再轮询) + → A103-2 按 paymentNo(awrp_src_code)50/批查询,同一来源单号取 modifytime 最新一条 + 命中 ──> 清除消失计数 ──> applyPayApplyResult(独立事务): + 回填 kingdee_bill_id / kingdee_bill_no + 状态映射:paystatus=Alreadypay→paid(终)> billstatus=D→closed(终)> Inpayment→paying > 其余→synced + 已付金额 = 分录 e_paidamt 汇总(分录缺失且 paid 时兜底=申请金额) + paid 联动履约:syncPreSettlementAdvance(预结算预付)+ syncFormalSettlementPayments(实付分摊/结算付款记录) + 未命中 ──> 消失解锁判定(ADR-0005,见下) + → A103-4 付款记录回填: + 候选 = 本轮 A103-2 查得 paystatus≠Unpaid 的单 + 已 paid 但无真实付款记录的单(补偿) + 按付款组织编码分组(org_number 优先取 A103-2 返回 payorg_number,兜底按合同所属组织解析,取不到 warn 跳过) + 每组按最近 1 个月业务日期窗口翻页查询(接口 ≤1 个月约束) + 勾稽:sourcebillid==kingdee_bill_id 优先,sourcebillnumber==kingdee_bill_no 兜底 + 命中 ──> applyPayBillRecord(独立事务):清理 MOCK-PAY- 模拟记录 → upsert blade_payment_application_record + (billno→payment_no、actpayamt→paid_amount、paydate→paid_date、整单 JSON→voucher_json 留档) +``` + +**★ 消失解锁(ADR-0005,`handlePayApplyMissed` :575)**:A103-2 连续未查得「已生成/付款中」单据时,按 Redis 计数累计消失轮次(键 `transport:payment-application:kingdee-pay-miss:{id}`,TTL 2100 秒≈覆盖 3 个回写周期;生成失败单据查不到属正常态不计数)。**连续 2 轮**(`PAY_RESULT_MISS_UNLOCK_ROUNDS=2`)未查得即判定金蝶侧已反审核删除,调用 `unlockBill`(:544):`kingdeeStatus synced/paying→failed`,**清空 kingdee_bill_id / kingdee_bill_no**(原值日志留档),不自动重推,等待人工核实后重推;重推时 A103-2 幂等预检兜底,误判场景(金蝶单据实际存在)自动回填不产生重复单据。Redis 异常时放弃本轮判定(解锁是破坏性动作,宁可延迟不可误判)。本轮重新查得或单据解锁时清除计数。 + +### 3.2 子流程②:A302-4 财务应收单查询 勾稽纠偏 + +实现:`FormalSettlementServiceImpl.executeArResultSync`(:690)。 + +- 轮询:approved + settlementType=receivable + kingdeeSyncStatus IN (synced, **failed**)。 +- 逐单查结算明细组装防重键(与 A302-2 财务应收单保存传参一致:`正式结算单号_结算明细单据号…`),同一防重键取首单。 +- `queryArBillExistNos` 批量查询:查到 → 回填缺失的 kingdee_bill_no、状态统一置 synced、记 syncedTime(failed→synced 即生成失败纠偏;synced 且已有单号的跳过);查不到 → 状态不变。 +- 同步成功回写已含 **kingdee_bill_id(星瀚单据内码,A902 附件上传定位用)**。 + +### 3.3 子流程③:A303-5 开票发票查询 结果回写 + +实现:`InvoiceApplicationServiceImpl.executeInvoiceResultSync`(:878)。 + +- 轮询:approved + kingdeeStatus=synced + kingdee_bill_no 非空(invoiced 终态不再轮询;failed 无真实单号,纠偏走重推时的 A303-4 批量预检)。 +- kingdee_bill_no 逗号拼接拆分为逐个 serialNo → 按开票单位反查客商档案取销方税号(取不到跳过并计数)→ 按税号分组 → 每组 ≤50/批调 A303-5 开票发票查询(票易通 openApi)。 +- 发票落库:发票号批量去重(批次内预查已有记录),新增插入 `blade_invoice_application_invoice`(serial_no、invoice_no、invoice_date、invoice_amount、**发票文件地址四字段**:板式文件/预览图/数电 PDF/数电 XML、raw_json);**已有记录缺失文件地址的按本次返回字段级补空**(不覆盖已有值)。 +- 有新增发票 → 申请单推进 invoiced(终态);发票插入、地址补齐、申请单更新三段**分开批量落库**。 + +--- + +## 4. 任务二:收票结果回写(A801-3 收票单查询,PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult) + +实现:`InvoiceReceiptServiceImpl.executeReceivedInvoiceResultSync`(:979)。处理推送链中断/异常的补偿,一轮一体处理三类场景: + +``` +待回写收票登记(kingdeePushStatus IN (pushed, assigned) + 非作废 + 已绑定票池镜像) + → 批量加载 blade_kingdee_invoice_pool 镜像,按镜像购方名称分组(接口必填购方名称) + → pushed 单据批量预组装财务应付单防重键(分摊→结算单→结算明细,一票一应付才可组装) + → 回看窗口 = thirdParty.bfi.receivedInvoice.resultWindowDays(默认 30 天),按购方分组翻页查询 + 匹配键 = 收票登记 id(★已修复:与 A303-2 收票单批量保存 落库的 awrp_src_code 同源, + 旧版按发票号匹配永远查不到自推单据的缺陷已关闭) + 窗口内查不到 ──> 不判删除(金蝶修改时间延迟/滑窗),保持现状由人工重推兜底 + 命中后三分支: + ① 发票状态异常(作废/红冲等) ──> 异常回收:置 failed +「金蝶删除/解除指定后重推」,记同步流水 + ② pushed 且状态正常 ──> 指定补偿:凭预组装防重键重发 A302-6 收票指定应付 → assigned + 防重键缺失(不满足一票一应付)→ 置 failed 报人工 + ③ assigned 但缺金蝶收票单号 ──> 单号补登:仅补 kingdee_received_invoice_no(历史响应丢失数据) + → 统一走 applyReceivedInvoicePushResult 落库(单号/状态/失败原因/推送时间),终态/失败记 blade_invoice_receipt_record 流水 +``` + +--- + +## 5. 任务三:客商数据定时同步(CustomerKingdeeSyncTask.syncKingdeeCustomerData) + +实现:`CustomerArchiveServiceImpl.executeCustomerDataSync`(:648)。一次查出全部审批通过客商(**★原 11.1-#3 硬编码 `.eq(customerCode,"KS000067")` 联调过滤已移除**),内存按金蝶协同状态分三组,单条失败不中断批次: + +``` +审批通过客商(approvalStatus=approved,一次全量查询) + ① 待推送(none/failed)── 批量 A902-1 客户查询 复查(信用代码去重大写、50/批、自动翻页,避免逐条 N+1) + 金蝶已有且已审核(status=C) ──> 直接置 exist(终态),跳过推送 + 金蝶侧暂存/已提交(A/B,审核中) ──> 置 failed「金蝶侧该客商审核中,请稍后重推」 + 金蝶不存在 ──> 逐条 A902-2 客户保存 推送(收款账户按客商 id 批量一次查,组装报文避免 N+1) + 成功 → synced / 失败 → failed + 原因(截断留痕) + ② 已推送复查(synced)── 与③共用同一次批量 A902-1(信用代码合并去重,仅调一次接口) + status=C ──> 升级 exist(终态,A902-2 受理 B 只是中间态) + 仍在审核 ──> 保持 synced 下轮再看(不重推,防金蝶重复建档);查无档案 warn 计数 + ③ 停用联动候选(synced/exist 且本地启用)── 复查结果 enable=0 + ──> TMS 档案联动停用(status=2);金蝶恢复可用不自动启用(单向,人工控制) + → 集中批量落库:updateBatchById 更新档案 + Db.saveBatch 插入 blade_customer_change_record 变更留痕(各自 try/catch) +``` + +--- + +## 6. 任务四:收款认领定时推送(ReceiptClaimPushTask.pushClaimToKingdee)★新增 + +> 背景:`2e91e11` 收款认领本地化改造——页面「认领」动作只在本地登记(`blade_receipt_claim` + 分摊表),**A301-3 收款认领结果推送改为「定时 + 手动」双入口**,按收款流水归集推送。旧方案的「认领事务内同步推送」「SKCL+时间戳防重码」口径均已废弃。 + +实现:`ReceiptFlowServiceImpl.pushClaimByTask`(:714,定时)与 `pushClaimToKingdee`(:685,手动单条)共用推送主体 `doPushClaimToKingdee`(:820)。 + +### 6.1 定时扫描与编排(executeReceiptClaimPush :728) + +``` +候选流水(blade_kingdee_receipt_flow):bill_id 非空(已同步到公告) + + push_status IN (none, failed)(未推送/推送失败,pushed 终态闸门不重推) + + claim_status = claimed(认领完成是唯一可推送状态)+ 有效 + → 按「认领人」分组(blade_receipt_claim.claimer_name,有效认领 claimed),认领人姓名排序串行处理: + 每个认领人一次 A301-2 收款认领公告查询(claimusers=认领人姓名,近 claimAnnounce.months 个月全量翻页) + ——定时任务无登录人上下文,A301-2 的 claimusers 又是单姓名必填,故必须逐人查询 + 公告按公告id(bill_id)归集 → 其名下每条候选流水按 bill_id 匹配: + 公告查询失败 ──> 该认领人名下流水全部跳过(计数),下轮重试 + 未命中公告 ──> 单条跳过(计数) + 命中 ──> pushClaimLocked 推送(成功后加入本轮已推集合,同一流水被多人认领只推一次) + → 摘要:扫描 N 条,成功 X,失败 Y,公告未命中跳过 Z +``` + +### 6.2 单条推送主体(doPushClaimToKingdee,事务 + 行锁) + +- **并发防护**:`pushClaimLocked` 经 `TransactionTemplate` 开事务,流水行 `FOR UPDATE` 行锁覆盖闸门、对账、推送、落库全过程——防定时任务与手动按钮对同一流水双推。 +- **闸门**:`push_status=pushed` 拒绝重复推送(作废重认领使条件再满足也不推,金蝶侧人工处置);公告 id 缺失拦截「请先手动同步金蝶收款流水」。 +- **推送前对账**:以最新公告刷新公告侧未认领金额/公告认领状态/我已认领,并按全部分摊行重算累计认领金额与 `claim_status` 快照落库;**非 claimed(金额与公告未认领金额不一致)拦截不推**。 +- **防重键 ★口径变化**:`awrp_src_code` = 该流水**全部有效分摊行的结算单号去重下划线拼接**(不再是 `SKCL+yyyyMMddHHmmss+尾号`)。分摊不变则重试编码不变,金蝶按 `awrp_src_sys + awrp_src_code` 幂等,防重试双单;失败落库后下轮自动重试复用同一编码。 +- **A301-3 报文(单分录)**:`billid`=公告 id(必填);`recpaytype_number`=常量 `999`(待金蝶确认)、`paymenttype`=`bd_customer`、`recviewpayer`/`recpayer`=流水付款方名称/账号;分录仅一条,`e_receivableamt`=累计认领金额(全部未推送金额),`seq`/`e_corebillno`=第一张结算单 id(`e_corebilltype` 已注释不传)。 +- **结果处理**:全项 `billStatus=true` 才算成功;成功响应 `number` 为空,金蝶收款单号取**回执键值 `keys.awrp_src_code`**。成功 → 流水 `push_status=pushed`、`kingdee_bill_no`、`kingdee_bill_status=pushed`、清失败原因,并级联刷新名下未作废认领主单冗余字段 + 记 `blade_receipt_flow_record` 流水;失败 → `push_status=failed` + 原因(截 1000 字),本地认领数据保留,等待下轮/手动重推。 +- **A301-1 收款单查询 回写**(仍纯手动 `POST /receipt-claim-record/query-kingdee-result`)按 `sourcebillnumber == 流水 awrp_src_code` 匹配回写收款单号与状态(A/B→pushed、C→approved、D→received 终态、G→failed),与上表状态机衔接。 + +--- + +## 7. 配置开关汇总(Nacos thirdParty.bfi.*) + +| 配置键 | 代码默认 | yaml 样例(doc/nacos/third-party-api.yaml) | 说明 | +|---|---|---|---| +| `payResult.syncEnabled` / `payResult.cron` | false / `0 */10 * * * ?` | **true**(`BFI_PAY_RESULT_SYNC_ENABLED`)/ 同左 | 付款结果回写总开关(含应收勾稽、开票回写) | +| `payResult.lockLeaseSeconds` | 1800 | — | single-flight 锁租约 | +| `receivedInvoiceResult.syncEnabled` / `.cron` | false / `0 */10 * * * ?` | — | 收票结果回写 | +| `receivedInvoice.lockLeaseSeconds` / `receivedInvoice.resultWindowDays` | 1800 / 30 | — | ⚠️ 键名无「Result」后缀;回看窗口天数 | +| `customer.syncEnabled` / `customer.cron` | false / `0 0 2 * * ?` | — | 客商同步 | +| `customer.lockLeaseSeconds` | 1800 | — | single-flight 锁租约 | +| `receiptClaimPush.syncEnabled` / `.cron` / `.lockLeaseSeconds` | false / `0 */10 * * * ?` / 1800 | false / 同左 / 1800 | 收款认领定时推送 ★新增 | +| `claimAnnounce.months` | 近 6 个月 | — | A301-2 公告查询窗口(同步/手动推送对账/定时推送对账共用) | + +> 分布式锁依赖 `blade.lock.enabled=true`;未开启时 single-flight 降级为直接执行(生产必须开启)。 + +--- + +## 8. 共性设计与既有关键机制的落地状态 + +1. **single-flight 全覆盖**:6 条定时/手动共用链路全部经分布式锁串行化,杜绝多副本重复回写/重复推送。 +2. **failed 纳入轮询的自愈纠偏**:付款申请(A103-2 查到即纠正 synced)、应收结算单(A302-4 勾稽纠偏)——金蝶侧已生成说明上次推送实际成功,查到即回填,避免人工重推产生重复单据。 +3. **消失解锁兜底**(ADR-0005):金蝶侧反审核删除的单据经「连续 2 轮查询消失」解锁置失败,解决 synced/paying 单据在金蝶被删后 TMS 永久锁死的问题。 +4. **终态不轮询**:paid/closed(付款)、exist(客商)、invoiced(开票)、assigned/failed+窗口外(收票)、pushed(认领流水)均退出扫描集。 +5. **批量防 N+1**:50/批远程查询、页大小 200 自动翻页、收款账户/镜像/分摊/结算明细批量预取、集中批量落库。 +6. **对照 09-27 总纲第 11 节的两个状态变化**: + - 11.2-#1「A801-3 定时回写按发票号匹配」缺陷 **已修复**(现按收票登记 id 匹配,`queryReceivedInvoiceBySrcCodes`); + - 11.1-#3「客商定时同步硬编码 KS000067」**已移除**。 +7. **旧文档过时点**(以本文为准):09-27 定时任务全梳理未收录 `ReceiptClaimPushTask`;「收款认领三接口全部手动」「A301-3 在认领事务内推送、awrp_src_code=SKCL 随机码」的描述已被认领本地化改造取代。 diff --git a/doc/docs/业务流程/2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md b/doc/docs/业务流程/2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md new file mode 100644 index 00000000..5f50817d --- /dev/null +++ b/doc/docs/业务流程/2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md @@ -0,0 +1,177 @@ +# 审批通过自动同步金蝶设计(事件触发 + 定时重推 + 手动兜底) + +> 整理日期:2026-10-05 +> 目标:推送到金蝶的各单据,**审批通过后自动推送,省掉页面手动点「同步金蝶」按钮这一步**;节点间的先后顺序由既有硬预检 + 失败重推消化,全程无需人工点击。 +> 依据:`2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md`、`TMS业财融合-集成流程图文字版与业务流程梳理.md`、当前代码(探索核实)。 + +--- + +## 1. 范围 + +| 业务域 | 推送链路 | 现状触发 | 本设计 | +|---|---|---|---| +| 正式结算单(应付/应收) | A102-4 预检 → A102-2 / A302-2 | 手动按钮 | ✅ 审批通过自动推 | +| 付款申请(正式/预付) | A102-4 硬校验 → A103-2 预检 → A103-1 / A103-3 | 手动按钮(单/批) | ✅ 审批通过自动推 | +| 开票申请 | A302-4 硬校验 → A303-4 预检 → A303-1 逐张次 | 手动按钮 | ✅ 审批通过自动推 | +| 收票登记 | A102-4 预检 + A801-3 预检 → A303-2 → A302-6 | 手动批量按钮 | ✅ 审批通过自动推 | +| 客商档案 | A902-2 | 已是审批通过自动推 | ➖ 不动(先例) | +| 收款认领 | A301-3 | 认领页面动作事务内推送 + ReceiptClaimPushTask 定时 | ➖ 不动(无审批节点) | +| 查询回写类 | A103-2 / A303-5 / A801-3 / A301-1 | 定时/手动 | ➖ 不动(不是推送) | + +## 2. 现状与差距(代码核实结论) + +1. **无工作流引擎**:blade-transport 全模块零 flowable 引用,审批 = 直接置 `approvalStatus=approved`(`TransportBusiConstant.java:44`)。 +2. **审批有两条入口**,自动推送必须全覆盖: + - 手动:`POST /xxx/approve` → `service.approve(request)`; + - OA 回调:`MkProcessPublicController` → `MkProcessMessageService` → `MkProcessBizHandlers.approveFromProcess`。 + - ⚠️ 其中 **formal-settlement 与 payment-application 的 OA handler 直接置 approved、绕过 `service.approve`**(付款侧因此漏掉票据额度联动 `useBillBalance`,结算侧不写变更记录——既有不一致,本设计顺手统一)。 +3. **推送失败落库口径不一致**:付款/收票失败落 `failed`;结算单/开票失败整体回滚保持 `unsynced`("没推过"与"推失败"无法区分,总纲 11.2-#3 缺陷)。 +4. **事件设施现成**:voucher 模块已有 `ApplicationEventPublisher` + `@TransactionalEventListener(AFTER_COMMIT)` 模式。 +5. 审批端点全部单条,无批量审批入口。 +6. 客商档案先例:审批后推送异常被吞(Controller 层 try/catch + Service 层落 failed),**推送失败不影响审批结果**。 + +## 3. 总体方案 + +``` + ┌─ 手动 POST /xxx/approve ─→ service.approve() ─┐ +审批通过(两条入口)─┤ ├─ 事务内置 approved + 发布 KingdeeBillApprovedEvent + └─ OA 回调 approveFromProcess ─────────────────┘ (OA handler 统一改走 service.approve) + │ 事务提交(AFTER_COMMIT) + ▼ + KingdeeApprovalSyncListener(域开关判断 + 全量 try/catch 吞异常) + │ 复用手动按钮同一 Service 方法 + ┌────────────┬────────────┴──────────┬──────────────┐ + ▼ ▼ ▼ ▼ + 结算单 sync 付款申请 sync 开票申请 sync 收票 push + (A102-2/A302-2) (A103-1/A103-3) (A303-1) (A303-2+A302-6) + │ │ │ │ + 成功→synced 成功→synced 成功→synced 成功→pushed/assigned + 失败→failed 失败→failed 失败→failed 失败→failed + └────────────┴───────────┬───────────┴──────────────┘ + ▼ + KingdeeAutoSyncRetryTask(定时,默认关):仅重推 failed + 前置未就绪(如应付单未生成)→ 预检拦截 → 再次 failed → 下轮继续 + 前置就绪 → 自动续推成功(硬顺序由预检保证,不做级联) +``` + +三条不变式: +1. **审批结果不受推送影响**:事件在审批事务提交后才消费,监听器整体 try/catch,推送异常只落库 + 记日志(客商先例)。 +2. **只有审批状态真正变为 approved 的那次才发布事件**(重复回调被 approve 的状态校验拒绝,天然不重复推送)。 +3. **复用手动按钮同一 Service 方法**:自动推送与手动推送走完全相同的校验、预检、回写逻辑,不另写推送路径。 + +## 4. 设计要点 + +### 4.1 触发点(覆盖双入口) + +- 新增事件 `KingdeeBillApprovedEvent(String bizType, Long id)`,`bizType` 复用 `MkProcessBizHandlers` 既有取值(`formal-settlement` / `payment-application` / `invoice-application` / `invoice-receipt`)。 +- 4 个实体的 `service.approve()` 在状态置 approved 成功后 `publishEvent`(须确保 approve 方法在事务内执行——均有变更记录写入,已是 @Transactional;无事务时 `@TransactionalEventListener` 默认丢弃事件,实现时核实兜底)。 +- **统一 OA 回调**:`MkProcessBizHandlers` 中 formal-settlement(:309-318)与 payment-application(:372-380)的 `approveFromProcess` 改为调 `service.approve(request)`,与 invoice-application / invoice-receipt 的 handler 写法对齐。副作用即修复:付款 OA 审批补上票据额度联动 `useBillBalance`、结算 OA 审批补上变更记录。 +- 客商档案已有自己的审批后推送,不动。 + +### 4.2 执行模型:AFTER_COMMIT 同步推送 + +- 监听器 `KingdeeApprovalSyncListener` 用 `@TransactionalEventListener(phase = AFTER_COMMIT)`,事务提交后在当前线程调用推送(与客商档案现网行为一致)。正常推送秒级;Feign 超时上限 10s 连接 / 60s 读取。 +- `ponytail:` 同步推送占用审批请求线程,若 BFI 网关大面积变慢导致审批接口可感知变慢,再把监听器内部改为提交到守护线程池(`MkProcessMessageService` 已有同款裸 `ScheduledExecutorService` 模式,1 处改动)。 +- 路由: + +| bizType | 调用(与手动按钮相同) | +|---|---| +| formal-settlement | `formalSettlementService.syncKingdee(id)`(内部按 settlementType 路由 A102-2/A302-2) | +| payment-application | `paymentApplicationService.syncKingdee(id)`(内部路由 A103-1/A103-3) | +| invoice-application | `invoiceApplicationService.syncKingdee(id)` | +| invoice-receipt | `invoiceReceiptService.pushKingdeeReceivedInvoice(List.of(id))` | + +### 4.3 失败统一落 failed(修 11.2-#3) + +- 结算单、开票申请的推送方法改为**"事务回滚后独立事务落 failed + 失败原因"**(沿用付款申请已有 `markKingdeeSyncFailed` 模式),与付款/收票口径统一。无需新列、无需 SQL(状态字段均为 varchar,新增 `failed` 取值即可)。 +- 收票自动推送遇到推送计划不满足(未恰好分摊 1 张审批通过应付结算单、查验未通过等,现写 `blockedReason` 不动状态):**自动推送路径下置 failed + 原因**,纳入重推循环;前置补齐后重推自动续上。 +- 前端列表的金蝶状态筛选/展示补 `failed` 选项(结算单/开票)。 + +### 4.4 节点先后顺序:预检保证,不做级联 + +- 每个节点只在**自身审批通过**时推自己;跨节点硬顺序(A102-2→A103-1/A303-2、A302-2→A303-1)仍由既有 A102-4/A302-4 硬预检强制。 +- 前置未就绪(上游还没推成)→ 本次推送失败落 failed(原因"前置应付/应收单未生成")→ 重推任务下轮再试 → 上游就绪后自动续推成功。预检与断点续传机制天然支持。 +- **不做级联**(上游推完自动连推下游):失败传播复杂、且下游有自己的审批节点,级联会绕过"审批通过才推"的语义。 + +### 4.5 定时重推任务(兜底) + +- 新增 `KingdeeAutoSyncRetryTask`(`task/` 包,@Component + @Value 开关 + @Scheduled cron,与既有任务同款)。 +- **只重推 `failed`,不扫描 `unsynced`**——存量"审批通过但从未推送"的历史单不自动补推(避免上线即批量推历史数据),仍走手动按钮。 +- 每域候选集(均要求未作废): + +| 域 | 查询条件 | 动作 | +|---|---|---| +| 结算单 | `kingdee_sync_status='failed'` 且 approved | `syncKingdee(id)` | +| 付款申请 | `kingdee_status='failed'` 且 approved | `syncKingdee(id)` | +| 开票申请 | `kingdee_status='failed'` 且 approved | `syncKingdee(id)` | +| 收票登记 | `kingdee_push_status='failed'` 且 approved | `pushKingdeeReceivedInvoice(List.of(id))` | + +- 逐条 try/catch,单条失败不中断;与付款勾稽纠偏(A103-2 查到即纠正 synced)天然兼容:重推前预检命中远端已落单会幂等回填,不会重复生成。 +- 开关 `thirdParty.bfi.autoSync.retryEnabled` 默认 false,cron 默认 `0 */10 * * * ?`。 +- `ponytail:` 重推无次数上限,永久性坏数据(如联行号未维护)会每 10 分钟失败一次;噪音可接受后,再加 retry_count 上限列。 + +### 4.6 配置开关与灰度 + +监听器内按域判断,4 个开关全部默认 false(Nacos 显式打开),与既有定时任务开关风格一致: + +```yaml +third-party: + bfi: + autoSync: + settlement: false # 结算单审批后自动推 A102-2/A302-2 + payment: false # 付款申请审批后自动推 A103-1/A103-3 + invoiceApply: false # 开票申请审批后自动推 A303-1 + receipt: false # 收票登记审批后自动推 A303-2+A302-6 + retryEnabled: false # failed 定时重推 +``` + +> 注意配置前缀:transport 侧读 `third-party.bfi`(BfiProperties 现行前缀,与 nacos 键对齐),落地时以实际前缀为准。 +> 关闭某域开关 = 该域回到现状(审批不推送、手动按钮照用);重推开关独立,可只关重推保手动。 + +### 4.7 保持不变的部分 + +- 手动同步按钮**全部保留**(失败重推与人工兜底入口,三入口并存:自动 + 定时 + 手动)。 +- 结算单汇率未维护 → 照旧"不传不阻断",不在审批侧强校验。 +- 定时回写任务(PaymentKingdeeResultTask / CustomerKingdeeSyncTask / ReceiptClaimPushTask)不动。 +- 状态守卫式锁单(synced 禁止作废等)不动;B901 锁单回调本分支未实现,与本设计无耦合。 + +## 5. 改动清单(文件级) + +| # | 文件 | 改动 | +|---|---|---| +| 1 | `transport/event/KingdeeBillApprovedEvent.java` | 新增 record(bizType, id) | +| 2 | `transport/listener/KingdeeApprovalSyncListener.java` | 新增:AFTER_COMMIT 监听 + 域开关 + 路由 4 个 sync 方法 + 全量 try/catch | +| 3 | `FormalSettlementServiceImpl.approve`(:539) | 末尾发布事件 | +| 4 | `PaymentApplicationServiceImpl.approve`(:400) | 末尾发布事件 | +| 5 | `InvoiceApplicationServiceImpl.approve`(:483) | 末尾发布事件 | +| 6 | `InvoiceReceiptServiceImpl.approve`(:587) | 末尾发布事件 | +| 7 | `MkProcessBizHandlers`(formal-settlement :309 / payment-application :372) | `approveFromProcess` 改调 `service.approve(request)`(统一入口 + 修复 useBillBalance 绕过) | +| 8 | `FormalSettlementServiceImpl` / `InvoiceApplicationServiceImpl` 推送失败路径 | 事务回滚后独立事务落 `failed` + 原因(沿用 `markKingdeeSyncFailed` 模式) | +| 9 | `InvoiceReceiptServiceImpl` 推送计划 blocked 分支 | 自动推送路径 blocked → failed + 原因 | +| 10 | `transport/task/KingdeeAutoSyncRetryTask.java` | 新增:4 域 failed 重推,开关 + cron | +| 11 | 前端结算单/开票列表 | 金蝶状态筛选/展示补 `failed`(小改) | + +无新表、无 DDL、无新依赖。 + +## 6. 边界场景推演 + +| 场景 | 行为 | +|---|---| +| 推送成功但审批事务实际回滚(AFTER_COMMIT 后回滚不存在,理论不发生) | 不适用——事件只在事务成功提交后消费 | +| 推送成功、落 failed 前进程崩溃 | 下轮重推预检命中(A102-4/A103-2/A303-4/A801-3)幂等回填,不重复建单 | +| 付款审批通过但结算单应付单未生成 | A102-4 硬校验拦截 → failed("前置应付单未生成") → 重推任务续推 | +| 结算单推送失败挂 failed,下游付款/收票/开票全failed等它 | 重推任务每轮都重试各域 failed,上游成功后下游自动续上 | +| OA 审批通过的单 | OA handler 统一走 service.approve 后同样触发事件,覆盖 | +| 同一单重复 OA 回调 | approve 状态校验拒绝重复审批,不发布事件 | +| 永久性坏数据(如联行号未维护) | 每轮重推失败落原因,修数后下轮自动成功;重推无上限(见 4.5 天花板) | +| 历史存量 approved-unsynced 单 | 不自动补推,手动按钮处理 | + +## 7. 待确认默认值(按推荐默认落地,可改) + +| 默认 | 内容 | 备选 | +|---|---|---| +| D1 | AFTER_COMMIT 同步推送(同线程) | 改守护线程池异步(1 处改动,见 4.2) | +| D2 | OA handler 统一改走 service.approve(顺修 useBillBalance 绕过) | 保持 handler 直置、在各 handler 单独发事件 | +| D3 | 重推只扫 failed,存量 unsynced 不补推 | 加"存量补推"一次性开关 | +| D4 | 收票 blocked 条件在自动推送时置 failed | 保持 blockedReason 不动状态 + 重推扫 blocked | +| D5 | 重推无次数上限 | 加 retry_count 上限 | diff --git a/doc/docs/业务流程/2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md b/doc/docs/业务流程/2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md new file mode 100644 index 00000000..385d2a98 --- /dev/null +++ b/doc/docs/业务流程/2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md @@ -0,0 +1,91 @@ +# 金蝶票池选票回切镜像读(本地真分页 + 票号回源 + 瘦身同步 + 认领闸门)业务流程 + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport;前端:tms-erp-web) +> 对接系统:金蝶发票云旗舰版·票易通(piaozone)全票池 A801-1,经业财融合 BFI 网关(rim 契约)透传 +> 关联文档:`2026-09-22-金蝶票池选票统一查镜像…业务流程.md`(2026-09-22 定稿镜像读版、同日改写为实时版,本文第三次定稿)、`2026-09-23-金蝶票池选票方案对比评估与优化建议(镜像读+回源+保存重查闸门).md`(本方案评估依据)、`2026-09-20-收票管理同步金蝶全票池发票…业务流程.md`(首版同步体系,绑定/补偿/白名单部分维持退役) +> 设计依据:2026-10-05 grilling 会话定稿(Q1 回源 / Q2 瘦身同步 / Q3 双闸门 / Q4 全恢复条件能力 / Q5 邻接功能不动不留开关 / Q6 新分支 / Q7 手动 bootstrap) +> 最后更新:2026-10-05 + +--- + +## 1. 背景与决策 + +实时透传方案(2026-09-22 二次决策)运行暴露:每次开弹窗/翻页/保存都是一次 BFI 网关远程链路(秒级)、无 total 游标翻页不可跳页、名称模糊与认领/登记过滤被删、金蝶抖动期间选票整体瘫痪。2026-09-23 评估文档结论成立:**该回切的是"查询架构",不该回切的是"同步体系负担"**,且保存重查闸门是两版方案中唯一资金安全级防线。 + +**定稿口径**: + +- 收票登记新增、正式结算单认领两处选票回切**查本地镜像** `blade_kingdee_invoice_pool`:本地 MySQL 真分页(total/跳页)、名称模糊、票种、双税号、claimed/registered 三态过滤全部恢复;**仅出可用票**(原 `isUsablePoolRow` 口径本地化执行:`available_amount > 0` 且 `amount_use_status = '0'`)。 +- **票号未命中回源**:票号精确查本地无结果(且无认领/登记过滤条件)时,窄查一次 A801-1(票号全等防御 + 可用复核)→ upsert 入镜像 → 本地重查返回。兜底增量窗口缝隙内的当日新票;名称模糊本地为空**不回源**(远程不支持名称条件,救不了)。 +- **瘦身版定时同步**回归:只保留「增量窗口分页拉取 + 镜像全列批量 upsert」,**不恢复**绑定回填、漏绑补偿、双白名单、指纹比对(保存即写存根绑定,登记侧无定时绑定需求;全列覆盖消灭指纹清单漏字段静默不同步的坑)。 +- **双闸门保留/补齐**:收票保存重查(`persistMirrorFromRemote`)原样保留;结算认领确认(`claimInvoices`)补齐服务端按票号逐张重查校验——窗口拉取口径 `invoiceStatus="0"`,被删除/红冲票不再出现在拉取结果,镜像可能陈旧,资金安全由闸门保证。 + +## 2. 接口契约(后端) + +| 端点 | 说明 | +| --- | --- | +| `GET /invoice-receipt/invoice-pool`、`GET /formal-settlement/invoice-pool` | 镜像统一查询,收参 `KingdeeInvoicePoolQueryDTO`,返回 `R>`(records/total/size/current) | +| `POST /invoice-receipt/sync-invoice-pool` | 手动触发全票池同步(bootstrap/联调/补偿三用),返回执行摘要 | + +- **已删除**:游标契约 `KingdeeInvoicePoolPageVO`(hasMore/nextCursor)与 DTO `cursor` 字段、实时链路 `PoolScanCursor`/`toPoolViewVO`/`decorateUseFlags`。 +- `KingdeeInvoicePoolQueryDTO` 条件:`current/size`(size 钳位 1~100)、`invoiceNo`(模糊)、`invoiceDateStart/End`(服务端强校验 yyyy-MM-dd)、`salerName/buyerName`(名称模糊)、`invoiceType`(票易通编码,匹配镜像 `invoice_type_code`)、`salerTaxNo/buyerTaxNo`(精确)、`claimed/registered`(三态过滤)。 +- **KingdeeInvoicePoolMapper.xml**:`selectInvoicePoolPage` 单条 SQL 承载全部口径——`usableFilter`(`available_amount > 0 AND amount_use_status = '0'`)+ claimed/registered EXISTS 子查询(剔除作废结算单/作废登记,手工带租户与逻辑删除条件)+ 动态条件,`ORDER BY source_updated_time DESC`。 +- VO 展示列:`availableTaxAmount`(剩余可用金额,金蝶可用税额口径,仅展示)与 `availableInvoiceAmount/matchedAmount`(价税合计口径,认领金额校验依赖)并存。 + +## 3. 保存与认领闸门 + +``` +收票保存/提交(不变):persistMirrorFromRemote 按票号重查 A801-1 + → 未命中/delete=1 中止;isUsablePoolRow 复核(金额可用 0/使用状态变更中止)→ 写存根 + 绑定 +结算认领确认(新增):claimInvoices 校验后逐张 assertInvoicesClaimable + → 窄查 A801-1 + 票号全等防御 + isUsablePoolRow 同口径复核,任一不通过 ServiceException 中止 +``` + +闸门数据不回写镜像(认领侧只校验不落库);可用性陈旧窗口由闸门兜底,无资损路径。 + +## 4. 定时同步(瘦身版) + +``` +InvoiceReceiptKingdeeSyncTask(@Scheduled cron,thirdParty.bfi.invoiceReceipt.cron 默认每30分钟,syncEnabled 默认关闭) + → IInvoiceReceiptService.syncKingdeeInvoicePool()(KingdeeSyncLockSupport.runSingleFlight 单飞,锁键按租户) + → executePoolSync():滚动近 incrementDays 天(默认30)窗口 + ① 发票种类白名单下推(thirdParty.bfi.invoiceReceipt.invoiceTypes,单值参数按编码拆轮,每轮独立额度;空不限制) + ② updateTime 切片续扫(syncWindowSlice):单查询窗口受 50页×500条=2.5万硬上限,触顶自动二分 + [start,mid)+[mid,end) 递归续扫(右片不含 mid 不重不漏;细到 1 秒仍满页防御报 error 跳过) + 出站口径 invoiceStatus="0",不推 expenseStatus(在用/已用票随 updateTime 进窗口刷新使用状态,本地过滤自然排除) + → batchUpsertInvoicePool:同页号码一次批量查既有镜像 → fillInvoicePool 全列填充 → 有变化才写: + 轻量比对 serialNo/价税合计/税额/剩余可用金额/金额使用状态(compareTo 比金额,空对空不算变化), + 无变化跳过 UPDATE(消整窗重复拉取的无效写);有变化 Db.updateBatchById、新增 Db.saveBatch + → 已登记票跟随补绑:同页票号批量定位登记,仅未绑定存量登记补绑+写 sync 流水(页级事务批量落库) + → 失败中断本轮由任务层记日志,下轮幂等重试 +``` + +- 手动端点与定时任务共用单飞锁;`blade.lock.enabled=false` 时降级无锁并 warn(与付款回写/客商同步同模式)。 +- 删除/红冲盲区:拉取不到的票镜像保持旧值看似可用——展示层陈旧可接受(无 `source_synced_time` 新增列,复用 `source_updated_time` 排序),资金安全由第 3 节双闸门保证。 + +## 5. 数据库与配置 + +| 项 | 内容 | +| --- | --- | +| 镜像表新增列 | `available_amount` decimal(18,2)、`amount_use_status` varchar(8),幂等脚本 `doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql`(可重跑) | +| 新增索引 | `blade_formal_settlement_invoice (tenant_id, invoice_no)`(认领过滤 EXISTS 走索引) | +| Nacos | `third-party-api.yaml` 新增 `thirdParty.bfi.invoiceReceipt.*`:`syncEnabled`(false)/`cron`(每30分钟)/`incrementDays`(30)/`invoiceTypes`(白名单编码,如 26,27,下推远端按编码拆轮)/`lockLeaseSeconds`(1800) | +| 存量数据 | 存量存根行两列为 NULL,不参与选票(可用过滤排除),随增量窗口同步自然回填;回切当天手动跑一次 `POST /invoice-receipt/sync-invoice-pool` bootstrap 铺底 | + +## 6. 前端统一组件(`src/components/kingdee-invoice-pool-dialog/main.vue`) + +- 分页区回切 `el-pagination`(total/sizes/prev/pager/next/jumper,可跳页);游标机件(nextCursor/pageCache/上一页缓存回显)删除。 +- 条件区恢复:开票单位/受票单位名称模糊 + 认领状态/登记状态三态下拉;保留:票号、日期区间(默认近一月)、双税号、发票类型(编码下拉 26/27)。 +- 保留实时版增强:剩余可用金额列、认领/登记 tag、附件查看/下载(A801-2 代理)、`rowKey = id || invoiceNo`(回源响应理论上有 id,兜底不变)。 +- 页脚提示"按名称检索范围为已同步票,未找到请改用发票号码或税号精确查找"。 +- 接线不变:收票登记表单(单选)、结算单编辑器(多选、`handleInvoiceClaimConfirm` 归一 `attachmentJson`)。 + +## 7. 邻接功能(本次不动) + +A801-2 附件下载(镜像优先/实时回源,镜像覆盖面恢复后受益)、A801-5 黑名单查询(实时全量)、收票推送链 A303-2/A302-6/A801-3(消费镜像 `invoice_type_code/items_json`,不受影响)、收票保存重查与存根写入语义。 + +## 8. 已知限制 + +- 名称模糊仅检索已同步票(组件页脚已声明);同步缝隙内新票靠票号回源兜底,纯浏览需等下一轮同步。 +- 30 分钟全列重写为已知写放大(千~万行级,成本可接受;量级上来后升级为整行载荷规范化哈希,**不恢复**人工字段清单式指纹)。 +- 首轮 bootstrap 前无条件浏览为空表(上线操作项,见第 5 节)。 +- 后端验证:`mvn compile -DskipTests -pl blade-service/blade-transport -am` 通过;前端 `pnpm run build` 通过。 diff --git a/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md b/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md new file mode 100644 index 00000000..0424a375 --- /dev/null +++ b/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md @@ -0,0 +1,153 @@ +# 金蝶单据锁定与解锁业务流程(lockBusiData 锁定 - unLockNotifyBusiData 回调 - 失败补偿 - 删单还原) + +- 日期:2026-10-09(2026-10-10 变更:① lockBusiData 确认为 1对1 接口,不支持逗号拼接批量,锁定/补偿改为逐张调用;② 付款申请单纳入锁单范围并完成接线) +- 范围:正式结算单(应付 A102-2 / 应收 A302-2)、开票申请单(A303-1)、付款申请单(A103-1 应付下推 / A103-3 直接保存,2026-10-10 新纳入)推送金蝶成功后的单据锁定、金蝶解锁/删单回调、锁定失败定时补偿 +- 设计文档:`docs/superpowers/specs/2026-10-09-kingdee-bill-lock-unlock-design.md` +- 锁记录表:`blade_kingdee_bill_lock`(一行 = 一张金蝶目标单) + +## 一、涉及单据与触发链路 + +| 业务单据 | 推送接口 | 推送入口 | 金蝶目标单 | 锁定触发 | +| --- | --- | --- | --- | --- | +| 正式结算单(应付) | A102-2 财务应付单保存 | 结算单页面「同步金蝶」 | ApFin_other_BT_S | 推送成功后 | +| 正式结算单(应收) | A302-2 财务应收单保存 | 同上 | arfin_other_BT_S | 推送成功后 | +| 开票申请单 | A303-1 开票申请新增 | 开票申请页面「同步金蝶」 | cdm_paybill_bank_elect_BT_S(每张发票张次一张) | 推送成功后 | +| 付款申请单 | A103-1 应付下推(正式结算付款)/ A103-3 直接保存(预付款) | 付款申请页面「同步金蝶」(单笔/批量) | ap_payapply_oth_BT_S(每张付款申请一张) | 推送成功后 | + +- lockBusiData:WLTMS 调金蝶,源系统 WLTMS、目标系统 XH、lockAction=LOCK +- unLockNotifyBusiData:金蝶调 WLTMS,源系统 XH、目标系统 WLTMS、lockAction=UNLOCK +- 传参约定:接口 1对1,单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo);多张金蝶单(开票申请一张主单对 N 张金蝶单)逐张调用,单张成败互不影响,成败结果批量落库 +- 付款申请天然 1对1:一张付款申请对应一张金蝶付款申请单(A103-1/A103-3 均同类型),源单号=付款申请号 paymentNo +- WLTMS 单据类型常量已预留:`KingdeeLockConstant.SOURCE_PAY_APPLY`(PAY_APPLY)/`TARGET_PAY_APPLY`(ap_payapply_oth_BT_S),且已在 `TARGET_TO_SOURCE_MAP` 反查映射中 +- 出入参同构,应答为 `{updateCount, updateResultList}` 裸结构(逐单 1-成功/0-失败) + +## 二、主线流程 + +### 1. 推送并锁定(WLTMS → 金蝶) + +``` +用户点「同步金蝶」 + ├─ doSyncKingdee(单据级事务):幂等预检 → A102-2/A302-2/A303-1/A103-1/A103-3 保存 → 回写 kingdeeBillNo/同步状态=synced + │ └─ 事务提交 + ├─ 锁定(lockAfterPush): + │ ├─ 每张金蝶目标单一行锁记录,先落 failed 占位(保证补偿扫表无遗漏) + │ ├─ 接口1对1:逐张调用 LOCK + SAVE,单张成败互不影响 + │ ├─ 单张应答成功 → 该张翻 locked ──→ 单据冻结 + │ └─ 单张失败/超时 → 保持 failed,tryCount+1,失败原因落 fail_reason(主单同步状态不受影响) + └─ 附件联带上传(A902,原有动作,后置不受影响) +``` + +**锁定后的效果**: + +- 开票申请:`作废`入口被 `checkEditable` 拦截(提示"该单据已被金蝶锁定,不可修改") +- 结算单:synced 状态本身已拦截编辑/调整/作废,锁定期被同一组守卫覆盖 +- 付款申请:`作废`入口被 `checkEditable` 拦截(金蝶锁定中不可改动;草稿保存/删除仅限推送前,无需守卫) +- 重复点「同步金蝶」:推送幂等命中直接返回已有单号,已 locked 的锁记录跳过,不会重复锁 + +### 2. 金蝶侧解锁回调(金蝶 → WLTMS) + +金蝶在星瀚侧对单据解锁或删单后,回调 `POST /kingdee-bfi/unLockNotifyBusiData`(`UNLOCK`;targetBillNo 兼容单张/逗号拼接): + +``` +校验:sourceSystem=XH、targetSystem=WLTMS、lockAction=UNLOCK(否则应答失败) + │ + ├─ operationType=SAVE(仅解锁,没删单) + │ └─ 锁记录 → unlocked,记录回调操作人/时间,解锁动作留痕 + │ + └─ operationType=DELETE(金蝶把单删了) + ├─ 锁记录 → unlocked(同上) + └─ 按 targetBillType 反查 WLTMS 单据类型,分发还原: + ├─ SETTLEMENT → 结算单作废(当前节点→已作废), + │ 同步信息还原推送前状态:kingdeeSyncStatus→unsynced、 + │ 清 kingdeeBillNo/kingdeeBillId/syncedTime + │ 变更流水记"金蝶删单回调自动废除",操作人=金蝶报文 operator + ├─ INVOICE_APPLY → 开票申请作废,kingdeeStatus→unsynced、清单据编号, + │ 刷新关联结算单开票状态,动作流水记 kingdee_delete + └─ PAY_APPLY → 付款申请复用 unlockBill(id, "金蝶删单回调")(ADR-0005 口径): + kingdeeStatus→生成失败、清 kingdeeBillId/kingdeeBillNo(原值日志留档), + 不作废源单、不自动重推,人工核实后重推(A103-2 勾稽预检防重复推单) +应答:{updateCount: 处理张数, updateResultList: [1]} +``` + +重复回调幂等:单据已作废或锁记录已 unlocked → 该张跳过,不重复还原。 + +### 3. 锁定失败补偿(定时兜底,不依赖人工) + +``` +KingdeeBillLockRetryTask(thirdParty.bfi.billLock.retryEnabled,默认每5分钟) + └─ 扫 lock_status=failed 且 try_count<5 的锁记录 + ├─ 接口1对1:逐张重调 lockBusiData,单张成败互不影响;成败结果批量落库 + ├─ 成功 → 翻 locked,进入正常锁定态 + └─ 失败 → try_count+1,超过 5 次停试,留 fail_reason 待人工排查 +``` + +## 三、状态视角 + +**锁记录状态机**: + +``` +failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked(终态) +``` + +同单重新推送生成新锁记录,走新一轮循环。 + +付款申请双路解锁共用同一还原口径(unlockBill,ADR-0005):① 金蝶删单/解锁回调(本流程);② A103-2 定时回写"已同步单据连续两轮查询消失"兜底。两路幂等由 kingdeeStatus 状态机保证,非已生成/付款中状态自动跳过。 + +**业务单据生命周期**(以结算单为例): + +``` +草稿 → 提交 → 审批通过 ──同步金蝶──→ synced(金蝶单已生成) + │ │ + │ ├─ 锁定成功:locked(冻结,不可动) + │ │ │ + │ │ ├─ 金蝶解锁(SAVE):unlocked,流水留痕 + │ │ └─ 金蝶删单(DELETE):作废 + 同步信息还原 unsynced + │ │ (结算明细/付款/开票关联的后续动作 + │ │ 由各自前置校验自然拦截重推) + │ └─ 锁定失败:failed,定时补偿重锁 + └─ 推送失败:failed(可重推) +``` + +## 四、关键设计点 + +1. **先占位再锁**:锁记录落库先于远程调用,补偿任务扫 `failed` 就是完整的待重试集合,不漏单 +2. **锁失败不回滚推送**:金蝶单已生成,本地回滚只会制造断链;代价是短暂"已推未锁"窗口,由补偿任务闭合 +3. **1对1逐张锁**:lockBusiData 单次仅支持一个源单号对一个金蝶单号,逐张调用互不连坐;成败结果 updateBatchById 批量落库,避免逐张 update +4. **回调幂等**:按 targetBillType+targetBillNo 找最新 locked 记录,找不到/已 unlocked 直接跳过应答成功,防金蝶重试风暴 +5. **DELETE 还原口径**:作废源单 + 清金蝶同步信息 + 关联状态刷新;作废是终态,重推需新单重走流程(口径待业务联调确认) +6. **前端锁状态展示**(2026-10-10 新增):结算单/开票申请列表与详情 VO 填充 `kingdeeLockStatus`/`kingdeeLockFailReason`/`kingdeeTryCount` + (后端 `mapActiveLocks` 每页一次批量查锁表,无 N+1;unlocked 终态不返回即 null=无锁/已解锁)。 + 前端渲染:`locked` 红 tag「金蝶锁定中」并禁用编辑/作废(后端守卫是权威);`failed` 且 `tryCount<5` 橙 tag「锁定失败·补偿中」tooltip 显示原因; + `failed` 且 `tryCount≥5` 红 tag「锁定失败·已达重试上限」提示重推同步金蝶;null 不显示 +7. **付款申请纳入**(2026-10-10 新增):一张付款申请=一张金蝶付款申请单,1对1约定天然满足,无需逐张拆分; + DELETE 还原不作废源单,复用既有 `unlockBill`(ADR-0005:生成失败+清编号内码+人工重推),与 A103-2 反审核删除兜底同口径; + 推送结果即时回填 kingdeeBillNo(`requireBfiBillIdAndFillNo`),推送事务提交后即以该单号 `lockAfterPush`; + 锁定失败摘要拼入「同步金蝶」返回值(与开票申请同款),页面直接可见 + +## 五、代码落点速查 + +| 内容 | 位置 | +| --- | --- | +| 锁单 Feign/DTO/应答 VO | `blade-bfi-api`:`BfiBillLockDTO`、`BfiBillLockResultVO`、`IBfiClient.lockBusiData`、`IBfiService.lockBusiData` | +| 锁类型常量与映射 | `transport/constant/KingdeeLockConstant`(PAY_APPLY/ap_payapply_oth_BT_S 已进 TARGET_TO_SOURCE_MAP) | +| 锁状态展示填充 | `IKingdeeBillLockService.mapActiveLocks`(批量查活跃锁)+ `FormalSettlementServiceImpl/InvoiceApplicationServiceImpl/PaymentApplicationServiceImpl.fillKingdeeLockStatus`(列表/详情填充)+ 三个 VO 的 `kingdeeLockStatus/kingdeeLockFailReason/kingdeeTryCount` 字段 | +| 锁记录实体/服务 | `KingdeeBillLock`、`IKingdeeBillLockService` + `KingdeeBillLockServiceImpl` | +| 回调端点 | `KingdeeBfiPublicController`:`POST /kingdee-bfi/unLockNotifyBusiData`(PERMIT_ALL + TenantIgnore) | +| 补偿定时任务 | `KingdeeBillLockRetryTask`(`thirdParty.bfi.billLock.retryEnabled`,默认每5分钟) | +| 推送后锁定触发 | `FormalSettlementServiceImpl.syncKingdee`、`InvoiceApplicationServiceImpl.syncKingdee`、`PaymentApplicationServiceImpl.syncKingdee`(均为事务提交后) | +| 删单还原 | `FormalSettlementServiceImpl.voidByKingdee`、`InvoiceApplicationServiceImpl.voidByKingdee`;付款申请复用 `PaymentApplicationServiceImpl.unlockBill`(ADR-0005 口径,回调分发于 `KingdeeBillLockServiceImpl.restoreOnKingdeeDelete`) | +| 建表脚本 | `doc/sql/transport/blade_kingdee_bill_lock_20261009.sql` | + +## 六、联调前配置 + +1. Nacos 配置 `thirdParty.bfi.billLockUrl`:lockBusiData 真实路径(代码默认占位 `/ierp/kapi/app/billLock/lockBusiData`,以金蝶最终提供为准) +2. Nacos 开启补偿:`thirdParty.bfi.billLock.retryEnabled: true`(按环境) +3. 向金蝶提供回调地址:网关地址 + transport 服务路由 + `/kingdee-bfi/unLockNotifyBusiData`; + 免登免授权已三层配置(照 BladeX 两层鉴权模型): + ① 第一层 Token 放行:`blade.secure.skip-url` 放行 `/blade-transport/kingdee-bfi/**` 与 `/kingdee-bfi/**`; + ② 第二层注解放行:端点类级 `@PreAuth(AuthConstant.PERMIT_ALL)`(即 permitAll())+ `@TenantIgnore`; + ③ 框架层规则兜底:`blade.secure.auth` 追加两 pattern 的 `permitAll()` 规则(模板与 Nacos 实际均已配)。 + Nacos 实际配置已核对一致;环境级 blade-dev/test/prod.yaml 无 secure 段覆盖。 + 联调验证:服务启动后无令牌 POST 应返回业务参数校验错误(JSON),返回 401「请求未授权」即白名单未生效 +4. 与金蝶确认 WLTMS 单据类型编码(SETTLEMENT/INVOICE_APPLY/PAY_APPLY)与 DELETE 还原口径 + (付款申请 DELETE 不作废源单、走生成失败+人工重推,与 A103-2 反审核删除兜底同口径,待业务确认) diff --git a/doc/docs/业务流程/2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md b/doc/docs/业务流程/2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md new file mode 100644 index 00000000..d6ae7d70 --- /dev/null +++ b/doc/docs/业务流程/2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md @@ -0,0 +1,177 @@ +# 金蝶应付/应收/收票单明细分录拆分与物料映射逻辑梳理(含取值口径) + +> 更新日期:2026-10-13 +> 适用链路:财务应付单(A102-2)、财务应收单(A302-2)、收票单(A303-2)明细分录 +> 核心代码:`blade-transport` 模块 `org.springblade.transport.support.KingdeeApBillSyncSupport`(共享解析) + +--- + +## 1. 总体设计 + +应付单、应收单、收票单三个金蝶单据的明细分录**共用同一份解析结果**(`KingdeeApBillSyncSupport.BfiEntryGroup`),拆分规则、物料映射、计量单位、税率分组只在应付支撑组件解析一次,三个消费端(`pushApBill` / `pushArBill` / `pushReceivedInvoice`)直接逐条转 DTO,保证三单明细同源同口径、分录标识同键、金蝶侧逐行勾稽。 + +``` +正式结算单(blade_formal_settlement) + ├── 结算明细行(blade_formal_settlement_detail,一个运单/一个车牌) + │ └── 货物费用行(blade_formal_settlement_detail_fee,一车多货按货物拆) + └── 结算合计费用项(blade_formal_settlement_summary_fee,仅取手动添加 manualFlag=1) + ↓ 解析(KingdeeApBillSyncSupport.resolveEntryGroups) + BfiEntryGroup 列表(货物 → 费用大类 → 税率 三级拆分后) + ↓ 同一份结果 + 应付单 A102-2 / 应收单 A302-2 / 收票单 A303-2 明细分录 +``` + +--- + +## 2. 取值来源总表 + +| 数据 | 来源表/配置 | 字段 | 用途 | +|---|---|---|---| +| 结算明细行 | `blade_formal_settlement_detail` | `document_no` 单据号、`vehicle_no` 车牌号、`transport_type` 运输方式、`cargo_name` 货物名称、`transport_quantity` 运输量、`fee_items_json` 费用项JSON、`settlement_amount_tax/no_tax` 结算金额(含税/不含税) | 无货物费用行时的唯一货物行;车号/运输方式/数量取数 | +| 货物费用行 | `blade_formal_settlement_detail_fee` | `cargo_name`、`transport_quantity`、`fee_items_json`、`settlement_amount_tax/no_tax`、`line_no` | 一车多货按货物拆行的金额与数量来源 | +| 结算合计费用项 | `blade_formal_settlement_summary_fee` | `fee_type` 费用类型、`fee_item` 费用项、`settlement_amount`、`manual_flag` | 手动添加费用(manualFlag=1)每行单独一条分录 | +| 合同 | `blade_contract_manage` | `billing_plan_json` 计费方案JSON(规则含 `feeType`/`feeItem`/`billingUnit`/`taxRate`) | 税率分组、计量单位匹配 | +| 计量单位档案 | system 服务 `getMeasurementUnits()` | 单位名称 → `unit_code` | 计费单位 → 金蝶计量单位编码 | +| 费用项档案 | `blade_fee_item`(system 服务 `getFeeItems()`) | `name` 费用项名、`fee_category` 费用类型编码、`tax_rate` 税率(备用) | 费用大类判定 | +| 业务字典 | 字典 `fee_category` | 编码 → 名称(如"装卸"、"物流配送") | 费用类型编码翻译成名称后做大类判定 | +| 项目 | `ProjectApply`(`project_id` 关联) | `business_mode` 业务模式、`business_type` 业务类型 | 国内/国际运输判定(含"国际"即国际) | +| 物料/计量单位映射 | Nacos `thirdparty.bfi.material.*`(`BfiProperties.Material`) | 见第 6 节 | 物料编码、计量单位编码映射 | + +`fee_items_json` 结构:`{"费用项名": 金额, ...}`(如 `{"运输费": 3120, "装卸费": 800}`),保持行序解析。 + +--- + +## 3. 明细拆分规则(核心逻辑) + +### 3.1 三级拆分:货物 → 费用大类 → 税率 + +对每条结算明细行(一个运单,同一车牌号就是一个车): + +1. **按货物拆**(一车多货):存在货物费用行(`blade_formal_settlement_detail_fee`)时,每个货物费用行单独拆;无货物费用行时结算明细行本身即唯一货物。 +2. **按费用大类拆**:货物行的 `fee_items_json` 里逐个费用项判定大类(见第 4 节)——装卸大类、运输大类(非装卸全部归运输类)。 +3. **按税率拆**:同大类内按计费方案税率分组,**税率不同分开多行**;费用项未配置税率时归入该大类"无税率组"(不拆)。 + +### 3.2 各字段取值 + +| 分录字段 | 运输大类行 | 装卸大类行 | 结算合计手动费用行 | +|---|---|---|---| +| 物料编码 | 按项目业务模式/运输方式映射(第 6 节) | `material.loading` 装卸物料 | 非运费类→`material.loading`;运费类→同运输行映射 | +| 计量单位 | 费用项/费用类型匹配合同计费方案计费单位 → 匹配计量单位档案编码 | 同左 | 同左 | +| 数量 | **运单运输量**(`transport_quantity`,未维护兜底 1),按税率拆多行时**每行都取运输量** | 1 | 1 | +| 含税金额 | 组内费用项金额合计 | 组内费用项金额合计 | 合计行 `settlement_amount` | +| 单价 | 金额 ÷ 数量(2 位小数反算) | 金额 ÷ 1 = 金额 | 金额 ÷ 1 = 金额 | +| 不含税金额 | **仅该货物行第一条运输类分录携带**整行 `settlement_amount_no_tax`(拆分行无分类不含税金额,保证不含税合计勾稽),其余行不传 | 不传 | 不传 | +| 运输货物 awrp_transport | 货物名称 | 货物名称 | 不传 | +| 车号 awrp_lpn | 结算明细车号(整单一个车) | 结算明细车号 | 不传 | +| 分录标识 awrp_entry_code | `单据号-货物序号-运输费[-税率]` | `单据号-货物序号-装卸[-税率]` | `结算单号-费用类型[-费用项]`(不变) | + +- 货物序号:货物费用行按行序 1、2、3…;无费用行时为 1。单据号为空兜底明细行 id。 +- 税率后缀:`-9`、`-6.5` 格式(去尾零);税率未配置不拼。 +- entryCode 是金蝶候选键,收票单与应付单分录同键勾稽。 + +### 3.3 残差勾稽 + +货物行结算金额(含税)与费用项金额合计的**差额(残差)并入运输类无税率组**,保证:同一货物行拆出的全部分录含税金额合计 = 该行结算金额。典型场景:历史数据 `fee_items_json` 未含"运输费"项(运费金额在 `freight_amount` 字段)时,自动补一条运输类无税率分录承接差额。 + +### 3.4 三个标准场景 + +**场景 1:同费用大类、多税率(运输费存在 2 种税率)** +运输费 2000 元@9% + 附加运输补贴 1120 元@6%,运单 10 吨(水路-国内)→ 拆 2 行,每行数量均取运输量 10: +- 行 1:物料=水路运输编码,数量 10,金额 2000,单价 200 +- 行 2:物料=水路运输编码,数量 10,金额 1120,单价 112 + +**场景 2:包含装卸大类** +运输费 3120 元@9% + 装卸费 800 元@6%,10 吨 → 拆 2 行: +- 运输行:物料=水路运输编码,数量 10,单价 312,金额 3120 +- 装卸行:物料=装卸编码(XDWL11002004),数量 1,单价 800,金额 800 + +**场景 3:结算单手动添加费用** +手动新增仓储服务费 500 元(非运费类)→ 单独一行:物料=装卸编码,数量 1,单价 500,金额 500。 + +--- + +## 4. 费用大类判定(档案 + 字典优先,关键字兜底) + +判定链路(`isLoadingCategory` / `isFreightCategory`): + +``` +费用项名 → blade_fee_item.fee_category(费用类型编码) + → 字典 fee_category 翻译成费用类型名称 + → 名称含"装卸" → 装卸大类;否则运输大类 +费用项不在档案 → 回退名称关键字判定(含"装卸"=装卸类;含"运费/运输费"=运费类) +``` + +- 判定对象从"费用项名"升级为"档案翻译后的费用类型名称":费用项叫"吊装作业"但档案归到"装卸"类型,也会正确映射装卸物料。 +- 结算合计手动费用行的运费判定:档案分类名称 / `fee_type` / `fee_item` 任一含"运费"或"运输费" → 运费类映射运输物料;否则装卸物料(场景 3)。 +- 档案读取失败直接拦截推送(与计量单位档案口径一致);分类映射缓存在 `KingdeeSyncContext.feeCategoryByName`,批量同步只加载一次。 + +--- + +## 5. 税率取值 + +- **来源**:合同计费方案 JSON(`blade_contract_manage.billing_plan_json`),每条规则的 `taxRate` 字段(百分数文本,"9" = 9%),按规则的 `feeItem`/`feeType` 建 Map(先到先得)。合同保存时已强制校验税率 0~100、最多 2 位小数。 +- **用途**:仅用于**拆行分组**和分录标识后缀,**不传金蝶**——分录 DTO 的 `taxrateid_number` 为待金蝶确认字段,暂不送。 +- **匹配方式**:按费用项名精确匹配;未配置税率的费用项归入无税率组。 +- 结算合计手动费用行不取税率(每行单独一条,不分组)。 + +--- + +## 6. 物料编码映射(Nacos 可配) + +配置前缀 `thirdparty.bfi.material.*`(`BfiProperties.Material`,代码带默认值,Nacos 覆盖即改,无需改代码): + +| 配置项 | 默认值 | 说明 | +|---|---|---| +| `domestic."["公路运输"]"` | XDWL11010001 | 国内道路货物运输服务—公路运输 | +| `domestic."["铁路运输"]"` | XDWL11010002 | 国内道路货物运输服务—铁路运输 | +| `domestic."["水路运输"]"` | XDWL11010003 | 国内道路货物运输服务—水路运输 | +| `domestic."["航空运输"]"` | Item-00001082 | 国内道路货物运输服务—航空运输 | +| `international` | Item-00001081 | 国际货运代理服务费(业务模式/业务类型含"国际") | +| `loading` | XDWL11002004 | 装卸(装卸大类 + 手动添加非运费杂费) | + +映射优先级(运输类):装卸大类 → `loading`;国际业务 → `international`;国内 → `domestic[运输方式]`,**运输方式未配置映射直接拦截报错(无兜底)**。物料编码缺失直接拦截报错。 + +**计量单位取值链路**:费用项/费用类型匹配合同计费方案 `billingUnit` → 按计量单位档案(单位名称匹配)取 `unit_code`;任一环节缺失拦截报错。 + +**⚠️ Nacos 配置坑**:`domestic` 的中文 key **必须用方括号写法** `"[公路运输]": xxx`,裸写中文 key 会导致 Spring Boot 绑定失败、服务无法启动(`No converter found from String to Map`)。 + +--- + +## 7. 消费链路与同源关系 + +| 单据 | 组件/方法 | 分录取数 | +|---|---|---| +| 财务应付单 A102-2 | `KingdeeApBillSyncSupport.pushApBill` | `entryGroups` 逐条转 `BfiApBillSaveEntryDTO` | +| 财务应收单 A302-2 | `KingdeeArBillSyncSupport.pushArBill` | 同一解析(`resolveArEntryGroups` 复用应付解析),逐条转 `BfiArBillSaveEntryDTO` | +| 收票单 A303-2 | `KingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice` | 分摊结算单的同一解析结果,逐条转 `BfiReceivedInvoiceSaveEntryDTO`(无计量单位字段) | +| 付款申请 A103-1/A103-3 | 付款链路 | 同源解析,分录携带 `formalSettlementId` 按结算单勾稽各自应付单 | + +幂等与防重:防重键 `awrp_src_code`(正式结算单号_结算明细单据号拼接)不变;分录标识 `awrp_entry_code` 因拆行规则变化,仅适用于新推送数据。 + +--- + +## 8. 代码位置索引 + +| 逻辑 | 位置 | +|---|---| +| 分录解析入口(多结算单/单结算单) | `KingdeeApBillSyncSupport.resolveEntryGroups` | +| 货物行拆分(货物→大类→税率) | `appendDetailEntries` / `appendCargoLines` | +| 费用项分组纯函数 | `splitFeeItems`(含残差勾稽,单测 `KingdeeEntrySplitTest`) | +| 费用大类判定 | `isLoadingCategory` / `isFreightCategory` / `feeCategoryByName` | +| 物料映射 | `resolveMaterialNumber`(读 `bfiProperties.getMaterial()`) | +| 计量单位解析 | `resolveEntryMeasureunit`(YAML 优先)→ `resolveMeasureunitNumber`(计费方案→档案) | +| 税率/计费单位解析 | `billingRuleUnits`(解析 `billing_plan_json` 一次取两样) | +| 结算合计手动费用行 | `appendSummaryEntry` | +| 配置类 | `org.springblade.transport.config.BfiProperties.Material` | +| Nacos 配置样例 | `doc/nacos/third-party-api.yaml`(thirdParty.bfi.material 段) | + +--- + +## 9. 已知口径与限制 + +1. **运输大类内部多税率**:靠费用项级税率分组实现;同大类多税率时每个税率组一行、每行数量都取运输量。未配置税率的费用项合入无税率组不拆。 +2. **非装卸的杂项费用**(运单自带,如补贴、其他费用):归运输大类、随运输行映射运输物料;金额残差兜底并入运输类无税率组。 +3. **不含税金额**:拆分后仅运输类首行携带整行不含税金额(合计勾稽正确,单行口径不精确);装卸行/手动费用行不传(`e_amount` 非必传)。 +4. **税率不传金蝶**:`taxrateid_number` 待金蝶确认编码规则(如 V13)后再补。 +5. **规格型号**:金蝶保存 DTO 无该字段;车号/货物已有 `awrp_lpn`/`awrp_transport` 传参口径。 +6. **装卸行计量单位**:走合同计费方案匹配(装卸费用项的计费方案规则计费单位 → 计量单位档案),装卸费用项未配计费方案规则时拦截报错,需在合同计费方案中维护。 diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png new file mode 100644 index 00000000..77fdd786 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png new file mode 100644 index 00000000..73b5f4f2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png new file mode 100644 index 00000000..5aeaf6ce Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png new file mode 100644 index 00000000..4b16d08d Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png new file mode 100644 index 00000000..2ecd50fa Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png new file mode 100644 index 00000000..022c9a96 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png new file mode 100644 index 00000000..7a39bace Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png new file mode 100644 index 00000000..f088fb92 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png new file mode 100644 index 00000000..63cbf98a Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png new file mode 100644 index 00000000..3bcf8a36 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png new file mode 100644 index 00000000..82271e1c Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png new file mode 100644 index 00000000..7e053b97 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png new file mode 100644 index 00000000..329d4645 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png new file mode 100644 index 00000000..2a30d9f3 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md new file mode 100644 index 00000000..97ddcdd2 --- /dev/null +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md @@ -0,0 +1,640 @@ +# 金蝶(星瀚)BFI 全链路梳理:前端入口 → 后端链路 → 定时任务(业务域汇总 + 操作手册) + +> 整理日期:2026-10-14 +> 代码基线:`tms-api` 当前 master(`805ca9d3`,2026-10-13),全量按当前代码重新核实(上一版总纲基线为 09-24 `50573e7`)。 +> 本文替代《2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版)》作为新总纲: +> - **组织方式改为三层**:每个业务域按「前端入口(页面/按钮)→ 后端链路(触发/顺序/口径/落库)→ 定时任务」梳理; +> - 新增**操作手册**(第 11 节):按业务时序给出端到端操作步骤与异常处理操作; +> - 自 09-27 以来代码有 40+ 提交(09-28~10-13),**收票回写缺陷已修、调试残留已清、新增锁单/解锁、审批自动同步、failed 重推、票池镜像读回切、全票池定时同步、A801-5 黑名单、A801-2/A902 附件接线、收款认领重构**等,接口清单、分录口径、状态机均有实质变化,全文按当前代码重写。 + +--- + +## 目录 + +- [0. 本次更新要点(09-27 → 10-14)](#0-本次更新要点09-27--10-14) +- [1. 集成总览与公共机制](#1-集成总览与公共机制) +- [2. 业务域一:客商档案(A902-1 / A902-2 / A801-5 黑名单)](#2-业务域一客商档案a902-1--a902-2--a801-5-黑名单) +- [3. 业务域二:财务应付与付款(A102-2 / A102-4 / A103-1 / A103-3 / A103-2 / A103-4)](#3-业务域二财务应付与付款) +- [4. 业务域三:财务应收与开票(A302-2 / A302-4 / A303-4 / A303-1 / A303-5)](#4-业务域三财务应收与开票) +- [5. 业务域四:收票与金蝶票池(A801-1 / A303-2 / A302-6 / A801-3 / A801-2 / A902 附件)](#5-业务域四收票与金蝶票池) +- [6. 业务域五:收款认领(A301-2 / A301-3 / A301-1)](#6-业务域五收款认领a301-2--a301-3--a301-1) +- [7. 业务域六:公共取数(A801-4 汇率)](#7-业务域六公共取数a801-4-汇率) +- [8. 全景:接口依赖与先后顺序](#8-全景接口依赖与先后顺序) +- [9. 状态机汇总](#9-状态机汇总) +- [10. 定时任务与配置开关汇总](#10-定时任务与配置开关汇总) +- [11. 操作流程手册](#11-操作流程手册) +- [12. 风险与待确认清单](#12-风险与待确认清单) + +--- + +## 0. 本次更新要点(09-27 → 10-14) + +| # | 变化 | 说明 | +|---|---|---| +| 1 | **⚠️ 旧总纲缺陷清单大面积清零** | A801-3 定时回写防重键已修复(改按收票登记 id,`7b98e33d` 系列);`if(true)` 调试组合块(强制物料/随机取物料)**已全部删除**;客商定时 KS000067 硬编码**已移除**;客商银行 `bankNumber` 硬编码已改真实联行号;A103-3 `e_bebank_number` 改取客商收款账户联行号(与 A103-1 口径统一) | +| 2 | **★新增横切机制一:单据锁定与解锁(10-09/10)** | BFI 新增 `lockBusiData`(出站锁单)与 `POST /kingdee-bfi/unLockNotifyBusiData`(金蝶回调,免登放行)。正式结算单/开票申请/付款申请推送成功后联带锁金蝶单据;金蝶侧删单回调还原本地状态;锁失败定时+手动补偿;列表回显锁状态。专项文档:`2026-10-09-金蝶单据锁定与解锁业务流程….md` | +| 3 | **★新增横切机制二:审批通过自动同步(10-10)** | `KingdeeApprovalSyncListener`(AFTER_COMMIT 事件)监听 4 域审批通过事件,自动调各自 `syncKingdee`;4 个 Nacos 开关默认 false;失败统一落 `failed`,审批接口回显警告文案;MK 流程审批回调统一改走 `service.approve`,OA 路径同样触发。专项文档:`2026-10-05-审批通过自动同步金蝶设计….md` | +| 4 | **★新增横切机制三:failed 单据定时重推 + 推送失败统一落 failed(10-10)** | `KingdeeAutoSyncRetryTask` 定时重推 4 域 failed 单据(不扫 unsynced);结算/开票链路异常也落 `failed`(此前只回滚保持 unsynced),旧总纲 11.2-#3 缺陷关闭 | +| 5 | **★票池架构回切:镜像读 + 全票池定时同步(10-05/10-08)** | 选票接口改读本地镜像表(本地真分页);票号精确查未命中才窄查 A801-1 回源;新增 `InvoiceReceiptKingdeeSyncTask` 全票池瘦身定时同步(invoiceTypes 白名单下推远端、2.5 万窗口硬上限二分时间片、绑定回填);结算单认领确认补上**保存重查闸门**(`assertInvoicesClaimable`)。旧总纲「实时回源、无定时」口径作废 | +| 6 | **★分录拆分口径重写(10-13)** | 应付/应收/收票分录改为**按 货物 → 费用大类(装卸/运输)→ 税率 三级拆分**,一车多货再按货物拆;0 元费用项/货物行跳过;物料编码改 Nacos 配置 `thirdparty.bfi.material.*`(`BFI_MATERIAL_NUMBER_MAP` 常量与 `blade_kingdee_material` 表已废弃);**计量单位当前不送**;`specification`(规格型号)字段本期不传。专项文档:`2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md` | +| 7 | **★收款认领链路重构(09-28~10-01)** | 认领本地化:先本地认领落库,**推送改定时+手动双入口按流水归集**(`ReceiptClaimPushTask`,旧总纲「全部手动、无定时」作废);`awrp_src_code`=分摊结算单号下划线拼接(重试复用幂等);分录单条、`e_corebillno` 传**结算单金蝶内码**(缺内码拦截不推);已推送认领禁止作废;A301-1 回写以真实收款单号覆盖占位单号 | +| 8 | **★A801-5 黑名单查询启用(10-01/10-08)** | 客商提审时按发票纳税人识别号(去重大写)调 A801-5;A 类命中硬拦截,B 类默认放行告警(可配置拦截),**查询异常上抛阻断提审** | +| 9 | **附件链路接线(09-27~10-05)** | A902 附件上传:正式结算/开票申请/付款申请/收票 4 条链路推送成功后联带上传(失败不污染主单,重推补传);A801-2 附件下载:前端统一走 `GET /invoice-receipt/invoice-pool/attachment/download` 流式代理,PDF 版式(`taxPdfUrl`/`pdfurl`)优先,OFD 按 `fileType=4` 识别 | +| 10 | **开票申请 A303-1 口径更新(09-30~10-11)** | 上游来源单据 `awrp_src_con_no`=首张结算单金蝶应收单防重键;特殊票种:纯货物运输行送 E04 否则 00;E04 随单报送货物运输单据体(车牌/起运/到达/货物/种类去重归并);`hsbz` 统一 0(不含税);旅客运输/代收车船税单据体 DTO 已建未接 | +| 11 | **组织编码改取金蝶部门编码(10-01)** | 应付/应收/开票的组织字段取 `SysCache.getDept().getKingdeeDeptCode()`,为空直接拦截(需在部门档案维护金蝶组织编码) | +| 12 | **定时任务加 single-flight 分布式锁(09-28/29)** | 全部金蝶定时任务经 `KingdeeSyncLockSupport.runSingleFlight`(Redis 锁,键含租户 id),多副本与手动触发共用一道闸;付款结果回写开关在 Nacos 样例中已显式开启,其余任务默认关闭 | +| 13 | **付款申请「消失解锁」兜底(09-29)** | A103-2 连续 2 轮查询不到已生成单(Redis 计数,TTL 2100s)→ 自动解锁置 `failed`,可重推 | + +--- + +## 1. 集成总览与公共机制 + +### 1.1 调用架构(前端 → 后端 → 金蝶) + +``` +tms-erp-web(Vue3,src/views/** + src/api/**) + │ HTTP(网关按服务名路由 /blade-transport/**) + ▼ +blade-transport(Controller → ServiceImpl;推送编排 + 状态落库 + 锁单 + 审批事件) + │ IBfiService(参数校验 + Redis 令牌缓存) + ▼ +blade-bfi-api(IBfiClient @FeignClient url=${thirdParty.bfi.baseUrl}) + │ access_token(业务令牌) + Authorization(iPaaS 网关头,BfiRequestInterceptor 注入) + ▼ +iPaaS 网关(/gwzh/BFI,不直连金蝶) + ▼ +金蝶星瀚 /ierp/kapi/... + +反向:金蝶删单 ──▶ POST /blade-transport/kingdee-bfi/unLockNotifyBusiData(免登 skip-url) +定时:blade-transport task/ 包 7 个任务(Spring @Scheduled,全库无 PowerJob/XxlJob 接线) +``` + +- 无 Feign 重试(NEVER_RETRY)、无 fallback;失败抛 `BfiException`,批量循环逐条 catch,单条失败不中断整批。 +- 令牌:Redis `blade:third:bfi:token`,TTL=expires_in−120s;`setIfAbsent` 写入(已有缓存不覆盖)。 +- 配置前缀两套并存(读同一批 Nacos 键):bfi-api `third-party.bfi.*`(baseUrl/认证/令牌),transport `thirdparty.bfi.*`(业务开关/口径/映射)。样例:`doc/nacos/third-party-api.yaml`。 + +### 1.2 接口清单总表(2026-10-14,含在用状态) + +来源:`blade-bfi-api/.../feign/IBfiClient.java`(URL 可被 `thirdParty.bfi.*Url` 覆盖)。 + +| 操作码 | 星瀚端点 | 用途 | 业务侧在用状态 | +|---|---|---|---| +| A100-1 | POST /ierp/kapi/oauth2/getToken | 获取令牌 | ✅ 自动(每次调用前) | +| A100-2 / A100-3 | verifyToken / withdrawToken | 校验/撤回令牌 | ❌ 已实现无调用 | +| A102-2 | POST …/awrp/ap/ap_finapbill/save | 财务应付单保存 | ✅ 正式结算单(应付)推送 | +| A102-4 | GET …/awrp/ap/ap_finapbill/query | 财务应付单查询 | ✅ 预检 / 硬校验 / 批量预热 | +| A103-1 | POST …/awrp/ap/ap/ap_finapbill/push | 应付下推生成付款申请 | ✅ 付款申请推送(单分录) | +| A103-2 | GET …/awrp/ap/ap_payapply/query | 付款申请单查询 | ✅ 推送预检 + 状态回写 + 消失解锁计数 | +| A103-3 | POST …/awrp/ap/ap/ap_payapply/save | 预付款申请保存 | ✅ 预付款直推 | +| A103-4 | GET …/awrp/cas/cas_paybill/query | 付款处理查询 | ✅ 付款记录回写(含 voucher_json) | +| A301-1 | GET …/awrp/cas/cas_recbill/queryRecBill | 收款单查询 | ✅ 认领结果回写(手动) | +| A301-2 | GET …/awrp/cas/cas_claimannounce/query | 收款认领公告查询 | ✅ 流水同步(手动 + 定时认领推送前置重查) | +| A301-3 | POST …/awrp/cas/cas/cas_claimbill/claimbillPush | 收款认领结果推送 | ✅ 定时 + 手动双入口,按流水归集 | +| A302-2 | POST …/awrp/ar/ar_finarbill/save | 财务应收单保存 | ✅ 正式结算单(应收)推送 | +| A302-4 | GET …/awrp/ar/ar_finarbill/query | 财务应收单查询 | ✅ 预检 / 硬校验 / 定时勾稽纠偏 | +| A302-6 | POST …/awrp/ap/ap/ap_invoice/assign | 收票指定应付 | ✅ 收票推送第二步 | +| A303-1 | POST …/awrp/sim/sim_original_bill/save | 开票申请单新增 | ✅ 按发票张次逐张推送 | +| A303-2 | POST …/awrp/ap/ap_invoice/cusBatchSave | 收票单批量保存 | ✅ 收票登记推送 | +| A303-4 | GET …/awrp/sim/sim_original_bill/query | 开票申请单查询 | ✅ 批量 billno 幂等预检 / 附件补传反查星瀚内码 | +| A303-5 | POST /ierp/kapi/app/sim/openApi | 开票发票查询(票易通) | ✅ 定时回写开票结果 | +| A801-1 | POST …/awrp/rim/rim_invoice/query | 全票池发票查询(rim 契约) | ✅ 定时全量同步 + 票号回源 + 保存/认领闸门 + 附件回源 | +| A801-2 | POST …/awrp/bosext/attachment/download | 进项发票文件下载 | ✅ **已接线**:附件查看/下载流式代理 | +| A801-3 | GET …/awrp/ap/ap_invoice/apinvoicequery | 收票单查询 | ✅ 推送预检 + 定时回写(防重键已修复=登记 id) | +| A801-4 | GET …/awrp/base/bd_exrate_tree/Forexquery | 汇率查询 | ✅ 结算页面取汇率 | +| A801-5 | POST …/awrp/bdm/bdm_blacklist_config/blacklistquery | 黑名单查询 | ✅ **新增启用**:客商提审校验 | +| A902 | POST /ierp/kapi/v2/frame/attachment/uploadFile | 附件上传 | ✅ **已接线**:4 条推送链路联带上传 | +| — | POST /API_lockAction_Router | lockBusiData 单据锁定 | ✅ 推送成功后锁单({updateCount,updateResultList} 应答) | +| — | (回调)POST /kingdee-bfi/unLockNotifyBusiData | 解锁/删单通知(unLockNotifyBusiData) | ✅ 金蝶回调本系统(免登放行) | + +> 仍未实现:A901-1 供应商查询、A913 项目、A101-1/2 合同、A102-1/A302-1 暂估单(合同/项目走合同合规系统)。 +> 查询时间窗约束:A103-2/A103-4/A102-4/A302-4/A303-4/A301-1 ≤ 1 个月;A902-1 ≤ 6 个月;A301-2 无时间窗参数(按认领人+分页,时间窗口取 `thirdParty.bfi.claimAnnounce.months` 默认 6)。 + +### 1.3 防重键(候选键)总表(2026-10-14 口径) + +金蝶侧按「来源系统 + 来源单据号」幂等(`awrp_src_sys` + `awrp_src_code`)。组装:`ExternalSrcCodeSupport.buildSrcCode`(下划线拼接、去空白、去重保序)。 + +| 金蝶单据 | awrp_src_code | srcType | 分录键 | 预检接口 | +|---|---|---|---|---| +| 财务应付单 A102-2 | `正式结算单号_结算明细单据号1_…` | TMS财务应付单 | 按 货物-费用大类-税率 拆分行顺序生成 | A102-4 | +| 财务应收单 A302-2 | 同应付口径 | TMS财务应收单 | 同应付口径 | A302-4 | +| 付款申请 A103-1 | `paymentNo` | 配置 `0KzXs` | 固定 `paymentNo-1`(单分录);勾稽键 `awrp_finapbill_entryid`=主结算单应付防重键(本地组装) | A103-2 | +| 预付款申请 A103-3 | 付款申请号口径 | `0KzXs` | `paymentNo-1` | A103-2 | +| 开票申请 A303-1 | billno:单张=`application_no`,多张=`application_no-张次序号`;`awrp_src_con_no`=首张结算单应收防重键 | `0KzXs` | 开票商品行 id | A303-4(批量 billno) | +| 收票单 A303-2 | **收票登记 id** | TMS收票登记 | 与 A102-2 分录同源同口径 | A801-3(预检与回写同键) | +| 收款认领 A301-3 | **分摊结算单号下划线拼接**(重试复用同一编码)★改 | WLTMS | 单条:seq/e_corebillno=第一张结算单**金蝶内码** | A301-1(sourcebillnumber 匹配) | +| 客户 A902-2 | `number`=TMS 客商编号(KS+6位);`societycreditcode` 为金蝶侧候选键 | awrp_datasourcesys=WLTMS | — | A902-1 | + +### 1.4 公共档案取数(应付/应收/收票复用 `KingdeeApBillSyncSupport`) + +| 取数项 | 规则(当前代码) | +|---|---| +| 组织 org/payorg/recorg | 合同/申请部门 → `SysCache.getDept().getKingdeeDeptCode()`(**金蝶部门编码**,为空拦截)★改 | +| 币种 | 合同 `settlement_currency`,RMB→CNY | +| 汇率 exchangerate/exratedate | 结算单页面维护值(A801-4 回填),未维护不送 | +| 往来户 asstact_number | 名称 → 本地 `blade_customer_archive` 匹配取 `customer_code`,查不到报错拦截 | +| 往来类型 asstacttype | **固定 `bd_customer`**★改(按合同类别区分供应商的逻辑已注释停用) | +| 物料编码 | 运输类按 运输方式/国际/装卸 → Nacos `thirdparty.bfi.material.domestic.{railway,highway,waterway,aviation}` / `international` / `loading` 映射(默认 XDWL1101000x / Item-00001081 / XDWL11002004);运输方式未配置直接拦截 | +| 计量单位 | **当前不送**(DTO 字段保留,无 set 调用) | +| 金额 | 含税=结算金额;不含税:明细行有、手工合计费用项行不传 | +| 数量/单价 | 运输类=运单运输量(兜底 1;同税率拆多行每行都取运输量);装卸/杂费=1;单价=金额/数量反算 | +| 0 元行 | 费用项 signum()==0 与 0 元货物行**整行跳过**★新增 | +| 运输货物/车号 | 明细行传 `awrp_transport`(货物名称)/ `awrp_lpn`(车号);合计费用项行不传 | + +### 1.5 三大横切机制(新总纲新增,读业务域前先看) + +#### ① 审批通过自动同步(`KingdeeApprovalSyncListener`) + +``` +业务审批通过(页面「通过」或 MK/OA 流程回调,统一走 service.approve) + ▶ 事务提交后 AFTER_COMMIT 发布 KingdeeBillApprovedEvent + ▶ KingdeeApprovalSyncListener.onBillApproved(@TransactionalEventListener,fallbackExecution=true) + ▶ 按 4 个开关分别调用 syncKingdee(id): + settlement → FormalSettlementServiceImpl.syncKingdee (A102-2 / A302-2) + payment → PaymentApplicationServiceImpl.syncKingdee (A103-1 / A103-3) + invoiceApply→ InvoiceApplicationServiceImpl.syncKingdee (A303-1 按张次) + receipt → InvoiceReceiptServiceImpl 推送 (A303-2 + A302-6) + ▶ 失败:独立事务落 failed + ThreadLocal 失败原因 + ▶ 4 个审批 Controller 统一 pollSyncFailure() 拼警告返回前端:"审批通过,但自动同步金蝶失败:xxx" +``` + +- 开关(默认全 false):`thirdParty.bfi.autoSync.settlement / payment / invoiceApply / receipt`。 +- 客商档案不走事件机制:`CustomerArchiveController.approve` 内直接 `syncKingdee(id)`(失败不阻断审批)。 +- 推送成功后各链路在**同一方法内继续执行 lockAfterPush** 锁金蝶单据(见②)。 + +#### ② 单据锁定与解锁(`KingdeeBillLockService` + `KingdeeBfiPublicController`) + +``` +推送成功 ─▶ lockAfterPush(先落 blade_kingdee_bill_lock failed 占位 → lockBusiData → 翻 locked) +锁失败 ─▶ KingdeeBillLockRetryTask 定时补偿(cron thirdParty.bfi.billLock.retryCron,默认每5分钟,开关默认 false) + + 手动端点 POST /formal-settlement/retry-kingdee-bill-lock(通用,扫 failed 且 try_count<5) +金蝶删单 ─▶ POST /kingdee-bfi/unLockNotifyBusiData(免登,@TenantIgnore) + ├─ SETTLEMENT:作废结算单 + kingdee_sync_status 回 unsynced、清 kingdee_bill_no/id + ├─ INVOICE_APPLY:作废开票申请 + 同上还原 + └─ PAY_APPLY:不作废源单,解锁置 failed、清 kingdee_bill_id/no(可重推) +本地拦截 ─▶ 付款申请/开票申请作废前 checkEditable:locked 即抛「该单据已被金蝶锁定」 + (正式结算单作废未接 checkEditable,靠列表锁状态前端禁用) +列表回显 ─▶ mapActiveLocks() 填充 VO kingdeeLockStatus(locked/failed),前端展示「金蝶锁定中/锁定失败」并禁编辑 +``` + +- 收票单本期**不锁**(`KingdeeLockConstant` 映射预留)。 +- 付款申请另有**消失解锁**:A103-2 连续 2 轮查询不到已生成单(Redis 计数 TTL 2100s)→ 自动解锁置 failed。 + +#### ③ 定时任务 single-flight 分布式锁(`KingdeeSyncLockSupport`) + +- 所有金蝶定时任务经 `runSingleFlight`:RedisLockClient 零等待可重入锁,锁键=前缀+租户 id(无用户上下文用 default);`blade.lock.enabled=false` 降级直跑。 +- 手动端点与定时任务共用同一把锁——**手动触发时若定时正在跑会直接返回**(防重复回写)。 + +--- + +## 2. 业务域一:客商档案(A902-1 / A902-2 / A801-5 黑名单) + +**定位**:全局数据前提。其他链路的往来户编码(`customer_code`)来自 `blade_customer_archive`;金蝶客户编码 = TMS 客商编号。 + +### 2.1 前端入口 + +| 页面 | 操作 | 后端端点 | 触发时机 | +|---|---|---|---| +| 客商档案(`/vehicle/customer-archive`) | 新增/编辑表单录统一社会信用代码(满 18 位防抖) | `GET /bfi/customer/query-by-credit-code`(A902-1) | 回填名称/法人/地址/经营范围等并锁定;金蝶状态 A/B 阻断保存 | +| 同上 | 行内「同步金蝶」链接(仅 approved 且 failed 显示) | `POST /customer-archive/sync-kingdee`(A902-2) | 失败人工重推 | +| 同上 | 行内「审批通过」 | `POST /customer-archive/approve` → 内部 `syncKingdee(id)` | **提审校验 A801-5 黑名单;通过后自动推送 A902-2** | + +### 2.2 后端链路 + +**A902-1 客户查询(3 入口)**:前端回填 / 单条推送前复查(`syncKingdee` 先查一次,C→exist 终态、A/B→failed)/ 定时批量复查(50 信用代码/批翻页取全)。 + +**A801-5 黑名单(★新增,提审拦截)**:`CustomerArchiveServiceImpl.submitApproval` → 取客商各发票 `taxNo` 去重大写 → `queryBlacklist("1", taxNos)`(enable=1,pageSize 上限 2000,循环翻页取全)→ **A 类硬拦截**;B 类默认放行告警(`thirdparty.bfi.blacklist.blockRiskWarning=true` 时拦截);其他类别放行告警;**查询异常上抛阻断提审**。 + +**A902-2 客户保存(3 入口收敛 `pushCustomerToKingdee`)**:审批通过自动(失败不影响审批)/ 人工重推(synced/exist 幂等返回)/ 定时重推(pending=approved 且 none/failed)。先 A902-1 复查(金蝶已有 C 直接置 exist 跳过)再保存。只推新增建档(审批后变更不重推)。参数要点:`entry_groupstandard` 按 `customerKind` internal→1101 / 其余→1102;`entry_bank` 取 `blade_customer_receipt_account`(账号/开户行/CNY/默认 + **真实联行号 cnaps_code**,硬编码已删);`bizfunction` 固定 `1,2,3,4`。 + +**定时附加职责**(`CustomerKingdeeSyncTask`):synced/exist 且启用客商复查 C→exist;金蝶侧停用 → 联动停用 TMS 档案 `status=2`(单向)。 + +**响应存储**:`kingdee_status / kingdee_fail_reason / kingdee_sync_time`。 + +**金蝶侧效果**: + +![金蝶客户列表收到 A902-2 推送的客商(编码=TMS 客商编号 KS000073,客户基本分类标准 1101 内部客户/1102 外部客商)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png) + +![TMS 客商档案与金蝶客户档案对照:编码一致(KS000060)、以统一社会信用代码为两侧关联键](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png) + +--- + +## 3. 业务域二:财务应付与付款 + +### 3.1 链路总览 + +``` +环节一 正式结算单(应付 Tab)审批通过自动同步 / 手动「同步金蝶」 + A102-4 预检 ▶ A102-2 保存应付单 ▶ 回写 kingdee_bill_no + synced ▶ lockBusiData 锁单 +环节二 付款申请审批通过自动同步 / 「批量同步」 + A102-4 硬校验(逐关联结算单应付已生成)▶ A103-2 幂等预检 + ▶ A103-1 下推(预付款走 A103-3)▶ 回写 kingdee_bill_id/no + synced ▶ lockBusiData 锁单 +环节三 PaymentKingdeeResultTask 定时(或手动「同步付款结果」) + A103-2 状态回写(synced→paying→paid/closed;failed 勾稽纠偏;连续2轮查不到→消失解锁置failed) + ▶ A103-4 付款处理查询 ▶ 付款记录落库(含 voucher_json 凭证/银行流水/电子回单) +``` + +**金蝶侧效果**: + +![金蝶财务应付单 AP-202609-800044 与 TMS 正式结算单对照:外部系统单据号=防重键(WLTMS_FJS2026092400001_YF20260912…),应付金额、明细行(物料/车号/运输货物/数量)逐行对应](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png) + +> **前置说明(预结算单)**:预结算单(PreSettlement)**不直接推送金蝶**——无金蝶字段与调用,`KingdeeBillApprovedEvent` 也不覆盖它。与金蝶的关联仅三处: +> ① 非 RMB 预结算在页面经 A801-4 带出汇率随单保存,**汇率不自动结转到正式结算单**——A102-2/A302-2 推送取的是正式结算单自身维护的 `exchange_rate/exchange_rate_date`(`KingdeeApBillSyncSupport:272-277`),转正式后须重新维护; +> ② 进度预付付款申请可关联预结算单(`payment_application.pre_settlement_id`),A103-3 汇率日期优先取关联结算单(正式→预结算)维护值,兜底申请日期; +> ③ A103-2 付款 paid 回写时联动履约:回写预结算垫资 `blade_pre_settlement_advance`(paidAmount 整额置满、billStatus=paid、kingdeeAdvanceNo=金蝶付款单号)并刷新预结算汇总与正式结算来源快照。 +> 预结算「预开票」金蝶推送方案(2026-09-29)**未实现**;预结算审批走 MK 流程(`MkProcessBizHandlers.PreSettlementHandler`),直接置 approved、不发布金蝶事件。 + +### 3.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 正式结算单(`/settlement/formal-settlement`,应付/应收 Tab) | 工具栏「同步金蝶」(单选一条) | `POST /formal-settlement/sync-kingdee?id=` | 30s 超时;成功回写金蝶单号 | +| 同上 | 行内「审批通过」 | `POST /formal-settlement/approve` | 开关开启时**自动同步**;`data=false` 前端提示失败原因 | +| 同上 | 列表「金蝶锁定」标签列 | 无调用(后端 mapActiveLocks 回显) | locked 红色标签禁编辑/作废 | +| 付款申请(`/payment/payment-application`) | 工具栏「批量同步」 | `POST /payment-application/sync-kingdee-batch`(ids) | 整批预热→逐单独立事务,单条失败不中断 | +| 同上 | 工具栏「同步付款结果」 | `POST /payment-application/sync-kingdee-result` | 手动触发 A103-2/A103-4 回写 | +| 同上 | 行内「审批通过」 | `POST /payment-application/approve` | 开关开启时自动下推+锁单 | +| 同上 | 单笔「同步金蝶」 | `POST /payment-application/sync-kingdee?id=` | 端点在;**前端 `handleSyncRow` 未绑定模板(死代码)**,单笔入口实际只有审批自动同步 | + +### 3.3 后端要点(当前口径) + +- **A102-2/A302-2 分录**:结算明细行按「货物→费用大类→税率」拆分 + 手工合计费用项(manualFlag=1,每行一条,0 元跳过);明细行带运输货物/车号;调试覆盖块已删。 +- **A103-1 下推**:单分录 `paymentNo-1`;`e_settlementtype_number` 按付款方式映射(汇票→012-02,其余→02);`e_bebank_number`=客商收款账户联行号(未维护拦截);`awrp_finapbill_entryid`=主结算单应付防重键(本地组装;⚠️ 支撑类注释仍写「A102-4 反查」,注释与代码不一致,见 12 节)。 +- **A103-3 预付款**:`e_bebank_number` 同样取客商收款账户联行号(★已与 A103-1 统一);project 取项目档案真实编码(无则 999)。 + +![TMS 付款申请(FKD2026092400001,申请付款 195.11)与金蝶付款申请单(FKSQ-00000199)对照:核销金额、往来户/往来账户(收款账号)、结算方式(银行转账)逐项对应;金蝶侧由 A103-1 下推自动生成付款计划](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png) + +![预付款对照:TMS 进度预付申请(240.12)下推金蝶生成「其他付款申请」FKSQ-00000219(进项货付款申请),分录预付款金额、往来户、结算方式对应;附件区可见 A902 联带上传的文件](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png) +- **A103-2 回写状态映射**:`Alreadypay`→paid(终)>`billstatus=D`→closed(终)>`Inpayment`→paying>其余 synced;`entry[].e_paidamt` 汇总→paid_amount(缺失且 paid 兜底=申请金额);paid 联动履约(预结算垫资/结算付款记录);**failed 纳入勾稽纠偏**(查到即纠正 synced);**查不到→消失解锁计数**(仅已生成/付款中参与,连续 2 轮→解锁置 failed)。 +- **A103-4 付款记录**:按付款组织编码分组查询(`org_number` 优先取 A103-2 返回值),窗口最近 1 个月,`sourcebillid`==kingdee_bill_id 优先、`sourcebillnumber` 兜底;upsert `blade_payment_application_record`(actpayamt/paid_date/整单 JSON→voucher_json);清理 MOCK-PAY- 模拟记录。 +- **A902 附件**:推送成功事务提交后联带上传(`KingdeeAttachmentSyncSupport.syncUserAttachments`),失败不污染主单状态,重推补传。 +- **异常落库**:手动/自动同步失败统一独立事务落 `failed` + 原因(截 500 字),由「手动重推/failed 重推任务」续推。 + +--- + +## 4. 业务域三:财务应收与开票 + +### 4.1 链路总览 + +``` +环节一 正式结算单(应收 Tab)审批通过自动同步 / 手动「同步金蝶」 + A302-4 预检 ▶ A302-2 保存应收单 ▶ 回写 kingdee_bill_no + synced ▶ lockBusiData 锁单 +环节二 开票申请审批通过自动同步 / 工具栏「批量同步」(逐单) + A302-4 硬校验(应收已生成)▶ A303-4 批量 billno 幂等预检(部分张次已生成可断链续推) + ▶ A303-1 按发票张次逐张新增(E04/货物运输单据体/A902 附件)▶ 张次+申请单回写 ▶ lockBusiData 逐张锁单 +环节三 PaymentKingdeeResultTask 定时(或手动端点) + A302-4 勾稽纠偏(failed→synced / 补单号) + A303-5 开票发票查询(serialNo 按 application.kingdee_bill_no 逗号拆分)▶ 发票落库(含文件地址)▶ invoiced(终态) +``` + +### 4.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 正式结算单(应收 Tab) | 同应付:同步金蝶 / 审批通过 / 锁状态列 | 同 3.2 | `settlementType=receivable` 路由 A302-2 | +| 开票申请(`/payment/invoice-application`) | 工具栏「批量同步」 | `POST /invoice-application/sync-kingdee?id=`(逐单 Promise.all) | 仅审批通过单 | +| 同上 | 行内「审批通过」 | `POST /invoice-application/approve` | 开关开启时自动按张次推送 | +| 同上 | 锁定状态列 | 无调用 | 「金蝶锁定中」禁编辑/作废 | +| 正式结算单编辑器「开票发票」区 | 查看/下载发票文件 | 金蝶发票云直链(开票结果回写的 invoicePdfFileUrl 等,不走 A801-2 代理) | 10-02 起落库发票文件地址 | + +### 4.3 后端要点(当前口径) + +**金蝶侧效果**: + +![金蝶财务应收单 AR-260924-012021 与 TMS 正式结算单对照:应收金额、费用合计对应;明细行物料编码/车号/运输货物/数量逐行对应](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png) + +![开票申请张次拆分对照:TMS 开票内容第1张/第2张(各含商品行)分别生成金蝶票易通开票申请单——购销方、不含税金额、未关联金额(¥4,386)逐项对应](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png) + +- **张次拆分**(`blade_invoice_application_sheet`):每张发票张次推一张金蝶开票申请单;billno=单张取 application_no、多张取 `-张次序号`;分录=本张商品行(按 invoice_sheet_id 分组)。 +- **A303-1 头字段口径(★更新)**:`invoicetype` 数电专票 08xdp / 数电普票 10xdp;`specialtype`:本张商品行**全部**为货物运输类(名称/分类含运费关键字)→ **E04**,否则 **00**;E04 随单报 `freights[]` 货物运输单据体(车牌 licenseplate/起运 startplace/到达 endplace/货物 transportgoods/运输方式 transporttype 字典映射,按「车牌×起运×到达×货物×种类」去重归并,空行剔除);`hsbz` 统一 **0**(不含税);`awrp_src_bt=ar_finarbill` + `awrp_src_con_no`=首张结算单金蝶应收防重键(金蝶按防重键校验源单,传编号会报源单不存在);`awrp_project_number`=999。旅客运输/代收车船税单据体 DTO 已建**未接入**。 +- **A303-5 回写**:按销方税号分组(开票单位→客商档案税号)≤50/批;落 `blade_invoice_application_invoice`(serial_no/invoice_no/invoice_date/invoice_amount/raw_json + 发票文件地址);唯一键防重;有新增发票→invoiced 终态。发票结果不反写结算单(文件地址随发票记录供结算详情查看)。 +- **A902 附件**:开票申请推送成功后联带上传(以本地 kingdeeBillId 定位金蝶单据,星瀚内码缺失时经 A303-4 反查回填)。 + +--- + +## 5. 业务域四:收票与金蝶票池 + +### 5.1 架构现状(★10-05 回切后终态) + +``` +【数据面】InvoiceReceiptKingdeeSyncTask 定时(默认关,每30分钟) + A801-1 全票池增量同步(incrementDays=30)→ invoiceTypes 白名单下推远端逐票种切片轮询 + → 2.5 万窗口硬上限触顶二分时间片递归 → 镜像 blade_kingdee_invoice_pool 批量 upsert(无变化跳写) + → refreshBoundReceipts:未绑定存量登记补绑(已绑定零写入) + 手动端点:POST /invoice-receipt/sync-invoice-pool(同一方法+同一把锁) + +【选票面】收票登记表单 / 结算单认领弹窗(KingdeeInvoicePoolDialog) + GET /invoice-receipt/invoice-pool、GET /formal-settlement/invoice-pool + → 本地镜像真分页;仅「票号精确查 + 无认领/登记过滤 + 本地空页」时窄查 A801-1 回源一次再本地重查 + 行过滤:票号非空 + amountUseStatus=0 + availableAmount>0 + +【闸门】收票登记保存 persistMirrorFromRemote(按票号精确单查+可用性复核+FOR UPDATE upsert) + 结算单认领确认 assertInvoicesClaimable(★10-05 补上:逐票窄查+可用复核,红冲/作废/被占用在此拦截) + +【推送链】审批通过自动推送(receipt 开关)/ 工具栏「批量同步」 + A102-4 预检(应付已生成)→ A801-3 幂等预检(防重键=收票登记 id) + → A303-2 收票单保存(付款方式固定现购)→ pushed → A302-6 指定应付 → assigned(终态) + → A902 附件联带上传;缺票种编码按票号窄查 A801-1 补填 + +【回写面】PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult(默认关,每10分钟)/ 手动端点 + A801-3 按「收票登记 id」匹配(★缺陷已修)→ 发票状态异常回收置 failed + / pushed 补偿重发 A302-6 / assigned 缺单号补登;窗口内查不到不改状态 + +【附件面】GET /invoice-receipt/invoice-pool/attachment/download?invoiceNo=(A801-2 流式代理) + 镜像优先、实时回源兜底;pdfurl 优先 taxPdfUrl 兜底,都缺退 downloadUrl(fileType=4 判 OFD) +``` + +### 5.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 收票登记(`/payment/invoice-receipt`) | 工具栏「批量同步」 | `POST /invoice-receipt/push-kingdee`(ids) | 单笔失败不中断整批,逐单报因 | +| 收票登记表单 | 发票号码放大镜(选票弹窗) | `GET /invoice-receipt/invoice-pool` | 镜像分页 + 回源;确认/保存前防重校验 | +| 收票登记表单 | 附件 查看/下载 | `GET /invoice-receipt/invoice-receipt/invoice-pool/attachment/download`(A801-2 代理) | 120s blob;PDF 优先 | +| 收票登记 | 行内「审批通过」 | `POST /invoice-receipt/approve` | receipt 开关开启时自动推送 | +| 正式结算单编辑器 | 发票认领区「查询可认领发票」/「确认识领」 | `GET /formal-settlement/invoice-pool`、`POST /formal-settlement/claim-invoices` | 确认时走认领闸门;未保存单仅本地暂存 | +| 共享弹窗 `KingdeeInvoicePoolDialog` | 行附件 查看/下载 | 同 A801-2 代理 | 票池弹窗、结算认领行、付款申请发票附件行、收票附件表 4 处复用 | + +### 5.3 后端要点 + +**金蝶侧效果**: + +![TMS 收票管理列表「金蝶状态」列:已指定应付(终态)/推送失败(带失败原因)/未推送,金蝶单据号 AVT-202608-000396 悬浮展示](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png) + +![A902 附件上传金蝶侧效果:金蝶财务应付单详情「附件」区收到 TMS 推送单据联带上传的文件;付款计划区同时可见下推生成的付款计划(外部系统来源标识=防重键)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png) + +![A801-2 发票查看/下载入口:正式结算单详情「发票信息」区认领的发票行提供 查看/下载(走后端 A801-2 流式代理,PDF 版式优先);付款信息区同时回显付款申请的金蝶单号](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png) + +- 推送计划校验:approved、状态≠assigned、已绑定镜像、`checkStatus=2`、**恰好分摊 1 张审批通过应付结算单(一票一应付)**、有结算明细;不满足写 `blockedReason` 不改状态。 +- A801-3 预检命中且状态正常→跳过 A303-2 直接续推指定(断点恢复);发票状态异常(作废2/红冲3/部分红冲7/全部红冲8)→failed 报人工。 +- 落库:`kingdee_received_invoice_no`、状态机 `unsynced→pushed→assigned/failed` →`kingdee_push_status`(唯一金蝶状态来源);终态/失败写 `blade_invoice_receipt_record`;pushed/assigned 禁止本地作废。 +- 全票池同步细节:单查询窗口 MAX_POOL_PAGES×500=2.5 万上限,`updateTime` 单调二分不重不漏;镜像 upsert 轻量比对(流水号/价税合计/税额/可用金额/使用状态)无变化跳写。 + +--- + +## 6. 业务域五:收款认领(A301-2 / A301-3 / A301-1) + +**定位**:业务先行认领。金蝶司库第一轮自动认领到客户维度 → TMS 二次认领到结算单维度 → **本地认领完成后**(定时或手动)回推金蝶生成收款单 → 手动查询回写结果。★09-28 重构后:认领与推送解耦,推送按流水归集、定时+手动双入口。 + +### 6.1 链路总览 + +``` +阶段一 收款流水页「流水同步」(手动) + POST /receipt-flow/sync ▶ A301-2 认领公告查询(claimusers=Nacos thirdParty.bfi.claimAnnounce.claimusers, + ★不再按登录用户姓名;未配置则以空值调用会查不到数据) + ▶ 全量翻页(窗口 months=6)▶ 按 detail_serial_no 幂等 upsert blade_kingdee_receipt_flow + +阶段二 流水详情/批量认领(手动) + POST /receipt-flow/claim ▶ 本地落库(claim + 分摊 + 流水 claimed_amount/claim_status 刷新 + 结算单收款汇总) + ▶ 本地完成即结束,**不调金蝶**(认领本地化★) + +阶段三 推送金蝶(定时 ReceiptClaimPushTask 默认关每10分钟 / 手动流水行内「推送金蝶」) + pushClaimToKingdee ▶ 按认领人逐人重查 A301-2 → 扫「认领完成未推送」流水按流水归集 + ▶ 逐流水:awrp_src_code=分摊结算单号拼接(重试复用幂等)→ A301-3 推送 + (分录单条:e_receivableamt=全部未推送金额;e_corebillno=第一张结算单金蝶内码,缺内码拦截不推) + ▶ 成功回写 kingdee_bill_no(回执 keys.awrp_src_code 占位)+ kingdee_bill_status=pushed + +阶段四 认领记录页「查询金蝶认领结果」(手动) + POST /receipt-claim-record/query-kingdee-result ▶ A301-1 收款单查询(今天回溯 30 天) + ▶ sourcebillnumber==awrp_src_code 匹配 ▶ 真实收款单号覆盖占位单号 + 状态推进(C→approved,D→received 终态) +``` + +### 6.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 收款流水(`/payment/receipt-flow`) | 工具栏「流水同步」 | `POST /receipt-flow/sync`(A301-2) | 权限 `receipt_flow_sync` | +| 同上 | 流水详情/批量认领表单「确认」 | `POST /receipt-flow/claim` | 选择审批通过应收结算单按比例分摊;已推送金蝶的认领禁止作废(后端硬校验) | +| 同上 | 行内「推送金蝶」 | `POST /receipt-flow/push-kingdee?flowId=` | 确认弹窗后按流水归集推送 | +| 认领记录(`/payment/receipt-claim-record`) | 工具栏「查询金蝶认领结果」 | `POST /receipt-claim-record/query-kingdee-result`(A301-1) | 手动;窗口内查不到不改状态 | + +### 6.3 后端要点 + +**金蝶侧效果**: + +![A301-2 对照:金蝶「认领公告列表」(RLTZ 通知单,待认领/部分认领)与 TMS 收款流水「手动同步流水」拉取的同批公告对照](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png) + +![A301-3 对照:TMS 认领记录(RLTZ-20260612-0073 认领 828.00)推送后,金蝶「认领处理列表」生成认领单 RL-20260929-0058(已确认,认领通知单 0073 对应)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png) + +![A301-3 金蝶侧认领结果详情:收款认领结果行「核心单据编号」=结算单星瀚内码(2586341365516352512,即 e_corebillno 传金蝶内码的落地证据)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png) + +- 推送闸门:已推送(pushed)流水跳过不重推;`billid`(认领公告 id)缺失拦截「请先同步金蝶收款流水」;结算单金蝶内码缺失拦截不推。 +- 幂等:`awrp_src_code` 重试复用同一编码;作废认领不再生成本地冲单(金蝶单据状态保留原值)。 +- A301-3 字段待金蝶确认项仍在:`recpaytype_number`(示例 101)、`paymenttype`、`recpayer`(金蝶付款人 master id)。 + +--- + +## 7. 业务域六:公共取数(A801-4 汇率) + +| 项 | 说明 | +|---|---| +| 前端入口 | 预结算单/正式结算单编辑器:非 RMB 且选了汇率日期(或币种切换)时自动 `GET /bfi/exchange-rate?currencyCode=&effectdate=`,回填结算汇率并重算本位币金额 | +| 后端 | 按日期查 A801-4 全量汇率(页大小固定 10)→ 本地按 `orgcur_number` 匹配 → 返回 VO;不落库 | +| 下游 | 汇率/汇率日期随结算单落 `blade_formal_settlement.exchange_rate / exchange_rate_date`,随 A102-2/A302-2 推送;A103-1 下推不传(按应付单自动带出) | + +![外币结算汇率推送效果:TMS 结算单结算汇率 6.736700(美元)推送后,金蝶应付单汇率表 6.7367、金额/金额(本位币) ¥9,923.16 自动换算,外部单据类型=TMS财务应付单](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png) + +--- + +## 8. 全景:接口依赖与先后顺序 + +``` + ┌──────── 客商档案(全局前提,软依赖) ────────┐ + │ A801-5 黑名单(提审拦截)→ A902-2 保存(审批自动/人工/定时)→ A902-1 复查 + └───────────────────┬────────────────────────┘ + ▼ +【应付主线】 正式结算单(应付)审批 ─A102-4 预检─▶ A102-2 保存 ─▶ synced ─▶ lockBusiData + │ A902 附件联带上传 (汇率 ← A801-4 页面回填) + │ A102-4 硬校验 + ▼ + 付款申请审批 ─A103-2 预检─▶ A103-1 下推(预付款→A103-3)─▶ synced ─▶ lockBusiData + │ + ▼ + 定时 A103-2 状态回写(纠偏/消失解锁)─▶ A103-4 付款记录(voucher_json) + +【应收主线】 正式结算单(应收)审批 ─A302-4 预检─▶ A302-2 保存 ─▶ synced ─▶ lockBusiData + │ A302-4 硬校验 + ▼ + 开票申请审批 ─A303-4 批量预检─▶ A303-1 逐张次新增(E04/freights/A902 附件)─▶ synced ─▶ 逐张锁单 + │ + ▼ + 定时 A302-4 勾稽纠偏 + A303-5 开票结果 ─▶ invoiced(终态,发票文件落库) + +【收票主线】 定时 A801-1 全票池同步 ─▶ 镜像表 ◀── 选票读镜像(票号未命中窄查回源) + │ 收票登记保存(闸门)/ 结算认领确认(闸门★新增) + ▼ + 收票登记审批 ─A102-4 预检─A801-3 预检(登记id)─▶ A303-2 保存 ─▶ pushed ─▶ A302-6 ─▶ assigned + │(本期不锁单;A902 附件联带上传) + ▼ + 定时 A801-3 回写(异常回收/指定补偿/单号补登,按登记 id 匹配★已修) + +【收款认领主线】 手动 A301-2 公告同步 ─▶ blade_kingdee_receipt_flow + │ 页面认领(本地化,不调金蝶) + ▼ + 定时/手动 A301-3 按流水归集推送(e_corebillno=结算单金蝶内码)─▶ pushed + ▼ + 手动 A301-1 收款单查询 ─▶ 真实收款单号覆盖 + approved/received(终) + +【横切】 审批通过 AFTER_COMMIT 自动同步(4 开关)│ 推送成功→lockBusiData(3 链路) + 金蝶删单→unLockNotifyBusiData 回调还原 │ 锁失败定时/手动补偿 + failed 单据→KingdeeAutoSyncRetryTask 定时重推 +``` + +**硬顺序规则(代码强制)**: + +| # | 规则 | 强制方式 | +|---|---|---| +| 1 | 客商档案先于一切推送(本地 customer_code 必须存在) | 查不到直接报错;金蝶侧审核状态不校验(软依赖) | +| 2 | 客商提审先过 A801-5 黑名单 | A 类命中/查询异常即阻断提审 | +| 3 | A102-2 先于 A103-1 与 A303-2 | A102-4 硬校验/预检 | +| 4 | A302-2 先于 A303-1 与 A301-3(e_corebillno 需金蝶内码) | A302-4 硬校验;内码缺失拦截不推 | +| 5 | A303-2 先于 A302-6 | 同一次推送内串行 | +| 6 | 推送先于回写(A103-2/A103-4、A302-4 勾稽、A303-5、A801-3、A301-1) | 查询键由推送链写入本地 | +| 7 | 锁单在推送成功之后、金蝶删单回调可还原 | lockAfterPush 事务提交后执行;unLockNotifyBusiData 免登回调 | +| 8 | A801-4 汇率查询先于 A102-2/A302-2(如需携带汇率) | 页面联动维护,未维护不传 | + +**断链恢复语义**:本地失败/远端未落单→unsynced 或 failed 可重推;远端成功本地回滚→重推时预检幂等回填(A102-4/A302-4/A103-2/A303-4/A801-3/awrp_src_code 复用);推送成功后状态滞后→定时回写;批量语义→整批预热+逐单独立事务+单条失败不中断。 + +--- + +## 9. 状态机汇总(2026-10-14) + +| 表.字段 | 状态集 | 迁移 | 写入方 | +|---|---|---|---| +| `blade_formal_settlement.kingdee_sync_status` | unsynced / synced / **failed**(★新增写入口) | unsynced→(A102-2/A302-2 成功)→synced;推送异常→failed(独立事务);failed 可手动/定时重推、A302-4 勾稽纠偏回 synced;金蝶删单回调→voided + unsynced 还原 | FormalSettlementServiceImpl | +| `blade_payment_application.kingdee_status` | unsynced / synced / paying / paid(终) / closed(终) / failed | 同前版映射;failed 可纠偏回 synced;**连续 2 轮查不到→消失解锁置 failed**;金蝶删单回调→failed+清单号(不作废源单) | PaymentApplicationServiceImpl + 定时 | +| `blade_invoice_application.kingdee_status` | unsynced / synced / invoiced(终) / **failed**(★新增写入口) | unsynced→(A303-1 全部张次成功)→synced→(A303-5 新发票)→invoiced;推送异常→failed;金蝶删单回调→作废还原 | InvoiceApplicationServiceImpl + 定时 | +| `blade_invoice_receipt.kingdee_push_status` | unsynced / pushed / assigned(终) / failed | 同前版;A801-3 回写匹配键已修复为登记 id(异常回收/补偿/补登生效) | InvoiceReceiptServiceImpl + 定时 | +| `blade_customer_archive.kingdee_status` | none / synced / failed / exist(终) | 同前版;提审新增 A801-5 黑名单拦截(不产生状态) | CustomerArchiveServiceImpl + 定时 | +| `blade_kingdee_bill_lock.lock_status` ★新增 | failed / locked / unlocked | 先落 failed 占位→lockBusiData 成功→locked;补偿重试(try_count<5)或金蝶解锁/删单回调→unlocked | KingdeeBillLockServiceImpl + 定时 + 手动端点 | +| `blade_receipt_claim.kingdee_bill_status` | none / pushed / approved / received(终) / failed / voided | none→(A301-3)→pushed→(A301-1: C)→approved→(D)→received;已推送禁止作废 | ReceiptFlowServiceImpl + ReceiptClaimRecordServiceImpl | +| `blade_kingdee_receipt_flow.claim_status` | unclaimed / partial / claimed | 认领分摊→partial/claimed;全部作废→unclaimed;公告同步不覆盖 | ReceiptFlowServiceImpl | + +--- + +## 10. 定时任务与配置开关汇总 + +全部在 `blade-transport/task/`,Spring `@Scheduled`,统一 single-flight(`KingdeeSyncLockSupport`,`blade.lock.enabled=false` 降级直跑)。 + +| 任务类 | 方法 | 覆盖业务(接口) | cron 键(默认) | 开关键(代码默认) | 手动端点 | +|---|---|---|---|---|---| +| PaymentKingdeeResultTask | syncKingdeePaymentResult | ① A103-2/A103-4 付款结果 ② A302-4 应收勾稽 ③ A303-5 开票结果 | `thirdParty.bfi.payResult.cron`(`0 */10 * * * ?`) | `thirdParty.bfi.payResult.syncEnabled`(false;**Nacos 样例 dev/prod 已显式 true**) | `/payment-application/sync-kingdee-result`、`/formal-settlement/sync-kingdee-ar-result`、`/invoice-application/sync-kingdee-invoice-result` | +| PaymentKingdeeResultTask | syncKingdeeReceivedInvoiceResult | A801-3 收票结果回写 | `thirdParty.bfi.receivedInvoiceResult.cron`(每10分钟) | `…receivedInvoiceResult.syncEnabled`(false) | `/invoice-receipt/sync-kingdee-result` | +| CustomerKingdeeSyncTask | syncKingdeeCustomerData | A902-1 复查 + A902-2 重推 + 金蝶停用联动 | `thirdParty.bfi.customer.cron`(`0 0 2 * * ?`) | `…customer.syncEnabled`(false;KS000067 残留已移除) | `/customer-archive/sync-kingdee` | +| **ReceiptClaimPushTask** ★新增 | pushClaimToKingdee | A301-3 认领推送(按流水归集,前置重查 A301-2) | `thirdParty.bfi.receiptClaimPush.cron`(每10分钟) | `…receiptClaimPush.syncEnabled`(false) | `/receipt-flow/push-kingdee` | +| **InvoiceReceiptKingdeeSyncTask** ★新增 | syncKingdeeInvoicePool | A801-1 全票池瘦身同步 + 绑定回填 | `thirdParty.bfi.invoiceReceipt.cron`(每30分钟) | `…invoiceReceipt.syncEnabled`(false);增量窗口 `…incrementDays`(30);票种白名单 `…invoiceTypes` | `/invoice-receipt/sync-invoice-pool` | +| **KingdeeAutoSyncRetryTask** ★新增 | retryKingdeeFailed | 4 域 failed 单据重推(结算/付款/开票/收票;不扫 unsynced;无次数上限,靠幂等预检防重) | `thirdParty.bfi.autoSync.retryCron`(每10分钟) | `…autoSync.retryEnabled`(false) | 各域 `/sync-kingdee` | +| **KingdeeBillLockRetryTask** ★新增 | retryFailedBillLocks | 锁单失败补偿(failed 且 try_count<5 重调 lockBusiData) | `thirdParty.bfi.billLock.retryCron`(每5分钟) | `…billLock.retryEnabled`(false) | `/formal-settlement/retry-kingdee-bill-lock` | + +**审批自动同步开关(监听器,非定时)**:`thirdParty.bfi.autoSync.settlement / payment / invoiceApply / receipt`(默认全 false)。 +**其他业务键**:`thirdParty.bfi.claimAnnounce.claimusers`(A301-2 认领人,**必配**)、`…claimAnnounce.months`(6)、`thirdparty.bfi.blacklist.blockRiskWarning`(false)、`thirdparty.bfi.material.*`(物料映射)。 + +> ⚠️ Nacos 样例(`doc/nacos/`)仅显式配置了 `payResult.syncEnabled=true`;`receiptClaimPush/invoiceReceipt` 样例仍为 false;`customer/receivedInvoiceResult/autoSync/billLock` 键未出现在样例中——生效值取决于各环境实际 Nacos,缺省走代码 false。**开启生产定时前逐项核对第 12 节风险清单。** + +--- + +## 11. 操作流程手册 + +### 11.1 一次性前置准备(实施/运维) + +| # | 事项 | 操作 | +|---|---|---| +| 1 | 部门金蝶组织编码 | 部门档案维护 `kingdee_dept_code`;未维护的部门推送组织取数会直接拦截 | +| 2 | 主数据 SQL | 执行 `doc/sql/transport/kingdee_master_data_init_20260924.sql`(计量单位档案等);认领链路执行 `blade_receipt_flow_claim_20260924.sql`;10-13 迁移脚本见 `doc/sql/transport/lqj/` | +| 3 | Nacos 业务配置 | `thirdParty.bfi.claimAnnounce.claimusers`(认领人,与金蝶公告 claimusers 一致)、`thirdParty.bfi.invoiceReceipt.invoiceTypes`(票池同步票种白名单)、`thirdparty.bfi.material.*`(物料映射,缺省有默认值但建议显式配置) | +| 4 | 定时开关按需开启 | 见第 10 节表:付款结果回写样例已开;其余按环境显式开启 `syncEnabled/retryEnabled` | +| 5 | 审批自动同步开关 | `thirdParty.bfi.autoSync.settlement/payment/invoiceApply/receipt` 按域开启;未开启时走页面「同步金蝶」手动推送 | +| 6 | 客商收款账户 | 维护 `blade_customer_receipt_account` 联行号(cnaps_code)——付款申请/预付款/客商推送均取真实联行号,缺失拦截 | + +### 11.2 日常业务操作时序(财务/业务) + +``` +① 客商建档 + 新增客商 → 录统一社会信用代码(自动查金蝶回填)→ 上传发票信息 → 提交审批 + (后端自动过 A801-5 黑名单,A 类命中被拦截)→ 审批通过 → 后端自动推送 A902-2 + → 列表 kingdee_status:synced(金蝶受理)→ 定时复查升级 exist(金蝶审核通过) + +② 结算(应付/应收) + 预结算/正式结算单编辑:非 RMB 币种选择汇率日期 → 自动带出金蝶汇率 → 提交审批 + → 审批通过(autoSync.settlement 开启时自动推送;或列表勾选点「同步金蝶」) + → 成功后列表显示金蝶单号 + 「金蝶锁定中」标签(单据已被金蝶锁定,不可编辑/作废) + +③ 付款 + 付款申请(关联已同步结算单)→ 审批通过(autoSync.payment 开启时自动下推;或「批量同步」) + → 后端校验应付已生成 → A103-1 下推/预付款 A103-3 直推 → 锁单 + → 等 A103-2/A103-4 定时回写付款状态与付款记录(或点「同步付款结果」立即回写) + → 状态流转 synced→paying→paid/closed,付款记录含凭证 JSON + +④ 开票 + 开票申请(关联已同步应收结算单)→ 审批通过(autoSync.invoiceApply 开启时自动按张次推送;或「批量同步」) + → 每张发票张次生成一张金蝶开票申请单 → 锁单 → 开票申请列表显示逗号拼接金蝶单号 + → 等 A303-5 定时回写发票结果(或手动端点)→ 状态 invoiced + → 正式结算单详情「开票发票」区查看/下载发票文件 + +⑤ 收票(进项) + 收票登记表单:发票号放大镜从金蝶票池选票(镜像读,票号未命中自动回源)→ 保存(重查闸门校验可用性) + → 分摊到一张审批通过应付结算单 → 审批通过(autoSync.receipt 开启时自动推送;或「批量同步」) + → A303-2 保存→pushed→A302-6 指定应付→assigned(终态) + → 等 A801-3 定时回写(pushed 断点自动补偿重发 A302-6) + +⑥ 收款认领 + 收款流水页「流水同步」拉取金蝶认领公告 → 流水详情/批量认领:选审批通过应收结算单按比例分摊(本地落库) + → 「推送金蝶」(行内手动,或等 ReceiptClaimPushTask 定时)→ 金蝶生成收款单 + → 认领记录页「查询金蝶认领结果」回写真实收款单号与状态(approved/received) + 注意:已推送金蝶的认领不可作废,需金蝶侧人工处置 + +⑦ 查看附件 + 金蝶票池/认领发票/收票附件的「查看/下载」统一走后端 A801-2 代理(PDF 版式优先) +``` + +### 11.3 异常处理操作 + +| 场景 | 现象 | 处理 | +|---|---|---| +| 推送失败(failed) | 列表金蝶状态 failed + 失败原因;审批时前端提示「审批通过,但自动同步金蝶失败:xxx」 | 修复原因后:客商/结算/付款/开票页手动「同步金蝶」重推;或等 `KingdeeAutoSyncRetryTask`(开启后每 10 分钟自动重推 failed) | +| 锁单失败 | 列表「锁定失败」标签;`blade_kingdee_bill_lock` failed 记录 | 等 `KingdeeBillLockRetryTask` 自动补偿(≤5 次);或 `POST /formal-settlement/retry-kingdee-bill-lock` 手动补偿;人工重推会重置计数 | +| 金蝶侧删单 | 金蝶回调 `POST /kingdee-bfi/unLockNotifyBusiData` | 自动还原:结算单/开票申请作废+状态回 unsynced 清单号(可重推);付款申请置 failed 清单号(不作废源单) | +| 单据被锁定后需修改 | 「金蝶锁定中」禁编辑/作废 | 只能在金蝶侧解锁/删单,触发回调还原后再改(无本地强制解锁入口,有意设计) | +| 付款单在金蝶被删且无回调 | A103-2 连续 2 轮(约 20 分钟)查不到 | 自动消失解锁置 failed,按 failed 重推 | +| 断链重推(远端已落单本地未同步) | 重推时预检命中 | 自动幂等回填单号跳过重复推送,无需人工清库 | +| 收票单发票状态异常 | 定时回写发现红冲/作废 → failed「已回收推送状态」 | 人工在金蝶侧处理后重新登记/推送 | +| 票池选不到票 | 镜像无数据 | 检查全票池同步开关与 `invoiceTypes` 白名单;或 `POST /invoice-receipt/sync-invoice-pool` 手动同步;票号精确查会自动回源 | +| 收款流水同步为空 | A301-2 查不到公告 | 检查 Nacos `claimAnnounce.claimusers` 是否与金蝶公告认领人一致 | +| 定时任务不跑/重复跑 | — | 核对 Nacos 开关键名(第 10 节);single-flight 锁保证多副本/手动不并发,`blade.lock.enabled=false` 时无锁 | + +--- + +## 12. 风险与待确认清单(2026-10-14) + +### 12.1 已关闭(旧总纲遗留,当前代码已核实修复) + +| 旧问题 | 状态 | +|---|---| +| A801-3 定时回写按发票号匹配防重键(回写失效) | ✅ 已修复(登记 id,`InvoiceReceiptServiceImpl:1003-1009`) | +| `if(true)` 调试组合块(强制/随机物料) | ✅ 全部删除,真实拆分+配置映射 | +| 客商定时 KS000067 硬编码 | ✅ 已移除 | +| 客商推送 bankNumber 硬编码 / A103-3 e_bebank_number 取名称字段 | ✅ 均改取客商收款账户联行号 | +| 结算/开票失败不落 failed | ✅ 统一落 failed(03b34310) | +| 结算单认领无保存重查闸门 | ✅ 已实现 assertInvoicesClaimable | + +### 12.2 仍需关注 / 联调确认 + +| # | 事项 | 证据/说明 | +|---|---|---| +| 1 | **A103-1 勾稽键口径**:代码传本地组装的应付防重键,`PaymentApplicationServiceImpl:1431-1443` 注释仍写「A102-4 反查 awrp_entry_code_p、禁止自行拼接」——注释与代码矛盾,金蝶侧付款计划分录标识是否等于防重键需联调实测 | 高优先 | +| 2 | **项目编码 999 残留**:A102-2/A302-2/A103-1/开票申请仍传测试值 999(仅 A103-3 已用真实项目编码) | 上线前处理 | +| 3 | **计量单位不送**:分录 DTO 有字段无赋值——需与金蝶确认不送计量单位是否影响单据校验 | 联调确认 | +| 4 | **A301-3 字段待金蝶确认**:`recpaytype_number`(101)/`paymenttype`/`recpayer` 取值口径 | 联调确认 | +| 5 | **A301-2 claimusers 未配置风险**:缺省空串调 A301-2 查不到数据(无报错),需在 Nacos 必配 | 配置项 | +| 6 | **其他链路不校验客商 kingdee_status**:客商未推送/未审核也会带编码推单(软依赖) | 既有风险 | +| 7 | **正式结算单作废未接 checkEditable**:仅靠前端锁状态标签禁用,接口层可绕过 | 建议补拦截 | +| 8 | **failed 重推无次数上限**:`KingdeeAutoSyncRetryTask` 每 10 分钟扫全量 failed,持续失败的单据会反复调金蝶(靠预检防重但不封顶) | 观察线上量 | +| 9 | **付款申请前端单笔「同步金蝶」按钮未绑定**(`handleSyncRow` 死代码):单笔入口仅剩审批自动同步 | 前端待补 | +| 10 | **旅客运输/代收车船税单据体**:DTO 已建未接(仅 freights 接入) | 按需迭代 | +| 11 | **方案 vs 代码差异(沿旧总纲)**:付款多次下推未实现(单分录单次);附件已落地(A902 4 链路)✅;收款流水同步已有定时(receiptClaimPush)✅;黑名单 A801-5 已实现 ✅ | — | + +--- + +## 附:文档关系 + +- 本文为新总纲(基线 2026-10-13 `805ca9d3`),替代 09-27 版;单链路细节仍以 `doc/docs/业务流程/` 对应专项文档为深入参考,冲突处以本文 + 当前代码为准。 +- 09-27 版中被本文修订的关键结论:A801-3 回写缺陷、票池「实时回源无定时」架构、收款认领「全手动无定时」、分录调试覆盖、物料映射常量、failed 落库口径、A801-5/A801-2/A902「未实现」状态。 diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/01-客商档案列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/01-客商档案列表.png new file mode 100644 index 00000000..34d12ccb Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/01-客商档案列表.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png new file mode 100644 index 00000000..bb378c20 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png new file mode 100644 index 00000000..0b7c3764 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png new file mode 100644 index 00000000..ff938893 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png new file mode 100644 index 00000000..7e2232b3 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png new file mode 100644 index 00000000..0b5f0ee2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png new file mode 100644 index 00000000..2437141d Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png new file mode 100644 index 00000000..80280656 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png new file mode 100644 index 00000000..60b481e2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png new file mode 100644 index 00000000..624cdb27 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png new file mode 100644 index 00000000..5809ec06 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png new file mode 100644 index 00000000..9e5436a7 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png new file mode 100644 index 00000000..77fdd786 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png new file mode 100644 index 00000000..73b5f4f2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png new file mode 100644 index 00000000..5aeaf6ce Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png new file mode 100644 index 00000000..4b16d08d Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png new file mode 100644 index 00000000..2ecd50fa Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png new file mode 100644 index 00000000..022c9a96 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png new file mode 100644 index 00000000..7a39bace Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png new file mode 100644 index 00000000..f088fb92 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png new file mode 100644 index 00000000..63cbf98a Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png new file mode 100644 index 00000000..3bcf8a36 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png new file mode 100644 index 00000000..82271e1c Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png new file mode 100644 index 00000000..7e053b97 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png new file mode 100644 index 00000000..329d4645 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png new file mode 100644 index 00000000..2a30d9f3 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md new file mode 100644 index 00000000..4483b8a8 --- /dev/null +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md @@ -0,0 +1,318 @@ +# 金蝶(星瀚)BFI 对接操作手册(纯操作版) + +> 整理日期:2026-10-14 +> 适用对象:TMS 业务操作人员(财务/业务)、系统管理员、实施运维。 +> 本文只讲**怎么操作**:在哪个页面、点什么按钮、前提是什么、成功的标志是什么、失败了怎么处理。 +> 接口与代码细节见同目录《2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册)》。 + +--- + +## 1. 全局须知 + +### 1.1 金蝶单据状态含义(列表里看到的字段) + +| 状态值 | 含义 | 还会变吗 | +|---|---|---| +| unsynced | 未推送金蝶 | 会(推送成功变 synced) | +| synced | 已推送金蝶,金蝶受理 | 会(继续走后续状态) | +| failed | 推送失败(有失败原因) | 会(重推成功变 synced) | +| paying | 金蝶付款中(仅付款申请) | 会(付款完成变 paid) | +| paid / closed | 已付款 / 已关闭(终态) | 不变 | +| invoiced | 已开票(终态,开票申请) | 不变 | +| assigned | 已指定应付(终态,收票) | 不变 | +| exist | 金蝶已有该客商且审核通过(终态,客商) | 不变 | + +### 1.2 「金蝶锁定」标签是什么 + +结算单、付款申请、开票申请推送成功后,TMS 会**自动锁定金蝶侧单据**。列表出现红色「金蝶锁定中」标签时:**该单据不可修改、不可作废**。要改动只能先在金蝶侧解锁/删单,金蝶会回调 TMS 自动还原本地状态。 + +### 1.3 两条推送路径 + +- **自动**:审批「通过」时系统自动推送金蝶(需管理员开启对应开关,见第 4 节);开启后审批接口会直接返回推送结果,失败时页面提示「审批通过,但自动同步金蝶失败:xxx」。 +- **手动**:列表勾选单据点工具栏「同步金蝶 / 批量同步 / 推送金蝶」按钮。开关没开时用这条路径。 + +两条路径效果完全一样,重复点不会重复推送(系统自动查重)。 + +--- + +## 2. 一次性准备(实施/管理员操作) + +| # | 事项 | 操作人 | 操作内容 | 不做的后果 | +|---|---|---|---|---| +| 1 | 部门金蝶组织编码 | 管理员 | 在部门档案维护「金蝶组织编码」字段 | 该部门单据推送时直接报错拦截 | +| 2 | 客商收款账户联行号 | 财务 | 客商档案维护收款账户(账号/开户行,**联行号必填**) | 付款推送、客商推送报错拦截 | +| 3 | 主数据初始化 SQL | 运维 | 执行 `doc/sql/transport/` 下金蝶相关脚本(计量单位等主数据、认领表、10-13 迁移脚本 `lqj/`) | 对应链路数据缺失 | +| 4 | 认领人配置 | 运维 | Nacos 配 `thirdParty.bfi.claimAnnounce.claimusers`(值须与金蝶认领公告的认领人一致) | 收款流水同步查不到数据(不报错) | +| 5 | 票池票种白名单 | 运维 | Nacos 配 `thirdParty.bfi.invoiceReceipt.invoiceTypes`(要同步的发票种类编码,逗号分隔) | 票池同步不到对应票种 | +| 6 | 物料映射 | 运维 | Nacos 配 `thirdparty.bfi.material.*`(有默认值,建议显式确认) | 推送报「运输方式未配置」类拦截 | +| 7 | 定时任务开关 | 运维 | 按第 4 节清单逐项开启 | 只能全靠手动按钮 | +| 8 | 审批自动同步开关 | 运维 | 按第 4 节 `autoSync.*` 按域开启 | 审批通过后不自动推送,走手动 | + +--- + +## 3. 日常操作流程 + +### 3.1 客商建档与同步 + +**页面:客商管理 → 客商档案** + +![客商档案列表:行内「同步金蝶」链接仅在审批通过且推送失败(failed)的单据上显示;审批通过的客商由系统自动推送](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/01-客商档案列表.png) + +1. 点「新增」,录入**统一社会信用代码**(满 18 位自动查金蝶回填工商信息并锁定相关字段)。 + - 若提示金蝶侧该客商「审核中」——等金蝶审核完再保存/推送。 +2. 维护**收款账户(联行号必填)**和发票信息(纳税人识别号用于黑名单校验)。 +3. 提交审批:系统自动做**黑名单校验**。 + - 提交被拦「命中黑名单」→ 该客商为国资委黑名单 A 类,**不能提交**,走线下处置。 +4. 审批「通过」→ 系统自动推送金蝶 → 列表 `kingdee_status` 变 **synced**。 + - 变 **failed** → 看失败原因(悬停/详情),修复后点行内「同步金蝶」重推。 +5. synced 是「金蝶已受理」;金蝶审核通过后由定时任务升级为 **exist**(终态,无需操作)。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![金蝶客户列表收到推送的客商:编码=TMS 客商编号(KS000073),客户分类 1101 内部客户/1102 外部客商](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png) + +![TMS 客商档案与金蝶客户档案对照:编码一致、以统一社会信用代码为关联键](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png) + +![新增客商档案:统一社会信用代码录入满 18 位自动到金蝶查询并回填工商信息](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png) + +> 注意:审批通过后的**档案变更不会重新推送金蝶**。改了信用代码等关键信息需人工确认金蝶侧一致性。 + +### 3.2 预结算单(正式结算与预付款的前置) + +**页面:结算管理 → 预结算单** + +预结算单**本身不推送金蝶**(列表无金蝶状态字段),它是正式结算和进度预付的前置单据,与金蝶的关联有两处:①非人民币项目在此维护汇率(从金蝶自动带出);②进度预付款在金蝶付讫后自动回写本单的垫资记录。 + +![预结算单列表:工具栏「新增 / 预付申请 / 尾款结算」;草稿行可 编辑/删除/提交,审批中可 通过/驳回](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png) + +1. 制单:「新增」录入结算金额与明细;**非人民币项目**:选择币种和汇率日期 → 系统自动从金蝶带出结算汇率并重算本位币。 + ![预结算单编辑页:汇率日期/结算汇率字段(人民币时禁用为 1.00,外币选择日期后自动带出)](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png) +2. 提交审批 → 审批「通过」(或驳回后修改重新提交)。 +3. 审批通过后的三个出口: + - **发起预付**:工具栏「预付申请」(限应付且已审批、未转正式)→ 生成进度预付付款申请,走 3.4 预付款推送金蝶; + - **转正式结算**:在正式结算单页面新建时选择本预结算单作为来源(可多张合并)→ 转入 3.3 正式结算推送;转正式后本单被锁定(节点显示「已锁定(正式结算)」并回写正式结算单号); + - **尾款结算**:应付单生成尾款结算单号(同样需先转入正式结算推金蝶)。 +4. 注意:**正式结算单的汇率不自动继承预结算单**,转正式后需在正式结算单编辑时重新选汇率日期带出(见 3.8),否则推送金蝶不带汇率。 +5. 金蝶付款回写联动:进度预付在金蝶付讫(paid)后,系统自动在本单垫资记录回写付款金额与金蝶付款单号,并刷新已付/未付汇总——无需手工登记(异常时管理员可用回写接口手工补)。 + +### 3.3 结算单推送(应付 / 应收) + +**页面:结算管理 → 正式结算单(应付 / 应收 Tab)** + +![正式结算单:应付/应收同页两个 Tab;工具栏「同步金蝶」需先勾选一条审批通过的单据;列表含结算汇率、金蝶单据状态、金蝶锁状态等列](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png) + +前提:结算单已审批通过;非人民币结算单已维护汇率(见 3.8);客商已 synced/exist。 + +1. **自动路径**:审批「通过」时自动推送(开关开启时)。 +2. **手动路径**:勾选一条单据 → 工具栏「同步金蝶」。 + - 按钮是单选操作,一次推一张。 +3. 成功标志:列表出现**金蝶单号**,状态变 **synced**,并出现「金蝶锁定中」标签。 +4. 失败处理:状态变 failed → 看失败原因 → 常见原因: + - 「请先同步金蝶应付单」类 → 关联结算单还没推(应付/应收/收票有先后依赖,见 3.6); + - 客商编码取不到 → 先完成 3.1; + - 部门金蝶组织编码缺失 → 找管理员维护。 +5. 纠偏:如果怀疑推送实际成功但本地状态没更新,等定时勾稽(或联系运维手动触发),系统会自动核对金蝶侧并纠正状态/补单号。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![金蝶财务应付单与 TMS 结算单对照:金蝶单上的「外部系统单据号」=TMS 防重键,应付金额与明细行(物料/车号/货物)逐行对应](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png) + +![金蝶财务应收单与 TMS 结算单对照:应收金额、费用合计对应,明细行物料/车号/运输货物逐行对应](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png) + +### 3.4 付款申请推送与结果回写 + +**页面:收付款管理 → 付款管理** + +![付款管理:工具栏「批量同步」推送付款申请;列表自带「金蝶单据号 / 金蝶单据状态 / 金蝶锁状态 / 附件同步」列——图中首行即「已生成 + 金蝶锁定中 + 附件已上传」的已推送单据](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png) + +前提:付款申请已审批通过,且**关联的结算单已推送金蝶(synced)**。 + +1. 推送: + - 自动路径:审批「通过」时自动下推。 + - 手动路径:勾选**审批通过**的单据 → 工具栏「批量同步」(一次可多张,单张失败不影响其他)。 + - 预付款(项目垫资/进度垫资)走同样的按钮,系统自动走预付款通道。 +2. 成功标志:状态 **synced** + 金蝶单号 + 锁定标签。 +3. 查付款结果: + - 正常等定时任务回写(默认每 10 分钟,开关开启时); + - 急用点工具栏「**同步付款结果**」(勾选单据后显示;若未见到该按钮,说明当前环境以定时回写为主,等下一轮即可)。 +4. 结果状态流转:synced → **paying**(金蝶付款中)→ **paid**(已付款,含付款记录/凭证信息)/ **closed**(关闭)。 +5. 失败与特殊场景: + - failed → 修复后重推(批量同步按钮即可,系统会自动跳过金蝶已有的); + - 单据在金蝶被删:回调后自动置 failed 并清单号,直接重推; + - 金蝶删了但回调丢了:约 20 分钟后系统自动检测到「查不到」并解锁置 failed,再重推。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![TMS 付款申请与金蝶付款申请单对照:申请付款金额、收款账号、结算方式逐项对应;金蝶单号 FKSQ-… 与 TMS 列表回写的金蝶单号一致](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png) + +![预付款推送效果:TMS 进度预付在金蝶生成「其他付款申请」,预付款金额、收款账户对应,附件随单上传](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png) + +> 注意:推送后单据被金蝶锁定,**不能改金额、不能作废**。发现金额错误必须先金蝶侧处理。 + +### 3.5 开票申请推送与开票结果 + +**页面:收付款管理 → 开票管理** + +![开票管理:工具栏「批量同步」逐张推送(按发票张次拆单);搜索区可按「金蝶单据状态」筛选](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png) + +前提:开票申请已审批通过,且**关联结算单的应收单已推送金蝶**。 + +1. 推送:审批「通过」自动推送,或勾选后工具栏「批量同步」(逐张处理)。 + - 系统按**发票张次**拆分:一张申请 3 张发票会生成 3 张金蝶开票申请单。 +2. 成功标志:状态 **synced**,金蝶单号多张时逗号拼接显示;逐张锁定。 +3. 查开票结果:等定时回写(默认每 10 分钟),状态变 **invoiced**(终态)。 +4. 查看发票文件:正式结算单详情 →「开票发票」区 → 查看/下载(PDF 优先)。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![开票申请按发票张次拆分:TMS 开票内容第1张/第2张分别生成金蝶开票申请单,购销方、不含税金额逐项对应](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png) + +### 3.6 收票登记(进项发票) + +**页面:收付款管理 → 收票管理** + +![收票管理:工具栏「批量同步」推送收票单并指定应付](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png) + +**硬性前提**:一张票只能登记一次;推送前必须已分摊到**恰好一张审批通过的应付结算单**,且该结算单已推送金蝶。 + +1. 选票:表单里点发票号放大镜 → 弹出「查询金蝶票据池」→ 勾选 → 确认(带出票面信息)。 + ![查询金蝶票据池弹窗:按发票号/日期/税号/票种等筛选;每行可「查看/下载」发票原件(PDF 优先);点「选择」带出到表单](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png) + - 票池数据来自定时同步(默认每 30 分钟);票号精确搜索时若本地没有会**自动去金蝶现查**。 + - 查不到票 → 等同步或让运维检查票种白名单配置/手动触发同步(见 4.2)。 +2. 保存:系统自动**重查金蝶票池校验可用性**——票已被别人登记/认领/红冲/作废会保存失败并提示原因。 +3. 分摊应付结算单后提交审批。 +4. 推送:审批「通过」自动推送,或勾选 → 工具栏「批量同步」。 +5. 状态流转:synced→(系统内)→ **pushed**(金蝶收票单已生成)→ **assigned**(已指定应付,终态)。 + - 卡在 pushed 不用管:定时任务会自动补发「指定应付」。 + - failed 且提示发票状态异常(红冲/作废)→ 该票在金蝶已不可用,需人工处理,不要重推。 +6. 附件:表单附件区可查看/下载发票原件(系统自动从金蝶取,PDF 优先)。 + +推送成功后金蝶侧状态与效果(供确认推送结果时对照): + +![收票管理列表「金蝶状态」列:已指定应付(终态)/推送失败(含失败原因)/未推送](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png) + +![推送的单据附件已上传到金蝶侧单据的「附件」区](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png) + +![正式结算单详情「发票信息」区:认领的发票可 查看/下载(系统从金蝶取原件,PDF 优先);付款信息区回显付款申请金蝶单号](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png) + +### 3.7 收款认领 + +**页面:收付款管理 → 收款流水、认领记录** + +这是「金蝶收到钱 → TMS 认领到结算单 → 回推金蝶」的流程: + +1. **同步流水**:收款流水页 → 工具栏「**手动同步流水**」→ 拉取金蝶认领公告。 + ![收款流水:工具栏「手动同步流水」拉取金蝶认领公告、「批量认领」进入认领;列表展示认领通知单/付款人/对方户名等](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png) + - 查不到数据先检查认领人配置(第 2 节 #4)。 +2. **认领**:点流水进入详情(或工具栏「批量认领」)→ 选择**审批通过且已推送金蝶的应收结算单**、填分摊金额 → 确认。 + - 认领此时只落本地,不推金蝶。 + - 分摊总额不能超过该流水未认领金额。 +3. **推送金蝶**: + - 手动:流水详情/符合条件的行内「推送金蝶」; + - 自动:定时任务(默认每 10 分钟,开关开启时)自动推送认领完成的流水。 + - 成功标志:流水显示金蝶收款单号、推送状态 pushed。 +4. **回写结果**:认领记录页 → 工具栏「**查询金蝶认领结果**」→ 状态推进 approved(金蝶已审核)/ **received**(已收款,终态)。 + ![认领记录:工具栏「查询金蝶认领结果」回写金蝶收款单状态](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png) +5. **作废限制**:已推送金蝶的认领**不能作废**(提示去金蝶侧人工处置)。未推送的可以作废。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![金蝶认领公告与 TMS 收款流水对照:「手动同步流水」拉取的就是金蝶认领公告列表里的 RLTZ 通知单](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png) + +![TMS 认领记录推送后,金蝶「认领处理列表」生成对应认领单(认领通知单单号一致)](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png) + +![金蝶认领单详情:认领结果行的核心单据编号=TMS 分摊结算单对应的金蝶单据](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png) + +### 3.8 汇率维护(非人民币结算) + +结算单(预结算/正式结算)编辑时:选择非 RMB 币种 → 选择**汇率日期** → 系统自动从金蝶带出汇率并重算本位币金额。带不出(金蝶该日无汇率)→ 换日期或联系金蝶侧确认。 + +![结算单详情:汇率日期/结算汇率字段由金蝶汇率接口自动带出,本位币金额随之重算](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png) + +外币结算推送后,金蝶侧按该汇率自动换算本位币: + +![金蝶侧效果:汇率 6.7367、金额(本位币)自动换算,与 TMS 结算单的本位币合计一致](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png) + +--- + +## 4. 定时任务与开关(运维视角) + +### 4.1 任务清单(Nacos `thirdparty.bfi.*` 配置) + +| 任务 | 干什么 | 开关键 | cron 键(默认频率) | 手动替代按钮 | +|---|---|---|---|---| +| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(10 分钟) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 | +| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | 每 10 分钟 | 收票「同步金蝶结果」 | +| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | 每天 02:00 | 客商「同步金蝶」 | +| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | 每 10 分钟 | 流水行内「推送金蝶」 | +| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | 每 30 分钟 | 手动端点 `POST /invoice-receipt/sync-invoice-pool` | +| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | 每 10 分钟 | 各页面「同步金蝶」 | +| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | 每 5 分钟 | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` | + +### 4.2 审批自动同步开关(按业务域独立) + +`thirdParty.bfi.autoSync.settlement`(结算单)/ `…payment`(付款)/ `…invoiceApply`(开票)/ `…receipt`(收票),默认全关。**代码默认全部关闭,必须在 Nacos 显式开启**;客商档案审批推送不受此开关控制(始终自动)。 + +### 4.3 运维注意 + +- 所有定时任务带**分布式锁**:多实例部署只跑一份;手动按钮和定时共用锁,定时运行中手动触发会直接跳过(属正常)。 +- 开了「failed 重推」后,持续失败的单据每 10 分钟会重试一次,**修复根因**(配置/数据)比反复观察更重要。 +- 手动触发全票池同步与定时是同一逻辑,数据量大时耐心等待,不要反复点。 + +--- + +## 5. 常见问题与异常处理(FAQ) + +| 现象 | 原因 | 处理 | +|---|---|---| +| 推送结算单不带汇率 | 正式结算单没维护汇率(预结算单的汇率**不自动继承**到正式结算单) | 在正式结算单编辑页重选汇率日期自动带出后再推 | +| 推送报「客商编码」类错误 | 客商未推送金蝶或档案缺数据 | 先完成客商同步(3.1),再推单据 | +| 推送报「请先同步金蝶应付单/应收单」 | 前置单据未推送 | 顺序:结算单(应付/应收) → 付款/开票/收票;先把前置结算单推送 | +| 推送报组织/部门编码类错误 | 部门没维护金蝶组织编码 | 管理员补维护后重推 | +| 推送报联行号类错误 | 客商收款账户联行号缺失 | 财务补维护收款账户后重推 | +| 提交客商审批被拦黑名单 | 纳税人识别号命中国资委黑名单(A 类) | 不可提交,走线下处置;B 类提示仅告警默认放行 | +| 审批通过但提示「自动同步金蝶失败」 | 推送失败但审批已生效 | 看失败原因 → 修复 → 手动「同步金蝶」重推(单据不会丢) | +| 列表「锁定失败」标签 | 推送成功但金蝶锁单接口失败 | 系统自动补偿(≤5 次);急用让运维调手动补偿端点 | +| 单据要改但显示「金蝶锁定中」 | 已推送并被金蝶锁定 | 到金蝶侧解锁/删单 → TMS 自动还原后即可编辑;不可强行作废 | +| 付款状态长时间不更新 | 回写定时未开启或金蝶侧未处理 | 点「同步付款结果」;仍无 → 金蝶侧查付款处理单 | +| 开票申请 synced 但没发票信息 | 开票结果未回写 | 等定时/联系运维手动触发;确认金蝶票易通已开票 | +| 票池选票查不到 | 镜像未同步到该票种 | 检查票种白名单配置;运维手动触发全票池同步;票号精确搜索可触发自动现查 | +| 保存收票登记被拦「可用金额为0」 | 票已被占用/认领/红冲 | 换票或在金蝶确认票据状态 | +| 收票卡在 pushed 不动 | 「指定应付」步骤未完成 | 开启收票回写定时自动补发,或重推一次 | +| 收款流水同步为空 | 认领人配置与金蝶不一致 | 检查 Nacos `claimAnnounce.claimusers` | +| 认领推送报「认领公告id缺失」 | 流水没同步过就认领了 | 先「流水同步」再操作 | +| 认领推送报金蝶内码缺失 | 分摊的结算单没推送金蝶 | 先完成结算单(应收)推送(3.3) | +| 已推送认领想作废 | 金蝶已生成收款单 | 不能作废,去金蝶侧人工处置 | +| 断网/金蝶宕机期间推了一半 | 部分单据 failed | 恢复后 failed 重推任务(或手动)自动续推,系统查重不会重复生成金蝶单据 | + +--- + +## 6. 附:操作入口速查表 + +| 页面 | 按钮/操作 | 作用 | +|---|---|---| +| 客商档案 | (表单)录信用代码 | 自动查金蝶回填工商信息 | +| 客商档案 | 行内「同步金蝶」 | 失败重推客商 | +| 预结算单 | 工具栏「预付申请」/「尾款结算」 | 审批通过后发起进度预付 / 生成尾款结算号(预结算本身不推金蝶) | +| 正式结算单 | 新建时选择来源预结算单 | 预结算转正式结算(转后预结算锁定) | +| 正式结算单 | 工具栏「同步金蝶」(单选) | 推送应付/应收单到金蝶 | +| 正式结算单 | (编辑)汇率日期 | 自动带出金蝶汇率 | +| 正式结算单 | (编辑)发票认领「查询/确认识领」 | 从金蝶票池选票认领到结算单 | +| 付款申请 | 工具栏「批量同步」 | 下推付款申请/预付款到金蝶 | +| 付款申请 | 工具栏「同步付款结果」 | 立即回写付款状态与付款记录 | +| 开票申请 | 工具栏「批量同步」 | 按发票张次推送开票申请 | +| 收票登记 | (表单)发票号放大镜 | 从金蝶票池选票 | +| 收票登记 | 工具栏「批量同步」 | 推送收票单+指定应付 | +| 收票登记 | (表单)附件查看/下载 | 从金蝶取发票原件(PDF 优先) | +| 收款流水 | 工具栏「手动同步流水」 | 拉取金蝶认领公告 | +| 收款流水 | (详情/工具栏「批量认领」)认领 | 本地认领到结算单 | +| 收款流水 | 行内「推送金蝶」 | 推送认领结果到金蝶 | +| 认领记录 | 工具栏「查询金蝶认领结果」 | 回写金蝶收款单状态 | +| 运维专用 | `POST /invoice-receipt/sync-invoice-pool` | 手动全票池同步 | +| 运维专用 | `POST /formal-settlement/retry-kingdee-bill-lock` | 手动补偿锁单 | +| 运维专用 | `POST /formal-settlement/sync-kingdee-ar-result` | 手动应收勾稽纠偏 | +| 运维专用 | `POST /invoice-application/sync-kingdee-invoice-result` | 手动开票结果回写 | + +--- + +> 本文基于 2026-10-14 代码现状整理;系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 diff --git a/doc/nacos/blade-dev.yaml b/doc/nacos/blade-dev.yaml index cc55bbcc..063e4cc1 100644 --- a/doc/nacos/blade-dev.yaml +++ b/doc/nacos/blade-dev.yaml @@ -161,9 +161,18 @@ thirdParty: # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) payResult: # 回写任务开关 - syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:false} + syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} + # 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) + receiptClaimPush: + # 推送任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIPT_CLAIM_PUSH_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟 + cron: ${BFI_RECEIPT_CLAIM_PUSH_CRON:0 */10 * * * ?} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告+逐条推送), + # 进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_RECEIPT_CLAIM_PUSH_LOCK_LEASE_SECONDS:1800} #百度OCR配置,API Key和Secret Key请通过环境变量注入 baidu: diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index 0f57ae39..a444dde6 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -132,9 +132,18 @@ thirdParty: # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) payResult: # 回写任务开关 - syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:false} + syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} + # 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) + receiptClaimPush: + # 推送任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIPT_CLAIM_PUSH_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟 + cron: ${BFI_RECEIPT_CLAIM_PUSH_CRON:0 */10 * * * ?} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告+逐条推送), + # 进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_RECEIPT_CLAIM_PUSH_LOCK_LEASE_SECONDS:1800} #百度OCR配置,API Key和Secret Key请通过环境变量注入 baidu: @@ -147,6 +156,15 @@ baidu: request-timeout: 30s token-refresh-advance: 1m +# 对象存储(blade-file 分片上传 + blade-transport 凭证读写共用) +# endpoint:强烈建议内网 MinIO(http://IP:19000);若只能走网关可填 https://tms-prod.xinghy56.com/oss(代码会自动处理 /oss) +# transform-endpoint:浏览器预签名外网地址 +file: + storage: + minio: + # endpoint: http://内网MinIO地址:端口 + transform-endpoint: https://tms-prod.xinghy56.com/oss + powerjob: worker: server-address: 172.16.203.228:7700 diff --git a/doc/nacos/blade.yaml b/doc/nacos/blade.yaml index dedb8470..0b182866 100644 --- a/doc/nacos/blade.yaml +++ b/doc/nacos/blade.yaml @@ -13,6 +13,9 @@ server: #spring配置 spring: + codec: + # 运单批量导入等大 JSON / 失败明细导出场景,默认 256KB 会触发网关 500 + max-in-memory-size: 20MB servlet: multipart: max-file-size: 500MB @@ -225,6 +228,9 @@ blade: - /customer-archive/public/** - /blade-transport/mk-process/public/** - /mk-process/public/** + # 金蝶单据解锁回调(unLockNotifyBusiData):金蝶侧无令牌调用,免登免授权 + - /blade-transport/kingdee-bfi/** + - /kingdee-bfi/** - /blade-openapi/openApi/mk/process/commonCallback - /openApi/mk/process/commonCallback - /feign/client/businessProcess/getCurrentNodes @@ -256,6 +262,13 @@ blade: - method: POST pattern: /dashboard/submit expression: "hasAnyRole('administrator', 'admin', 'user')" + # 金蝶单据解锁回调(unLockNotifyBusiData):金蝶侧无令牌调用,免登免授权 + - method: POST + pattern: /blade-transport/kingdee-bfi/** + expression: "permitAll()" + - method: POST + pattern: /kingdee-bfi/** + expression: "permitAll()" # HCS 合同/付款特批结果回传:合规系统经 iPaaS 网关无令牌调用,免登免授权 - method: POST pattern: /blade-transport/hcs/contractLegal/result diff --git a/doc/nacos/routes/blade-gateway-dev.json b/doc/nacos/routes/blade-gateway-dev.json index d68db7a8..dce7bc48 100644 --- a/doc/nacos/routes/blade-gateway-dev.json +++ b/doc/nacos/routes/blade-gateway-dev.json @@ -49,6 +49,28 @@ ], "uri": "lb://blade-system" }, + { + "id": "resource-route", + "order": 0, + "predicates": [ + { + "name": "Path", + "args": { + "pattern": "/blade-resource/**" + } + } + ], + "filters": [ + { + "name": "RewritePath", + "args": { + "regexp": "/blade-resource/(?.*)", + "replacement": "/$\\{segment}" + } + } + ], + "uri": "http://127.0.0.1:8010" + }, { "id": "example-route", "order": 0, diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index fa11f060..9db1511d 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -49,6 +49,21 @@ thirdParty: language: ${BFI_LANGUAGE:zh_CN} # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) srcSys: ${BFI_SRC_SYS:WLTMS} + # 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值): + # 运输类按项目业务模式映射——国内运输按运单运输方式映射、国际运输映射国际货代服务; + # 装卸大类及手动添加的非运费杂费映射装卸物料 + # 注意:Map 的中文 key 必须用 ["中文"] 方括号写法,否则 Spring Boot 绑定失败、服务启动报错 + material: + # 国内运输:运单运输方式 -> 金蝶物料编码(公路运输/铁路运输/水路运输/航空运输四种) + domestic: + "[公路运输]": ${BFI_MATERIAL_DOMESTIC_ROAD:XDWL11010001} + "[铁路运输]": ${BFI_MATERIAL_DOMESTIC_RAIL:XDWL11010002} + "[水路运输]": ${BFI_MATERIAL_DOMESTIC_WATER:XDWL11010003} + "[航空运输]": ${BFI_MATERIAL_DOMESTIC_AIR:Item-00001082} + # 国际运输业务模式物料编码(国际货运代理服务) + international: ${BFI_MATERIAL_INTERNATIONAL:Item-00001081} + # 装卸大类、手动添加非运费杂费物料编码(装卸) + loading: ${BFI_MATERIAL_LOADING:XDWL11002004} # 付款申请推送配置(A103-3 付款申请保存) payApply: # 单据类型编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请 @@ -61,9 +76,74 @@ thirdParty: # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) payResult: # 回写任务开关 - syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:false} - # 调度 cron,默认每10分钟 - cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} + syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} + # 调度 cron,默认每10分钟(:01 起步,错峰避让其他金蝶任务) + cron: ${BFI_PAY_RESULT_CRON:0 1/10 * * * ?} + # 金蝶收票单结果定时回写(A801-3 异常回收/指定补偿/单号补登) + receivedInvoiceResult: + # 回写任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIVED_INVOICE_RESULT_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟(:08 起步,错峰避让其他金蝶任务) + cron: ${BFI_RECEIVED_INVOICE_RESULT_CRON:0 8/10 * * * ?} + # 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) + receiptClaimPush: + # 推送任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIPT_CLAIM_PUSH_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟(:04 起步,错峰避让其他金蝶任务) + cron: ${BFI_RECEIPT_CLAIM_PUSH_CRON:0 4/10 * * * ?} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告+逐条推送), + # 进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_RECEIPT_CLAIM_PUSH_LOCK_LEASE_SECONDS:1800} + # 金蝶单据锁定定时补偿 + billLock: + # 锁定/解锁总开关(推送后锁定+解锁回调+补偿重锁共用),默认开启保持既有行为 + enabled: ${BFI_BILL_LOCK_ENABLED:true} + # 锁定补偿开关,默认关闭(安全兜底) + retryEnabled: ${BFI_BILL_LOCK_RETRY_ENABLED:false} + # 补偿调度 cron,默认每5分钟(:02 起步,错峰避让其他金蝶任务) + retryCron: ${BFI_BILL_LOCK_RETRY_CRON:0 2/5 * * * ?} + # 收款认领公告查询配置(A301-2) + claimAnnounce: + # 查询时间窗口(月数) + months: ${BFI_CLAIM_ANNOUNCE_MONTHS:6} + # 认领人:配置后公告查询固定使用该值,不再按当前登录用户姓名查询 + claimusers: ${BFI_CLAIM_ANNOUNCE_CLAIMUSERS:} + # 金蝶全票池发票定时同步(A801-1 窗口拉取+镜像 upsert,选票统一查本地) + invoiceReceipt: + # 同步任务开关,默认关闭(上线先手动 POST /invoice-receipt/sync-invoice-pool 跑一次全窗口铺底) + syncEnabled: ${BFI_INVOICE_RECEIPT_SYNC_ENABLED:false} + # 调度 cron,默认每30分钟(:16 起步,错峰避让其他金蝶任务) + cron: ${BFI_INVOICE_RECEIPT_CRON:0 16/30 * * * ?} + # 增量窗口天数(按票池更新时间滚动) + incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} + # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 + invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:26,27} + # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) + compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_INVOICE_RECEIPT_LOCK_LEASE_SECONDS:1800} + # 审批通过自动同步金蝶(事件触发:结算单A102-2/A302-2、付款申请A103-1/A103-3、开票申请A303-1、收票A303-2+A302-6) + autoSync: + # 结算单审批后自动推送开关 + settlement: ${BFI_AUTO_SYNC_SETTLEMENT:false} + # 付款申请审批后自动推送开关 + payment: ${BFI_AUTO_SYNC_PAYMENT:false} + # 开票申请审批后自动推送开关 + invoiceApply: ${BFI_AUTO_SYNC_INVOICE_APPLY:false} + # 收票登记审批后自动推送开关 + receipt: ${BFI_AUTO_SYNC_RECEIPT:false} + # failed 单据定时重推开关(仅重推生成失败,不扫 unsynced 存量) + retryEnabled: ${BFI_AUTO_SYNC_RETRY_ENABLED:false} + # 重推调度 cron,默认每10分钟(:05 起步,错峰避让其他金蝶任务) + retryCron: ${BFI_AUTO_SYNC_RETRY_CRON:0 5/10 * * * ?} + # 客商金蝶数据定时同步(A902-1 复查 / A902-2 推送) + customer: + # 同步任务开关,默认关闭 + syncEnabled: ${BFI_CUSTOMER_SYNC_ENABLED:false} + # 同步调度 cron,默认每天 02:00 执行 + cron: ${BFI_CUSTOMER_SYNC_CRON:0 0 2 * * ?} + # 同步 single-flight 锁租约秒数,须大于最坏一轮耗时(复查 + 批量推送 + 停用联动) + lockLeaseSeconds: ${BFI_CUSTOMER_SYNC_LOCK_LEASE_SECONDS:1800} # 合同合规系统 hcs: # Mock 开关(true 时 IHcsClient 走本地 JSON 桩 F:/home/tms/mock/hcs/{方法名}.json,联调前自测用,生产必须 false;改配置需重启,改桩文件即时生效) diff --git a/doc/sql/bladex/bladex.kingbase.all.create.sql b/doc/sql/bladex/bladex.kingbase.all.create.sql index b7bf2e51..c1c00b5e 100644 --- a/doc/sql/bladex/bladex.kingbase.all.create.sql +++ b/doc/sql/bladex/bladex.kingbase.all.create.sql @@ -2442,7 +2442,7 @@ CREATE TABLE "blade_railway_station" ( "id" int8 NOT NULL, "code" varchar(20) NOT NULL, "tmis_code" varchar(5) NOT NULL, - "telegraph_code" varchar(3) NOT NULL, + "telegraph_code" varchar(3) NULL, "name" varchar(100) NOT NULL, "railway_line" varchar(100), "province_code" varchar(12), diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index a251ed82..67ea9959 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -303,6 +303,8 @@ CREATE TABLE `blade_dept` ( `dept_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门名', `full_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门全称', `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 '合同编码前缀', `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 '助记码', @@ -1259,11 +1261,11 @@ CREATE TABLE `blade_user` ( `real_name` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '真名', `avatar` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像', `email` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', - `phone` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机', + `phone` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机', `birthday` datetime NULL DEFAULT NULL COMMENT '生日', `sex` int NULL DEFAULT NULL COMMENT '性别', `role_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色id', - `dept_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id', + `dept_id` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id', `post_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '岗位id', `leader_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '主管id', `is_leader` int NULL DEFAULT 0 COMMENT '是否主管', @@ -1549,7 +1551,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint NOT NULL COMMENT '主键', `code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '编码', `tmis_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车站名称', `railway_line` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属省份编码', diff --git a/doc/sql/bladex/bladex.postgres.all.create.sql b/doc/sql/bladex/bladex.postgres.all.create.sql index b7bf2e51..c1c00b5e 100644 --- a/doc/sql/bladex/bladex.postgres.all.create.sql +++ b/doc/sql/bladex/bladex.postgres.all.create.sql @@ -2442,7 +2442,7 @@ CREATE TABLE "blade_railway_station" ( "id" int8 NOT NULL, "code" varchar(20) NOT NULL, "tmis_code" varchar(5) NOT NULL, - "telegraph_code" varchar(3) NOT NULL, + "telegraph_code" varchar(3) NULL, "name" varchar(100) NOT NULL, "railway_line" varchar(100), "province_code" varchar(12), diff --git a/doc/sql/bladex/bladex.sqlserver.all.create.sql b/doc/sql/bladex/bladex.sqlserver.all.create.sql index 632f8014..69b7edd4 100644 --- a/doc/sql/bladex/bladex.sqlserver.all.create.sql +++ b/doc/sql/bladex/bladex.sqlserver.all.create.sql @@ -7592,7 +7592,7 @@ CREATE TABLE [dbo].[blade_railway_station] ( [id] bigint NOT NULL, [code] nvarchar(20) NOT NULL, [tmis_code] nvarchar(5) NOT NULL, - [telegraph_code] nvarchar(3) NOT NULL, + [telegraph_code] nvarchar(3) NULL, [name] nvarchar(100) NOT NULL, [railway_line] nvarchar(100) NULL, [province_code] nvarchar(12) NULL, diff --git a/doc/sql/changelog/mk-form-bind-202609290215.sql b/doc/sql/changelog/mk-form-bind-202609290215.sql new file mode 100644 index 00000000..6b70923e --- /dev/null +++ b/doc/sql/changelog/mk-form-bind-202609290215.sql @@ -0,0 +1,43 @@ +-- MK 业务ID与表单实例ID绑定 +CREATE TABLE IF NOT EXISTS `blade_mk_form_bind` +( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` bigint NOT NULL COMMENT '业务主键', + `form_instance_id` bigint NOT NULL COMMENT 'MK表单实例id', + `process_instance_id` varchar(64) DEFAULT NULL COMMENT 'MK流程实例id', + `is_current` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否当前生效(1是0否)', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_blade_mk_form_bind_form_instance_id` (`form_instance_id`) USING BTREE, + KEY `idx_blade_mk_form_bind_biz_current` (`biz_type`, `biz_id`, `is_current`) USING BTREE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci COMMENT ='MK表单实例绑定表'; + +-- 业务流程表增加表单实例id +ALTER TABLE `blade_business_process` + ADD COLUMN `form_instance_id` bigint DEFAULT NULL COMMENT 'MK表单实例id' AFTER `biz_id`; + +-- 历史数据:原 biz_id 即 formInstanceId,回填并写入绑定表 +UPDATE `blade_business_process` +SET `form_instance_id` = `biz_id` +WHERE `form_instance_id` IS NULL; + +INSERT INTO `blade_mk_form_bind` (`id`, `tenant_id`, `biz_type`, `biz_id`, `form_instance_id`, + `process_instance_id`, `is_current`, `create_time`, `update_time`) +SELECT `id`, + IFNULL(`tenant_id`, '000000'), + '', + `biz_id`, + `biz_id`, + `process_instance_id`, + 1, + IFNULL(`create_time`, NOW()), + IFNULL(`update_time`, NOW()) +FROM `blade_business_process` bp +WHERE NOT EXISTS (SELECT 1 + FROM `blade_mk_form_bind` b + WHERE b.`form_instance_id` = bp.`biz_id`); diff --git a/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql b/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql new file mode 100644 index 00000000..8ebe38ed --- /dev/null +++ b/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql @@ -0,0 +1,3 @@ +-- 合同管理新增申请日期:正式合同提交时写入当天日期 +ALTER TABLE `blade_contract_manage` + ADD COLUMN `apply_date` date DEFAULT NULL COMMENT '申请日期(提交正式合同时写入)' AFTER `sign_date`; diff --git a/doc/sql/transport/blade_contract_manage_main_content_20261009.sql b/doc/sql/transport/blade_contract_manage_main_content_20261009.sql new file mode 100644 index 00000000..ae0d144c --- /dev/null +++ b/doc/sql/transport/blade_contract_manage_main_content_20261009.sql @@ -0,0 +1,3 @@ +-- 合同管理新增合同主要内容(非必填,最多1000字) +ALTER TABLE `blade_contract_manage` + ADD COLUMN `main_content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同主要内容' AFTER `terminate_reason`; diff --git a/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql b/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql new file mode 100644 index 00000000..781b00b1 --- /dev/null +++ b/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql @@ -0,0 +1,3 @@ +-- 组织机构新增合同编码前缀:合同编号 = 前缀(年份)三位流水号,流水按部门独立 +ALTER TABLE `blade_dept` + ADD COLUMN `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同编码前缀' AFTER `kingdee_dept_code`; diff --git a/doc/sql/transport/blade_dept_kingdee_dept_code_20260929.sql b/doc/sql/transport/blade_dept_kingdee_dept_code_20260929.sql new file mode 100644 index 00000000..3ec785cd --- /dev/null +++ b/doc/sql/transport/blade_dept_kingdee_dept_code_20260929.sql @@ -0,0 +1,4 @@ +-- 机构表:新增「金蝶部门代码」字段,非必填 + +ALTER TABLE `blade_dept` + ADD COLUMN `kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '金蝶部门代码' AFTER `dept_code`; diff --git a/doc/sql/transport/blade_dept_oa_modified_20261010.sql b/doc/sql/transport/blade_dept_oa_modified_20261010.sql new file mode 100644 index 00000000..97960aff --- /dev/null +++ b/doc/sql/transport/blade_dept_oa_modified_20261010.sql @@ -0,0 +1,4 @@ +-- 机构表:记录 OA 的 modified(上次更新时间),用于增量同步比对 + +ALTER TABLE `blade_dept` + ADD COLUMN `oa_modified` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上次更新时间(modified)' AFTER `oa_sup_sub_com_id`; diff --git a/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql b/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql new file mode 100644 index 00000000..a9a03e6c --- /dev/null +++ b/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql @@ -0,0 +1,9 @@ +-- 合同附件类型字典:合同文件 → 用印材料 +-- 业务字典 code = contract_attachment_type + +UPDATE blade_dict_biz +SET dict_key = '用印材料', + dict_value = '用印材料' +WHERE code = 'contract_attachment_type' + AND is_deleted = 0 + AND (dict_key = '合同文件' OR dict_value = '合同文件'); diff --git a/doc/sql/transport/blade_exception_disposal_20260812.sql b/doc/sql/transport/blade_exception_disposal_20260812.sql index 4b2c1769..1592b95d 100644 --- a/doc/sql/transport/blade_exception_disposal_20260812.sql +++ b/doc/sql/transport/blade_exception_disposal_20260812.sql @@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', `waybill_id` bigint(20) DEFAULT NULL COMMENT '运单ID', `waybill_no` varchar(100) DEFAULT NULL COMMENT '运单号', + `node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)', + `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)', `loading_or_master_no` varchar(100) DEFAULT NULL COMMENT '配载单号/总单号', `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_name` varchar(100) DEFAULT NULL COMMENT '项目', @@ -34,7 +36,7 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal` ( KEY `idx_exception_disposal_status` (`disposal_status`) USING BTREE, KEY `idx_exception_disposal_report_time` (`report_time`) USING BTREE, KEY `idx_exception_disposal_create_time` (`create_time`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常处置'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常处置'; CREATE TABLE IF NOT EXISTS `blade_exception_disposal_follow_record` ( `id` bigint(20) NOT NULL COMMENT '主键', @@ -48,13 +50,14 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal_follow_record` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', `disposal_id` bigint(20) NOT NULL COMMENT '异常处置ID', `follow_content` varchar(500) NOT NULL COMMENT '跟进说明', + `attachments_json` text DEFAULT NULL COMMENT '附件JSON', `follow_user` bigint(20) DEFAULT NULL COMMENT '跟进人', `follow_user_name` varchar(100) DEFAULT NULL COMMENT '跟进人姓名', `follow_time` datetime DEFAULT NULL COMMENT '跟进时间', PRIMARY KEY (`id`) USING BTREE, KEY `idx_exception_follow_disposal` (`disposal_id`) USING BTREE, KEY `idx_exception_follow_time` (`follow_time`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常处置跟进记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常处置跟进记录'; INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES diff --git a/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql new file mode 100644 index 00000000..51ad9dfb --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql @@ -0,0 +1,33 @@ +-- 异常处置新增编辑权限码 +-- 日期:2026-10-10 + +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000002006,2090000000000002001,'exception_disposal_edit','编辑','exception_disposal_edit','','',5,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `parent_id`=VALUES(`parent_id`), + `name`=VALUES(`name`), + `path`=VALUES(`path`), + `sort`=VALUES(`sort`), + `is_deleted`=0; + +-- 授权给超级管理员角色。 +-- 不能按「持有 exception_disposal 菜单的角色」批量授权:司机角色同样持有该菜单, +-- 会被一并授予编辑权——而编辑是运管员才有的操作。 +-- 注意:运管角色当前并未持有 exception_disposal 菜单,故此处不涉及它; +-- 若日后把异常处置开放给运管,需另行授予本编辑码。 +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2090000000000002006 + (r.`id` % 1000), 2090000000000002006, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2090000000000002006 AND x.`role_id` = r.`id` + ); + +-- 幂等收口:本编辑码只应挂在超级管理员上,其余角色一律收回。 +-- 上面的 SELECT 是按授权规则下发,这条是按同一规则回收,二者合起来可重复执行。 +DELETE FROM `blade_role_menu` +WHERE `menu_id` = 2090000000000002006 + AND `role_id` NOT IN (SELECT `id` FROM `blade_role` WHERE `role_alias` = 'administrator'); diff --git a/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql b/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql new file mode 100644 index 00000000..84f6cccb --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql @@ -0,0 +1,27 @@ +-- 异常处置跟进记录补充附件 +-- 日期:2026-10-10 +-- +-- 附件以 JSON 数组存放([{"name":"...","url":"..."}]),与本仓库其它附件字段一致。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_blade_exception_disposal_follow_attachments_20261010`$$ +CREATE PROCEDURE `upgrade_blade_exception_disposal_follow_attachments_20261010`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal_follow_record' + AND column_name = 'attachments_json') THEN + ALTER TABLE `blade_exception_disposal_follow_record` + ADD COLUMN `attachments_json` text DEFAULT NULL COMMENT '附件JSON' + AFTER `follow_content`; + END IF; +END$$ + +CALL `upgrade_blade_exception_disposal_follow_attachments_20261010`()$$ +DROP PROCEDURE `upgrade_blade_exception_disposal_follow_attachments_20261010`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql new file mode 100644 index 00000000..ca09481d --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql @@ -0,0 +1,37 @@ +-- 异常处置增加过程节点归属 +-- 日期:2026-10-05 +-- 节点归属用于打卡门禁:某节点有未了结异常时,该节点及其后的节点不可打卡。 +-- node_code 为空表示未指明节点(全局入口上报),此类异常阻断该运单所有尚未打卡的节点。 +-- node_name 在上报时复制,此后不随过程配置变化,与车牌、项目、承运商等展示字段同一口径。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_blade_exception_disposal_node_code_20261005`$$ +CREATE PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal' + AND column_name = 'node_code') THEN + ALTER TABLE `blade_exception_disposal` + ADD COLUMN `node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)' + AFTER `waybill_no`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal' + AND column_name = 'node_name') THEN + ALTER TABLE `blade_exception_disposal` + ADD COLUMN `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)' + AFTER `node_code`; + END IF; +END$$ + +CALL `upgrade_blade_exception_disposal_node_code_20261005`()$$ +DROP PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_invoice_application_invoice_urls_20260930.sql b/doc/sql/transport/blade_invoice_application_invoice_urls_20260930.sql new file mode 100644 index 00000000..849813f8 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_invoice_urls_20260930.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- blade_invoice_application_invoice_urls_20260930.sql +-- ============================================================ +-- 开票申请金蝶发票结果表补充发票文件下载地址(A303-5 返回,正式结算单详情查看/下载发票): +-- invoice_file_url 板式文件下载地址(数电票为OFD,航信百望税盘普通电票为PDF,电子专票/UKey电票为OFD) +-- invoice_image_url PDF图片预览地址 +-- invoice_pdf_file_url 数电PDF下载地址(发票云5.0.025版本返回) +-- invoice_xml_file_url 数电XML下载地址(发票云5.0.025版本返回) +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_application_invoice.invoice_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''板式文件下载地址(数电票为OFD,普通电票为PDF,电子专票为OFD)'' AFTER `invoice_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_application_invoice.invoice_image_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_image_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''PDF图片预览地址'' AFTER `invoice_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_image_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_application_invoice.invoice_pdf_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_pdf_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电PDF下载地址(发票云5.0.025版本返回)'' AFTER `invoice_image_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_pdf_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application_invoice.invoice_xml_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_xml_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电XML下载地址(发票云5.0.025版本返回)'' AFTER `invoice_pdf_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_xml_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql b/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql new file mode 100644 index 00000000..aa6a3e58 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql @@ -0,0 +1,75 @@ +-- ① blade_kingdee_attachment_sync 建表【修复索引超长问题】 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'CREATE TABLE `blade_kingdee_attachment_sync` ( + `id` bigint NOT NULL COMMENT ''主键'', + `tenant_id` varchar(12) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''租户ID'', + `biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application)'', + `biz_id` bigint NOT NULL COMMENT ''业务单据id'', + `attachment_key` varchar(768) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''附件幂等键(用户附件=附件url;发票文件=A801-2的path)'', + `attachment_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''附件名称(展示/排查用)'', + `kingdee_path` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶侧文件路径(A902 返回 data.path)'', + `sync_status` tinyint NOT NULL DEFAULT 0 COMMENT ''同步状态:1 成功 / 0 失败'', + `fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''失败原因(超限/下载失败/金蝶报错)'', + `sync_time` datetime DEFAULT NULL COMMENT ''最近上传时间'', + `create_user` bigint DEFAULT NULL COMMENT ''创建人'', + `create_dept` bigint DEFAULT NULL COMMENT ''创建部门'', + `create_time` datetime DEFAULT NULL COMMENT ''创建时间'', + `update_user` bigint DEFAULT NULL COMMENT ''修改人'', + `update_time` datetime DEFAULT NULL COMMENT ''修改时间'', + `status` int DEFAULT NULL COMMENT ''业务状态'', + `is_deleted` int DEFAULT 0 COMMENT ''是否已删除'', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_biz_attachment` (`biz_type`,`biz_id`,`attachment_key`(256)), + KEY `idx_biz_type_biz_id` (`biz_type`,`biz_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT=''金蝶附件同步表(A902)''', + 'SELECT 1') + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_attachment_sync' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_formal_settlement.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_received_invoice_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_kingdee_bill_lock_20261009.sql b/doc/sql/transport/blade_kingdee_bill_lock_20261009.sql new file mode 100644 index 00000000..98c6ac33 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_bill_lock_20261009.sql @@ -0,0 +1,32 @@ +-- 金蝶单据锁定记录表:lockBusiData 锁定 / unLockNotifyBusiData 解锁回调 / 锁定补偿 +-- 一行 = 一张金蝶目标单:锁定留痕(人/时间/失败原因)+ 当前锁状态(编辑守卫/回调定位/补偿扫表) +-- lock_status:failed-锁定失败(补偿重试对象)/ locked-金蝶锁定中 / unlocked-已解锁(终态) + +CREATE TABLE `blade_kingdee_bill_lock` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID', + `source_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单据类型:SETTLEMENT正式结算单/INVOICE_APPLY开票申请单(预留PAY_APPLY付款申请单/RECEIVED_INVOICE收票单)', + `source_bill_id` bigint NOT NULL COMMENT 'WLTMS业务主键', + `source_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单号', + `target_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据类型:ApFin_other_BT_S应付单/arfin_other_BT_S应收单/cdm_paybill_bank_elect_BT_S开票单等固定值', + `target_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据编号(开票申请按发票张次逐张一行)', + `lock_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '锁状态:failed锁定失败/locked金蝶锁定中/unlocked已解锁', + `operation_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最近动作来源操作类型:SAVE单据新增/DELETE单据删除', + `try_count` int DEFAULT '0' COMMENT '锁定重试次数(补偿任务用,超上限停试)', + `lock_time` datetime DEFAULT NULL COMMENT '锁定时间', + `unlock_time` datetime DEFAULT NULL COMMENT '解锁时间', + `lock_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '推单发起人(锁定操作人)', + `notify_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回调操作人(金蝶侧操作人)', + `fail_reason` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '锁定失败原因', + `create_user` bigint DEFAULT NULL COMMENT '创建人', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int DEFAULT NULL COMMENT '状态', + `is_deleted` int DEFAULT '0' COMMENT '是否已删除', + PRIMARY KEY (`id`), + INDEX `idx_kbl_source` (`source_bill_type`, `source_bill_id`), + INDEX `idx_kbl_target` (`target_bill_type`, `target_bill_no`), + INDEX `idx_kbl_status` (`lock_status`, `try_count`) +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '金蝶单据锁定记录' ROW_FORMAT = DYNAMIC; diff --git a/doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql b/doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql new file mode 100644 index 00000000..98a36137 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql @@ -0,0 +1,46 @@ +-- 金蝶票池选票回切镜像读:镜像表新增可用过滤列 + 认领过滤索引 +-- available_amount 剩余可用金额(A801-1 availableAmount,金蝶可用税额口径,选票本地过滤 >0) +-- amount_use_status 金额使用状态(A801-1 amountUseStatus,'0' 未使用可选 / '2' 全部使用) +-- 由定时同步(InvoiceReceiptKingdeeSyncTask)/ 手动同步端点 / 票号回源 / 收票保存重查 统一按 tenant_id + invoice_no upsert 回填; +-- 存量存根行两列为 NULL,不参与选票(可用过滤为 NULL 排除),随增量窗口同步自然回填 +-- 认领过滤 EXISTS 子查询补 (tenant_id, invoice_no) 索引,避免统一查询全表扫 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `available_amount` decimal(18,2) DEFAULT NULL COMMENT ''剩余可用金额(A801-1 availableAmount,金蝶可用税额口径)'' AFTER `source_updated_time`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'available_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `amount_use_status` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金额使用状态(0 未使用 / 2 全部使用)'' AFTER `available_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'amount_use_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @idx_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement_invoice` ADD INDEX `idx_settlement_invoice_no` (`tenant_id`, `invoice_no`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_invoice' + AND INDEX_NAME = 'idx_settlement_invoice_no' +); +PREPARE idx_stmt FROM @idx_sql; +EXECUTE idx_stmt; +DEALLOCATE PREPARE idx_stmt; diff --git a/doc/sql/transport/blade_mp_user_20261012.sql b/doc/sql/transport/blade_mp_user_20261012.sql new file mode 100644 index 00000000..9d34d49c --- /dev/null +++ b/doc/sql/transport/blade_mp_user_20261012.sql @@ -0,0 +1,45 @@ +-- ---------------------------- +-- 小程序用户表(与 blade_user 分离,仅用于小程序登录) +-- 身份 identities:逗号分隔,当前支持 driver(司机)、admin(管理员),可同时拥有,后续可扩展 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `blade_mp_user` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '000000' COMMENT '租户ID', + `code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户编号', + `account` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '账号(通常为手机号)', + `password` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '密码(小程序登录可不使用)', + `name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '昵称', + `real_name` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '真名', + `avatar` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像', + `email` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', + `phone` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机', + `birthday` datetime NULL DEFAULT NULL COMMENT '生日', + `sex` int NULL DEFAULT NULL COMMENT '性别', + `openid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '微信小程序 openid', + `identities` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '小程序身份,逗号分隔:driver,admin', + `dept_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id', + `post_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '岗位id', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注', + `create_user` bigint NULL DEFAULT NULL COMMENT '创建人', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `update_user` bigint NULL DEFAULT NULL COMMENT '修改人', + `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间', + `status` int NULL DEFAULT 1 COMMENT '状态', + `is_deleted` int NULL DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_mp_user_tenant_phone` (`tenant_id`, `phone`) USING BTREE, + UNIQUE KEY `uk_mp_user_tenant_openid` (`tenant_id`, `openid`) USING BTREE, + KEY `idx_mp_user_account` (`tenant_id`, `account`) USING BTREE, + KEY `idx_mp_user_identities` (`identities`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '小程序用户表'; + +-- 司机表 user_id 语义变更为关联小程序用户 +ALTER TABLE `blade_transport_driver` + MODIFY COLUMN `user_id` bigint(20) DEFAULT NULL COMMENT '关联小程序用户ID(blade_mp_user.id)'; + +-- 示例:创建小程序管理员(调度端),identities 可同时包含 driver,admin +-- INSERT INTO `blade_mp_user` +-- (`id`, `tenant_id`, `account`, `name`, `real_name`, `phone`, `identities`, `create_time`, `status`, `is_deleted`) +-- VALUES +-- (2091900000000000001, '000000', '13800000000', '调度管理员', '调度管理员', '13800000000', 'admin', NOW(), 1, 0); diff --git a/doc/sql/transport/blade_mp_user_menu_20261012.sql b/doc/sql/transport/blade_mp_user_menu_20261012.sql new file mode 100644 index 00000000..c918cd6b --- /dev/null +++ b/doc/sql/transport/blade_mp_user_menu_20261012.sql @@ -0,0 +1,90 @@ +-- ---------------------------- +-- 小程序用户管理菜单(挂在系统管理下) +-- 日期:2026-10-12 +-- ---------------------------- + +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2091901200000000101,1123598815738675203,'mp_user','小程序用户','menu','/system/mp-user','iconfont icon-yonghu',3,1,0,1,NULL,'小程序用户管理',0), + (2091901200000000102,2091901200000000101,'mp_user_add','新增','add','','',1,2,0,1,NULL,'',0), + (2091901200000000103,2091901200000000101,'mp_user_edit','编辑','edit','','',2,2,0,1,NULL,'',0), + (2091901200000000104,2091901200000000101,'mp_user_delete','删除','delete','','',3,2,0,1,NULL,'',0), + (2091901200000000105,2091901200000000101,'mp_user_view','查看','view','','',4,2,0,1,NULL,'',0), + (2091901200000000106,2091901200000000101,'mp_user_status','修改状态','status','','',5,2,0,1,NULL,'',0), + (2091901200000000107,2091901200000000101,'mp_user_reset','密码重置','reset','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `parent_id`=VALUES(`parent_id`), + `code`=VALUES(`code`), + `name`=VALUES(`name`), + `alias`=VALUES(`alias`), + `path`=VALUES(`path`), + `source`=VALUES(`source`), + `sort`=VALUES(`sort`), + `category`=VALUES(`category`), + `remark`=VALUES(`remark`), + `is_deleted`=0; + +-- 授权给超级管理员角色(幂等) +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000101 + (r.`id` % 1000), 2091901200000000101, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000101 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000102 + (r.`id` % 1000), 2091901200000000102, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000102 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000103 + (r.`id` % 1000), 2091901200000000103, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000103 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000104 + (r.`id` % 1000), 2091901200000000104, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000104 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000105 + (r.`id` % 1000), 2091901200000000105, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000105 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000106 + (r.`id` % 1000), 2091901200000000106, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000106 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000107 + (r.`id` % 1000), 2091901200000000107, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000107 AND x.`role_id` = r.`id` + ); diff --git a/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql b/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql new file mode 100644 index 00000000..9f3063b5 --- /dev/null +++ b/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql @@ -0,0 +1,61 @@ +-- ---------------------------- +-- 将已关联 blade_user 的司机迁移到 blade_mp_user(可选,按环境执行) +-- 注意:执行前请先执行 blade_mp_user_20261012.sql 建表 +-- 策略:小程序用户主键使用司机表 id(避免与 blade_user.id 冲突导致令牌刷新串用户) +-- 迁移后回写 driver.user_id;原 blade_user 账号不会自动删除,请人工确认后再清理 +-- 调度端管理员需另行在 blade_mp_user 中维护 identities 含 admin 的账号 +-- ---------------------------- + +-- 1) 按司机写入小程序用户 +INSERT INTO `blade_mp_user` ( + `id`, `tenant_id`, `account`, `name`, `real_name`, `phone`, `sex`, + `openid`, `identities`, `dept_id`, + `create_user`, `create_dept`, `create_time`, `update_user`, `update_time`, + `status`, `is_deleted` +) +SELECT + d.`id` AS `id`, + IFNULL(d.`tenant_id`, '000000') AS `tenant_id`, + d.`mobile` AS `account`, + d.`driver_name` AS `name`, + d.`driver_name` AS `real_name`, + d.`mobile` AS `phone`, + CASE + WHEN d.`gender` IN ('男', '1') THEN 1 + WHEN d.`gender` IN ('女', '2') THEN 2 + ELSE NULL + END AS `sex`, + oauth.`uuid` AS `openid`, + 'driver' AS `identities`, + u.`dept_id` AS `dept_id`, + d.`create_user`, + d.`create_dept`, + IFNULL(d.`create_time`, NOW()) AS `create_time`, + d.`update_user`, + IFNULL(d.`update_time`, NOW()) AS `update_time`, + IFNULL(d.`status`, 1) AS `status`, + 0 AS `is_deleted` +FROM `blade_transport_driver` d +LEFT JOIN `blade_user` u ON u.`id` = d.`user_id` AND u.`is_deleted` = 0 +LEFT JOIN `blade_user_oauth` oauth + ON oauth.`user_id` = d.`user_id` + AND oauth.`source` = 'WECHAT_MINI' + AND oauth.`is_deleted` = 0 +WHERE d.`is_deleted` = 0 + AND d.`mobile` IS NOT NULL + AND d.`mobile` <> '' + AND NOT EXISTS ( + SELECT 1 FROM `blade_mp_user` m + WHERE m.`id` = d.`id` + OR (m.`tenant_id` = IFNULL(d.`tenant_id`, '000000') AND m.`phone` = d.`mobile` AND m.`is_deleted` = 0) + ); + +-- 2) 回写司机关联的小程序用户ID +UPDATE `blade_transport_driver` d +INNER JOIN `blade_mp_user` m + ON m.`id` = d.`id` + OR (m.`tenant_id` = IFNULL(d.`tenant_id`, '000000') AND m.`phone` = d.`mobile` AND m.`is_deleted` = 0) +SET d.`user_id` = m.`id` +WHERE d.`is_deleted` = 0 + AND d.`mobile` IS NOT NULL + AND d.`mobile` <> ''; diff --git a/doc/sql/transport/blade_process_config_cargo_types_20261005.sql b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql new file mode 100644 index 00000000..b03a51bc --- /dev/null +++ b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql @@ -0,0 +1,19 @@ +-- 订正过程配置中「上传货量为是、货量类型为空」的脏配置 +-- 日期:2026-10-05 +-- +-- 成因:过程配置页的「到场」「回单」两个节点模板缺 supportCargo 字段,货量开关 +-- 在界面上不渲染,导致这两个节点被写成 uploadCargo=true 且 cargoTypes 恒为空。 +-- 语义:「是否需要采集货量」以已勾选的货量类型为准——类型为空即不采集, +-- 不存在「没配类型所以三项全采集」。 +-- 影响:订正后这些节点在小程序不再显示货量表单。因司机端解析过程配置时优先取 +-- 启用中的活配置,本次订正会同时改变所有引用该项目的运单(含历史运单)的呈现。 +-- 范围:只订正过程配置表,不修改 blade_waybill.process_json 快照列(那是历史留痕)。 +-- 幂等:执行后目标子串不再存在,重复执行匹配 0 行。 + +UPDATE `blade_process_config` +SET `node_config_json` = REPLACE( + `node_config_json`, + '"uploadCargo":true,"cargoTypes":[]', + '"uploadCargo":false,"cargoTypes":[]') +WHERE `is_deleted` = 0 + AND `node_config_json` LIKE '%"uploadCargo":true,"cargoTypes":[]%'; diff --git a/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql new file mode 100644 index 00000000..b2d5d222 --- /dev/null +++ b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql @@ -0,0 +1,4 @@ +-- 平台公司支持多选:ID 逗号分隔,名称顿号分隔 +ALTER TABLE `blade_project_apply` + MODIFY COLUMN `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + MODIFY COLUMN `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔'; diff --git a/doc/sql/transport/blade_project_contract_management.sql b/doc/sql/transport/blade_project_contract_management.sql index 2d720698..59890b1b 100644 --- a/doc/sql/transport/blade_project_contract_management.sql +++ b/doc/sql/transport/blade_project_contract_management.sql @@ -18,8 +18,8 @@ CREATE TABLE `blade_project_apply` ( `project_type` varchar(50) DEFAULT NULL COMMENT '项目类型', `business_dept_id` bigint(20) DEFAULT NULL COMMENT '业务部门ID', `business_dept_name` varchar(100) DEFAULT NULL COMMENT '业务部门', - `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', - `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', + `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', `project_source` varchar(50) DEFAULT NULL COMMENT '项目由来', `source_remark` varchar(500) DEFAULT NULL COMMENT '项目由来说明', `fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', @@ -80,8 +80,8 @@ CREATE TABLE `blade_temporary_credit_limit` ( `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编号', `project_name` varchar(100) DEFAULT NULL COMMENT '项目名称', - `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', - `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', + `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', `project_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', `used_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '已使用项目资金额度(万元)', `remaining_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '剩余项目资金使用额度(万元)', @@ -119,8 +119,8 @@ CREATE TABLE `blade_contract_manage` ( `contract_name` varchar(100) DEFAULT NULL COMMENT '合同名称', `project_id` bigint(20) DEFAULT NULL COMMENT '所属项目ID', `project_name` varchar(100) DEFAULT NULL COMMENT '所属项目', - `organization_id` bigint(20) DEFAULT NULL COMMENT '所属组织ID', - `organization_name` varchar(100) DEFAULT NULL COMMENT '所属组织', + `organization_id` bigint(20) DEFAULT NULL COMMENT '合同负责部门ID', + `organization_name` varchar(100) DEFAULT NULL COMMENT '合同负责部门', `contract_category` varchar(50) DEFAULT NULL COMMENT '合同类别', `sign_type` varchar(50) DEFAULT NULL COMMENT '签约类型', `party_a` varchar(200) DEFAULT NULL COMMENT '甲方', @@ -132,6 +132,7 @@ CREATE TABLE `blade_contract_manage` ( `handler_user_id` bigint(20) DEFAULT NULL COMMENT '经办人ID', `handler_user_name` varchar(100) DEFAULT NULL COMMENT '经办人', `sign_date` date DEFAULT NULL COMMENT '签订日期', + `apply_date` date DEFAULT NULL COMMENT '申请日期(提交正式合同时写入)', `settlement_mode` varchar(100) DEFAULT NULL COMMENT '结算方式', `contract_format` varchar(50) DEFAULT NULL COMMENT '合同格式', `legal_seal_flag` int(11) DEFAULT '0' COMMENT '是否需要加盖法人章', @@ -160,6 +161,7 @@ CREATE TABLE `blade_contract_manage` ( `change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因', `change_attachments_json` text DEFAULT NULL COMMENT '待审批变更附件JSON', `terminate_reason` varchar(500) DEFAULT NULL COMMENT '终止原因', + `main_content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同主要内容', `remark` varchar(2000) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`) USING BTREE, KEY `idx_contract_project` (`project_id`) USING BTREE, diff --git a/doc/sql/transport/blade_railway_station.sql b/doc/sql/transport/blade_railway_station.sql index 17cf66c5..dc5e4979 100644 --- a/doc/sql/transport/blade_railway_station.sql +++ b/doc/sql/transport/blade_railway_station.sql @@ -6,7 +6,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint NOT NULL COMMENT '主键', `code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '编码', `tmis_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车站名称', `railway_line` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属省份编码', diff --git a/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql b/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql new file mode 100644 index 00000000..7977f448 --- /dev/null +++ b/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql @@ -0,0 +1,5 @@ +-- 铁路车站电报略码改为非必填(有值时仍保持唯一) +-- 执行前请先备份数据库;如字段已是可空,可跳过本脚本。 + +ALTER TABLE `blade_railway_station` + MODIFY COLUMN `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码'; diff --git a/doc/sql/transport/blade_receipt_flow_push_20260928.sql b/doc/sql/transport/blade_receipt_flow_push_20260928.sql new file mode 100644 index 00000000..f4f4c542 --- /dev/null +++ b/doc/sql/transport/blade_receipt_flow_push_20260928.sql @@ -0,0 +1,136 @@ +-- 收款认领推送金蝶重构(认领本地化 / A301-3 定时+手动推送 / 按流水归集) +-- 支持重复执行,兼容 20260924 脚本已执行/未执行的环境,与该脚本无执行顺序要求 +-- 变更点: +-- 1. 收款流水表新增推送域字段(推送状态/失败原因/推送时间/推送编码/金蝶收款单号/回写状态) +-- 2. 认领主单移除 awrp_src_code 及其索引(推送编码上移至流水:分摊结算单号拼接) +-- 3. 收款流水表新增定时任务扫描索引与 A301-1 回写匹配索引 + +-- 1. 收款流水表:推送状态 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''推送状态(none未推送/pushed已推送/failed推送失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 2. 收款流水表:最近一次推送失败原因 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_fail_reason` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''最近一次推送失败原因(成功后清空)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 3. 收款流水表:首次推送成功时间 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `pushed_time` datetime DEFAULT NULL COMMENT ''首次推送金蝶成功时间''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'pushed_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 4. 收款流水表:推送来源单据编码(A301-3 awrp_src_code,分摊结算单号拼接,A301-1 回写匹配键) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `awrp_src_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送来源单据编码(分摊结算单号下划线拼接,A301-1回写匹配键)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 5. 收款流水表:金蝶收款单号(推送响应回填 / A301-1 回写推进) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收款单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 6. 收款流水表:金蝶回写状态(A301-1 回写生命周期) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''金蝶回写状态(none未推送/pushed已推送/approved审核通过/received已收款/failed失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 7. 认领主单:删除 awrp_src_code 索引(列上移至流水表;0924 未执行过则本就无此索引) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP KEY `idx_receipt_claim_src_code`', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND INDEX_NAME = 'idx_receipt_claim_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 8. 认领主单:删除 awrp_src_code 列(0924 未执行过则本就无此列) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP COLUMN `awrp_src_code`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 9. 收款流水表:定时任务扫描索引(push_status IN (none,failed) AND claim_status='claimed' AND bill_id 非空) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_push_scan` (`tenant_id`,`push_status`,`claim_status`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_push_scan' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 10. 收款流水表:A301-1 回写匹配索引(sourcebillnumber = awrp_src_code) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_src_code` (`tenant_id`,`awrp_src_code`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 11. 按钮权限:收款流水页「推送金蝶」(父级菜单 2090000000000001260 收款流水,排序 4) +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000001264,2090000000000001260,'receipt_flow_push','推送金蝶','receipt_flow_push','','',4,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `name`=VALUES(`name`), + `alias`=VALUES(`alias`), + `is_deleted`=0; diff --git a/doc/sql/transport/blade_tms_business.sql b/doc/sql/transport/blade_tms_business.sql index 62a0472c..a91fa295 100644 --- a/doc/sql/transport/blade_tms_business.sql +++ b/doc/sql/transport/blade_tms_business.sql @@ -283,6 +283,7 @@ CREATE TABLE `blade_waybill` ( `plan_name` varchar(100) DEFAULT NULL COMMENT '计划名称', `master_no` varchar(100) DEFAULT NULL COMMENT '多联总单', `loading_no` varchar(100) DEFAULT NULL COMMENT '配载单号', + `loading_identifier` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '配载标识号', `batch_no` varchar(100) DEFAULT NULL COMMENT '运单批次号', `relation_no` varchar(100) DEFAULT NULL COMMENT '关联单号', `current_process_node` varchar(100) DEFAULT NULL COMMENT '当前过程节点', diff --git a/doc/sql/transport/blade_transport_cert_backfill_20261007.sql b/doc/sql/transport/blade_transport_cert_backfill_20261007.sql new file mode 100644 index 00000000..91dd544e --- /dev/null +++ b/doc/sql/transport/blade_transport_cert_backfill_20261007.sql @@ -0,0 +1,277 @@ +-- ---------------------------- +-- 认证:存量司机 / 车辆补生效快照 +-- 写业务数据。执行前必须先建备份表(第 0 节),执行后必须跑第 3 节校验。可重复执行。 +-- ---------------------------- +-- +-- 为什么必须补: +-- 「能否接单」在运营状态之上叠加了认证门槛,判据是生效快照指针 +-- blade_transport_driver.effective_cert_record_id 与 +-- blade_transport_vehicle.effective_cert_record_id。这两列与门槛一起新增, +-- 新增时对存量对象一律为 NULL;门槛默认开启,于是存量司机与车辆全部被判成 +-- 「未认证」,接单被直接拒绝。它们在此之前一直正常承运,可用性是既成事实, +-- 不该因为换了判定方式就丢掉。 +-- +-- 处置口径:给尚无生效快照的存量对象补一条「已通过」记录,并把指针指过去。 +-- 这是自动认可、不是任何人的审核行为,故 audit_user / audit_time 一律留空, +-- 不凭空编造审核留痕。 +-- +-- 快照键名必须与 PersonCertSubmitRequest / VehicleCertSubmitRequest 完全一致。 +-- 键名对不上不会报错、只会静默丢值:司机端「重新认证」表单会缺字段, +-- 审核详情会把每个字段都报成「已变更」。改 DTO 字段名时务必同步本脚本。 +-- +-- 司机的从业资格证有效期起(qualificationValidStart)在快照里没有来源: +-- blade_transport_driver 没有对应列,管理端的同名录入项也是只收不存。 +-- 此处同样不造值,该键留空。 +-- +-- 司机快照里没有 mobile:审核列表的司机姓名/手机号取的是司机档案当前值, +-- 不是提交时的快照值,故无需写进快照。 +-- +-- 提交司机(submit_user)为什么一起补: +-- 审核列表的「司机姓名 / 司机手机号」取数路径是 submit_user -> 系统账号 -> 司机档案。 +-- submit_user 为空,这两列就永远空白,审核队列「找得到人、联系得上」的价值就没了。 +-- 车辆认证记录本身不含司机信息,其提交司机取「该车当前绑定的司机」—— +-- 车辆认证的提交链路强制校验只能操作本人绑定的车辆,故真实提交的车辆认证记录, +-- 其提交司机就是这辆车的绑定司机。一条车牌有多个绑定司机时 +-- (driving_vehicle 是逗号分隔的文本列、无唯一索引),取司机档案 update_time 最新者: +-- 库里没有绑定时间戳,这是可用的最好代理指标。 +-- 解析不到系统账号的(该司机根本没有 blade_user),留 NULL,不编造。 +-- +-- 号段:blade_transport_cert_record.id 由应用侧雪花算法发号(当前约 2.1e18), +-- 本脚本自行发号,必须落在应用永远到不了的区间,否则会与真实提交撞主键。 +-- 车辆与司机各占一段,段距 1e17,互不重叠,且都远低于 bigint 上限 9.22e18。 +-- 该号段同时是回退的识别依据(第 4 节)。 +-- ⚠️ 不要改成「取 MAX(id) + 偏移量」:雪花 id 每秒最多 +1000, +-- 取 MAX+1000000 的话应用约 17 分钟就能追平并开始撞号。 +-- ⚠️ 也不要把 id 读出来再拼 UPDATE 的 WHERE:id 约 2.1e18 超过 JS/JSON +-- 安全整数上限 2^53,传回数据库时末位被截断,UPDATE 会静默匹配 0 行—— +-- 记录插进去了、指针却没写,且不报任何错。故全程用 SQL 自关联 +-- (JOIN ... ON r.biz_id = v.id),id 只在库内流转。 +-- +-- 发号用会话变量而不是 ROW_NUMBER():窗口函数要 MySQL 8.0 起,本脚本按 5.7 也能执行写。 +-- +-- 前置条件:blade_transport_cert_record 表与两张业务表上的 +-- certification_status / certification_reject_reason / effective_cert_record_id +-- 列都已存在(见 blade_transport_cert_record_20261002.sql)。缺表时报 +-- 「Table ... doesn't exist」,先补跑那个脚本再执行本脚本。 + +-- ---------------------------- +-- 0. 备份(必须在订正之前执行,仅首次需要) +-- ---------------------------- +-- CREATE TABLE blade_transport_vehicle_certbak AS +-- SELECT id, certification_status, certification_reject_reason, effective_cert_record_id +-- FROM blade_transport_vehicle; +-- CREATE TABLE blade_transport_driver_certbak AS +-- SELECT id, certification_status, certification_reject_reason, effective_cert_record_id +-- FROM blade_transport_driver; +-- CREATE TABLE blade_transport_cert_record_bak AS SELECT * FROM blade_transport_cert_record; + +START TRANSACTION; + +-- ---------------------------- +-- 1. 为尚无生效快照的车辆补「已通过」记录 +-- ---------------------------- +-- 从段内已有的最大号之后接着发,而不是每次从段首发: +-- 订正过一次之后又导入了新车、再跑本脚本时,从段首发号会与已有记录撞主键。 +-- 变量存的是**段内序号**(段基址已减掉),下面 SELECT 里再加回段基址。 +SET @vehicle_seq := (SELECT IFNULL(MAX(id) - 9000000000000000000, 0) + FROM blade_transport_cert_record + WHERE id >= 9000000000000000000 AND id < 9100000000000000000); + +INSERT INTO blade_transport_cert_record + (id, tenant_id, biz_type, biz_id, biz_key, status, snapshot_json, + submit_time, submit_user, create_time, update_time, is_deleted) +SELECT 9000000000000000000 + (@vehicle_seq := @vehicle_seq + 1), + IFNULL(v.tenant_id, '000000'), 'vehicle', v.id, IFNULL(v.plate_no, ''), 1, + JSON_OBJECT( + 'plateNo', v.plate_no, + 'ownerName', v.owner_name, + 'plateColor', v.plate_color, + 'vehicleType', v.vehicle_type, + 'vin', v.vin, + 'outerLength', v.outer_length, + 'outerWidth', v.outer_width, + 'outerHeight', v.outer_height, + 'approvedLoadKg', v.approved_load_kg, + 'tractionMassKg', v.traction_mass_kg, + 'energyType', v.energy_type, + 'businessRelation', v.business_relation, + 'compulsoryScrapDate', v.compulsory_scrap_date, + 'compulsoryScrapLongTerm', IF(v.compulsory_scrap_long_term = 1, 1, 0), + 'drivingLicenseNo', v.driving_license_no, + 'drivingLicenseImage', v.driving_license_image, + 'drivingLicenseMainBack', v.driving_license_main_back, + 'drivingLicenseViceFront', v.driving_license_vice_front, + 'drivingLicenseViceBack', v.driving_license_vice_back, + 'drivingLicenseStartDate', v.driving_license_start_date, + 'drivingLicenseEndDate', v.driving_license_end_date, + 'drivingLicenseLongTerm', IF(v.driving_license_long_term = 1, 1, 0), + 'roadTransportCertNo', v.road_transport_cert_no, + 'roadTransportCertImage', v.road_transport_cert_image, + 'roadTransportCertStartDate', v.road_transport_cert_start_date, + 'roadTransportCertEndDate', v.road_transport_cert_end_date, + 'roadTransportCertLongTerm', IF(v.road_transport_cert_long_term = 1, 1, 0), + 'registrationNo', v.registration_no, + 'registrationImage', v.registration_image + ), + NOW(), + (SELECT dr.user_id + FROM blade_transport_driver dr + WHERE dr.is_deleted = 0 + AND dr.user_id IS NOT NULL + AND dr.driving_vehicle IS NOT NULL + AND FIND_IN_SET(v.plate_no, REPLACE(dr.driving_vehicle, ',', ',')) > 0 + ORDER BY dr.update_time DESC, dr.id DESC + LIMIT 1), + NOW(), NOW(), 0 +FROM blade_transport_vehicle v +LEFT JOIN blade_transport_cert_record r + ON r.biz_type = 'vehicle' AND r.biz_id = v.id + AND r.id >= 9000000000000000000 AND r.id < 9100000000000000000 +WHERE v.is_deleted = 0 + AND v.effective_cert_record_id IS NULL + AND r.id IS NULL +ORDER BY v.id; + +-- 回填指针:靠 biz_id 自关联,不把 id 带出数据库 +UPDATE blade_transport_vehicle v +JOIN blade_transport_cert_record r + ON r.biz_type = 'vehicle' AND r.biz_id = v.id + AND r.id >= 9000000000000000000 AND r.id < 9100000000000000000 AND r.status = 1 +SET v.effective_cert_record_id = r.id, + v.certification_status = 1, + v.certification_reject_reason = NULL +WHERE v.effective_cert_record_id IS NULL AND v.is_deleted = 0; + +-- ---------------------------- +-- 2. 为尚无生效快照的司机补「已通过」记录 +-- ---------------------------- +SET @driver_seq := (SELECT IFNULL(MAX(id) - 9100000000000000000, 0) + FROM blade_transport_cert_record + WHERE id >= 9100000000000000000 AND id < 9200000000000000000); + +INSERT INTO blade_transport_cert_record + (id, tenant_id, biz_type, biz_id, biz_key, status, snapshot_json, + submit_time, submit_user, create_time, update_time, is_deleted) +SELECT 9100000000000000000 + (@driver_seq := @driver_seq + 1), + IFNULL(d.tenant_id, '000000'), 'driver', d.id, IFNULL(d.driver_name, ''), 1, + JSON_OBJECT( + 'realName', d.driver_name, + 'idCardNo', d.id_card_no, + 'idCardFrontUrl', d.id_card_front, + 'idCardBackUrl', d.id_card_back, + 'idCardValidStart', d.id_card_valid_start, + 'idCardValidEnd', d.id_card_valid_end, + 'idCardLongTerm', IF(d.id_card_long_term = 1, 1, 0), + 'driverFileNo', d.driving_license_no, + 'driverClass', d.driving_type, + 'driverLicenseFrontUrl', d.driving_license_front, + 'driverLicenseBackUrl', d.driving_license_back, + 'driverValidStart', d.driving_license_start_date, + 'driverValidEnd', d.driving_license_end_date, + 'driverLongTerm', IF(d.driving_license_long_term = 1, 1, 0), + 'qualificationType', d.qualification_type, + 'qualificationNo', d.qualification_no, + 'qualificationFront', d.qualification_front, + 'qualificationBack', d.qualification_back, + 'qualificationValidEnd', d.qualification_end_date, + 'qualificationLongTerm', IF(d.qualification_long_term = 1, 1, 0), + 'headPhoto', d.head_photo + ), + NOW(), + -- 注册在司机档案上的用户 id 最直接;它为空时退回「按手机号找账号」 + -- (司机登录账号就是手机号)。两者都落空说明这名司机没有系统账号, + -- 此时留 NULL —— 审核列表的司机列本来就取不到人,编不出来。 + IFNULL(d.user_id, + (SELECT u.id + FROM blade_user u + WHERE u.account = d.mobile COLLATE utf8mb4_general_ci + ORDER BY u.id DESC + LIMIT 1)), + NOW(), NOW(), 0 +FROM blade_transport_driver d +LEFT JOIN blade_transport_cert_record r + ON r.biz_type = 'driver' AND r.biz_id = d.id + AND r.id >= 9100000000000000000 AND r.id < 9200000000000000000 +WHERE d.is_deleted = 0 + AND d.effective_cert_record_id IS NULL + AND r.id IS NULL +ORDER BY d.id; + +UPDATE blade_transport_driver d +JOIN blade_transport_cert_record r + ON r.biz_type = 'driver' AND r.biz_id = d.id + AND r.id >= 9100000000000000000 AND r.id < 9200000000000000000 AND r.status = 1 +SET d.effective_cert_record_id = r.id, + d.certification_status = 1, + d.certification_reject_reason = NULL +WHERE d.effective_cert_record_id IS NULL AND d.is_deleted = 0; + +COMMIT; + +-- ---------------------------- +-- 3. 校验(执行后必看,四个都必须返回 0) +-- ---------------------------- +-- 下列查询非 0 说明还有对象没有生效快照,门槛开着它们就不可用。 +-- SELECT COUNT(*) AS vehicle_missing FROM blade_transport_vehicle +-- WHERE effective_cert_record_id IS NULL AND is_deleted = 0; +-- SELECT COUNT(*) AS driver_missing FROM blade_transport_driver +-- WHERE effective_cert_record_id IS NULL AND is_deleted = 0; +-- +-- 下列查询非 0 说明指针指向的不是一条「已通过」记录,生效值取不到内容。 +-- SELECT COUNT(*) AS vehicle_bad_pointer +-- FROM blade_transport_vehicle v +-- LEFT JOIN blade_transport_cert_record r ON r.id = v.effective_cert_record_id +-- WHERE v.effective_cert_record_id IS NOT NULL AND (r.id IS NULL OR r.status <> 1); +-- SELECT COUNT(*) AS driver_bad_pointer +-- FROM blade_transport_driver d +-- LEFT JOIN blade_transport_cert_record r ON r.id = d.effective_cert_record_id +-- WHERE d.effective_cert_record_id IS NOT NULL AND (r.id IS NULL OR r.status <> 1); +-- +-- 快照字段完整性:确认关键字段没有因为键名写错而丢失。 +-- 司机快照必须含 driverClass(准驾车型),车辆快照必须含 drivingLicenseNo(行驶证档案编号)。 +-- SELECT COUNT(*) AS driver_snapshot_bad FROM blade_transport_cert_record +-- WHERE biz_type = 'driver' AND JSON_EXTRACT(snapshot_json, '$.driverClass') IS NULL; +-- SELECT COUNT(*) AS vehicle_snapshot_bad FROM blade_transport_cert_record +-- WHERE biz_type = 'vehicle' AND JSON_EXTRACT(snapshot_json, '$.drivingLicenseNo') IS NULL; +-- +-- 号段账目:号段内的记录数应等于本次订正的对象数(首次订正 = 车辆数 + 尚无快照的在用司机数), +-- 指针落在号段内的对象数也应等于它。两个数对不上说明有对象漏订正。 +-- SELECT COUNT(*) AS in_range_record FROM blade_transport_cert_record +-- WHERE id >= 9000000000000000000 AND id < 9200000000000000000; +-- SELECT COUNT(*) AS driver_backfilled FROM blade_transport_driver +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- SELECT COUNT(*) AS vehicle_backfilled FROM blade_transport_vehicle +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; + +-- ---------------------------- +-- 4. 回退 +-- ---------------------------- +-- 识别依据是 id 号段(>= 9.0e18),不是「备份表里没有的 id」: +-- 号段法不依赖备份表是否建过,也不会误删订正之后产生的真实提交记录。 +-- 回退前先把门槛开关置 false(transport.certification.driver-gate-enabled / +-- vehicle-gate-enabled),否则清掉指针的瞬间所有对象都不可用。 +-- 顺序:先清指针,再删记录;反了会在中间态留下悬空指针。 +-- START TRANSACTION; +-- UPDATE blade_transport_driver +-- SET effective_cert_record_id = NULL, certification_status = NULL, +-- certification_reject_reason = NULL +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- UPDATE blade_transport_vehicle +-- SET effective_cert_record_id = NULL, certification_status = 0, +-- certification_reject_reason = NULL +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- DELETE FROM blade_transport_cert_record +-- WHERE id >= 9000000000000000000 AND id < 9200000000000000000; +-- COMMIT; +-- +-- 车辆 certification_status 还原为 0 是近似值:订正会把原本 0 与 1 的车都写成 1, +-- 原分布无法反推。这只影响页面上的状态标,不影响可用性(重跑订正会再次全部写成 1)。 +-- 要精确还原只能用订正前建好的 blade_transport_vehicle_certbak: +-- UPDATE blade_transport_vehicle v +-- JOIN blade_transport_vehicle_certbak b ON b.id = v.id +-- SET v.certification_status = b.certification_status, +-- v.certification_reject_reason = b.certification_reject_reason; diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql new file mode 100644 index 00000000..8e9b8870 --- /dev/null +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -0,0 +1,140 @@ +-- ---------------------------- +-- 认证记录:建表,司机表 / 车辆表补认证字段与生效快照指针,订正司机表排序规则, +-- 并新增认证审核菜单。可重复执行。 +-- ---------------------------- + +-- ---------------------------- +-- 1. 认证记录表 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `blade_transport_cert_record` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `biz_type` varchar(20) NOT NULL COMMENT '认证对象类型:driver-司机,vehicle-车辆', + `biz_id` bigint(20) NOT NULL COMMENT '认证对象主键:司机ID或车辆ID(车辆按具体车辆,不是司机)', + `biz_key` varchar(100) DEFAULT NULL COMMENT '展示键:司机姓名/车牌号', + `status` int(11) NOT NULL COMMENT '审核状态:0审核中 1已通过 2已驳回 3已作废', + `snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '该次提交的全量认证内容JSON,一经创建不可修改', + `reject_reason` varchar(200) DEFAULT NULL COMMENT '驳回原因,仅状态为已驳回时有值', + `submit_time` datetime DEFAULT NULL COMMENT '提交时间', + `submit_user` bigint(20) DEFAULT NULL COMMENT '提交人', + `audit_time` datetime DEFAULT NULL COMMENT '审核时间', + `audit_user` bigint(20) DEFAULT NULL COMMENT '审核人', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', + `pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_cert_record_pending` (`tenant_id`,`biz_type`,`pending_biz_id`) USING BTREE, + KEY `idx_cert_record_biz` (`biz_type`,`biz_id`,`submit_time`) USING BTREE, + KEY `idx_cert_record_status` (`biz_type`,`status`,`submit_time`) USING BTREE, + KEY `idx_cert_record_submit_time` (`submit_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='认证记录'; + +-- ---------------------------- +-- 2~4. 加列与排序规则订正 +-- ---------------------------- +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_cert_record_20261002`$$ +CREATE PROCEDURE `upgrade_transport_cert_record_20261002`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_status') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交' AFTER `remark`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_reject_reason') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_reject_reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最新一次驳回原因展示缓存' AFTER `certification_status`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'effective_cert_record_id') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_start') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起' AFTER `id_card_back`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_end') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_end` date DEFAULT NULL COMMENT '身份证有效期止' AFTER `id_card_valid_start`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_long_term') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是' AFTER `id_card_valid_end`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' AND column_name = 'effective_cert_record_id') THEN + ALTER TABLE `blade_transport_vehicle` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND table_collation <> 'utf8mb4_general_ci') THEN + ALTER TABLE `blade_transport_driver` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' + AND column_name = 'pending_biz_id' AND generation_expression NOT LIKE '%is_deleted%' + ) THEN + ALTER TABLE `blade_transport_cert_record` + MODIFY COLUMN `pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照'; + END IF; + + -- tenant_id 必须 NOT NULL:唯一索引不对 NULL 去重,允许 NULL 则同一对象可并存两条待审记录 + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' + AND column_name = 'tenant_id' AND is_nullable = 'YES' + ) THEN + UPDATE `blade_transport_cert_record` SET `tenant_id` = '000000' WHERE `tenant_id` IS NULL; + ALTER TABLE `blade_transport_cert_record` + MODIFY COLUMN `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID'; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' + AND column_name = 'certification_status' AND column_comment NOT LIKE '%已作废%' + ) THEN + ALTER TABLE `blade_transport_vehicle` + MODIFY COLUMN `certification_status` int(11) DEFAULT '0' COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废;可用性判定只看 effective_cert_record_id'; + END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' + AND column_name = 'certification_reject_reason' AND column_comment NOT LIKE '%展示缓存%' + ) THEN + ALTER TABLE `blade_transport_vehicle` + MODIFY COLUMN `certification_reject_reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最新一次驳回原因展示缓存'; + END IF; +END$$ + +CALL `upgrade_transport_cert_record_20261002`()$$ +DROP PROCEDURE `upgrade_transport_cert_record_20261002`$$ + +DELIMITER ; + +-- ---------------------------- +-- 5. 菜单 +-- 挂在「运力管理」2079000000000000001 下;ON DUPLICATE KEY UPDATE 使重跑可覆盖旧值。 +-- ---------------------------- +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES +(2079000000000000300, 2079000000000000001, 'cert_certification_audit_driver', '司机审核', 'cert_certification_audit_driver', '/transportCapacity/cert-audit-driver', 'iconfont iconicon_doc', 4, 1, 0, 1, '', '司机认证待审列表', 0), +(2079000000000000301, 2079000000000000300, 'cert_certification_audit_driver_audit', '审核', 'cert_certification_audit_driver_audit', '', '', 1, 2, 0, 1, NULL, '通过/驳回司机认证', 0), +(2079000000000000310, 2079000000000000001, 'cert_certification_audit_vehicle', '车辆审核', 'cert_certification_audit_vehicle', '/transportCapacity/cert-audit-vehicle', 'iconfont iconicon_doc', 5, 1, 0, 1, '', '车辆认证待审列表', 0), +(2079000000000000311, 2079000000000000310, 'cert_certification_audit_vehicle_audit', '审核', 'cert_certification_audit_vehicle_audit', '', '', 1, 2, 0, 1, NULL, '通过/驳回车辆认证', 0) +ON DUPLICATE KEY UPDATE + `parent_id` = VALUES(`parent_id`), + `code` = VALUES(`code`), + `name` = VALUES(`name`), + `alias` = VALUES(`alias`), + `path` = VALUES(`path`), + `source` = VALUES(`source`), + `sort` = VALUES(`sort`), + `category` = VALUES(`category`), + `action` = VALUES(`action`), + `is_open` = VALUES(`is_open`), + `component` = VALUES(`component`), + `remark` = VALUES(`remark`), + `is_deleted` = VALUES(`is_deleted`); diff --git a/doc/sql/transport/blade_transport_driver.sql b/doc/sql/transport/blade_transport_driver.sql index 57ccd4fc..19809bf5 100644 --- a/doc/sql/transport/blade_transport_driver.sql +++ b/doc/sql/transport/blade_transport_driver.sql @@ -13,10 +13,13 @@ CREATE TABLE `blade_transport_driver` ( `education` varchar(20) DEFAULT NULL COMMENT '学历', `address_region` varchar(100) DEFAULT NULL COMMENT '地址区划', `address` varchar(200) DEFAULT NULL COMMENT '详细地址', - `driving_vehicle` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号', + `driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔', `posts` varchar(100) DEFAULT NULL COMMENT '岗位,多个使用逗号分隔', `id_card_front` varchar(1000) DEFAULT NULL COMMENT '身份证正面照', `id_card_back` varchar(1000) DEFAULT NULL COMMENT '身份证反面照', + `id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起', + `id_card_valid_end` date DEFAULT NULL COMMENT '身份证有效期止', + `id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是', `head_photo` varchar(1000) DEFAULT NULL COMMENT '大头照', `driving_type` varchar(20) NOT NULL COMMENT '准驾车型', `driving_license_no` varchar(50) DEFAULT NULL COMMENT '驾驶证档案编号', @@ -39,6 +42,9 @@ CREATE TABLE `blade_transport_driver` ( `emergency_contact_name` varchar(20) NOT NULL COMMENT '紧急联系人姓名', `emergency_contact_mobile` varchar(20) NOT NULL COMMENT '紧急联系人手机号', `remark` varchar(200) DEFAULT NULL COMMENT '备注', + `certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交', + `certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '最新一次驳回原因展示缓存', + `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退', `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', `create_time` datetime DEFAULT NULL COMMENT '创建时间', @@ -54,7 +60,7 @@ CREATE TABLE `blade_transport_driver` ( KEY `idx_transport_driver_license_end` (`driving_license_end_date`) USING BTREE, KEY `idx_transport_driver_qualification_end` (`qualification_end_date`) USING BTREE, KEY `idx_transport_driver_status` (`status`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机管理'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='司机管理'; -- ---------------------------- -- Records of blade_menu for transport capacity driver diff --git a/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql new file mode 100644 index 00000000..b845ecba --- /dev/null +++ b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql @@ -0,0 +1,27 @@ +-- 司机「驾驶车辆」字段扩长:varchar(30) -> varchar(100)。 +-- 该字段是逗号并列的车牌列表,一个司机最多 5 辆,30 字符不够;超长会静默截断成半个车牌。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_driver_driving_vehicle_widen_20261003`$$ +CREATE PROCEDURE `upgrade_transport_driver_driving_vehicle_widen_20261003`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_driver' + AND column_name = 'driving_vehicle' + AND character_maximum_length < 100 + ) THEN + ALTER TABLE `blade_transport_driver` + MODIFY COLUMN `driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔'; + END IF; +END$$ + +CALL `upgrade_transport_driver_driving_vehicle_widen_20261003`()$$ +DROP PROCEDURE `upgrade_transport_driver_driving_vehicle_widen_20261003`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_transport_vehicle.sql b/doc/sql/transport/blade_transport_vehicle.sql index 3a5f3f22..1aa8f30b 100644 --- a/doc/sql/transport/blade_transport_vehicle.sql +++ b/doc/sql/transport/blade_transport_vehicle.sql @@ -8,8 +8,10 @@ CREATE TABLE `blade_transport_vehicle` ( `organization_name` varchar(50) NOT NULL COMMENT '所属组织', `use_department` varchar(50) DEFAULT NULL COMMENT '使用部门', `plate_no` varchar(16) NOT NULL COMMENT '车牌号', + `owner_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所有人(行驶证登记的单位名称,不是司机)', `plate_color` varchar(20) DEFAULT NULL COMMENT '车牌颜色', `vehicle_type` varchar(50) NOT NULL COMMENT '车辆类型', + `vin` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆识别代号(车架号)', `outer_length` int(11) DEFAULT NULL COMMENT '外廓长度,毫米', `outer_width` int(11) DEFAULT NULL COMMENT '外廓宽度,毫米', `outer_height` int(11) DEFAULT NULL COMMENT '外廓高度,毫米', @@ -39,8 +41,9 @@ CREATE TABLE `blade_transport_vehicle` ( `registration_date` date DEFAULT NULL COMMENT '机动车登记日期', `registration_image` varchar(1000) DEFAULT NULL COMMENT '机动车登记本图片', `remark` varchar(200) DEFAULT NULL COMMENT '备注', - `certification_status` int(11) DEFAULT '0' COMMENT '认证状态:0认证中 1认证通过 2认证驳回', - `certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '认证驳回原因', + `certification_status` int(11) DEFAULT '0' COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废;可用性判定只看 effective_cert_record_id', + `certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '最新一次驳回原因展示缓存', + `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退', `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', `create_time` datetime DEFAULT NULL COMMENT '创建时间', diff --git a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql new file mode 100644 index 00000000..5ac828c0 --- /dev/null +++ b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql @@ -0,0 +1,36 @@ +-- 车辆档案补两列:所有人(行驶证登记的单位名称,不是司机)、车辆识别代号(车架号)。 +-- 两列均可空,存量车辆没有值。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_vehicle_owner_vin_20261003`$$ +CREATE PROCEDURE `upgrade_transport_vehicle_owner_vin_20261003`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_vehicle' + AND column_name = 'owner_name' + ) THEN + ALTER TABLE `blade_transport_vehicle` + ADD COLUMN `owner_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所有人(行驶证登记的单位名称,不是司机)' AFTER `plate_no`; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_vehicle' + AND column_name = 'vin' + ) THEN + ALTER TABLE `blade_transport_vehicle` + ADD COLUMN `vin` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆识别代号(车架号)' AFTER `vehicle_type`; + END IF; +END$$ + +CALL `upgrade_transport_vehicle_owner_vin_20261003`()$$ +DROP PROCEDURE `upgrade_transport_vehicle_owner_vin_20261003`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_user_dept_id_expand_20261014.sql b/doc/sql/transport/blade_user_dept_id_expand_20261014.sql new file mode 100644 index 00000000..63d78877 --- /dev/null +++ b/doc/sql/transport/blade_user_dept_id_expand_20261014.sql @@ -0,0 +1,9 @@ +-- 用户多部门聚合字段扩容,避免 OA/IAM 同步时 dept_id 写入超长导致 Data truncation +-- 部署前先执行本脚本,再发布后端 + +ALTER TABLE `blade_user` + MODIFY COLUMN `dept_id` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id'; + +-- 若库中已有 dept_codes 列,请同步执行以下语句(无该列时可跳过) +ALTER TABLE `blade_user` + MODIFY COLUMN `dept_codes` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门编码'; diff --git a/doc/sql/transport/blade_user_oa_modified_20261010.sql b/doc/sql/transport/blade_user_oa_modified_20261010.sql new file mode 100644 index 00000000..1b92853f --- /dev/null +++ b/doc/sql/transport/blade_user_oa_modified_20261010.sql @@ -0,0 +1,4 @@ +-- 用户表:记录 OA 的 modified(上次更新时间),用于增量同步比对 + +ALTER TABLE `blade_user` + ADD COLUMN `oa_modified` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上次更新时间(modified)' AFTER `is_oa`; diff --git a/doc/sql/transport/blade_user_phone_expand_20261014.sql b/doc/sql/transport/blade_user_phone_expand_20261014.sql new file mode 100644 index 00000000..a0d376d0 --- /dev/null +++ b/doc/sql/transport/blade_user_phone_expand_20261014.sql @@ -0,0 +1,5 @@ +-- 用户手机号字段扩容:SM2 加密后 Hex 远超 varchar(45),避免 IAM/OA 同步插入失败 +-- 部署前先执行本脚本,再发布后端 + +ALTER TABLE `blade_user` + MODIFY COLUMN `phone` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机'; diff --git a/doc/sql/transport/blade_waybill_loading_identifier_20260927.sql b/doc/sql/transport/blade_waybill_loading_identifier_20260927.sql new file mode 100644 index 00000000..aeadca4a --- /dev/null +++ b/doc/sql/transport/blade_waybill_loading_identifier_20260927.sql @@ -0,0 +1,2 @@ +ALTER TABLE `blade_waybill` + ADD COLUMN `loading_identifier` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '配载标识号' AFTER `loading_no`; diff --git a/doc/sql/transport/blade_waybill_node_punch_20260911.sql b/doc/sql/transport/blade_waybill_node_punch_20260911.sql index f595a23b..6ba2077c 100644 --- a/doc/sql/transport/blade_waybill_node_punch_20260911.sql +++ b/doc/sql/transport/blade_waybill_node_punch_20260911.sql @@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` ( `longitude` decimal(12, 8) DEFAULT NULL COMMENT '经度', `latitude` decimal(12, 8) DEFAULT NULL COMMENT '纬度', `address` varchar(500) DEFAULT NULL COMMENT '打卡地址', - `photos` varchar(2000) DEFAULT NULL COMMENT '凭证照片URL,多张逗号分隔', + `photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张', `weight` varchar(32) DEFAULT NULL COMMENT '重量(吨)', `volume` varchar(32) DEFAULT NULL COMMENT '体积(方)', `quantity` varchar(32) DEFAULT NULL COMMENT '数量(件)', @@ -27,4 +27,4 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` ( PRIMARY KEY (`id`) USING BTREE, KEY `idx_node_punch_waybill` (`waybill_id`, `node_code`, `punch_time`) USING BTREE, KEY `idx_node_punch_driver` (`driver_id`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单过程节点打卡记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运单过程节点打卡记录'; diff --git a/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql new file mode 100644 index 00000000..98a1673f --- /dev/null +++ b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql @@ -0,0 +1,10 @@ +-- ---------------------------- +-- 节点打卡照片列加宽:varchar(2000) 改为 text +-- ---------------------------- +-- 一个节点可传「打卡照片 9 张 + 每个凭证类型各 9 张」,勾了 4 类凭证时共 45 张; +-- 库里实测每张照片含类型的 JSON 串约 111 字符,36 张约 4000 字符,已超 varchar(2000) 一倍。 +-- 超长时严格模式报 Data too long,非严格模式会静默截断成半个 JSON,后者更难查。 +-- 内容形状不变,仍是扁平 [{"type","url"}]。可重复执行。 + +ALTER TABLE `blade_waybill_node_punch` + MODIFY COLUMN `photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张'; diff --git a/doc/sql/transport/lqj/20261013/01_blade_payment_application_kingdee_bill_id_20260917.sql b/doc/sql/transport/lqj/20261013/01_blade_payment_application_kingdee_bill_id_20260917.sql new file mode 100644 index 00000000..5b230785 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/01_blade_payment_application_kingdee_bill_id_20260917.sql @@ -0,0 +1,17 @@ +-- 付款管理:金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) +-- 新增 kingdee_bill_id:保存 A103-1/A103-3 推送返回的星瀚单据内部主键(A902 附件上传等使用); +-- kingdee_bill_no 字段语义调整为保存金蝶单据编号(billno,如 FKSQ-xxxx),由定时回写任务回填 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/02_blade_customer_archive_kingdee_20260917.sql b/doc/sql/transport/lqj/20261013/02_blade_customer_archive_kingdee_20260917.sql new file mode 100644 index 00000000..1850aa03 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/02_blade_customer_archive_kingdee_20260917.sql @@ -0,0 +1,16 @@ +-- 客商档案:金蝶(星瀚)协同字段 +-- kingdee_status:none未推送 / exist金蝶已有档案(A902-1查到已审核,跳过推送) / synced已推送成功 / failed推送失败或金蝶审核中 +-- 金蝶客户编码与 TMS 客商编号(customer_code)一致(A902-2 number 传入客商编号),无需单独存储 + +ALTER TABLE `blade_customer_archive` + ADD COLUMN `kingdee_status` varchar(20) NOT NULL DEFAULT 'none' COMMENT '金蝶协同状态:none未推送/exist金蝶已有/synced已推送/failed推送失败' AFTER `approved_time`, + ADD COLUMN `kingdee_fail_reason` varchar(500) NULL DEFAULT NULL COMMENT '金蝶推送失败原因' AFTER `kingdee_status`, + ADD COLUMN `kingdee_sync_time` datetime NULL DEFAULT NULL COMMENT '金蝶推送时间' AFTER `kingdee_fail_reason`; + +CREATE INDEX idx_kingdee_status ON blade_customer_archive (kingdee_status); + +-- 列表「同步金蝶」按钮权限(审批通过后推送失败的重推入口;parent_id 通过菜单 code 关联,避免写死 id) +INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted) +SELECT 2075431800000000001, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 1, 2, 0, 1, '', '', 0 +FROM blade_menu m +WHERE m.code = 'customer_archive' AND m.is_deleted = 0; diff --git a/doc/sql/transport/lqj/20261013/03_blade_kingdee_invoice_pool_a8011_20260920.sql b/doc/sql/transport/lqj/20261013/03_blade_kingdee_invoice_pool_a8011_20260920.sql new file mode 100644 index 00000000..d499ee03 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/03_blade_kingdee_invoice_pool_a8011_20260920.sql @@ -0,0 +1,61 @@ +-- 收票管理:金蝶全票池发票同步(A801-1 查询 + 批量同步 + 定时绑定关联) +-- 票据池镜像表新增金蝶票池字段: +-- serial_no 发票流水号(票池唯一标识,A801-1 serialNo,同步回填 kingdee_bill_no) +-- buyer_tax_no 购方税号(A801-1 buyerTaxNo) +-- saler_tax_no 销方税号(A801-1 salerTaxNo) +-- check_status 查验状态(A801-1 checkStatus,0 未查验 / 1 通过 / 2 不通过) +-- 由手动批量同步与定时绑定任务按 tenant_id + invoice_no upsert 回填; +-- 发票号码唯一索引 uk_kingdee_invoice_pool_no(tenant_id, invoice_no)建表时已存在,无需新增 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `serial_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶发票流水号(票池唯一标识)'' AFTER `invoice_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'serial_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `buyer_tax_no` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''购方税号'' AFTER `receiver_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'buyer_tax_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `saler_tax_no` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''销方税号'' AFTER `issuer_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'saler_tax_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `check_status` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''查验状态(0未查验/1通过/2不通过)'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'check_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/04_blade_invoice_application_line_tax_code_20260921.sql b/doc/sql/transport/lqj/20261013/04_blade_invoice_application_line_tax_code_20260921.sql new file mode 100644 index 00000000..dd06c8f5 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/04_blade_invoice_application_line_tax_code_20260921.sql @@ -0,0 +1,21 @@ +-- 应收结算与开票申请同步金蝶(A303-1 开票申请推送) +-- 开票申请商品行新增字段: +-- tax_classification_code 税收分类编码(开票项目主数据 blade_invoice_item.tax_classification_code, +-- 新增/编辑开票申请选择开票内容时快照落库,A303-1 分录 taxratecodeid.number 取数来源) +-- 销方名称/销方税号不走 Nacos 配置与表结构:销方名称取开票申请开票单位快照(issuer_name,结算单收款方带出), +-- 销方税号按开票单位查客商档案统一社会信用代码(unified_credit_code) +-- 开票申请基础建表脚本 blade_invoice_application_20260821.sql 保持不动,本脚本支持重复执行 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_line` ADD COLUMN `tax_classification_code` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''税收分类编码(开票内容快照)'' AFTER `goods_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_line' + AND COLUMN_NAME = 'tax_classification_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/05_blade_invoice_application_invoice_20260920.sql b/doc/sql/transport/lqj/20261013/05_blade_invoice_application_invoice_20260920.sql new file mode 100644 index 00000000..12e77271 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/05_blade_invoice_application_invoice_20260920.sql @@ -0,0 +1,22 @@ +-- 开票申请金蝶发票结果表(A303-5 定时回写落库,发票号唯一幂等) + +CREATE TABLE IF NOT EXISTS `blade_invoice_application_invoice` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int(11) DEFAULT '1' COMMENT '状态', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除', + `invoice_application_id` bigint(20) NOT NULL COMMENT '开票申请ID', + `serial_no` varchar(64) DEFAULT NULL COMMENT '金蝶开票申请单编号(A303-5 serialNo)', + `invoice_no` varchar(64) NOT NULL COMMENT '发票号码', + `invoice_date` datetime DEFAULT NULL COMMENT '开票日期', + `invoice_amount` decimal(18,2) DEFAULT NULL COMMENT '价税合计', + `raw_json` text COMMENT 'A303-5返回原文JSON留档', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_application_invoice` (`tenant_id`,`invoice_application_id`,`invoice_no`), + KEY `idx_iai_application_id` (`invoice_application_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开票申请金蝶发票结果'; diff --git a/doc/sql/transport/lqj/20261013/06_blade_invoice_receipt_kingdee_push_20260922.sql b/doc/sql/transport/lqj/20261013/06_blade_invoice_receipt_kingdee_push_20260922.sql new file mode 100644 index 00000000..fee3d8f5 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/06_blade_invoice_receipt_kingdee_push_20260922.sql @@ -0,0 +1,94 @@ +-- 收票管理 图05 环节②③:推送金蝶收票单(A303-2 收票单批量保存 + A302-6 收票指定应付 + A801-3 定时回写) +-- 票据池镜像表 blade_kingdee_invoice_pool 新增: +-- invoice_type_code 票易通原始发票类型编码(A801-1 invoiceType,如 26/27,A303-2 invoicetypef7_number 取数) +-- items_json A801-1 发票明细行JSON(品名/数量/单价/金额/税率/税额/税收分类编码,A303-2 分录取数来源) +-- 收票登记表 blade_invoice_receipt 新增: +-- kingdee_received_invoice_no 金蝶收票单单据编号(A303-2 返回/幂等预检命中回填) +-- kingdee_push_status 收票单推送状态(unsynced 未推送 / pushed 已保存 / assigned 已指定应付 / failed 推送失败) +-- kingdee_push_msg 推送失败原因(金蝶侧删除/红冲/查验不通过等异常回收提示人工) +-- kingdee_push_time 最近一次推送/回写时间 +-- 以下结构升级支持重复执行 + +-- ① blade_kingdee_invoice_pool.invoice_type_code +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `invoice_type_code` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''票易通原始发票类型编码(A801-1 invoiceType,如26/27)'' AFTER `invoice_type`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'invoice_type_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_kingdee_invoice_pool.items_json +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `items_json` longtext COLLATE utf8mb4_general_ci COMMENT ''A801-1发票明细行JSON(A303-2分录取数来源)'' AFTER `attachments_json`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'items_json' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单单据编号(A303-2生成)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_receipt.kingdee_push_status +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_status` varchar(16) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''unsynced'' COMMENT ''收票单推送状态(unsynced未推送/pushed已保存/assigned已指定应付/failed推送失败)'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑤ blade_invoice_receipt.kingdee_push_msg +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_msg` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收票单推送失败原因'' AFTER `kingdee_push_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_msg' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑥ blade_invoice_receipt.kingdee_push_time +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_time` datetime DEFAULT NULL COMMENT ''最近一次收票单推送/回写时间'' AFTER `kingdee_push_msg`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/07_blade_kingdee_invoice_pool_prune_20260922.sql b/doc/sql/transport/lqj/20261013/07_blade_kingdee_invoice_pool_prune_20260922.sql new file mode 100644 index 00000000..45a3d772 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/07_blade_kingdee_invoice_pool_prune_20260922.sql @@ -0,0 +1,17 @@ +-- 金蝶票池镜像降级为登记存根(2026-09-22 票池改回实时查询收尾) +-- 背景:选票链路已改为实时查询 A801-1,blade_kingdee_invoice_pool 仅保留被收票登记引用的镜像行(登记存根), +-- 历史全量同步落库的未被引用镜像行属于退役数据,予以清理。 +-- 幂等:可重复执行,第二次影响 0 行。 +-- 注意:执行前必须先向用户报盘点数字并获确认(dev 库清理口径:事务 + 回滚 SQL + mysqldump 文本兜底)。 + +-- 执行前先跑盘点(只读): +-- SELECT COUNT(*) AS prune_rows FROM blade_kingdee_invoice_pool p +-- LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +-- WHERE r.id IS NULL; +-- SELECT COUNT(*) AS keep_rows FROM blade_kingdee_invoice_pool p +-- INNER JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0; + +-- 票池镜像降级为登记存根:删除未被任何未删收票登记引用的镜像行(实时查询模式上线前执行) +-- DELETE p FROM blade_kingdee_invoice_pool p +-- LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +-- WHERE r.id IS NULL; diff --git a/doc/sql/transport/lqj/20261013/08_blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql b/doc/sql/transport/lqj/20261013/08_blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql new file mode 100644 index 00000000..2665393a --- /dev/null +++ b/doc/sql/transport/lqj/20261013/08_blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql @@ -0,0 +1,33 @@ +-- 收票管理金蝶功能精简:删除 blade_invoice_receipt 冗余同步字段 +-- kingdee_bill_no 金蝶单据号(冗余,等于票池镜像 serialNo,可经 kingdee_invoice_pool_id 关联查询) +-- kingdee_status 金蝶同步状态(冗余,手动同步功能已下线,推送状态 kingdee_push_status 为唯一状态源) +-- 金蝶状态展示口径:kingdee_push_status 四态经 kingdeePushStatusName 展示(未推送/已保存收票单/已指定应付/推送失败) +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_receipt.kingdee_bill_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_receipt.kingdee_status(索引 idx_invoice_receipt_kingdee 随列自动删除) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/09_blade_kingdee_attachment_sync_20260924.sql b/doc/sql/transport/lqj/20261013/09_blade_kingdee_attachment_sync_20260924.sql new file mode 100644 index 00000000..aa6a3e58 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/09_blade_kingdee_attachment_sync_20260924.sql @@ -0,0 +1,75 @@ +-- ① blade_kingdee_attachment_sync 建表【修复索引超长问题】 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'CREATE TABLE `blade_kingdee_attachment_sync` ( + `id` bigint NOT NULL COMMENT ''主键'', + `tenant_id` varchar(12) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''租户ID'', + `biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application)'', + `biz_id` bigint NOT NULL COMMENT ''业务单据id'', + `attachment_key` varchar(768) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''附件幂等键(用户附件=附件url;发票文件=A801-2的path)'', + `attachment_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''附件名称(展示/排查用)'', + `kingdee_path` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶侧文件路径(A902 返回 data.path)'', + `sync_status` tinyint NOT NULL DEFAULT 0 COMMENT ''同步状态:1 成功 / 0 失败'', + `fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''失败原因(超限/下载失败/金蝶报错)'', + `sync_time` datetime DEFAULT NULL COMMENT ''最近上传时间'', + `create_user` bigint DEFAULT NULL COMMENT ''创建人'', + `create_dept` bigint DEFAULT NULL COMMENT ''创建部门'', + `create_time` datetime DEFAULT NULL COMMENT ''创建时间'', + `update_user` bigint DEFAULT NULL COMMENT ''修改人'', + `update_time` datetime DEFAULT NULL COMMENT ''修改时间'', + `status` int DEFAULT NULL COMMENT ''业务状态'', + `is_deleted` int DEFAULT 0 COMMENT ''是否已删除'', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_biz_attachment` (`biz_type`,`biz_id`,`attachment_key`(256)), + KEY `idx_biz_type_biz_id` (`biz_type`,`biz_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT=''金蝶附件同步表(A902)''', + 'SELECT 1') + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_attachment_sync' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_formal_settlement.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_received_invoice_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/10_blade_invoice_application_sheet_kingdee_bill_no_20260924.sql b/doc/sql/transport/lqj/20261013/10_blade_invoice_application_sheet_kingdee_bill_no_20260924.sql new file mode 100644 index 00000000..ec7bf6f3 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/10_blade_invoice_application_sheet_kingdee_bill_no_20260924.sql @@ -0,0 +1,50 @@ +-- ============================================================ +-- blade_invoice_application_sheet_kingdee_bill_no_20260924.sql +-- ============================================================ +-- 开票申请同步金蝶按发票张次拆分开票(A303-1 一张发票张次一张开票申请单) +-- blade_invoice_application_sheet.kingdee_bill_no 本张发票对应的金蝶开票申请单编号(A303-1 返回,A303-5 serialNo 查询入口) +-- blade_invoice_application.kingdee_bill_no 多张发票时逗号拼接全部金蝶单号,列加宽至 varchar(500) 防溢出 +-- blade_invoice_application_record.kingdee_bill_no 操作记录的同一字段同步加宽,避免多张单号拼接超长导致插入失败 +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_application_sheet.kingdee_bill_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_sheet` ADD COLUMN `kingdee_bill_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(本张发票对应)'' AFTER `invoice_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_sheet' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_application.kingdee_bill_no 加宽 +SET @ddl_sql = ( + SELECT IF(CHARACTER_MAXIMUM_LENGTH < 500, + 'ALTER TABLE `blade_invoice_application` MODIFY COLUMN `kingdee_bill_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(多张发票逗号拼接)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_application_record.kingdee_bill_no 加宽 +SET @ddl_sql = ( + SELECT IF(CHARACTER_MAXIMUM_LENGTH < 500, + 'ALTER TABLE `blade_invoice_application_record` MODIFY COLUMN `kingdee_bill_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(多张发票逗号拼接)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_record' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; \ No newline at end of file diff --git a/doc/sql/transport/lqj/20261013/11_blade_receipt_flow_claim_20260924.sql b/doc/sql/transport/lqj/20261013/11_blade_receipt_flow_claim_20260924.sql new file mode 100644 index 00000000..16779503 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/11_blade_receipt_flow_claim_20260924.sql @@ -0,0 +1,103 @@ +-- 收款认领金蝶对接(A301-2 认领公告同步 / A301-3 认领结果推送 / A301-1 收款单回写) +-- 支持重复执行,兼容已执行 DDL 的场景 + +-- 1. 收款流水表新增金蝶认领公告字段 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `bill_id` bigint(20) DEFAULT NULL COMMENT ''金蝶认领公告id(A301-2返回,A301-3推送必填)'' AFTER `source_updated_time`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `payer_id` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶付款人id(A301-3推送用)'' AFTER `bill_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'payer_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_claim_status` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''公告认领状态(A301-2 claimstatus:0未认领/1已认领)'' AFTER `payer_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_claim_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `claim_me` tinyint(1) DEFAULT NULL COMMENT ''我已认领(A301-2 claimme)'' AFTER `kingdee_claim_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'claim_me' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `unclaimed_amount` decimal(18,2) NOT NULL DEFAULT ''0.00'' COMMENT ''未认领金额(A301-2 unclaimamount)'' AFTER `claim_me`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'unclaimed_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `fee` decimal(18,2) DEFAULT NULL COMMENT ''手续费(A301-2 fee)'' AFTER `unclaimed_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'fee' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `payee_name` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收款人(A301-2 recpayee)'' AFTER `fee`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'payee_name' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 2. 认领主单新增来源单据编码(A301-3 awrp_src_code,A301-1 回写匹配键) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_receipt_claim` ADD COLUMN `awrp_src_code` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶认领结果推送来源单据编码(awrp_src_code)'' AFTER `kingdee_bill_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_receipt_claim` ADD KEY `idx_receipt_claim_src_code` (`tenant_id`,`awrp_src_code`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND INDEX_NAME = 'idx_receipt_claim_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; \ No newline at end of file diff --git a/doc/sql/transport/lqj/20261013/12_blade_receipt_flow_push_20260928.sql b/doc/sql/transport/lqj/20261013/12_blade_receipt_flow_push_20260928.sql new file mode 100644 index 00000000..f4f4c542 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/12_blade_receipt_flow_push_20260928.sql @@ -0,0 +1,136 @@ +-- 收款认领推送金蝶重构(认领本地化 / A301-3 定时+手动推送 / 按流水归集) +-- 支持重复执行,兼容 20260924 脚本已执行/未执行的环境,与该脚本无执行顺序要求 +-- 变更点: +-- 1. 收款流水表新增推送域字段(推送状态/失败原因/推送时间/推送编码/金蝶收款单号/回写状态) +-- 2. 认领主单移除 awrp_src_code 及其索引(推送编码上移至流水:分摊结算单号拼接) +-- 3. 收款流水表新增定时任务扫描索引与 A301-1 回写匹配索引 + +-- 1. 收款流水表:推送状态 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''推送状态(none未推送/pushed已推送/failed推送失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 2. 收款流水表:最近一次推送失败原因 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_fail_reason` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''最近一次推送失败原因(成功后清空)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 3. 收款流水表:首次推送成功时间 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `pushed_time` datetime DEFAULT NULL COMMENT ''首次推送金蝶成功时间''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'pushed_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 4. 收款流水表:推送来源单据编码(A301-3 awrp_src_code,分摊结算单号拼接,A301-1 回写匹配键) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `awrp_src_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送来源单据编码(分摊结算单号下划线拼接,A301-1回写匹配键)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 5. 收款流水表:金蝶收款单号(推送响应回填 / A301-1 回写推进) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收款单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 6. 收款流水表:金蝶回写状态(A301-1 回写生命周期) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''金蝶回写状态(none未推送/pushed已推送/approved审核通过/received已收款/failed失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 7. 认领主单:删除 awrp_src_code 索引(列上移至流水表;0924 未执行过则本就无此索引) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP KEY `idx_receipt_claim_src_code`', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND INDEX_NAME = 'idx_receipt_claim_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 8. 认领主单:删除 awrp_src_code 列(0924 未执行过则本就无此列) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP COLUMN `awrp_src_code`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 9. 收款流水表:定时任务扫描索引(push_status IN (none,failed) AND claim_status='claimed' AND bill_id 非空) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_push_scan` (`tenant_id`,`push_status`,`claim_status`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_push_scan' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 10. 收款流水表:A301-1 回写匹配索引(sourcebillnumber = awrp_src_code) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_src_code` (`tenant_id`,`awrp_src_code`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 11. 按钮权限:收款流水页「推送金蝶」(父级菜单 2090000000000001260 收款流水,排序 4) +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000001264,2090000000000001260,'receipt_flow_push','推送金蝶','receipt_flow_push','','',4,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `name`=VALUES(`name`), + `alias`=VALUES(`alias`), + `is_deleted`=0; diff --git a/doc/sql/transport/lqj/20261013/13_blade_invoice_application_invoice_urls_20260930.sql b/doc/sql/transport/lqj/20261013/13_blade_invoice_application_invoice_urls_20260930.sql new file mode 100644 index 00000000..849813f8 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/13_blade_invoice_application_invoice_urls_20260930.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- blade_invoice_application_invoice_urls_20260930.sql +-- ============================================================ +-- 开票申请金蝶发票结果表补充发票文件下载地址(A303-5 返回,正式结算单详情查看/下载发票): +-- invoice_file_url 板式文件下载地址(数电票为OFD,航信百望税盘普通电票为PDF,电子专票/UKey电票为OFD) +-- invoice_image_url PDF图片预览地址 +-- invoice_pdf_file_url 数电PDF下载地址(发票云5.0.025版本返回) +-- invoice_xml_file_url 数电XML下载地址(发票云5.0.025版本返回) +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_application_invoice.invoice_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''板式文件下载地址(数电票为OFD,普通电票为PDF,电子专票为OFD)'' AFTER `invoice_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_application_invoice.invoice_image_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_image_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''PDF图片预览地址'' AFTER `invoice_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_image_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_application_invoice.invoice_pdf_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_pdf_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电PDF下载地址(发票云5.0.025版本返回)'' AFTER `invoice_image_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_pdf_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application_invoice.invoice_xml_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_xml_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电XML下载地址(发票云5.0.025版本返回)'' AFTER `invoice_pdf_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_xml_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/14_blade_kingdee_invoice_pool_available_20261005.sql b/doc/sql/transport/lqj/20261013/14_blade_kingdee_invoice_pool_available_20261005.sql new file mode 100644 index 00000000..98a36137 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/14_blade_kingdee_invoice_pool_available_20261005.sql @@ -0,0 +1,46 @@ +-- 金蝶票池选票回切镜像读:镜像表新增可用过滤列 + 认领过滤索引 +-- available_amount 剩余可用金额(A801-1 availableAmount,金蝶可用税额口径,选票本地过滤 >0) +-- amount_use_status 金额使用状态(A801-1 amountUseStatus,'0' 未使用可选 / '2' 全部使用) +-- 由定时同步(InvoiceReceiptKingdeeSyncTask)/ 手动同步端点 / 票号回源 / 收票保存重查 统一按 tenant_id + invoice_no upsert 回填; +-- 存量存根行两列为 NULL,不参与选票(可用过滤为 NULL 排除),随增量窗口同步自然回填 +-- 认领过滤 EXISTS 子查询补 (tenant_id, invoice_no) 索引,避免统一查询全表扫 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `available_amount` decimal(18,2) DEFAULT NULL COMMENT ''剩余可用金额(A801-1 availableAmount,金蝶可用税额口径)'' AFTER `source_updated_time`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'available_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `amount_use_status` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金额使用状态(0 未使用 / 2 全部使用)'' AFTER `available_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'amount_use_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @idx_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement_invoice` ADD INDEX `idx_settlement_invoice_no` (`tenant_id`, `invoice_no`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_invoice' + AND INDEX_NAME = 'idx_settlement_invoice_no' +); +PREPARE idx_stmt FROM @idx_sql; +EXECUTE idx_stmt; +DEALLOCATE PREPARE idx_stmt; diff --git a/doc/sql/transport/lqj/20261013/15_blade_kingdee_bill_lock_20261009.sql b/doc/sql/transport/lqj/20261013/15_blade_kingdee_bill_lock_20261009.sql new file mode 100644 index 00000000..98c6ac33 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/15_blade_kingdee_bill_lock_20261009.sql @@ -0,0 +1,32 @@ +-- 金蝶单据锁定记录表:lockBusiData 锁定 / unLockNotifyBusiData 解锁回调 / 锁定补偿 +-- 一行 = 一张金蝶目标单:锁定留痕(人/时间/失败原因)+ 当前锁状态(编辑守卫/回调定位/补偿扫表) +-- lock_status:failed-锁定失败(补偿重试对象)/ locked-金蝶锁定中 / unlocked-已解锁(终态) + +CREATE TABLE `blade_kingdee_bill_lock` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID', + `source_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单据类型:SETTLEMENT正式结算单/INVOICE_APPLY开票申请单(预留PAY_APPLY付款申请单/RECEIVED_INVOICE收票单)', + `source_bill_id` bigint NOT NULL COMMENT 'WLTMS业务主键', + `source_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单号', + `target_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据类型:ApFin_other_BT_S应付单/arfin_other_BT_S应收单/cdm_paybill_bank_elect_BT_S开票单等固定值', + `target_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据编号(开票申请按发票张次逐张一行)', + `lock_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '锁状态:failed锁定失败/locked金蝶锁定中/unlocked已解锁', + `operation_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最近动作来源操作类型:SAVE单据新增/DELETE单据删除', + `try_count` int DEFAULT '0' COMMENT '锁定重试次数(补偿任务用,超上限停试)', + `lock_time` datetime DEFAULT NULL COMMENT '锁定时间', + `unlock_time` datetime DEFAULT NULL COMMENT '解锁时间', + `lock_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '推单发起人(锁定操作人)', + `notify_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回调操作人(金蝶侧操作人)', + `fail_reason` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '锁定失败原因', + `create_user` bigint DEFAULT NULL COMMENT '创建人', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int DEFAULT NULL COMMENT '状态', + `is_deleted` int DEFAULT '0' COMMENT '是否已删除', + PRIMARY KEY (`id`), + INDEX `idx_kbl_source` (`source_bill_type`, `source_bill_id`), + INDEX `idx_kbl_target` (`target_bill_type`, `target_bill_no`), + INDEX `idx_kbl_status` (`lock_status`, `try_count`) +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '金蝶单据锁定记录' ROW_FORMAT = DYNAMIC; diff --git a/doc/sql/transport/stat_account_module_create_count.sql b/doc/sql/transport/stat_account_module_create_count.sql new file mode 100644 index 00000000..6cc9a5b2 --- /dev/null +++ b/doc/sql/transport/stat_account_module_create_count.sql @@ -0,0 +1,443 @@ +-- 统计指定账号在指定日期内、各业务模块创建的数据量 +-- +-- 使用前只改下面两个日期(含首尾)。单日统计时两个日期填同一天。 +-- 统计口径: +-- 1. 按 blade_user.account 找到用户 ID,再按各业务主表 create_user、create_time 计数。 +-- 2. 同一账号若存在多个用户 ID(含已删除账号),历史数据会一并计入该账号。 +-- 3. 只统计各功能列表对应的主表。随主单保存的明细、联系人、附件、变更流水不重复计数。 +-- 4. 含已逻辑删除的数据,因为这些记录确实被创建过。 +-- 5. 客商档案菜单挂在「车/船务管理」下,这里按统计口径单独计入「客商管理」。 +-- 6. 未纳入:结算管理、首付款管理、在途管理,以及系统管理下的开票项目。 +-- 7. 导入运单产生的运单计入「业务模块-运单管理」,不另计导入批次。 + +SET NAMES utf8mb4; + +SET @stat_start = '2026-09-29'; +SET @stat_end = '2026-09-29'; +SET @stat_end_exclusive = DATE_ADD(@stat_end, INTERVAL 1 DAY); + +DROP TEMPORARY TABLE IF EXISTS tmp_stat_account; +CREATE TEMPORARY TABLE tmp_stat_account ( + sort_no INT NOT NULL, + account VARCHAR(45) NOT NULL, + PRIMARY KEY (account) +); + +INSERT INTO tmp_stat_account (sort_no, account) VALUES +(1, 'zhanghu01'), +(2, 'zhanghu02'), +(3, 'zhanghu03'), +(4, 'zhanghu04'), +(5, 'zhanghu05'), +(6, 'zhanghu06'), +(7, 'zhanghu07'), +(8, 'zhanghu08'), +(9, 'zhanghu09'), +(10, 'zhanghu10'), +(11, 'zhanghu11'), +(12, 'zhanghu12'), +(13, 'zhanghu13'), +(14, 'zhanghu14'), +(15, 'zhanghu15'), +(16, 'zhanghu16'); + +DROP TEMPORARY TABLE IF EXISTS tmp_stat_user; +CREATE TEMPORARY TABLE tmp_stat_user AS +SELECT u.id, u.account, u.real_name, u.tenant_id, u.is_deleted, a.sort_no +FROM blade_user u +INNER JOIN tmp_stat_account a ON a.account = u.account; + +ALTER TABLE tmp_stat_user ADD PRIMARY KEY (id); +ALTER TABLE tmp_stat_user ADD INDEX idx_tmp_stat_user_account (account); + +DROP TEMPORARY TABLE IF EXISTS tmp_stat_detail; +CREATE TEMPORARY TABLE tmp_stat_detail ( + create_user BIGINT NOT NULL, + module_sort INT NOT NULL, + module_name VARCHAR(32) NOT NULL, + biz_sort INT NOT NULL, + biz_name VARCHAR(64) NOT NULL, + table_name VARCHAR(64) NOT NULL, + cnt BIGINT NOT NULL, + KEY idx_tmp_stat_detail_user (create_user) +); + +-- 每张表单独插入。MySQL 不允许同一条语句多次打开临时表。 +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 1, '客商类型', 'blade_customer_type', COUNT(*) +FROM blade_customer_type t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 2, '费用项', 'blade_fee_item', COUNT(*) +FROM blade_fee_item t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 3, '币种汇率管理', 'blade_currency', COUNT(*) +FROM blade_currency t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 4, '计量单位', 'blade_measurement_unit', COUNT(*) +FROM blade_measurement_unit t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 5, '货物类型', 'blade_cargo_type', COUNT(*) +FROM blade_cargo_type t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 6, '行政区划', 'blade_region', COUNT(*) +FROM blade_region t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 7, '港口码头主数据', 'blade_port_terminal', COUNT(*) +FROM blade_port_terminal t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 8, '空港机场主数据', 'blade_airport_master', COUNT(*) +FROM blade_airport_master t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 9, '铁路车站主数据', 'blade_railway_station', COUNT(*) +FROM blade_railway_station t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 1, '基础配置', 10, '保险OCR识别模板', 'blade_insurance_ocr_template', COUNT(*) +FROM blade_insurance_ocr_template t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 2, '基础数据', 1, '常用地址', 'blade_common_address', COUNT(*) +FROM blade_common_address t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 3, '客商管理', 1, '客商档案', 'blade_customer_archive', COUNT(*) +FROM blade_customer_archive t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 4, '项目管理', 1, '项目管理', 'blade_project_apply', COUNT(*) +FROM blade_project_apply t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 4, '项目管理', 2, '临时额度管理', 'blade_temporary_credit_limit', COUNT(*) +FROM blade_temporary_credit_limit t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 5, '合同模块', 1, '合同管理', 'blade_contract_manage', COUNT(*) +FROM blade_contract_manage t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 1, '常用货物', 'blade_common_cargo', COUNT(*) +FROM blade_common_cargo t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 2, '常用线路', 'blade_common_route', COUNT(*) +FROM blade_common_route t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 3, '发货模板', 'blade_shipping_template', COUNT(*) +FROM blade_shipping_template t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 4, '过程配置', 'blade_process_config', COUNT(*) +FROM blade_process_config t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 5, '总单管理', 'blade_master_order', COUNT(*) +FROM blade_master_order t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 6, '运输计划', 'blade_transport_plan', COUNT(*) +FROM blade_transport_plan t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 7, '配载管理', 'blade_loading_manage', COUNT(*) +FROM blade_loading_manage t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 8, '运单管理', 'blade_waybill', COUNT(*) +FROM blade_waybill t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 6, '业务模块', 9, '凭证管理', 'blade_voucher_manage', COUNT(*) +FROM blade_voucher_manage t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 1, '设备台账', 'blade_equipment_ledger', COUNT(*) +FROM blade_equipment_ledger t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 2, '保险记录', 'blade_insurance_record', COUNT(*) +FROM blade_insurance_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 3, '年检记录', 'blade_annual_inspection_record', COUNT(*) +FROM blade_annual_inspection_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 4, '保养记录', 'blade_vehicle_maintenance_plan', COUNT(*) +FROM blade_vehicle_maintenance_plan t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 5, '维修记录', 'blade_vehicle_maintenance_record', COUNT(*) +FROM blade_vehicle_maintenance_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 6, '违章记录', 'blade_violation_record', COUNT(*) +FROM blade_violation_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 7, '事故记录', 'blade_accident_record', COUNT(*) +FROM blade_accident_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 8, 'ETC记录', 'blade_etc_record', COUNT(*) +FROM blade_etc_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 9, '油电记录', 'blade_oil_electric_record', COUNT(*) +FROM blade_oil_electric_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 10, '里程记录', 'blade_mileage_record', COUNT(*) +FROM blade_mileage_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 11, '换胎记录', 'blade_tire_replacement_record', COUNT(*) +FROM blade_tire_replacement_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 12, '其他费用记录', 'blade_other_expense_record', COUNT(*) +FROM blade_other_expense_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 13, '变更记录', 'blade_transport_change_record', COUNT(*) +FROM blade_transport_change_record t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 7, '车/船务模块', 14, '评分量化表', 'blade_credit_score_quantification', COUNT(*) +FROM blade_credit_score_quantification t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 8, '运力模块', 1, '车辆管理', 'blade_transport_vehicle', COUNT(*) +FROM blade_transport_vehicle t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 8, '运力模块', 2, '船舶管理', 'blade_transport_ship', COUNT(*) +FROM blade_transport_ship t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 8, '运力模块', 3, '司机管理', 'blade_transport_driver', COUNT(*) +FROM blade_transport_driver t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user +; + +INSERT INTO tmp_stat_detail (create_user, module_sort, module_name, biz_sort, biz_name, table_name, cnt) +SELECT t.create_user, 8, '运力模块', 4, '车辆调度', 'blade_transport_vehicle_dispatch', COUNT(*) +FROM blade_transport_vehicle_dispatch t +INNER JOIN tmp_stat_user u ON u.id = t.create_user +WHERE t.create_time >= @stat_start AND t.create_time < @stat_end_exclusive +GROUP BY t.create_user; + +-- 结果 1:账号匹配情况。用户 ID 为空表示库中没有该账号。 +SELECT + a.sort_no AS `序号`, + a.account AS `账号`, + u.id AS `用户ID`, + u.real_name AS `姓名`, + u.tenant_id AS `租户`, + CASE WHEN u.id IS NULL THEN '未找到' WHEN u.is_deleted = 0 THEN '正常' ELSE '已删除' END AS `账号状态` +FROM tmp_stat_account a +LEFT JOIN tmp_stat_user u ON u.account = a.account +ORDER BY a.sort_no, u.id; + +-- 结果 2:按账号、模块汇总创建数量。 +SELECT + a.account AS `账号`, + IFNULL(GROUP_CONCAT(DISTINCT NULLIF(u.real_name, '') ORDER BY u.id SEPARATOR '、'), '') AS `姓名`, + IFNULL(SUM(CASE WHEN d.module_name = '基础配置' THEN d.cnt ELSE 0 END), 0) AS `基础配置`, + IFNULL(SUM(CASE WHEN d.module_name = '基础数据' THEN d.cnt ELSE 0 END), 0) AS `基础数据`, + IFNULL(SUM(CASE WHEN d.module_name = '客商管理' THEN d.cnt ELSE 0 END), 0) AS `客商管理`, + IFNULL(SUM(CASE WHEN d.module_name = '项目管理' THEN d.cnt ELSE 0 END), 0) AS `项目管理`, + IFNULL(SUM(CASE WHEN d.module_name = '合同模块' THEN d.cnt ELSE 0 END), 0) AS `合同模块`, + IFNULL(SUM(CASE WHEN d.module_name = '业务模块' THEN d.cnt ELSE 0 END), 0) AS `业务模块`, + IFNULL(SUM(CASE WHEN d.module_name = '车/船务模块' THEN d.cnt ELSE 0 END), 0) AS `车/船务模块`, + IFNULL(SUM(CASE WHEN d.module_name = '运力模块' THEN d.cnt ELSE 0 END), 0) AS `运力模块`, + IFNULL(SUM(d.cnt), 0) AS `合计` +FROM tmp_stat_account a +LEFT JOIN tmp_stat_user u ON u.account = a.account +LEFT JOIN tmp_stat_detail d ON d.create_user = u.id +GROUP BY a.sort_no, a.account +ORDER BY a.sort_no; + +-- 结果 3:按账号、功能展开,只显示创建数量大于 0 的项。 +SELECT + a.account AS `账号`, + d.module_name AS `模块`, + d.biz_name AS `功能`, + d.table_name AS `数据表`, + SUM(d.cnt) AS `创建数量` +FROM tmp_stat_account a +INNER JOIN tmp_stat_user u ON u.account = a.account +INNER JOIN tmp_stat_detail d ON d.create_user = u.id +GROUP BY a.sort_no, a.account, d.module_sort, d.module_name, d.biz_sort, d.biz_name, d.table_name +ORDER BY a.sort_no, d.module_sort, d.biz_sort; diff --git a/doc/sql/transport/transport.sql b/doc/sql/transport/transport.sql index e9ba572e..c2bc3826 100644 --- a/doc/sql/transport/transport.sql +++ b/doc/sql/transport/transport.sql @@ -1420,7 +1420,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint(20) NOT NULL COMMENT '主键', `code` varchar(20) NOT NULL COMMENT '编码', `tmis_code` varchar(5) NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) DEFAULT NULL COMMENT '电报码', `name` varchar(100) NOT NULL COMMENT '车站名称', `railway_line` varchar(100) DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) DEFAULT NULL COMMENT '所属省份编码', @@ -5422,11 +5422,11 @@ CREATE TABLE `blade_user` ( `real_name` varchar(10) DEFAULT NULL COMMENT '真名', `avatar` varchar(500) DEFAULT NULL COMMENT '头像', `email` varchar(45) DEFAULT NULL COMMENT '邮箱', - `phone` varchar(45) DEFAULT NULL COMMENT '手机', + `phone` varchar(500) DEFAULT NULL COMMENT '手机', `birthday` datetime DEFAULT NULL COMMENT '生日', `sex` int(11) DEFAULT NULL COMMENT '性别', `role_id` varchar(1000) DEFAULT NULL COMMENT '角色id', - `dept_id` varchar(1000) DEFAULT NULL COMMENT '部门id', + `dept_id` varchar(2000) DEFAULT NULL COMMENT '部门id', `post_id` varchar(1000) DEFAULT NULL COMMENT '岗位id', `leader_id` varchar(1000) DEFAULT NULL COMMENT '主管id', `is_leader` int(11) DEFAULT '0' COMMENT '是否主管', diff --git a/hs_err_pid22077.log b/hs_err_pid22077.log deleted file mode 100644 index 8dc42817..00000000 --- a/hs_err_pid22077.log +++ /dev/null @@ -1,1379 +0,0 @@ -# -# A fatal error has been detected by the Java Runtime Environment: -# -# SIGBUS (0xa) at pc=0x0000000100ed64c0, pid=22077, tid=36647 -# -# JRE version: OpenJDK Runtime Environment Zulu17.44+15-CA (17.0.8+7) (build 17.0.8+7-LTS) -# Java VM: OpenJDK 64-Bit Server VM Zulu17.44+15-CA (17.0.8+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64) -# Problematic frame: -# C [libzip.dylib+0x64c0] newEntry+0x68 -# -# No core dump will be written. Core dumps have been disabled. To enable core dumping, try "ulimit -c unlimited" before starting Java again -# -# If you would like to submit a bug report, please visit: -# http://www.azul.com/support/ -# The crash happened outside the Java Virtual Machine in native code. -# See problematic frame for where to report the bug. -# - ---------------- S U M M A R Y ------------ - -Command Line: -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:50259,suspend=y,server=n -javaagent:/Users/liangxin/Library/Caches/JetBrains/IntelliJIdea2026.1/captureAgent/debugger-agent.jar=file:///var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/capture10140205674518191061.props -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dkotlinx.coroutines.debug.enable.creation.stack.trace=false -Ddebugger.agent.enable.coroutines=true -Dkotlinx.coroutines.debug.enable.flows.stack.trace=true -Dkotlinx.coroutines.debug.enable.mutable.state.flows.stack.trace=true -Ddebugger.async.stack.trace.for.all.threads=true -Dfile.encoding=UTF-8 org.springblade.desk.DeskApplication - -Host: "Mac15,6" arm64, 12 cores, 36G, Darwin 25.6.0, macOS 26.6.2 (25G83) -Time: Fri Sep 18 14:44:41 2026 CST elapsed time: 9.129557 seconds (0d 0h 0m 9s) - ---------------- T H R E A D --------------- - -Current thread (0x000000012b49c600): JavaThread "sentinel-datafile-log-executor-thread-1" daemon [_thread_in_native, id=36647, stack(0x0000000174680000,0x0000000174883000)] - -Stack: [0x0000000174680000,0x0000000174883000], sp=0x00000001748819d0, free space=2054k -Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) -C [libzip.dylib+0x64c0] newEntry+0x68 -C [libzip.dylib+0x6390] ZIP_GetEntry2+0x14c -C [libzip.dylib+0x6d78] ZIP_FindEntry+0x3c -V [libjvm.dylib+0x25a408] ClassPathZipEntry::open_entry(JavaThread*, char const*, int*, bool)+0xb4 -V [libjvm.dylib+0x25a53c] ClassPathZipEntry::open_stream(JavaThread*, char const*)+0x20 -V [libjvm.dylib+0x25d918] ClassLoader::load_class(Symbol*, bool, JavaThread*)+0x150 -V [libjvm.dylib+0x981d60] SystemDictionary::load_instance_class_impl(Symbol*, Handle, JavaThread*)+0x2d0 -V [libjvm.dylib+0x98063c] SystemDictionary::load_instance_class(unsigned int, Symbol*, Handle, JavaThread*)+0x30 -V [libjvm.dylib+0x97fd48] SystemDictionary::resolve_instance_class_or_null(Symbol*, Handle, Handle, JavaThread*)+0x4dc -V [libjvm.dylib+0x97f334] SystemDictionary::resolve_or_fail(Symbol*, Handle, Handle, bool, JavaThread*)+0x80 -V [libjvm.dylib+0x2beb54] ConstantPool::klass_at_impl(constantPoolHandle const&, int, JavaThread*)+0x1e0 -V [libjvm.dylib+0x46d6f0] InterpreterRuntime::_new(JavaThread*, ConstantPool*, int)+0x94 -j com.intellij.rt.debugger.agent.CaptureStorage$DeepCapturedStack.collectStacks(Ljava/util/List;)Lcom/intellij/rt/debugger/agent/CaptureStorage$StackData;+89 -j com.intellij.rt.debugger.agent.CaptureStorage.getStackTrace(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+30 -j com.intellij.rt.debugger.agent.CaptureStorage.access$1000(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()[Ljava/lang/StackTraceElement;+31 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()Ljava/lang/Object;+1 -j com.intellij.rt.debugger.agent.CaptureStorage.withoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$Callable;)Ljava/lang/Object;+21 -j com.intellij.rt.debugger.agent.CaptureStorage.getAsyncStackTrace(Ljava/lang/Throwable;)[Ljava/lang/StackTraceElement;+19 -j java.lang.Throwable.printStackTrace(Ljava/lang/Throwable$PrintStreamOrWriter;)V+32 java.base@17.0.8 -j java.lang.Throwable.printStackTrace(Ljava/io/PrintWriter;)V+9 java.base@17.0.8 -j com.alibaba.csp.sentinel.log.jul.CspFormatter.format(Ljava/util/logging/LogRecord;)Ljava/lang/String;+103 -j java.util.logging.StreamHandler.publish(Ljava/util/logging/LogRecord;)V+14 java.logging@17.0.8 -j java.util.logging.FileHandler.publish(Ljava/util/logging/LogRecord;)V+11 java.logging@17.0.8 -j com.alibaba.csp.sentinel.log.jul.DateFileLogHandler$LogTask.run()V+8 -j java.util.concurrent.ThreadPoolExecutor.runWorker(Ljava/util/concurrent/ThreadPoolExecutor$Worker;)V+92 java.base@17.0.8 -j java.util.concurrent.ThreadPoolExecutor$Worker.run()V+5 java.base@17.0.8 -j java.lang.Thread.run()V+11 java.base@17.0.8 -v ~StubRoutines::call_stub -V [libjvm.dylib+0x4781f8] JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, JavaThread*)+0x394 -V [libjvm.dylib+0x47720c] JavaCalls::call_virtual(JavaValue*, Klass*, Symbol*, Symbol*, JavaCallArguments*, JavaThread*)+0x11c -V [libjvm.dylib+0x4772d8] JavaCalls::call_virtual(JavaValue*, Handle, Klass*, Symbol*, Symbol*, JavaThread*)+0x64 -V [libjvm.dylib+0x52ebfc] thread_entry(JavaThread*, JavaThread*)+0xc4 -V [libjvm.dylib+0x9b22e8] JavaThread::thread_main_inner()+0x150 -V [libjvm.dylib+0x9b0990] Thread::call_run()+0xe0 -V [libjvm.dylib+0x7d0364] thread_native_entry(Thread*)+0x158 -C [libsystem_pthread.dylib+0x6c58] _pthread_start+0x88 - -Java frames: (J=compiled Java code, j=interpreted, Vv=VM code) -j com.intellij.rt.debugger.agent.CaptureStorage$DeepCapturedStack.collectStacks(Ljava/util/List;)Lcom/intellij/rt/debugger/agent/CaptureStorage$StackData;+89 -j com.intellij.rt.debugger.agent.CaptureStorage.getStackTrace(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+30 -j com.intellij.rt.debugger.agent.CaptureStorage.access$1000(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()[Ljava/lang/StackTraceElement;+31 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()Ljava/lang/Object;+1 -j com.intellij.rt.debugger.agent.CaptureStorage.withoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$Callable;)Ljava/lang/Object;+21 -j com.intellij.rt.debugger.agent.CaptureStorage.getAsyncStackTrace(Ljava/lang/Throwable;)[Ljava/lang/StackTraceElement;+19 -j java.lang.Throwable.printStackTrace(Ljava/lang/Throwable$PrintStreamOrWriter;)V+32 java.base@17.0.8 -j java.lang.Throwable.printStackTrace(Ljava/io/PrintWriter;)V+9 java.base@17.0.8 -j com.alibaba.csp.sentinel.log.jul.CspFormatter.format(Ljava/util/logging/LogRecord;)Ljava/lang/String;+103 -j java.util.logging.StreamHandler.publish(Ljava/util/logging/LogRecord;)V+14 java.logging@17.0.8 -j java.util.logging.FileHandler.publish(Ljava/util/logging/LogRecord;)V+11 java.logging@17.0.8 -j com.alibaba.csp.sentinel.log.jul.DateFileLogHandler$LogTask.run()V+8 -j java.util.concurrent.ThreadPoolExecutor.runWorker(Ljava/util/concurrent/ThreadPoolExecutor$Worker;)V+92 java.base@17.0.8 -j java.util.concurrent.ThreadPoolExecutor$Worker.run()V+5 java.base@17.0.8 -j java.lang.Thread.run()V+11 java.base@17.0.8 -v ~StubRoutines::call_stub - -siginfo: si_signo: 10 (SIGBUS), si_code: 1 (BUS_ADRALN), si_addr: 0x0000000100dd1e7b - -Register to memory mapping: - - x0=0x00006000018b9130 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x1=0x0 is NULL - x2=0x0 is NULL - x3=0x00006000018b9140 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x4=0x00006000018b9180 points into unknown readable memory: 0x00000000bf074454 | 54 44 07 bf 00 00 00 00 - x5=0x00000000a0a4a7fb is an unknown value - x6=0x0000000020c00000 is an unknown value - x7=0x000000000000000a is an unknown value - x8=0x0000000100ef9e5f points into unknown readable memory: 00 - x9=0x0000000000128000 is an unknown value -x10=0x00006000018b8000 points into unknown readable memory: 0x4c28004120ed0001 | 01 00 ed 20 41 00 28 4c -x11=0x0000000000001130 is an unknown value -x12=0x0000000000000050 is an unknown value -x13=0x0000000000000001 is an unknown value -x14=0x00000000ffffff6b is an unknown value -x15=0x00000000000007fb is an unknown value -x16=0x0000000186e7d030: __bzero+0 in /usr/lib/system/libsystem_platform.dylib at 0x0000000186e7a000 -x17=0x00000001f4ef54a8 points into unknown readable memory: 0x0000000186e7d030 | 30 d0 e7 86 01 00 00 00 -x18=0x0 is NULL -x19=0x00006000018b9130 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x20=0x0 is NULL -x21=0x0000600000d1c480 points into unknown readable memory: 0x0000600001c14720 | 20 47 c1 01 00 60 00 00 -x22=0x0000000100dd1e5f points into unknown readable memory: 50 -x23=0x00000000d3a18b02 is an unknown value -x24=0x000000000000002f is an unknown value -x25=0x000000000000003d is an unknown value -x26=0x00000000000000cd is an unknown value -x27=0x00006000018b9158 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x28=0x000000010f116600 points into unknown readable memory: 0x65746e692f6d6f63 | 63 6f 6d 2f 69 6e 74 65 - - -Registers: - x0=0x00006000018b9130 x1=0x0000000000000000 x2=0x0000000000000000 x3=0x00006000018b9140 - x4=0x00006000018b9180 x5=0x00000000a0a4a7fb x6=0x0000000020c00000 x7=0x000000000000000a - x8=0x0000000100ef9e5f x9=0x0000000000128000 x10=0x00006000018b8000 x11=0x0000000000001130 -x12=0x0000000000000050 x13=0x0000000000000001 x14=0x00000000ffffff6b x15=0x00000000000007fb -x16=0x0000000186e7d030 x17=0x00000001f4ef54a8 x18=0x0000000000000000 x19=0x00006000018b9130 -x20=0x0000000000000000 x21=0x0000600000d1c480 x22=0x0000000100dd1e5f x23=0x00000000d3a18b02 -x24=0x000000000000002f x25=0x000000000000003d x26=0x00000000000000cd x27=0x00006000018b9158 -x28=0x000000010f116600 fp=0x0000000174881a50 lr=0x0000000100ed648c sp=0x00000001748819d0 -pc=0x0000000100ed64c0 cpsr=0x0000000060001000 -Top of Stack: (sp=0x00000001748819d0) -0x00000001748819d0: 0000000000000000 0000000000000000 -0x00000001748819e0: 0000000000000000 0000000000000000 -0x00000001748819f0: 0000000000000000 0000000000000000 -0x0000000174881a00: 000000010f116600 000000012c81cc00 -0x0000000174881a10: 00000000000000cd 000000000000003d -0x0000000174881a20: 000000000000002f 00000000d3a18b02 -0x0000000174881a30: 00006000018923a0 0000000000000000 -0x0000000174881a40: 000000010f116640 0000600000d1c480 -0x0000000174881a50: 0000000174881ab0 0000000100ed6390 -0x0000000174881a60: 000000010f116600 0000000000000037 -0x0000000174881a70: 0000000000000001 000000010f116640 -0x0000000174881a80: 000000012b49c948 000000010f116640 -0x0000000174881a90: 0000600000d1c480 000000010f116640 -0x0000000174881aa0: 0000000174881bdc 0000000174881af4 -0x0000000174881ab0: 0000000174881ae0 0000000100ed6d78 -0x0000000174881ac0: 0000000174881bdc 0000600003615c50 -0x0000000174881ad0: 0000000000000000 000000012b49c600 -0x0000000174881ae0: 0000000174881bc0 000000010225e408 -0x0000000174881af0: 0000000102abc388 0000000000000100 -0x0000000174881b00: 0000000174881b20 00000001025325dc -0x0000000174881b10: 0000000102abc388 0000000174881ba0 -0x0000000174881b20: 0000000174881b70 00000001022e496c -0x0000000174881b30: 0000000000000000 0000000000000000 -0x0000000174881b40: 0000000000000001 00000001314a0290 -0x0000000174881b50: 000000012b49c600 000000010f1169d8 -0x0000000174881b60: 0000000102ad11e2 0000000174881c68 -0x0000000174881b70: 0000000174881b90 31ade61c7bb700f3 -0x0000000174881b80: 0000000000000001 000000010f116640 -0x0000000174881b90: 0000600003615c50 00000001314a0290 -0x0000000174881ba0: 000000012b49c600 000000010f1169d8 -0x0000000174881bb0: 000000010f1165f0 0000600003615c50 -0x0000000174881bc0: 0000000174881bf0 000000010225e53c - -Instructions: (pc=0x0000000100ed64c0) -0x0000000100ed63c0: 6b0c017f 54ffff60 17ffffde d2800016 -0x0000000100ed63d0: 72001ebf 54000160 b5000156 f100073f -0x0000000100ed63e0: 54fff7cb 8b140328 385ff108 7100bd1f -0x0000000100ed63f0: 54fff741 d2800016 14000002 f9004e7f -0x0000000100ed6400: f9402a60 94000451 aa1603e0 a9457bfd -0x0000000100ed6410: a9444ff4 a94357f6 a9425ff8 a94167fa -0x0000000100ed6420: a8c66ffc d65f03c0 6b03003f 540000e1 -0x0000000100ed6430: 71000421 540000eb 38401408 38401449 -0x0000000100ed6440: 6b09011f 54ffff60 52800000 d65f03c0 -0x0000000100ed6450: 52800020 d65f03c0 d10243ff a9036ffc -0x0000000100ed6460: a90467fa a9055ff8 a90657f6 a9074ff4 -0x0000000100ed6470: a9087bfd 910203fd aa0203f4 aa0103f6 -0x0000000100ed6480: aa0003f5 52800900 94000481 aa0003f3 -0x0000000100ed6490: b4001320 f900027f aa1303fb f8028f7f -0x0000000100ed64a0: f9001a7f 3940c2a8 34000288 f9400ea8 -0x0000000100ed64b0: f94006c9 8b090108 f94016a9 cb090116 -0x0000000100ed64c0: 79403ad8 39407ada 39407edc 794042c8 -0x0000000100ed64d0: f90017e8 b9400ec8 f9000668 b9401ac8 -0x0000000100ed64e0: f9000fe8 f9000a68 794016c8 34000488 -0x0000000100ed64f0: b94016c8 14000023 f94006d7 34000d54 -0x0000000100ed6500: f9401ea8 b4000288 f94022a9 eb17013f -0x0000000100ed6510: 5400022c 5283fa4a 8b0a012a eb17015f -0x0000000100ed6520: 540001ab 9140092a 8b170108 cb090116 -0x0000000100ed6530: 79403ac8 79403ec9 794042cb 8b0802e8 -0x0000000100ed6540: 8b090108 8b0b0108 9100b908 eb0a011f -0x0000000100ed6550: 54000b4d aa1503e0 aa1703e1 52840002 -0x0000000100ed6560: 94000384 aa0003f6 b4000aa0 f9401ea0 -0x0000000100ed6570: 94000429 a903deb6 17ffffd2 d2800008 -0x0000000100ed6580: aa0803f7 f9000e68 b94012c8 b9002268 -0x0000000100ed6590: b842a2c9 f9405ea8 f9000be9 8b090108 -0x0000000100ed65a0: cb0803e8 f9001e68 794012c8 b9004268 -0x0000000100ed65b0: 91000700 94000436 aa0003f9 f9000260 - - -Stack slot to memory mapping: -stack at sp + 0 slots: 0x0 is NULL -stack at sp + 1 slots: 0x0 is NULL -stack at sp + 2 slots: 0x0 is NULL -stack at sp + 3 slots: 0x0 is NULL -stack at sp + 4 slots: 0x0 is NULL -stack at sp + 5 slots: 0x0 is NULL -stack at sp + 6 slots: 0x000000010f116600 points into unknown readable memory: 0x65746e692f6d6f63 | 63 6f 6d 2f 69 6e 74 65 -stack at sp + 7 slots: 0x000000012c81cc00 points into unknown readable memory: 0xffffffff5bbd78a2 | a2 78 bd 5b ff ff ff ff - - ---------------- P R O C E S S --------------- - -Threads class SMR info: -_java_thread_list=0x0000600003f37760, length=73, elements={ -0x000000010b808a00, 0x000000010b809600, 0x000000012c90ac00, 0x000000010b008600, -0x000000010b00ae00, 0x000000011e808200, 0x000000011e00a400, 0x000000010f80a200, -0x000000010b809c00, 0x000000010f80b200, 0x000000011e00aa00, 0x000000010f80b800, -0x000000012d039a00, 0x000000011d808200, 0x000000012c01ea00, 0x000000012d810400, -0x000000012d838200, 0x000000010f829800, 0x000000012b0ffe00, 0x00000001018b8200, -0x000000012dae5600, 0x000000012c39d000, 0x000000011db9d000, 0x000000012c3bba00, -0x000000012c3cb200, 0x000000011dbcc600, 0x000000012db42e00, 0x000000012cedde00, -0x000000012db60800, 0x000000011dc28800, 0x000000012db67000, 0x000000011dc39e00, -0x000000012b336400, 0x000000012c4a3400, 0x000000012cf7fc00, 0x000000010b190600, -0x000000011ead0200, 0x000000011dccd000, 0x000000010b17fa00, 0x000000011e1f1a00, -0x000000010b995e00, 0x000000010f8ffa00, 0x000000011f024a00, 0x000000011dcc2400, -0x000000011eb04e00, 0x000000012d1e3200, 0x000000012d1c0200, 0x000000011e257200, -0x000000010f997000, 0x000000012c55be00, 0x000000012b406400, 0x000000011eb76400, -0x000000010b9b3200, 0x000000010b985800, 0x000000011f02f400, 0x000000012d1f9a00, -0x000000011dd00400, 0x000000011dd2cc00, 0x000000012b437400, 0x000000012b45ba00, -0x000000012b49c600, 0x000000011ebea000, 0x000000011ea07e00, 0x000000011f161800, -0x000000012c5d9e00, 0x000000011f162e00, 0x000000012dd08600, 0x0000000101998600, -0x000000010b1ef200, 0x000000010ba06000, 0x000000011dda3c00, 0x000000011e3d8e00, -0x000000011f24c800 -} - -Java Threads: ( => current thread ) - 0x000000010b808a00 JavaThread "main" [_thread_in_native, id=5891, stack(0x000000016f17c000,0x000000016f37f000)] - 0x000000010b809600 JavaThread "Reference Handler" daemon [_thread_blocked, id=19971, stack(0x000000016ffd0000,0x00000001701d3000)] - 0x000000012c90ac00 JavaThread "Finalizer" daemon [_thread_blocked, id=19715, stack(0x00000001701dc000,0x00000001703df000)] - 0x000000010b008600 JavaThread "Signal Dispatcher" daemon [_thread_blocked, id=30979, stack(0x0000000170500000,0x0000000170703000)] - 0x000000010b00ae00 JavaThread "Service Thread" daemon [_thread_blocked, id=30467, stack(0x000000017070c000,0x000000017090f000)] - 0x000000011e808200 JavaThread "Monitor Deflation Thread" daemon [_thread_blocked, id=23299, stack(0x0000000170918000,0x0000000170b1b000)] - 0x000000011e00a400 JavaThread "C1 CompilerThread0" daemon [_thread_blocked, id=23555, stack(0x0000000170b24000,0x0000000170d27000)] - 0x000000010f80a200 JavaThread "Sweeper thread" daemon [_thread_blocked, id=29699, stack(0x0000000170d30000,0x0000000170f33000)] - 0x000000010b809c00 JavaThread "C1 CompilerThread1" daemon [_thread_blocked, id=24067, stack(0x0000000170f3c000,0x000000017113f000)] - 0x000000010f80b200 JavaThread "Common-Cleaner" daemon [_thread_blocked, id=24323, stack(0x0000000171148000,0x000000017134b000)] - 0x000000011e00aa00 JavaThread "C1 CompilerThread2" daemon [_thread_blocked, id=24835, stack(0x0000000171354000,0x0000000171557000)] - 0x000000010f80b800 JavaThread "C1 CompilerThread3" daemon [_thread_blocked, id=29187, stack(0x0000000171560000,0x0000000171763000)] - 0x000000012d039a00 JavaThread "JDWP Transport Listener: dt_socket" daemon [_thread_blocked, id=25603, stack(0x000000017176c000,0x000000017196f000)] - 0x000000011d808200 JavaThread "JDWP Event Helper Thread" daemon [_thread_blocked, id=25859, stack(0x0000000171978000,0x0000000171b7b000)] - 0x000000012c01ea00 JavaThread "JDWP Command Reader" daemon [_thread_in_native, id=26115, stack(0x0000000171b84000,0x0000000171d87000)] - 0x000000012d810400 JavaThread "IntelliJ Suspend Helper" daemon [_thread_blocked, id=26371, stack(0x0000000171d90000,0x0000000171f93000)] - 0x000000012d838200 JavaThread "Notification Thread" daemon [_thread_blocked, id=26883, stack(0x0000000171f9c000,0x000000017219f000)] - 0x000000010f829800 JavaThread "CoarseTimer" daemon [_thread_blocked, id=27139, stack(0x00000001721a8000,0x00000001723ab000)] - 0x000000012b0ffe00 JavaThread "RMI TCP Accept-0" daemon [_thread_in_native, id=41219, stack(0x0000000173620000,0x0000000173823000)] - 0x00000001018b8200 JavaThread "com.alibaba.nacos.client.logging.0" daemon [_thread_blocked, id=40707, stack(0x0000000173a38000,0x0000000173c3b000)] - 0x000000012dae5600 JavaThread "Attach Listener" daemon [_thread_blocked, id=35331, stack(0x0000000173c44000,0x0000000173e47000)] - 0x000000012c39d000 JavaThread "RMI TCP Connection(2)-127.0.0.1" daemon [_thread_in_native, id=39939, stack(0x0000000173e50000,0x0000000174053000)] - 0x000000011db9d000 JavaThread "RMI Scheduler(0)" daemon [_thread_blocked, id=35587, stack(0x000000017405c000,0x000000017425f000)] - 0x000000012c3bba00 JavaThread "nacos.publisher-com.alibaba.nacos.common.notify.SlowEvent" daemon [_thread_blocked, id=43523, stack(0x00000001750bc000,0x00000001752bf000)] - 0x000000012c3cb200 JavaThread "nacos.publisher-com.alibaba.nacos.client.config.impl.ConfigFuzzyWatchNotifyEvent" daemon [_thread_blocked, id=43779, stack(0x00000001752c8000,0x00000001754cb000)] - 0x000000011dbcc600 JavaThread "nacos.publisher-com.alibaba.nacos.client.config.impl.ConfigFuzzyWatchLoadEvent" daemon [_thread_blocked, id=44291, stack(0x00000001754d4000,0x00000001756d7000)] - 0x000000012db42e00 JavaThread "com.alibaba.nacos.client.auth.ram.identify.watcher.0" daemon [_thread_blocked, id=65027, stack(0x00000001756e0000,0x00000001758e3000)] - 0x000000012cedde00 JavaThread "com.alibaba.nacos.client.login-executor.0" daemon [_thread_blocked, id=36883, stack(0x0000000174a98000,0x0000000174c9b000)] - 0x000000012db60800 JavaThread "com.alibaba.nacos.client.listen-executor.0" daemon [_thread_blocked, id=45059, stack(0x00000001758ec000,0x0000000175aef000)] - 0x000000011dc28800 JavaThread "com.alibaba.nacos.client.fuzzy-watcher-executor.0" daemon [_thread_blocked, id=64771, stack(0x0000000175af8000,0x0000000175cfb000)] - 0x000000012db67000 JavaThread "com.alibaba.nacos.client.remote.worker.0" daemon [_thread_blocked, id=64259, stack(0x0000000175d04000,0x0000000175f07000)] - 0x000000011dc39e00 JavaThread "com.alibaba.nacos.client.remote.worker.1" daemon [_thread_blocked, id=45827, stack(0x0000000175f10000,0x0000000176113000)] - 0x000000012b336400 JavaThread "grpc-nio-worker-ELG-1-1" daemon [_thread_in_native, id=64019, stack(0x000000017611c000,0x000000017631f000)] - 0x000000012c4a3400 JavaThread "grpc-default-executor-0" daemon [_thread_blocked, id=63747, stack(0x0000000176328000,0x000000017652b000)] - 0x000000012cf7fc00 JavaThread "nacos-grpc-client-executor-127.0.0.1-0" daemon [_thread_blocked, id=46595, stack(0x0000000176534000,0x0000000176737000)] - 0x000000010b190600 JavaThread "nacos-grpc-client-executor-127.0.0.1-1" daemon [_thread_blocked, id=63235, stack(0x0000000176740000,0x0000000176943000)] - 0x000000011ead0200 JavaThread "grpc-nio-worker-ELG-1-2" daemon [_thread_in_native, id=62995, stack(0x000000017694c000,0x0000000176b4f000)] - 0x000000011dccd000 JavaThread "nacos-grpc-client-executor-127.0.0.1-2" daemon [_thread_blocked, id=47363, stack(0x0000000176b58000,0x0000000176d5b000)] - 0x000000010b17fa00 JavaThread "nacos-grpc-client-executor-127.0.0.1-3" daemon [_thread_blocked, id=62211, stack(0x0000000176d64000,0x0000000176f67000)] - 0x000000011e1f1a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-4" daemon [_thread_blocked, id=47619, stack(0x0000000176f70000,0x0000000177173000)] - 0x000000010b995e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-5" daemon [_thread_blocked, id=61699, stack(0x000000017717c000,0x000000017737f000)] - 0x000000010f8ffa00 JavaThread "nacos-grpc-client-executor-127.0.0.1-6" daemon [_thread_blocked, id=48387, stack(0x0000000177388000,0x000000017758b000)] - 0x000000011f024a00 JavaThread "nacos.publisher-com.alibaba.nacos.common.ability.AbstractAbilityControlManager$AbilityUpdateEvent" daemon [_thread_blocked, id=61187, stack(0x0000000177594000,0x0000000177797000)] - 0x000000011dcc2400 JavaThread "nacos-grpc-client-executor-127.0.0.1-7" daemon [_thread_blocked, id=48899, stack(0x00000001777a0000,0x00000001779a3000)] - 0x000000011eb04e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-8" daemon [_thread_blocked, id=60675, stack(0x00000001779ac000,0x0000000177baf000)] - 0x000000012d1e3200 JavaThread "nacos-grpc-client-executor-127.0.0.1-9" daemon [_thread_blocked, id=49411, stack(0x0000000177bb8000,0x0000000177dbb000)] - 0x000000012d1c0200 JavaThread "nacos-grpc-client-executor-127.0.0.1-10" daemon [_thread_blocked, id=60163, stack(0x0000000177dc4000,0x0000000177fc7000)] - 0x000000011e257200 JavaThread "nacos-grpc-client-executor-127.0.0.1-11" daemon [_thread_blocked, id=59659, stack(0x0000000328004000,0x0000000328207000)] - 0x000000010f997000 JavaThread "nacos-grpc-client-executor-127.0.0.1-12" daemon [_thread_blocked, id=49923, stack(0x0000000328210000,0x0000000328413000)] - 0x000000012c55be00 JavaThread "nacos-grpc-client-executor-127.0.0.1-13" daemon [_thread_blocked, id=59395, stack(0x000000032841c000,0x000000032861f000)] - 0x000000012b406400 JavaThread "nacos-grpc-client-executor-127.0.0.1-14" daemon [_thread_blocked, id=59139, stack(0x0000000328628000,0x000000032882b000)] - 0x000000011eb76400 JavaThread "nacos-grpc-client-executor-127.0.0.1-15" daemon [_thread_blocked, id=50691, stack(0x0000000328834000,0x0000000328a37000)] - 0x000000010b9b3200 JavaThread "nacos-grpc-client-executor-127.0.0.1-16" daemon [_thread_blocked, id=51203, stack(0x0000000328a40000,0x0000000328c43000)] - 0x000000010b985800 JavaThread "nacos-grpc-client-executor-127.0.0.1-17" daemon [_thread_blocked, id=51715, stack(0x0000000328c4c000,0x0000000328e4f000)] - 0x000000011f02f400 JavaThread "nacos-grpc-client-executor-127.0.0.1-18" daemon [_thread_blocked, id=51971, stack(0x0000000328e58000,0x000000032905b000)] - 0x000000012d1f9a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-19" daemon [_thread_blocked, id=58115, stack(0x0000000329064000,0x0000000329267000)] - 0x000000011dd00400 JavaThread "nacos-grpc-client-executor-127.0.0.1-20" daemon [_thread_blocked, id=57859, stack(0x0000000329270000,0x0000000329473000)] - 0x000000011dd2cc00 JavaThread "nacos-grpc-client-executor-127.0.0.1-21" daemon [_thread_blocked, id=57347, stack(0x000000032947c000,0x000000032967f000)] - 0x000000012b437400 JavaThread "nacos-grpc-client-executor-127.0.0.1-22" daemon [_thread_blocked, id=57091, stack(0x0000000329688000,0x000000032988b000)] - 0x000000012b45ba00 JavaThread "RMI TCP Connection(3)-127.0.0.1" daemon [_thread_in_native, id=39179, stack(0x0000000174268000,0x000000017446b000)] -=>0x000000012b49c600 JavaThread "sentinel-datafile-log-executor-thread-1" daemon [_thread_in_native, id=36647, stack(0x0000000174680000,0x0000000174883000)] - 0x000000011ebea000 JavaThread "sentinel-heartbeat-send-task-thread-1" daemon [_thread_blocked, id=56595, stack(0x0000000329894000,0x0000000329a97000)] - 0x000000011ea07e00 JavaThread "sentinel-command-center-executor-thread-1" daemon [_thread_in_native, id=36139, stack(0x0000000174474000,0x0000000174677000)] - 0x000000011f161800 JavaThread "nacos-grpc-client-executor-127.0.0.1-23" daemon [_thread_blocked, id=36367, stack(0x000000017488c000,0x0000000174a8f000)] - 0x000000012c5d9e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-24" daemon [_thread_blocked, id=53003, stack(0x0000000329aa0000,0x0000000329ca3000)] - 0x000000011f162e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-25" daemon [_thread_blocked, id=56323, stack(0x0000000329cac000,0x0000000329eaf000)] - 0x000000012dd08600 JavaThread "nacos-grpc-client-executor-127.0.0.1-26" daemon [_thread_blocked, id=53763, stack(0x0000000329eb8000,0x000000032a0bb000)] - 0x0000000101998600 JavaThread "nacos-grpc-client-executor-127.0.0.1-27" daemon [_thread_blocked, id=55811, stack(0x000000032a0c4000,0x000000032a2c7000)] - 0x000000010b1ef200 JavaThread "nacos-grpc-client-executor-127.0.0.1-28" daemon [_thread_blocked, id=54019, stack(0x000000032a2d0000,0x000000032a4d3000)] - 0x000000010ba06000 JavaThread "nacos-grpc-client-executor-127.0.0.1-29" daemon [_thread_blocked, id=55043, stack(0x000000032a4dc000,0x000000032a6df000)] - 0x000000011dda3c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-30" daemon [_thread_blocked, id=54787, stack(0x000000032a6e8000,0x000000032a8eb000)] - 0x000000011e3d8e00 JavaThread "sentinel-time-tick-thread" daemon [_thread_blocked, id=31875, stack(0x000000032a8f4000,0x000000032aaf7000)] - 0x000000011f24c800 JavaThread "sentinel-heartbeat-send-task-thread-2" daemon [_thread_blocked, id=65843, stack(0x000000032ab00000,0x000000032ad03000)] - -Other Threads: - 0x000000012b8059d0 VMThread "VM Thread" [stack: 0x000000016fdc4000,0x000000016ffc7000] [id=18435] - 0x000000010f109630 WatcherThread [stack: 0x000000017382c000,0x0000000173a2f000] [id=40963] - 0x000000012af08740 GCTaskThread "GC Thread#0" [stack: 0x000000016f388000,0x000000016f58b000] [id=12035] - 0x000000012ae08da0 GCTaskThread "GC Thread#1" [stack: 0x00000001723b4000,0x00000001725b7000] [id=27395] - 0x000000012ae09060 GCTaskThread "GC Thread#2" [stack: 0x00000001725c0000,0x00000001727c3000] [id=32771] - 0x0000000101107cb0 GCTaskThread "GC Thread#3" [stack: 0x00000001727cc000,0x00000001729cf000] [id=33027] - 0x000000010f30cd30 GCTaskThread "GC Thread#4" [stack: 0x00000001729d8000,0x0000000172bdb000] [id=33283] - 0x000000012ae094f0 GCTaskThread "GC Thread#5" [stack: 0x0000000172be4000,0x0000000172de7000] [id=42755] - 0x000000010f30cff0 GCTaskThread "GC Thread#6" [stack: 0x0000000172df0000,0x0000000172ff3000] [id=42243] - 0x000000012b909b70 GCTaskThread "GC Thread#7" [stack: 0x0000000172ffc000,0x00000001731ff000] [id=41987] - 0x000000010f209c10 GCTaskThread "GC Thread#8" [stack: 0x0000000173208000,0x000000017340b000] [id=34051] - 0x000000010f30d870 GCTaskThread "GC Thread#9" [stack: 0x0000000173414000,0x0000000173617000] [id=34307] - 0x000000012af08e00 ConcurrentGCThread "G1 Main Marker" [stack: 0x000000016f594000,0x000000016f797000] [id=13571] - 0x000000012af09690 ConcurrentGCThread "G1 Conc#0" [stack: 0x000000016f7a0000,0x000000016f9a3000] [id=12547] - 0x000000010f41fcd0 ConcurrentGCThread "G1 Conc#1" [stack: 0x0000000174ca4000,0x0000000174ea7000] [id=37891] - 0x000000010f32ed40 ConcurrentGCThread "G1 Conc#2" [stack: 0x0000000174eb0000,0x00000001750b3000] [id=37379] - 0x000000012af0bbd0 ConcurrentGCThread "G1 Refine#0" [stack: 0x000000016f9ac000,0x000000016fbaf000] [id=16643] - 0x000000010f104080 ConcurrentGCThread "G1 Service" [stack: 0x000000016fbb8000,0x000000016fdbb000] [id=21507] - -Threads with active compile tasks: - -VM state: not at safepoint (normal execution) - -VM Mutex/Monitor currently owned by a thread: None - -Heap address: 0x00000005c0000000, size: 9216 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 - -CDS archive(s) mapped at: [0x0000000500000000-0x0000000500c14000-0x0000000500c14000), size 12664832, SharedBaseAddress: 0x0000000500000000, ArchiveRelocationMode: 1. -Compressed class space mapped at: 0x0000000501000000-0x0000000541000000, reserved size: 1073741824 -Narrow klass base: 0x0000000500000000, Narrow klass shift: 0, Narrow klass range: 0x100000000 - -GC Precious Log: - CPUs: 12 total, 12 available - Memory: 36864M - Large Page Support: Disabled - NUMA Support: Disabled - Compressed Oops: Enabled (Zero based) - Heap Region Size: 8M - Heap Min Capacity: 8M - Heap Initial Capacity: 576M - Heap Max Capacity: 9G - Pre-touch: Disabled - Parallel Workers: 10 - Concurrent Workers: 3 - Concurrent Refinement Workers: 10 - Periodic GC: Disabled - -Heap: - garbage-first heap total 262144K, used 92456K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 9 young (73728K), 3 survivors (24576K) - Metaspace used 63382K, committed 63872K, reserved 1114112K - class space used 8462K, committed 8704K, reserved 1048576K - -Heap Regions: E=young(eden), S=young(survivor), O=old, HS=humongous(starts), HC=humongous(continues), CS=collection set, F=free, OA=open archive, CA=closed archive, TAMS=top-at-mark-start (previous, next) -| 0|0x00000005c0000000, 0x00000005c0800000, 0x00000005c0800000|100%| O| |TAMS 0x00000005c0800000, 0x00000005c0000000| Untracked -| 1|0x00000005c0800000, 0x00000005c1000000, 0x00000005c1000000|100%| O| |TAMS 0x00000005c1000000, 0x00000005c0800000| Untracked -| 2|0x00000005c1000000, 0x00000005c1800000, 0x00000005c1800000|100%| O| |TAMS 0x00000005c1800000, 0x00000005c1000000| Untracked -| 3|0x00000005c1800000, 0x00000005c1800000, 0x00000005c2000000| 0%| F| |TAMS 0x00000005c1800000, 0x00000005c1800000| Untracked -| 4|0x00000005c2000000, 0x00000005c207c000, 0x00000005c2800000| 6%| O| |TAMS 0x00000005c207c000, 0x00000005c2000000| Untracked -| 5|0x00000005c2800000, 0x00000005c2800000, 0x00000005c3000000| 0%| F| |TAMS 0x00000005c2800000, 0x00000005c2800000| Untracked -| 6|0x00000005c3000000, 0x00000005c3000000, 0x00000005c3800000| 0%| F| |TAMS 0x00000005c3000000, 0x00000005c3000000| Untracked -| 7|0x00000005c3800000, 0x00000005c3800000, 0x00000005c4000000| 0%| F| |TAMS 0x00000005c3800000, 0x00000005c3800000| Untracked -| 8|0x00000005c4000000, 0x00000005c4000000, 0x00000005c4800000| 0%| F| |TAMS 0x00000005c4000000, 0x00000005c4000000| Untracked -| 9|0x00000005c4800000, 0x00000005c4800000, 0x00000005c5000000| 0%| F| |TAMS 0x00000005c4800000, 0x00000005c4800000| Untracked -| 10|0x00000005c5000000, 0x00000005c5000000, 0x00000005c5800000| 0%| F| |TAMS 0x00000005c5000000, 0x00000005c5000000| Untracked -| 11|0x00000005c5800000, 0x00000005c5800000, 0x00000005c6000000| 0%| F| |TAMS 0x00000005c5800000, 0x00000005c5800000| Untracked -| 12|0x00000005c6000000, 0x00000005c6000000, 0x00000005c6800000| 0%| F| |TAMS 0x00000005c6000000, 0x00000005c6000000| Untracked -| 13|0x00000005c6800000, 0x00000005c6800000, 0x00000005c7000000| 0%| F| |TAMS 0x00000005c6800000, 0x00000005c6800000| Untracked -| 14|0x00000005c7000000, 0x00000005c71d6220, 0x00000005c7800000| 22%| S|CS|TAMS 0x00000005c7000000, 0x00000005c7000000| Complete -| 15|0x00000005c7800000, 0x00000005c8000000, 0x00000005c8000000|100%| S|CS|TAMS 0x00000005c7800000, 0x00000005c7800000| Complete -| 16|0x00000005c8000000, 0x00000005c8800000, 0x00000005c8800000|100%| S|CS|TAMS 0x00000005c8000000, 0x00000005c8000000| Complete -| 17|0x00000005c8800000, 0x00000005c8800000, 0x00000005c9000000| 0%| F| |TAMS 0x00000005c8800000, 0x00000005c8800000| Untracked -| 18|0x00000005c9000000, 0x00000005c9000000, 0x00000005c9800000| 0%| F| |TAMS 0x00000005c9000000, 0x00000005c9000000| Untracked -| 19|0x00000005c9800000, 0x00000005c9800000, 0x00000005ca000000| 0%| F| |TAMS 0x00000005c9800000, 0x00000005c9800000| Untracked -| 20|0x00000005ca000000, 0x00000005ca000000, 0x00000005ca800000| 0%| F| |TAMS 0x00000005ca000000, 0x00000005ca000000| Untracked -| 21|0x00000005ca800000, 0x00000005ca800000, 0x00000005cb000000| 0%| F| |TAMS 0x00000005ca800000, 0x00000005ca800000| Untracked -| 22|0x00000005cb000000, 0x00000005cb000000, 0x00000005cb800000| 0%| F| |TAMS 0x00000005cb000000, 0x00000005cb000000| Untracked -| 23|0x00000005cb800000, 0x00000005cb800000, 0x00000005cc000000| 0%| F| |TAMS 0x00000005cb800000, 0x00000005cb800000| Untracked -| 24|0x00000005cc000000, 0x00000005cc5eec00, 0x00000005cc800000| 74%| E| |TAMS 0x00000005cc000000, 0x00000005cc000000| Complete -| 25|0x00000005cc800000, 0x00000005cd000000, 0x00000005cd000000|100%| E|CS|TAMS 0x00000005cc800000, 0x00000005cc800000| Complete -| 26|0x00000005cd000000, 0x00000005cd800000, 0x00000005cd800000|100%| E|CS|TAMS 0x00000005cd000000, 0x00000005cd000000| Complete -| 27|0x00000005cd800000, 0x00000005ce000000, 0x00000005ce000000|100%| E|CS|TAMS 0x00000005cd800000, 0x00000005cd800000| Complete -| 64|0x00000005e0000000, 0x00000005e0800000, 0x00000005e0800000|100%| E|CS|TAMS 0x00000005e0000000, 0x00000005e0000000| Complete -| 71|0x00000005e3800000, 0x00000005e4000000, 0x00000005e4000000|100%| E|CS|TAMS 0x00000005e3800000, 0x00000005e3800000| Complete -|1150|0x00000007ff000000, 0x00000007ff778000, 0x00000007ff800000| 93%|OA| |TAMS 0x00000007ff778000, 0x00000007ff000000| Untracked -|1151|0x00000007ff800000, 0x00000007ff880000, 0x0000000800000000| 6%|CA| |TAMS 0x00000007ff880000, 0x00000007ff800000| Untracked - -Card table byte_map: [0x0000000119200000,0x000000011a400000] _byte_map_base: 0x0000000116400000 - -Marking Bits (Prev, Next): (CMBitMap*) 0x000000012c82f250, (CMBitMap*) 0x000000012c82f210 - Prev Bits: [0x0000000141000000, 0x000000014a000000) - Next Bits: [0x0000000138000000, 0x0000000141000000) - -Polling page: 0x0000000100da4000 - -Metaspace: - -Usage: - Non-class: 53.63 MB used. - Class: 8.26 MB used. - Both: 61.90 MB used. - -Virtual space: - Non-class space: 64.00 MB reserved, 53.88 MB ( 84%) committed, 1 nodes. - Class space: 1.00 GB reserved, 8.50 MB ( <1%) committed, 1 nodes. - Both: 1.06 GB reserved, 62.38 MB ( 6%) committed. - -Chunk freelists: - Non-Class: 10.08 MB - Class: 7.50 MB - Both: 17.58 MB - -MaxMetaspaceSize: unlimited -CompressedClassSpaceSize: 1.00 GB -Initial GC threshold: 21.00 MB -Current GC threshold: 99.06 MB -CDS: on -MetaspaceReclaimPolicy: balanced - - commit_granule_bytes: 65536. - - commit_granule_words: 8192. - - virtual_space_node_default_size: 8388608. - - enlarge_chunks_in_place: 1. - - new_chunks_are_fully_committed: 0. - - uncommit_free_chunks: 1. - - use_allocation_guard: 0. - - handle_deallocations: 1. - - -Internal statistics: - -num_allocs_failed_limit: 9. -num_arena_births: 684. -num_arena_deaths: 0. -num_vsnodes_births: 2. -num_vsnodes_deaths: 0. -num_space_committed: 998. -num_space_uncommitted: 0. -num_chunks_returned_to_freelist: 9. -num_chunks_taken_from_freelist: 2682. -num_chunk_merges: 6. -num_chunk_splits: 2009. -num_chunks_enlarged: 1609. -num_inconsistent_stats: 0. - -CodeCache: size=49152Kb used=12939Kb max_used=12939Kb free=36212Kb - bounds [0x000000010c100000, 0x000000010cdb0000, 0x000000010f100000] - total_blobs=6555 nmethods=5927 adapters=554 - compilation: enabled - stopped_count=0, restarted_count=0 - full_count=0 - -Compilation events (20 events): -Event: 9.067 Thread 0x000000010b809c00 nmethod 6207 0x000000010cd9aa90 code [0x000000010cd9ac40, 0x000000010cd9ae18] -Event: 9.067 Thread 0x000000010b809c00 6211 1 org.springframework.beans.factory.config.BeanDefinitionVisitor::visitScope (33 bytes) -Event: 9.067 Thread 0x000000010f80b800 nmethod 6210 0x000000010cd9af90 code [0x000000010cd9b140, 0x000000010cd9b358] -Event: 9.067 Thread 0x000000011e00aa00 nmethod 6208 0x000000010cd9b510 code [0x000000010cd9b700, 0x000000010cd9ba38] -Event: 9.067 Thread 0x000000010b809c00 nmethod 6211 0x000000010cd9bc90 code [0x000000010cd9be40, 0x000000010cd9c058] -Event: 9.068 Thread 0x000000011e00a400 nmethod 6206 0x000000010cd9c210 code [0x000000010cd9c580, 0x000000010cd9d558] -Event: 9.068 Thread 0x000000010b809c00 6212 1 org.springframework.beans.AbstractNestablePropertyAccessor::getWrappedInstance (22 bytes) -Event: 9.068 Thread 0x000000010b809c00 nmethod 6212 0x000000010cd9e010 code [0x000000010cd9e1c0, 0x000000010cd9e338] -Event: 9.073 Thread 0x000000011e00a400 6213 1 java.lang.reflect.Constructor::getParameterTypes (11 bytes) -Event: 9.073 Thread 0x000000011e00a400 nmethod 6213 0x000000010cd9e410 code [0x000000010cd9e5c0, 0x000000010cd9e6f8] -Event: 9.126 Thread 0x000000011e00aa00 6216 1 java.util.regex.Pattern::qtype (39 bytes) -Event: 9.126 Thread 0x000000010b809c00 6217 1 java.util.regex.Pattern::sequence (647 bytes) -Event: 9.126 Thread 0x000000010f80b800 6218 1 java.util.regex.Pattern$BranchConn::study (5 bytes) -Event: 9.126 Thread 0x000000010f80b800 nmethod 6218 0x000000010cd9f090 code [0x000000010cd9f200, 0x000000010cd9f2d8] -Event: 9.126 Thread 0x000000011e00aa00 nmethod 6216 0x000000010cd9f390 code [0x000000010cd9f580, 0x000000010cd9f8b8] -Event: 9.127 Thread 0x000000011e00a400 6219 1 jdk.internal.misc.Unsafe::putReferenceOpaque (9 bytes) -Event: 9.127 Thread 0x000000011e00a400 nmethod 6219 0x000000010cd9fa90 code [0x000000010cd9fc00, 0x000000010cd9fd18] -Event: 9.128 Thread 0x000000010b809c00 nmethod 6217 0x000000010cd9fd90 code [0x000000010cda0180, 0x000000010cda1618] -Event: 9.128 Thread 0x000000011e00aa00 6220 1 java.util.IdentityHashMap::put (137 bytes) -Event: 9.129 Thread 0x000000011e00aa00 nmethod 6220 0x000000010cda2290 code [0x000000010cda2480, 0x000000010cda2998] - -GC Heap History (20 events): -Event: 0.442 GC heap before -{Heap before GC invocations=1 (full 0): - garbage-first heap total 606208K, used 38051K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 3 young (24576K), 1 survivors (8192K) - Metaspace used 9689K, committed 9856K, reserved 1114112K - class space used 1076K, committed 1152K, reserved 1048576K -} -Event: 0.444 GC heap after -{Heap after GC invocations=2 (full 0): - garbage-first heap total 606208K, used 23419K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 9689K, committed 9856K, reserved 1114112K - class space used 1076K, committed 1152K, reserved 1048576K -} -Event: 0.807 GC heap before -{Heap before GC invocations=2 (full 0): - garbage-first heap total 606208K, used 47995K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 5 young (40960K), 1 survivors (8192K) - Metaspace used 13722K, committed 13888K, reserved 1114112K - class space used 1603K, committed 1664K, reserved 1048576K -} -Event: 0.813 GC heap after -{Heap after GC invocations=3 (full 0): - garbage-first heap total 606208K, used 27477K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 13722K, committed 13888K, reserved 1114112K - class space used 1603K, committed 1664K, reserved 1048576K -} -Event: 1.392 GC heap before -{Heap before GC invocations=3 (full 0): - garbage-first heap total 606208K, used 68437K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 7 young (57344K), 1 survivors (8192K) - Metaspace used 21201K, committed 21504K, reserved 1114112K - class space used 2676K, committed 2816K, reserved 1048576K -} -Event: 1.395 GC heap after -{Heap after GC invocations=4 (full 0): - garbage-first heap total 606208K, used 31119K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 21201K, committed 21504K, reserved 1114112K - class space used 2676K, committed 2816K, reserved 1048576K -} -Event: 2.847 GC heap before -{Heap before GC invocations=5 (full 0): - garbage-first heap total 196608K, used 137615K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 14 young (114688K), 1 survivors (8192K) - Metaspace used 32674K, committed 33088K, reserved 1114112K - class space used 4093K, committed 4288K, reserved 1048576K -} -Event: 2.861 GC heap after -{Heap after GC invocations=6 (full 0): - garbage-first heap total 196608K, used 32785K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 32674K, committed 33088K, reserved 1114112K - class space used 4093K, committed 4288K, reserved 1048576K -} -Event: 2.927 GC heap before -{Heap before GC invocations=6 (full 0): - garbage-first heap total 196608K, used 40977K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 1 survivors (8192K) - Metaspace used 34086K, committed 34432K, reserved 1114112K - class space used 4246K, committed 4416K, reserved 1048576K -} -Event: 2.931 GC heap after -{Heap after GC invocations=7 (full 0): - garbage-first heap total 196608K, used 33192K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 34086K, committed 34432K, reserved 1114112K - class space used 4246K, committed 4416K, reserved 1048576K -} -Event: 3.207 GC heap before -{Heap before GC invocations=7 (full 0): - garbage-first heap total 196608K, used 41384K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 4 young (32768K), 1 survivors (8192K) - Metaspace used 35986K, committed 36288K, reserved 1114112K - class space used 4497K, committed 4672K, reserved 1048576K -} -Event: 3.213 GC heap after -{Heap after GC invocations=8 (full 0): - garbage-first heap total 196608K, used 33908K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 35986K, committed 36288K, reserved 1114112K - class space used 4497K, committed 4672K, reserved 1048576K -} -Event: 4.154 GC heap before -{Heap before GC invocations=9 (full 0): - garbage-first heap total 196608K, used 115828K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 12 young (98304K), 1 survivors (8192K) - Metaspace used 45522K, committed 45952K, reserved 1114112K - class space used 5884K, committed 6080K, reserved 1048576K -} -Event: 4.157 GC heap after -{Heap after GC invocations=10 (full 0): - garbage-first heap total 262144K, used 37091K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 45522K, committed 45952K, reserved 1114112K - class space used 5884K, committed 6080K, reserved 1048576K -} -Event: 4.268 GC heap before -{Heap before GC invocations=10 (full 0): - garbage-first heap total 262144K, used 45283K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 1 survivors (8192K) - Metaspace used 46797K, committed 47168K, reserved 1114112K - class space used 6085K, committed 6272K, reserved 1048576K -} -Event: 4.273 GC heap after -{Heap after GC invocations=11 (full 0): - garbage-first heap total 262144K, used 37205K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 46797K, committed 47168K, reserved 1114112K - class space used 6085K, committed 6272K, reserved 1048576K -} -Event: 6.516 GC heap before -{Heap before GC invocations=11 (full 0): - garbage-first heap total 262144K, used 176469K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 18 young (147456K), 1 survivors (8192K) - Metaspace used 55389K, committed 55808K, reserved 1114112K - class space used 7288K, committed 7488K, reserved 1048576K -} -Event: 6.521 GC heap after -{Heap after GC invocations=12 (full 0): - garbage-first heap total 262144K, used 45059K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 2 survivors (16384K) - Metaspace used 55389K, committed 55808K, reserved 1114112K - class space used 7288K, committed 7488K, reserved 1048576K -} -Event: 8.053 GC heap before -{Heap before GC invocations=12 (full 0): - garbage-first heap total 262144K, used 135171K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 15 young (122880K), 2 survivors (16384K) - Metaspace used 60370K, committed 60800K, reserved 1114112K - class space used 7985K, committed 8192K, reserved 1048576K -} -Event: 8.065 GC heap after -{Heap after GC invocations=13 (full 0): - garbage-first heap total 262144K, used 51496K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 3 young (24576K), 3 survivors (24576K) - Metaspace used 60370K, committed 60800K, reserved 1114112K - class space used 7985K, committed 8192K, reserved 1048576K -} - -Dll operation events (11 events): -Event: 0.007 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjava.dylib -Event: 0.007 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -Event: 0.078 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libinstrument.dylib -Event: 0.081 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnio.dylib -Event: 0.084 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnet.dylib -Event: 0.124 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjimage.dylib -Event: 0.134 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -Event: 0.211 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement.dylib -Event: 0.216 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement_ext.dylib -Event: 0.319 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libextnet.dylib -Event: 6.751 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libverify.dylib - -Deoptimization events (20 events): -Event: 9.084 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56ea28 sp=0x000000016f37db60 -Event: 9.084 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d800 mode 1 -Event: 9.084 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56dcbc sp=0x000000016f37dc30 -Event: 9.084 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d900 mode 1 -Event: 9.085 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56ea28 sp=0x000000016f37db60 -Event: 9.085 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d800 mode 1 -Event: 9.085 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56dcbc sp=0x000000016f37dc30 -Event: 9.085 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d900 mode 1 -Event: 9.104 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56ea28 sp=0x000000016f37db80 -Event: 9.104 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d820 mode 1 -Event: 9.104 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56dcbc sp=0x000000016f37dc50 -Event: 9.104 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d920 mode 1 -Event: 9.105 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56ea28 sp=0x000000016f37db60 -Event: 9.105 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d800 mode 1 -Event: 9.105 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56dcbc sp=0x000000016f37dc30 -Event: 9.105 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d900 mode 1 -Event: 9.106 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56ea28 sp=0x000000016f37db80 -Event: 9.106 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d820 mode 1 -Event: 9.106 Thread 0x000000010b808a00 DEOPT PACKING pc=0x000000010c56dcbc sp=0x000000016f37dc50 -Event: 9.106 Thread 0x000000010b808a00 DEOPT UNPACKING pc=0x000000010c14777c sp=0x000000016f37d920 mode 1 - -Classes unloaded (0 events): -No events - -Classes redefined (1 events): -Event: 0.114 Thread 0x000000012b8059d0 redefined class name=java.lang.Throwable, count=1 - -Internal exceptions (20 events): -Event: 6.369 Thread 0x000000010b808a00 Exception (0x00000005c61a56a8) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 6.370 Thread 0x000000010b808a00 Exception (0x00000005c61ac0f8) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 6.371 Thread 0x000000010b808a00 Exception (0x00000005c61b00f0) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 6.388 Thread 0x000000012b45ba00 Exception (0x00000005c7a137e0) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 6.404 Thread 0x000000010b808a00 Exception (0x00000005c6294068) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 6.891 Thread 0x000000012b45ba00 Exception (0x00000005cbfd3b50) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 7.393 Thread 0x000000012b45ba00 Exception (0x00000005cbfe0578) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 7.899 Thread 0x000000012b45ba00 Exception (0x00000005c9409f48) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 8.401 Thread 0x000000012b45ba00 Exception (0x00000005e02167a0) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 8.927 Thread 0x000000012b45ba00 Exception (0x00000005ccc28338) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 8.948 Thread 0x000000010b808a00 Exception (0x00000005ccc21b10) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 8.948 Thread 0x000000010b808a00 Exception (0x00000005ccc26808) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 8.949 Thread 0x000000010b808a00 Exception (0x00000005ccc3f340) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 8.949 Thread 0x000000010b808a00 Exception (0x00000005ccc4b0d8) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 9.084 Thread 0x000000010b808a00 Exception (0x00000005cc19f8f0) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 9.085 Thread 0x000000010b808a00 Exception (0x00000005cc1a9e48) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 9.105 Thread 0x000000010b808a00 Exception (0x00000005cc24dc60) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 9.105 Thread 0x000000010b808a00 Exception (0x00000005cc2577d0) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 9.106 Thread 0x000000010b808a00 Exception (0x00000005cc2624f8) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 9.126 Thread 0x000000011ebea000 Exception (0x00000005cc5769a0) -thrown [src/hotspot/share/prims/jni.cpp, line 516] - -VM Operations (20 events): -Event: 8.053 Executing VM operation: CollectForMetadataAllocation -Event: 8.070 Executing VM operation: CollectForMetadataAllocation done -Event: 8.080 Executing VM operation: G1PauseRemark -Event: 8.084 Executing VM operation: G1PauseRemark done -Event: 8.088 Executing VM operation: G1PauseCleanup -Event: 8.088 Executing VM operation: G1PauseCleanup done -Event: 8.450 Executing VM operation: HandshakeAllThreads -Event: 8.450 Executing VM operation: HandshakeAllThreads done -Event: 8.461 Executing VM operation: HandshakeAllThreads -Event: 8.462 Executing VM operation: HandshakeAllThreads done -Event: 8.462 Executing VM operation: HandshakeAllThreads -Event: 8.462 Executing VM operation: HandshakeAllThreads done -Event: 8.957 Executing VM operation: HandshakeAllThreads -Event: 8.957 Executing VM operation: HandshakeAllThreads done -Event: 8.964 Executing VM operation: HandshakeAllThreads -Event: 8.964 Executing VM operation: HandshakeAllThreads done -Event: 8.980 Executing VM operation: HandshakeAllThreads -Event: 8.980 Executing VM operation: HandshakeAllThreads done -Event: 9.027 Executing VM operation: ICBufferFull -Event: 9.033 Executing VM operation: ICBufferFull done - -Events (20 events): -Event: 9.126 loading class java/net/SocksSocketImpl$3 done -Event: 9.126 loading class sun/net/util/SocketExceptions -Event: 9.126 loading class sun/net/util/SocketExceptions done -Event: 9.127 Thread 0x000000011f24c800 Thread added: 0x000000011f24c800 -Event: 9.127 Protecting memory [0x000000032ab00000,0x000000032ab0c000] with protection modes 0 -Event: 9.127 loading class java/lang/Throwable$WrappedPrintWriter -Event: 9.127 loading class java/lang/Throwable$WrappedPrintWriter done -Event: 9.128 loading class com/intellij/rt/debugger/agent/CaptureStorage$9 -Event: 9.128 loading class com/intellij/rt/debugger/agent/CaptureStorage$Callable -Event: 9.128 loading class com/intellij/rt/debugger/agent/CaptureStorage$Callable done -Event: 9.128 loading class com/intellij/rt/debugger/agent/CaptureStorage$9 done -Event: 9.128 loading class jdk/internal/loader/BootLoader$PackageHelper$1 -Event: 9.128 loading class jdk/internal/loader/BootLoader$PackageHelper$1 done -Event: 9.128 loading class jdk/internal/loader/BootLoader$PackageHelper$2 -Event: 9.128 loading class jdk/internal/loader/BootLoader$PackageHelper$2 done -Event: 9.128 loading class java/util/jar/JarInputStream -Event: 9.128 loading class java/util/zip/ZipInputStream -Event: 9.129 loading class java/util/zip/ZipInputStream done -Event: 9.129 loading class java/util/jar/JarInputStream done -Event: 9.129 loading class com/intellij/rt/debugger/agent/CaptureStorage$StackData - - -Dynamic libraries: -0x0000000100d44000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjli.dylib -0x0000000196c18000 /usr/lib/libz.1.dylib -0x0000000196cce000 /usr/lib/libSystem.B.dylib -0x0000000196cc8000 /usr/lib/system/libcache.dylib -0x0000000196c83000 /usr/lib/system/libcommonCrypto.dylib -0x0000000196cae000 /usr/lib/system/libcompiler_rt.dylib -0x0000000196ca3000 /usr/lib/system/libcopyfile.dylib -0x0000000186bb6000 /usr/lib/system/libcorecrypto.dylib -0x0000000186cb6000 /usr/lib/system/libdispatch.dylib -0x0000000186a53000 /usr/lib/system/libdyld.dylib -0x0000000196cbe000 /usr/lib/system/libkeymgr.dylib -0x0000000196c66000 /usr/lib/system/libmacho.dylib -0x0000000195ef9000 /usr/lib/system/libquarantine.dylib -0x0000000196cbb000 /usr/lib/system/libremovefile.dylib -0x000000018d629000 /usr/lib/system/libsystem_asl.dylib -0x0000000186b3c000 /usr/lib/system/libsystem_blocks.dylib -0x0000000186d01000 /usr/lib/system/libsystem_c.dylib -0x0000000196cb2000 /usr/lib/system/libsystem_collections.dylib -0x0000000194899000 /usr/lib/system/libsystem_configuration.dylib -0x0000000193487000 /usr/lib/system/libsystem_containermanager.dylib -0x0000000196698000 /usr/lib/system/libsystem_coreservices.dylib -0x000000018ae50000 /usr/lib/system/libsystem_darwin.dylib -0x000000028c8a4000 /usr/lib/system/libsystem_darwindirectory.dylib -0x0000000196cbf000 /usr/lib/system/libsystem_dnssd.dylib -0x000000028c8a8000 /usr/lib/system/libsystem_eligibility.dylib -0x0000000186cfe000 /usr/lib/system/libsystem_featureflags.dylib -0x0000000186e83000 /usr/lib/system/libsystem_info.dylib -0x0000000196c27000 /usr/lib/system/libsystem_m.dylib -0x0000000186c65000 /usr/lib/system/libsystem_malloc.dylib -0x000000018d58c000 /usr/lib/system/libsystem_networkextension.dylib -0x000000018b2bb000 /usr/lib/system/libsystem_notify.dylib -0x000000019489e000 /usr/lib/system/libsystem_sandbox.dylib -0x000000028c8b3000 /usr/lib/system/libsystem_sanitizers.dylib -0x0000000196cb7000 /usr/lib/system/libsystem_secinit.dylib -0x0000000186e2f000 /usr/lib/system/libsystem_kernel.dylib -0x0000000186e7a000 /usr/lib/system/libsystem_platform.dylib -0x0000000186e6d000 /usr/lib/system/libsystem_pthread.dylib -0x000000018f1e2000 /usr/lib/system/libsystem_symptoms.dylib -0x0000000186b95000 /usr/lib/system/libsystem_trace.dylib -0x000000028c8bb000 /usr/lib/system/libsystem_trial.dylib -0x0000000196c91000 /usr/lib/system/libunwind.dylib -0x0000000186b40000 /usr/lib/system/libxpc.dylib -0x0000000186a00000 /usr/lib/libobjc.A.dylib -0x0000000186eb3000 /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation -0x000000019a5c3000 /usr/lib/swift/libswiftCore.dylib -0x0000000186e14000 /usr/lib/libc++abi.dylib -0x000000028ac91000 /usr/lib/libRosetta.dylib -0x0000000186d83000 /usr/lib/libc++.1.dylib -0x0000000188722000 /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation -0x00000001a41a3000 /usr/lib/swift/libswiftObjectiveC.dylib -0x000000028c10d000 /usr/lib/libswiftPrespecialized.dylib -0x0000000188391000 /System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration -0x0000000191703000 /System/Library/PrivateFrameworks/CoreAutoLayout.framework/Versions/A/CoreAutoLayout -0x0000000196cd0000 /usr/lib/libfakelink.dylib -0x0000000196f79000 /usr/lib/libcompression.dylib -0x000000018d1d6000 /System/Library/Frameworks/CFNetwork.framework/Versions/A/CFNetwork -0x0000000190b34000 /System/Library/Frameworks/DiskArbitration.framework/Versions/A/DiskArbitration -0x0000000196d23000 /usr/lib/libarchive.2.dylib -0x0000000190a39000 /usr/lib/libDiagnosticMessagesClient.dylib -0x000000018ab7a000 /usr/lib/libicucore.A.dylib -0x000000019174c000 /usr/lib/libxml2.2.dylib -0x000000019f452000 /System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices -0x00000001948ac000 /usr/lib/liblangid.dylib -0x000000018b1d2000 /System/Library/Frameworks/IOKit.framework/Versions/A/IOKit -0x000000019d0c4000 /System/Library/Frameworks/Combine.framework/Versions/A/Combine -0x000000023fff3000 /System/Library/PrivateFrameworks/CollectionsInternal.framework/Versions/A/CollectionsInternal -0x000000026c039000 /System/Library/PrivateFrameworks/ReflectionInternal.framework/Versions/A/ReflectionInternal -0x000000026cf8d000 /System/Library/PrivateFrameworks/RuntimeInternal.framework/Versions/A/RuntimeInternal -0x0000000196cd2000 /System/Library/PrivateFrameworks/SoftLinking.framework/Versions/A/SoftLinking -0x00000001b488c000 /usr/lib/swift/libswiftCoreFoundation.dylib -0x00000001b164f000 /usr/lib/swift/libswiftDarwin.dylib -0x00000001a11c9000 /usr/lib/swift/libswiftDispatch.dylib -0x00000001b48ed000 /usr/lib/swift/libswiftIOKit.dylib -0x000000028c550000 /usr/lib/swift/libswiftSystem.dylib -0x00000001b489f000 /usr/lib/swift/libswiftXPC.dylib -0x000000028c582000 /usr/lib/swift/libswift_Builtin_float.dylib -0x000000028c583000 /usr/lib/swift/libswift_Concurrency.dylib -0x000000028c60f000 /usr/lib/swift/libswift_DarwinFoundation1.dylib -0x000000028c6b3000 /usr/lib/swift/libswift_StringProcessing.dylib -0x00000001a41a7000 /usr/lib/swift/libswiftos.dylib -0x000000018b152000 /System/Library/PrivateFrameworks/CoreServicesInternal.framework/Versions/A/CoreServicesInternal -0x0000000196c9b000 /usr/lib/liboah.dylib -0x000000018a75a000 /System/Library/Frameworks/Security.framework/Versions/A/Security -0x00000001a35d7000 /System/Library/PrivateFrameworks/DiskImages.framework/Versions/A/DiskImages -0x00000001b10f3000 /System/Library/Frameworks/NetFS.framework/Versions/A/NetFS -0x00000001916c8000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/FSEvents.framework/Versions/A/FSEvents -0x000000018ae5a000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore -0x0000000190aa8000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/Metadata.framework/Versions/A/Metadata -0x000000019669f000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/OSServices.framework/Versions/A/OSServices -0x0000000196e1b000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/SearchKit.framework/Versions/A/SearchKit -0x000000018f15c000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/AE.framework/Versions/A/AE -0x0000000187412000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices -0x0000000198224000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/DictionaryServices.framework/Versions/A/DictionaryServices -0x00000001916d5000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/SharedFileList.framework/Versions/A/SharedFileList -0x0000000196eae000 /usr/lib/libapple_nghttp2.dylib -0x000000018ed78000 /usr/lib/libsqlite3.dylib -0x000000018ef61000 /System/Library/Frameworks/Accounts.framework/Versions/A/Accounts -0x00000001a3819000 /System/Library/PrivateFrameworks/AppSupport.framework/Versions/A/AppSupport -0x00000001b363e000 /System/Library/Frameworks/AVFoundation.framework/Versions/A/AVFoundation -0x0000000190a09000 /System/Library/PrivateFrameworks/CoreAnalytics.framework/Versions/A/CoreAnalytics -0x000000018dc1c000 /System/Library/Frameworks/CoreGraphics.framework/Versions/A/CoreGraphics -0x000000019b2fe000 /System/Library/Frameworks/GSS.framework/Versions/A/GSS -0x00000001996e6000 /System/Library/PrivateFrameworks/InternationalSupport.framework/Versions/A/InternationalSupport -0x000000018f0f0000 /System/Library/PrivateFrameworks/RunningBoardServices.framework/Versions/A/RunningBoardServices -0x00000001a412b000 /System/Library/PrivateFrameworks/StreamingZip.framework/Versions/A/StreamingZip -0x000000018d5a7000 /usr/lib/libenergytrace.dylib -0x000000018f1eb000 /System/Library/Frameworks/Network.framework/Versions/A/Network -0x0000000195f21000 /usr/lib/libbsm.0.dylib -0x0000000196c6a000 /usr/lib/system/libkxld.dylib -0x000000023b5c5000 /System/Library/PrivateFrameworks/AppleKeyStore.framework/Versions/A/AppleKeyStore -0x000000028a993000 /usr/lib/libCoreEntitlements.dylib -0x0000000260705000 /System/Library/PrivateFrameworks/MessageSecurity.framework/Versions/A/MessageSecurity -0x000000018ed5c000 /System/Library/PrivateFrameworks/ProtocolBuffer.framework/Versions/A/ProtocolBuffer -0x00000001a05d8000 /System/Library/PrivateFrameworks/SymptomDiagnosticReporter.framework/Versions/A/SymptomDiagnosticReporter -0x0000000198469000 /System/Library/PrivateFrameworks/CrashReporterSupport.framework/Versions/A/CrashReporterSupport -0x000000018d5a9000 /usr/lib/libMobileGestalt.dylib -0x000000019667f000 /System/Library/PrivateFrameworks/AppleFSCompression.framework/Versions/A/AppleFSCompression -0x0000000195f09000 /usr/lib/libcoretls.dylib -0x000000019829a000 /usr/lib/libcoretls_cfhelpers.dylib -0x0000000196f73000 /usr/lib/libpam.2.dylib -0x0000000198310000 /usr/lib/libxar.1.dylib -0x000000019829c000 /System/Library/PrivateFrameworks/APFS.framework/Versions/A/APFS -0x0000000278713000 /System/Library/PrivateFrameworks/SwiftASN1Internal.framework/Versions/A/SwiftASN1Internal -0x000000019831f000 /usr/lib/libutil.dylib -0x00000001948a7000 /System/Library/PrivateFrameworks/AppleSystemInfo.framework/Versions/A/AppleSystemInfo -0x0000000195bd0000 /System/Library/PrivateFrameworks/IOMobileFramebuffer.framework/Versions/A/IOMobileFramebuffer -0x00000001934c0000 /System/Library/Frameworks/IOSurface.framework/Versions/A/IOSurface -0x00000001a2f37000 /System/Library/PrivateFrameworks/CoreWiFi.framework/Versions/A/CoreWiFi -0x00000001b474c000 /System/Library/PrivateFrameworks/LoggingSupport.framework/Versions/A/LoggingSupport -0x000000019b361000 /System/Library/PrivateFrameworks/MobileAsset.framework/Versions/A/MobileAsset -0x00000001a05e8000 /System/Library/PrivateFrameworks/PowerLog.framework/Versions/A/PowerLog -0x00000001a1aaa000 /System/Library/PrivateFrameworks/Rapport.framework/Versions/A/Rapport -0x000000023416e000 /System/Library/Frameworks/SwiftData.framework/Versions/A/SwiftData -0x000000018cc0a000 /System/Library/Frameworks/UniformTypeIdentifiers.framework/Versions/A/UniformTypeIdentifiers -0x00000001918f5000 /System/Library/PrivateFrameworks/UserManagement.framework/Versions/A/UserManagement -0x000000018d0fd000 /usr/lib/libboringssl.dylib -0x000000018f1d0000 /usr/lib/libdns_services.dylib -0x00000001b3772000 /usr/lib/libquic.dylib -0x000000019a554000 /usr/lib/libusrtcp.dylib -0x000000023c47f000 /System/Library/PrivateFrameworks/AtomicsInternal.framework/Versions/A/AtomicsInternal -0x00000001dab32000 /System/Library/PrivateFrameworks/InternalSwiftProtobuf.framework/Versions/A/InternalSwiftProtobuf -0x000000028c3d7000 /usr/lib/swift/libswiftDistributed.dylib -0x000000028c400000 /usr/lib/swift/libswiftObservation.dylib -0x000000028c53c000 /usr/lib/swift/libswiftSynchronization.dylib -0x00000001948a5000 /System/Library/PrivateFrameworks/AggregateDictionary.framework/Versions/A/AggregateDictionary -0x000000023ccaf000 /System/Library/PrivateFrameworks/BiomeLibrary.framework/Versions/A/BiomeLibrary -0x00000001c38d5000 /System/Library/PrivateFrameworks/BiomeStreams.framework/Versions/A/BiomeStreams -0x00000001bf924000 /System/Library/PrivateFrameworks/BiomeFoundation.framework/Versions/A/BiomeFoundation -0x00000001c9b82000 /System/Library/PrivateFrameworks/BiomePubSub.framework/Versions/A/BiomePubSub -0x000000018e96e000 /System/Library/Frameworks/CoreData.framework/Versions/A/CoreData -0x00000001a510e000 /System/Library/PrivateFrameworks/ProactiveSupport.framework/Versions/A/ProactiveSupport -0x00000002361cc000 /System/Library/Frameworks/_LocationEssentials.framework/Versions/A/_LocationEssentials -0x000000019827b000 /usr/lib/liblzma.5.dylib -0x000000019f6d1000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Accelerate -0x0000000195e02000 /System/Library/PrivateFrameworks/MobileKeyBag.framework/Versions/A/MobileKeyBag -0x00000001a3b2d000 /System/Library/PrivateFrameworks/InternationalTextSearch.framework/Versions/A/InternationalTextSearch -0x00000001bbdf7000 /System/Library/PrivateFrameworks/SoftwareUpdateCoreSupport.framework/Versions/A/SoftwareUpdateCoreSupport -0x00000001c4374000 /System/Library/PrivateFrameworks/SoftwareUpdateCoreConnect.framework/Versions/A/SoftwareUpdateCoreConnect -0x00000001a36d1000 /System/Library/PrivateFrameworks/RemoteServiceDiscovery.framework/Versions/A/RemoteServiceDiscovery -0x00000001bb9ce000 /System/Library/PrivateFrameworks/MSUDataAccessor.framework/Versions/A/MSUDataAccessor -0x00000001b691f000 /usr/lib/libbootpolicy.dylib -0x00000001a36e8000 /System/Library/PrivateFrameworks/RemoteXPC.framework/Versions/A/RemoteXPC -0x00000001c37a9000 /usr/lib/libFDR.dylib -0x00000001c9784000 /usr/lib/libamsupport.dylib -0x000000028ac89000 /usr/lib/libReverseProxyDevice.dylib -0x000000023ae33000 /System/Library/PrivateFrameworks/AppleDeviceQuerySupport.framework/Versions/A/AppleDeviceQuerySupport -0x00000001cc94e000 /usr/lib/libpartition2_dynamic.dylib -0x0000000196e8a000 /System/Library/PrivateFrameworks/AppleSauce.framework/Versions/A/AppleSauce -0x000000028a83e000 /usr/lib/libAppleArchive.dylib -0x000000019668b000 /usr/lib/libbz2.1.0.dylib -0x0000000190b3e000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vImage.framework/Versions/A/vImage -0x000000019f42d000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/vecLib -0x0000000198356000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvMisc.dylib -0x0000000187916000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib -0x00000001a3b2c000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices -0x0000000191833000 /System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo -0x000000018e372000 /System/Library/Frameworks/ColorSync.framework/Versions/A/ColorSync -0x0000000189d9a000 /System/Library/Frameworks/CoreText.framework/Versions/A/CoreText -0x0000000193fb3000 /System/Library/Frameworks/ImageIO.framework/Versions/A/ImageIO -0x000000019af0e000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATS.framework/Versions/A/ATS -0x000000018e51a000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/HIServices -0x00000001995dc000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Versions/A/PrintCore -0x000000019b2c7000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/QD.framework/Versions/A/QD -0x000000019b2c2000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ColorSyncLegacy.framework/Versions/A/ColorSyncLegacy -0x000000019aee0000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/SpeechSynthesis.framework/Versions/A/SpeechSynthesis -0x000000018d665000 /System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight -0x000000019398e000 /System/Library/PrivateFrameworks/FontServices.framework/libFontParser.dylib -0x000000018effe000 /System/Library/PrivateFrameworks/BaseBoard.framework/Versions/A/BaseBoard -0x00000001a152a000 /System/Library/PrivateFrameworks/BoardServices.framework/Versions/A/BoardServices -0x00000001a33f9000 /System/Library/PrivateFrameworks/BackBoardServices.framework/Versions/A/BackBoardServices -0x000000023cbb4000 /System/Library/PrivateFrameworks/BackBoardHIDEventFoundation.framework/Versions/A/BackBoardHIDEventFoundation -0x00000001898b6000 /System/Library/Frameworks/CoreDisplay.framework/Versions/A/CoreDisplay -0x0000000198f35000 /System/Library/Frameworks/VideoToolbox.framework/Versions/A/VideoToolbox -0x0000000196f71000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/MetalPerformanceShaders -0x000000026af43000 /System/Library/PrivateFrameworks/ProDisplayLibrary.framework/Versions/A/ProDisplayLibrary -0x00000001a722e000 /System/Library/PrivateFrameworks/IOSurfaceAccelerator.framework/Versions/A/IOSurfaceAccelerator -0x00000001934e8000 /System/Library/Frameworks/Metal.framework/Versions/A/Metal -0x00000001934dd000 /System/Library/PrivateFrameworks/IOAccelerator.framework/Versions/A/IOAccelerator -0x00000001937ec000 /System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia -0x000000018d641000 /System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC -0x0000000198eed000 /System/Library/PrivateFrameworks/WatchdogClient.framework/Versions/A/WatchdogClient -0x0000000190f63000 /System/Library/Frameworks/QuartzCore.framework/Versions/A/QuartzCore -0x0000000198eef000 /System/Library/PrivateFrameworks/MultitouchSupport.framework/Versions/A/MultitouchSupport -0x00000001cc730000 /usr/lib/swift/libswiftAccelerate.dylib -0x00000001b486c000 /usr/lib/swift/libswiftCoreAudio.dylib -0x00000001d08c1000 /usr/lib/swift/libswiftCoreMedia.dylib -0x00000001c2862000 /usr/lib/swift/libswiftMetal.dylib -0x00000001d2074000 /usr/lib/swift/libswiftOSLog.dylib -0x00000001c7c88000 /usr/lib/swift/libswiftQuartzCore.dylib -0x00000001cc720000 /usr/lib/swift/libswiftUniformTypeIdentifiers.dylib -0x000000028c56a000 /usr/lib/swift/libswiftVideoToolbox.dylib -0x00000001b83e6000 /usr/lib/swift/libswiftsimd.dylib -0x00000001c9be3000 /System/Library/PrivateFrameworks/BiomeStorage.framework/Versions/A/BiomeStorage -0x00000002593f4000 /System/Library/PrivateFrameworks/IntelligencePlatformLibrary.framework/Versions/A/IntelligencePlatformLibrary -0x0000000269d07000 /System/Library/PrivateFrameworks/PoirotSchematizer.framework/Versions/A/PoirotSchematizer -0x000000023d56a000 /System/Library/PrivateFrameworks/BiomeSync.framework/Versions/A/BiomeSync -0x000000023cc95000 /System/Library/PrivateFrameworks/BiomeDSL.framework/Versions/A/BiomeDSL -0x00000001e2d3d000 /System/Library/PrivateFrameworks/FeatureFlags.framework/Versions/A/FeatureFlags -0x0000000269d75000 /System/Library/PrivateFrameworks/PoirotUDFs.framework/Versions/A/PoirotUDFs -0x000000028c612000 /usr/lib/swift/libswift_DarwinFoundation2.dylib -0x000000028c613000 /usr/lib/swift/libswift_DarwinFoundation3.dylib -0x00000001a1a9f000 /System/Library/PrivateFrameworks/CoreTime.framework/Versions/A/CoreTime -0x0000000196d08000 /usr/lib/libiconv.2.dylib -0x0000000196c65000 /usr/lib/libcharset.1.dylib -0x0000000269cca000 /System/Library/PrivateFrameworks/PoirotSQLite.framework/Versions/A/PoirotSQLite -0x000000028c614000 /usr/lib/swift/libswift_RegexParser.dylib -0x000000023eba0000 /System/Library/PrivateFrameworks/CascadeSets.framework/Versions/A/CascadeSets -0x000000019b4d8000 /System/Library/PrivateFrameworks/CorePhoneNumbers.framework/Versions/A/CorePhoneNumbers -0x0000000198ce7000 /System/Library/PrivateFrameworks/AppleJPEG.framework/Versions/A/AppleJPEG -0x00000001986c0000 /usr/lib/libexpat.1.dylib -0x00000001994b2000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libPng.dylib -0x00000001994dd000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libTIFF.dylib -0x00000001995c5000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libGIF.dylib -0x0000000198d2c000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJP2.dylib -0x00000001983d0000 /usr/lib/libate.dylib -0x000000019956c000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJPEG.dylib -0x0000000199563000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libRadiance.dylib -0x000000024f044000 /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/libllvm-flatbuffers.dylib -0x0000000249aa3000 /System/Library/PrivateFrameworks/FramePacing.framework/Versions/A/FramePacing -0x000000022cbb3000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCoreFSCache.dylib -0x000000024abd1000 /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/libGPUCompilerUtils.dylib -0x00000001a15f7000 /System/Library/PrivateFrameworks/GraphicsServices.framework/Versions/A/GraphicsServices -0x000000022cbc1000 /System/Library/Frameworks/OpenGL.framework/Versions/A/OpenGL -0x000000022cc12000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGLU.dylib -0x000000022cbd5000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGFXShared.dylib -0x000000022cda2000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib -0x000000022cbde000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGLImage.dylib -0x000000022cbd2000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCVMSPluginSupport.dylib -0x000000022cbbb000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCoreVMClient.dylib -0x000000019955e000 /System/Library/PrivateFrameworks/GPUWrangler.framework/Versions/A/GPUWrangler -0x000000019953e000 /System/Library/PrivateFrameworks/IOPresentment.framework/Versions/A/IOPresentment -0x0000000199566000 /System/Library/PrivateFrameworks/DSExternalDisplay.framework/Versions/A/DSExternalDisplay -0x00000002805c9000 /System/Library/PrivateFrameworks/VideoToolboxParavirtualizationSupport.framework/Versions/A/VideoToolboxParavirtualizationSupport -0x0000000198677000 /System/Library/PrivateFrameworks/AppleVA.framework/Versions/A/AppleVA -0x000000022ec3e000 /System/Library/Frameworks/ExtensionFoundation.framework/Versions/A/ExtensionFoundation -0x00000001995cb000 /System/Library/PrivateFrameworks/CMCaptureCore.framework/Versions/A/CMCaptureCore -0x0000000198951000 /usr/lib/libspindump.dylib -0x0000000189fc4000 /System/Library/Frameworks/CoreAudio.framework/Versions/A/CoreAudio -0x0000000198944000 /System/Library/PrivateFrameworks/AppServerSupport.framework/Versions/A/AppServerSupport -0x000000019b2d0000 /System/Library/PrivateFrameworks/perfdata.framework/Versions/A/perfdata -0x00000001899d7000 /System/Library/PrivateFrameworks/AudioToolboxCore.framework/Versions/A/AudioToolboxCore -0x00000001937c2000 /System/Library/PrivateFrameworks/caulk.framework/Versions/A/caulk -0x000000019aec6000 /usr/lib/libAudioStatistics.dylib -0x00000001b3867000 /System/Library/PrivateFrameworks/SystemPolicy.framework/Versions/A/SystemPolicy -0x000000019b174000 /usr/lib/libSMC.dylib -0x00000001bb1dd000 /usr/lib/swift/libswiftCoreMIDI.dylib -0x00000001a651d000 /System/Library/Frameworks/CoreMIDI.framework/Versions/A/CoreMIDI -0x000000019948c000 /usr/lib/libAudioToolboxUtility.dylib -0x000000019b2de000 /usr/lib/libperfcheck.dylib -0x000000023c54e000 /System/Library/PrivateFrameworks/AudioAnalytics.framework/Versions/A/AudioAnalytics -0x00000001da81e000 /System/Library/Frameworks/OSLog.framework/Versions/A/OSLog -0x0000000265777000 /System/Library/PrivateFrameworks/OSEligibility.framework/Versions/A/OSEligibility -0x0000000198746000 /System/Library/PrivateFrameworks/IconServices.framework/Versions/A/IconServices -0x0000000230025000 /System/Library/Frameworks/LightweightCodeRequirements.framework/Versions/A/LightweightCodeRequirements -0x00000001985c0000 /System/Library/PrivateFrameworks/PlugInKit.framework/Versions/A/PlugInKit -0x0000000195e1a000 /System/Library/PrivateFrameworks/AssertionServices.framework/Versions/A/AssertionServices -0x00000001986e5000 /System/Library/PrivateFrameworks/IconFoundation.framework/Versions/A/IconFoundation -0x0000000255f80000 /System/Library/PrivateFrameworks/IconRendering.framework/Versions/A/IconRendering -0x00000001913ca000 /System/Library/PrivateFrameworks/CoreUI.framework/Versions/A/CoreUI -0x00000001942f4000 /System/Library/Frameworks/CoreImage.framework/Versions/A/CoreImage -0x000000026d172000 /System/Library/PrivateFrameworks/SFSymbols.framework/Versions/A/SFSymbols -0x000000022eaf4000 /System/Library/Frameworks/DeveloperToolsSupport.framework/Versions/A/DeveloperToolsSupport -0x00000001ab7ca000 /System/Library/PrivateFrameworks/RenderBox.framework/Versions/A/RenderBox -0x0000000193f75000 /System/Library/PrivateFrameworks/CoreSVG.framework/Versions/A/CoreSVG -0x000000019964f000 /System/Library/PrivateFrameworks/TextureIO.framework/Versions/A/TextureIO -0x00000001b48ec000 /usr/lib/swift/libswiftCoreImage.dylib -0x00000001988f4000 /System/Library/PrivateFrameworks/GraphVisualizer.framework/Versions/A/GraphVisualizer -0x00000002499ae000 /System/Library/PrivateFrameworks/FontServices.framework/Versions/A/FontServices -0x0000000198904000 /System/Library/PrivateFrameworks/OTSVG.framework/Versions/A/OTSVG -0x0000000191379000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATS.framework/Versions/A/Resources/libFontRegistry.dylib -0x000000028b763000 /usr/lib/libhvf.dylib -0x0000000266404000 /System/Library/PrivateFrameworks/ParsingInternal.framework/Versions/A/ParsingInternal -0x00000002499b2000 /System/Library/PrivateFrameworks/FontServices.framework/libXTFontStaticRegistryData.dylib -0x00000001947df000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSCore.framework/Versions/A/MPSCore -0x00000001965ea000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSImage.framework/Versions/A/MPSImage -0x0000000195fa9000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSNeuralNetwork.framework/Versions/A/MPSNeuralNetwork -0x00000001963e8000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSMatrix.framework/Versions/A/MPSMatrix -0x0000000196200000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSRayIntersector.framework/Versions/A/MPSRayIntersector -0x000000019641a000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSNDArray.framework/Versions/A/MPSNDArray -0x0000000230eaa000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSFunctions.framework/Versions/A/MPSFunctions -0x0000000230e8b000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSBenchmarkLoop.framework/Versions/A/MPSBenchmarkLoop -0x0000000230ebe000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSHost.framework/Versions/A/MPSHost -0x000000018772d000 /System/Library/PrivateFrameworks/MetalTools.framework/Versions/A/MetalTools -0x00000001b9b0d000 /System/Library/PrivateFrameworks/IOAccelMemoryInfo.framework/Versions/A/IOAccelMemoryInfo -0x00000001c807b000 /System/Library/PrivateFrameworks/kperf.framework/Versions/A/kperf -0x00000001b4868000 /System/Library/PrivateFrameworks/GPURawCounter.framework/Versions/A/GPURawCounter -0x00000001a5299000 /System/Library/PrivateFrameworks/ASEProcessing.framework/Versions/A/ASEProcessing -0x00000001d618a000 /System/Library/PrivateFrameworks/Symbolication.framework/Versions/A/Symbolication -0x00000002698b5000 /System/Library/PrivateFrameworks/PhotosensitivityProcessing.framework/Versions/A/PhotosensitivityProcessing -0x000000026d1f8000 /System/Library/PrivateFrameworks/SILManager.framework/Versions/A/SILManager -0x00000001a1943000 /System/Library/PrivateFrameworks/CoreSymbolication.framework/Versions/A/CoreSymbolication -0x00000001b47db000 /System/Library/PrivateFrameworks/MallocStackLogging.framework/Versions/A/MallocStackLogging -0x00000001a1921000 /System/Library/PrivateFrameworks/DebugSymbols.framework/Versions/A/DebugSymbols -0x00000001c67cc000 /System/Library/PrivateFrameworks/OSAnalytics.framework/Versions/A/OSAnalytics -0x0000000247709000 /System/Library/PrivateFrameworks/DeviceRecovery.framework/Versions/A/DeviceRecovery -0x000000027be51000 /System/Library/PrivateFrameworks/Tightbeam.framework/Versions/A/Tightbeam -0x00000001c2870000 /usr/lib/swift/libswiftCompression.dylib -0x00000001ccebd000 /System/Library/PrivateFrameworks/AFKUser.framework/Versions/A/AFKUser -0x0000000199597000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATSUI.framework/Versions/A/ATSUI -0x000000019ac6f000 /System/Library/Frameworks/AudioToolbox.framework/Versions/A/AudioToolbox -0x0000000196a5c000 /System/Library/Frameworks/UserNotifications.framework/Versions/A/UserNotifications -0x00000001ba2a4000 /System/Library/PrivateFrameworks/SiriInstrumentation.framework/Versions/A/SiriInstrumentation -0x000000026f688000 /System/Library/PrivateFrameworks/SiriAnalytics.framework/Versions/A/SiriAnalytics -0x00000001b7a03000 /System/Library/PrivateFrameworks/FeedbackLogger.framework/Versions/A/FeedbackLogger -0x00000001d230d000 /usr/lib/swift/libswiftAVFoundation.dylib -0x000000027e67a000 /System/Library/PrivateFrameworks/UnifiedAssetFramework.framework/Versions/A/UnifiedAssetFramework -0x000000019ae45000 /System/Library/PrivateFrameworks/AudioSession.framework/Versions/A/AudioSession -0x0000000198805000 /System/Library/PrivateFrameworks/MediaExperience.framework/Versions/A/MediaExperience -0x000000019ac19000 /System/Library/PrivateFrameworks/AudioSession.framework/libSessionUtility.dylib -0x00000001a04cd000 /System/Library/Frameworks/CoreBluetooth.framework/Versions/A/CoreBluetooth -0x0000000195c8d000 /System/Library/PrivateFrameworks/CoreUtils.framework/Versions/A/CoreUtils -0x00000001ac4fe000 /System/Library/PrivateFrameworks/HID.framework/Versions/A/HID -0x00000002465fa000 /System/Library/PrivateFrameworks/CoreUtilsExtras.framework/Versions/A/CoreUtilsExtras -0x0000000255ed5000 /System/Library/PrivateFrameworks/IO80211.framework/Versions/A/IO80211 -0x000000019ce10000 /System/Library/Frameworks/IOBluetooth.framework/Versions/A/IOBluetooth -0x000000028c41f000 /usr/lib/swift/libswiftRegexBuilder.dylib -0x0000000198460000 /usr/lib/libIOReport.dylib -0x00000001e2dc2000 /System/Library/PrivateFrameworks/WiFiPeerToPeer.framework/Versions/A/WiFiPeerToPeer -0x0000000195e29000 /System/Library/Frameworks/SecurityFoundation.framework/Versions/A/SecurityFoundation -0x000000023ec54000 /System/Library/PrivateFrameworks/Centauri.framework/Versions/A/Centauri -0x0000000188111000 /System/Library/PrivateFrameworks/Lexicon.framework/Versions/A/Lexicon -0x000000028bbb3000 /usr/lib/libmrc.dylib -0x0000000255f40000 /System/Library/PrivateFrameworks/IPConfiguration.framework/Versions/A/IPConfiguration -0x00000001d6961000 /System/Library/PrivateFrameworks/Netrb.framework/Versions/A/Netrb -0x00000001a1450000 /System/Library/PrivateFrameworks/FrontBoardServices.framework/Versions/A/FrontBoardServices -0x0000000195f92000 /usr/lib/libgermantok.dylib -0x00000001949ce000 /System/Library/PrivateFrameworks/LinguisticData.framework/Versions/A/LinguisticData -0x00000001a06d6000 /System/Library/PrivateFrameworks/MediaKit.framework/Versions/A/MediaKit -0x00000001a0624000 /System/Library/Frameworks/DiscRecording.framework/Versions/A/DiscRecording -0x00000001986db000 /usr/lib/libheimdal-asn1.dylib -0x00000001a4101000 /System/Library/Frameworks/AudioUnit.framework/Versions/A/AudioUnit -0x0000000191690000 /System/Library/Frameworks/OpenDirectory.framework/Versions/A/OpenDirectory -0x000000019169e000 /System/Library/Frameworks/OpenDirectory.framework/Versions/A/Frameworks/CFOpenDirectory.framework/Versions/A/CFOpenDirectory -0x000000019d1b8000 /System/Library/PrivateFrameworks/GeoServices.framework/Versions/A/GeoServices -0x000000019abdf000 /System/Library/PrivateFrameworks/LocationSupport.framework/Versions/A/LocationSupport -0x0000000252bfe000 /System/Library/PrivateFrameworks/GeoServicesCore.framework/Versions/A/GeoServicesCore -0x00000001ad402000 /System/Library/PrivateFrameworks/PhoneNumbers.framework/Versions/A/PhoneNumbers -0x000000025b386000 /System/Library/PrivateFrameworks/LocationLogEncryption.framework/Versions/A/LocationLogEncryption -0x000000022d12e000 /System/Library/Frameworks/AVFAudio.framework/Versions/A/AVFAudio -0x000000022d272000 /System/Library/Frameworks/AVRouting.framework/Versions/A/AVRouting -0x00000001ad51a000 /usr/lib/libAccessibility.dylib -0x0000000259d70000 /System/Library/PrivateFrameworks/IsolatedCoreAudioClient.framework/Versions/A/IsolatedCoreAudioClient -0x00000002423ee000 /System/Library/PrivateFrameworks/CoreAudioOrchestration.framework/Versions/A/CoreAudioOrchestration -0x0000000199ab3000 /System/Library/Frameworks/MediaToolbox.framework/Versions/A/MediaToolbox -0x00000001a07fc000 /System/Library/PrivateFrameworks/CoreAVCHD.framework/Versions/A/CoreAVCHD -0x000000019f720000 /System/Library/Frameworks/MediaAccessibility.framework/Versions/A/MediaAccessibility -0x00000001a07f8000 /System/Library/PrivateFrameworks/Mangrove.framework/Versions/A/Mangrove -0x000000023e290000 /System/Library/PrivateFrameworks/CMPhoto.framework/Versions/A/CMPhoto -0x00000001a0fc5000 /System/Library/Frameworks/CoreTelephony.framework/Versions/A/CoreTelephony -0x00000001a07eb000 /System/Library/PrivateFrameworks/CoreAUC.framework/Versions/A/CoreAUC -0x000000023b3f6000 /System/Library/PrivateFrameworks/AppleJPEGXL.framework/Versions/A/AppleJPEGXL -0x000000019b4e8000 /usr/lib/libTelephonyUtilDynamic.dylib -0x00000001dd93f000 /System/Library/Frameworks/CryptoKit.framework/Versions/A/CryptoKit -0x00000001a40fc000 /System/Library/PrivateFrameworks/CryptoKitCBridging.framework/Versions/A/CryptoKitCBridging -0x00000001a1609000 /System/Library/Frameworks/CryptoTokenKit.framework/Versions/A/CryptoTokenKit -0x0000000247179000 /System/Library/PrivateFrameworks/Dendrite.framework/Versions/A/Dendrite -0x00000001b440c000 /System/Library/Frameworks/NaturalLanguage.framework/Versions/A/NaturalLanguage -0x0000000252901000 /System/Library/PrivateFrameworks/GenerativeModels.framework/Versions/A/GenerativeModels -0x00000001e2d49000 /usr/lib/swift/libswiftNaturalLanguage.dylib -0x000000023bf6d000 /System/Library/PrivateFrameworks/AppleMobileFileIntegrity.framework/Versions/A/AppleMobileFileIntegrity -0x000000028ad01000 /usr/lib/libTLE.dylib -0x00000001b480d000 /usr/lib/libmis.dylib -0x00000001ec491000 /System/Library/PrivateFrameworks/ConfigProfileHelper.framework/Versions/A/ConfigProfileHelper -0x00000001a428b000 /System/Library/PrivateFrameworks/Espresso.framework/Versions/A/Espresso -0x0000000191e20000 /System/Library/Frameworks/CoreML.framework/Versions/A/CoreML -0x00000001e0bf5000 /usr/lib/libedit.3.dylib -0x0000000229f3c000 /System/Library/PrivateFrameworks/ANECompiler.framework/Versions/A/ANECompiler -0x00000001a6361000 /System/Library/PrivateFrameworks/AppleNeuralEngine.framework/Versions/A/AppleNeuralEngine -0x000000025b4a4000 /System/Library/PrivateFrameworks/MIL.framework/Versions/A/MIL -0x0000000230ec4000 /System/Library/Frameworks/MetalPerformanceShadersGraph.framework/Versions/A/MetalPerformanceShadersGraph -0x000000025bc13000 /System/Library/PrivateFrameworks/MLCompilerServices.framework/Versions/A/MLCompilerServices -0x00000001a50dd000 /System/Library/PrivateFrameworks/ANEServices.framework/Versions/A/ANEServices -0x00000001b9ad0000 /usr/lib/libncurses.5.4.dylib -0x000000018b2ce000 /usr/lib/libsandbox.1.dylib -0x0000000198601000 /usr/lib/libMatch.1.dylib -0x00000002654f9000 /System/Library/PrivateFrameworks/ODIE.framework/Versions/A/ODIE -0x000000025e8a0000 /System/Library/PrivateFrameworks/MLModelAsset.framework/Versions/A/MLModelAsset -0x000000025bbb9000 /System/Library/PrivateFrameworks/MLCompilerRuntime.framework/Versions/A/MLCompilerRuntime -0x0000000196255000 /System/Library/Frameworks/MLCompute.framework/Versions/A/MLCompute -0x000000025bb3b000 /System/Library/PrivateFrameworks/MLAssetIO.framework/Versions/A/MLAssetIO -0x000000028c3f1000 /usr/lib/swift/libswiftMLCompute.dylib -0x00000001a11e0000 /System/Library/PrivateFrameworks/AVFCore.framework/Versions/A/AVFCore -0x00000001aae1b000 /System/Library/PrivateFrameworks/AVFCapture.framework/Versions/A/AVFCapture -0x000000023e087000 /System/Library/PrivateFrameworks/CMImaging.framework/Versions/A/CMImaging -0x00000001ab05d000 /System/Library/PrivateFrameworks/Quagga.framework/Versions/A/Quagga -0x00000001ab18e000 /System/Library/PrivateFrameworks/CMCapture.framework/Versions/A/CMCapture -0x000000019b071000 /System/Library/Frameworks/CoreMediaIO.framework/Versions/A/CoreMediaIO -0x000000023dfc2000 /System/Library/PrivateFrameworks/CMCaptureDevice.framework/Versions/A/CMCaptureDevice -0x0000000198a3d000 /System/Library/PrivateFrameworks/CoreBrightness.framework/Versions/A/CoreBrightness -0x000000023f14b000 /System/Library/PrivateFrameworks/CinematicFraming.framework/Versions/A/CinematicFraming -0x00000002619dd000 /System/Library/PrivateFrameworks/ModelManagerServices.framework/Versions/A/ModelManagerServices -0x00000001cf375000 /System/Library/PrivateFrameworks/CPMS.framework/Versions/A/CPMS -0x0000000279581000 /System/Library/PrivateFrameworks/SystemStatus.framework/Versions/A/SystemStatus -0x00000001b323c000 /System/Library/Frameworks/CoreMotion.framework/Versions/A/CoreMotion -0x00000001c3854000 /System/Library/PrivateFrameworks/TimeSync.framework/Versions/A/TimeSync -0x0000000247b41000 /System/Library/PrivateFrameworks/DistributedSensing.framework/Versions/A/DistributedSensing -0x00000001bec33000 /System/Library/PrivateFrameworks/MobileBluetooth.framework/Versions/A/MobileBluetooth -0x00000001c5518000 /System/Library/PrivateFrameworks/IOKitten.framework/Versions/A/IOKitten -0x000000023b270000 /System/Library/PrivateFrameworks/AppleIntelligenceReporting.framework/Versions/A/AppleIntelligenceReporting -0x0000000195b9c000 /System/Library/PrivateFrameworks/CoreEmoji.framework/Versions/A/CoreEmoji -0x0000000188425000 /usr/lib/libCRFSuite.dylib -0x0000000189706000 /System/Library/PrivateFrameworks/LanguageModeling.framework/Versions/A/LanguageModeling -0x00000001948ae000 /System/Library/PrivateFrameworks/CoreNLP.framework/Versions/A/CoreNLP -0x000000018e683000 /System/Library/PrivateFrameworks/Montreal.framework/Versions/A/Montreal -0x0000000196d10000 /usr/lib/libcmph.dylib -0x0000000195f33000 /usr/lib/libmecab.dylib -0x0000000196e81000 /usr/lib/libThaiTokenizer.dylib -0x00000002529e3000 /System/Library/PrivateFrameworks/GenerativeModelsFoundation.framework/Versions/A/GenerativeModelsFoundation -0x000000027c356000 /System/Library/PrivateFrameworks/TokenGeneration.framework/Versions/A/TokenGeneration -0x00000002527b7000 /System/Library/PrivateFrameworks/GenerativeFunctions.framework/Versions/A/GenerativeFunctions -0x0000000252805000 /System/Library/PrivateFrameworks/GenerativeFunctionsFoundation.framework/Versions/A/GenerativeFunctionsFoundation -0x000000026169e000 /System/Library/PrivateFrameworks/ModelCatalog.framework/Versions/A/ModelCatalog -0x000000026e8a2000 /System/Library/PrivateFrameworks/SensitiveContentAnalysisML.framework/Versions/A/SensitiveContentAnalysisML -0x000000025289b000 /System/Library/PrivateFrameworks/GenerativeFunctionsInstrumentation.framework/Versions/A/GenerativeFunctionsInstrumentation -0x000000026b6cb000 /System/Library/PrivateFrameworks/PromptKit.framework/Versions/A/PromptKit -0x000000026b0e1000 /System/Library/PrivateFrameworks/ProactiveDaemonSupport.framework/Versions/A/ProactiveDaemonSupport -0x000000027c58e000 /System/Library/PrivateFrameworks/TokenGenerationCore.framework/Versions/A/TokenGenerationCore -0x00000001b52db000 /System/Library/PrivateFrameworks/Trial.framework/Versions/A/Trial -0x00000001b525c000 /System/Library/PrivateFrameworks/TrialProto.framework/Versions/A/TrialProto -0x000000023ae9a000 /System/Library/PrivateFrameworks/AppleFlatBuffers.framework/Versions/A/AppleFlatBuffers -0x000000026eb72000 /System/Library/PrivateFrameworks/SentencePieceInternal.framework/Versions/A/SentencePieceInternal -0x000000019f77a000 /System/Library/Frameworks/Vision.framework/Versions/A/Vision -0x0000000246298000 /System/Library/PrivateFrameworks/CoreSceneUnderstanding.framework/Versions/A/CoreSceneUnderstanding -0x00000002811df000 /System/Library/PrivateFrameworks/VisionCore.framework/Versions/A/VisionCore -0x00000001999f0000 /System/Library/PrivateFrameworks/DataDetectorsCore.framework/Versions/A/DataDetectorsCore -0x00000001bdbc1000 /System/Library/Frameworks/Vision.framework/libfaceCore.dylib -0x00000001be6db000 /System/Library/PrivateFrameworks/Futhark.framework/Versions/A/Futhark -0x00000001c2629000 /System/Library/PrivateFrameworks/InertiaCam.framework/Versions/A/InertiaCam -0x00000001be468000 /System/Library/PrivateFrameworks/TextRecognition.framework/Versions/A/TextRecognition -0x000000022eadd000 /System/Library/Frameworks/DataDetection.framework/Versions/A/DataDetection -0x00000001b8674000 /System/Library/PrivateFrameworks/TextInput.framework/Versions/A/TextInput -0x000000019849e000 /System/Library/PrivateFrameworks/CVNLP.framework/Versions/A/CVNLP -0x00000001dad09000 /System/Library/PrivateFrameworks/HIDDisplay.framework/Versions/A/HIDDisplay -0x000000019b255000 /usr/lib/libcups.2.dylib -0x000000019b2ec000 /System/Library/Frameworks/Kerberos.framework/Versions/A/Kerberos -0x000000019af5c000 /usr/lib/libresolv.9.dylib -0x0000000198958000 /System/Library/PrivateFrameworks/Heimdal.framework/Versions/A/Heimdal -0x00000001a4100000 /System/Library/Frameworks/Kerberos.framework/Versions/A/Libraries/libHeimdalProxy.dylib -0x000000019b350000 /System/Library/PrivateFrameworks/CommonAuth.framework/Versions/A/CommonAuth -0x00000001ad40e000 /System/Library/PrivateFrameworks/AXCoreUtilities.framework/Versions/A/AXCoreUtilities -0x00000001bda0a000 /System/Library/PrivateFrameworks/AttributeGraph.framework/Versions/A/AttributeGraph -0x000000028a801000 /usr/lib/libAXSafeCategoryBundle.dylib -0x0000000235252000 /System/Library/Frameworks/TabularData.framework/Versions/A/TabularData -0x000000023c11d000 /System/Library/PrivateFrameworks/ArgumentParserInternal.framework/Versions/A/ArgumentParserInternal -0x0000000195a5e000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib -0x0000000197053000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libLAPACK.dylib -0x0000000195f95000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libLinearAlgebra.dylib -0x0000000196ec7000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libSparseBLAS.dylib -0x000000019704e000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libQuadrature.dylib -0x00000001949d5000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBNNS.dylib -0x0000000188221000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libSparse.dylib -0x000000022e5c2000 /System/Library/Frameworks/CoreTransferable.framework/Versions/A/CoreTransferable -0x000000019b2b4000 /System/Library/PrivateFrameworks/NetAuth.framework/Versions/A/NetAuth -0x00000001918b4000 /System/Library/PrivateFrameworks/login.framework/Versions/A/Frameworks/loginsupport.framework/Versions/A/loginsupport -0x000000018ca51000 /System/Library/PrivateFrameworks/UIFoundation.framework/Versions/A/UIFoundation -0x0000000195efd000 /usr/lib/libCheckFix.dylib -0x0000000190a3b000 /System/Library/PrivateFrameworks/MetadataUtilities.framework/Versions/A/MetadataUtilities -0x00000002569c7000 /System/Library/PrivateFrameworks/InstalledContentLibrary.framework/Versions/A/InstalledContentLibrary -0x000000018b192000 /System/Library/PrivateFrameworks/CoreServicesStore.framework/Versions/A/CoreServicesStore -0x00000001916ff000 /usr/lib/libapp_launch_measurement.dylib -0x00000001c8192000 /System/Library/PrivateFrameworks/MobileSystemServices.framework/Versions/A/MobileSystemServices -0x0000000198323000 /usr/lib/libxslt.1.dylib -0x0000000195ebc000 /System/Library/PrivateFrameworks/BackgroundTaskManagement.framework/Versions/A/BackgroundTaskManagement -0x00000001a3792000 /usr/lib/libcurl.4.dylib -0x000000028b517000 /usr/lib/libcrypto.46.dylib -0x000000028c09a000 /usr/lib/libssl.48.dylib -0x00000001a346c000 /System/Library/Frameworks/LDAP.framework/Versions/A/LDAP -0x00000001a34a8000 /System/Library/PrivateFrameworks/TrustEvaluationAgent.framework/Versions/A/TrustEvaluationAgent -0x000000019af79000 /usr/lib/libsasl2.2.dylib -0x00000001a6710000 /System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa -0x000000018b32d000 /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit -0x000000023ffce000 /System/Library/PrivateFrameworks/CollectionViewCore.framework/Versions/A/CollectionViewCore -0x0000000193f6f000 /System/Library/PrivateFrameworks/XCTTargetBootstrap.framework/Versions/A/XCTTargetBootstrap -0x0000000199a42000 /System/Library/PrivateFrameworks/UserActivity.framework/Versions/A/UserActivity -0x0000000249ab0000 /System/Library/PrivateFrameworks/FrontBoard.framework/Versions/A/FrontBoard -0x000000027df9f000 /System/Library/PrivateFrameworks/UIIntelligenceSupport.framework/Versions/A/UIIntelligenceSupport -0x00000002342db000 /System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore -0x0000000284b4b000 /System/Library/PrivateFrameworks/WritingTools.framework/Versions/A/WritingTools -0x0000000283942000 /System/Library/PrivateFrameworks/WindowManagement.framework/Versions/A/WindowManagement -0x00000002497e0000 /System/Library/PrivateFrameworks/FocusEngine.framework/Versions/A/FocusEngine -0x00000002471e9000 /System/Library/PrivateFrameworks/DesignLibrary.framework/Versions/A/DesignLibrary -0x0000000193f5a000 /System/Library/PrivateFrameworks/DFRFoundation.framework/Versions/A/DFRFoundation -0x000000027eeec000 /System/Library/PrivateFrameworks/UpdateCycle.framework/Versions/A/UpdateCycle -0x0000000193c5e000 /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox -0x000000019f040000 /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/SpeechRecognition.framework/Versions/A/SpeechRecognition -0x0000000191686000 /System/Library/PrivateFrameworks/PerformanceAnalysis.framework/Versions/A/PerformanceAnalysis -0x000000019f3d0000 /System/Library/Frameworks/Accessibility.framework/Versions/A/Accessibility -0x0000000235238000 /System/Library/Frameworks/Symbols.framework/Versions/A/Symbols -0x0000000252dac000 /System/Library/PrivateFrameworks/Gestures.framework/Versions/A/Gestures -0x000000028c4c2000 /usr/lib/swift/libswiftSpatial.dylib -0x00000001b164e000 /usr/lib/swift/libswiftCoreGraphics.dylib -0x000000019fe14000 /usr/lib/swift/libswiftFoundation.dylib -0x00000001ebe32000 /usr/lib/swift/libswiftSwiftOnoneSupport.dylib -0x000000028c748000 /usr/lib/swift/libswiftsys_time.dylib -0x00000001d699f000 /System/Library/PrivateFrameworks/CoreMaterial.framework/Versions/A/CoreMaterial -0x000000028acfe000 /usr/lib/libSpatial.dylib -0x000000028a71e000 /System/Library/SubFrameworks/UIUtilities.framework/Versions/A/UIUtilities -0x0000000102004000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/server/libjvm.dylib -0x0000000100db8000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjimage.dylib -0x0000000100e14000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjdwp.dylib -0x0000000100e5c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjava.dylib -0x0000000100de8000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libinstrument.dylib -0x0000000100ed0000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -0x000000028a7f3000 /usr/lib/i18n/libiconv_std.dylib -0x000000028a7e9000 /usr/lib/i18n/libUTF8.dylib -0x000000028a7f8000 /usr/lib/i18n/libmapper_none.dylib -0x0000000100f5c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libdt_socket.dylib -0x0000000101058000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnio.dylib -0x000000010109c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnet.dylib -0x0000000101038000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement.dylib -0x0000000101078000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement_ext.dylib -0x00000001010c0000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libextnet.dylib -0x0000000101200000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libverify.dylib - - -VM Arguments: -jvm_args: -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:50259,suspend=y,server=n -javaagent:/Users/liangxin/Library/Caches/JetBrains/IntelliJIdea2026.1/captureAgent/debugger-agent.jar=file:///var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/capture10140205674518191061.props -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dkotlinx.coroutines.debug.enable.creation.stack.trace=false -Ddebugger.agent.enable.coroutines=true -Dkotlinx.coroutines.debug.enable.flows.stack.trace=true -Dkotlinx.coroutines.debug.enable.mutable.state.flows.stack.trace=true -Ddebugger.async.stack.trace.for.all.threads=true -Dfile.encoding=UTF-8 -java_command: org.springblade.desk.DeskApplication -java_class_path (initial): /Users/liangxin/Project/JAVA/tms-erp-api/blade-service/blade-desk/target/classes:/Users/liangxin/.m2/repository/org/springblade/blade-core-boot/4.10.0.BASE-SNAPSHOT/blade-core-boot-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-context/4.10.0.BASE-SNAPSHOT/blade-core-context-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-db/4.10.0.BASE-SNAPSHOT/blade-core-db-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/3.5.16/spring-boot-starter-jdbc-3.5.16.jar:/Users/liangxin/.m2/repository/com/zaxxer/HikariCP/6.3.3/HikariCP-6.3.3.jar:/Users/liangxin/.m2/repository/com/baomidou/mybatis-plus-spring-boot3-starter/3.5.16/mybatis-plus-spring-boot3-starter-3.5.16.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/3.5.16/spring-boot-autoconfigure-3.5.16.jar:/Users/liangxin/.m2/repository/com/alibaba/druid-spring-boot-3-starter/1.2.28/druid-spring-boot-3-starter-1.2.28.jar:/Users/liangxin/.m2/repository/com/mysql/mysql-connector-j/9.4.0/mysql-connector-j-9.4.0.jar:/Users/liangxin/.m2/repository/com/google/protobuf/protobuf-java/4.31.1/protobuf-java-4.31.1.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-secure/4.10.0.BASE-SNAPSHOT/blade-core-secure-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-cloud/4.10.0.BASE-SNAPSHOT/blade-core-cloud-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/de/codecentric/spring-boot-admin-starter-client/3.5.9/spring-boot-admin-starter-client-3.5.9.jar:/Users/liangxin/.m2/repository/de/codecentric/spring-boot-admin-client/3.5.9/spring-boot-admin-client-3.5.9.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-starter-actuator/3.5.16/spring-boot-starter-actuator-3.5.16.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-actuator-autoconfigure/3.5.16/spring-boot-actuator-aut -Launcher Type: SUN_STANDARD - -[Global flags] - intx CICompilerCount = 4 {product} {ergonomic} - uint ConcGCThreads = 3 {product} {ergonomic} - uint G1ConcRefinementThreads = 10 {product} {ergonomic} - size_t G1HeapRegionSize = 8388608 {product} {ergonomic} - uintx GCDrainStackTargetSize = 64 {product} {ergonomic} - size_t InitialHeapSize = 603979776 {product} {ergonomic} - bool ManagementServer = true {product} {command line} - size_t MarkStackSize = 4194304 {product} {ergonomic} - size_t MaxHeapSize = 9663676416 {product} {ergonomic} - size_t MaxNewSize = 5796528128 {product} {ergonomic} - size_t MinHeapDeltaBytes = 8388608 {product} {ergonomic} - size_t MinHeapSize = 8388608 {product} {ergonomic} - uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} - bool ProfileInterpreter = false {pd product} {command line} - uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} - size_t SoftMaxHeapSize = 9663676416 {manageable} {ergonomic} - intx TieredStopAtLevel = 1 {product} {command line} - bool UseCompressedClassPointers = true {product lp64_product} {ergonomic} - bool UseCompressedOops = true {product lp64_product} {ergonomic} - bool UseG1GC = true {product} {ergonomic} - bool UseNUMA = false {product} {ergonomic} - bool UseNUMAInterleaving = false {product} {ergonomic} - -Logging: -Log output configuration: - #0: stdout all=warning uptime,level,tags - #1: stderr all=off uptime,level,tags - -Environment Variables: -JAVA_HOME=/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home -PATH=/Users/liangxin/ai-infra/.venv/bin:/Users/liangxin/.nacos/bin:/Applications/Docker.app/Contents/Resources/bin:/Users/liangxin/Library/pnpm:/opt/homebrew/opt/ruby@3.2/bin:/opt/homebrew/opt/openssl@3/bin:/opt/miniconda3/bin:/opt/miniconda3/condabin:/usr/local/sbin:/usr/local/bin:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Users/liangxin/fvm/default/bin:/Applications/MAMP/bin/php/php8.1.13/bin:/opt/homebrew/opt/ruby@3.2/bin:/Users/liangxin/.nvm/versions/node/v20.18.3/bin:/Applications/apache-tomcat-9.0.78:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Users/liangxin/fvm/default/bin:/opt/homebrew/opt/libpng/bin:/Applications/pngquant:/Users/liangxin/AndroidSDK/platform-tools:/Users/liangxin/Library/Android/sdk/platform-tools:/Users/liangxin/Library/Andriod/sdk/cmdline-tools/latest/bin:/Users/liangxin/Library/Andriod/sdk:/Applications/apache-maven-3.8.1/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/local/bin:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Library/Frameworks/Python.framework/Versions/3.9/bin:/Users/liangxin/.local/bin:/Users/liangxin/fvm/default/bin:/Applications/MAMP/bin/php/php8.1.13/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/Library/Apple/usr/bin:/usr/local/share/dotnet:~/.dotnet/tools:/Library/Frameworks/Mono.framework/Versions/Current/Commands:/Users/liangxin/.cargo/bin:true:/Applications/极空间.app/Contents/Resources/app.asar.unpacked/bin/platform-tools -SHELL=/bin/zsh -LANG=C.UTF-8 -TMPDIR=/var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/ - -Active Locale: -LC_ALL=C.UTF-8 -LC_COLLATE=C.UTF-8 -LC_CTYPE=C.UTF-8 -LC_MESSAGES=C.UTF-8 -LC_MONETARY=C.UTF-8 -LC_NUMERIC=C.UTF-8 -LC_TIME=C.UTF-8 - -Signal Handlers: - SIGSEGV: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGBUS: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGFPE: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGPIPE: javaSignalHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGXFSZ: javaSignalHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGILL: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGUSR2: SR_handler in libjvm.dylib, mask=00000000000000000000000000000000, flags=SA_RESTART|SA_SIGINFO, blocked - SIGHUP: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGINT: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGTERM: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGQUIT: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGTRAP: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - - ---------------- S Y S T E M --------------- - -OS: -uname: Darwin 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:16:36 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6030 arm64 -OS uptime: 2 days 23:37 hours -rlimit (soft/hard): STACK 8176k/65520k , CORE 0k/infinity , NPROC 6000/9000 , NOFILE 10240/infinity , AS infinity/infinity , CPU infinity/infinity , DATA infinity/infinity , FSIZE infinity/infinity , MEMLOCK infinity/infinity , RSS infinity/infinity -load average: 19.31 16.17 13.60 - -CPU: total 12 (initial active 12) 0x61:0x0:0x5f4dea93:0, fp, simd, crc, lse -machdep.cpu.brand_string:Apple M3 Pro -hw.cachelinesize:128 -hw.l1icachesize:131072 -hw.l1dcachesize:65536 -hw.l2cachesize:4194304 - -Memory: 16k page, physical 37748736k(166608k free), swap 16777216k(965120k free) - -vm_info: OpenJDK 64-Bit Server VM (17.0.8+7-LTS) for bsd-aarch64 JRE (17.0.8+7-LTS) (Zulu17.44+15-CA), built on Jul 5 2023 00:50:04 by "zulu_re" with clang Apple LLVM 12.0.0 (clang-1200.0.32.28) - -END. diff --git a/hs_err_pid22822.log b/hs_err_pid22822.log deleted file mode 100644 index 9cc29df9..00000000 --- a/hs_err_pid22822.log +++ /dev/null @@ -1,157 +0,0 @@ -# -# A fatal error has been detected by the Java Runtime Environment: -# -# SIGBUS (0xa) at pc=0x0000000104d364c0, pid=22822, tid=5379 -# -# JRE version: OpenJDK Runtime Environment Zulu17.44+15-CA (17.0.8+7) (build 17.0.8+7-LTS) -# Java VM: OpenJDK 64-Bit Server VM Zulu17.44+15-CA (17.0.8+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64) -# Problematic frame: -# C [libzip.dylib+0x64c0] newEntry+0x68 -# -# No core dump will be written. Core dumps have been disabled. To enable core dumping, try "ulimit -c unlimited" before starting Java again -# -# If you would like to submit a bug report, please visit: -# http://www.azul.com/support/ -# The crash happened outside the Java Virtual Machine in native code. -# See problematic frame for where to report the bug. -# - ---------------- S U M M A R Y ------------ - -Command Line: -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:54448,suspend=y,server=n -javaagent:/Users/liangxin/Library/Caches/JetBrains/IntelliJIdea2026.1/captureAgent/debugger-agent.jar=file:///var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/capture14073385024433055703.props -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dkotlinx.coroutines.debug.enable.creation.stack.trace=false -Ddebugger.agent.enable.coroutines=true -Dkotlinx.coroutines.debug.enable.flows.stack.trace=true -Dkotlinx.coroutines.debug.enable.mutable.state.flows.stack.trace=true -Ddebugger.async.stack.trace.for.all.threads=true -Dfile.encoding=UTF-8 org.springblade.admin.AdminApplication - -Host: "Mac15,6" arm64, 12 cores, 36G, Darwin 25.6.0, macOS 26.6.2 (25G83) -Time: Mon Sep 14 22:15:03 2026 CST elapsed time: 5.909458 seconds (0d 0h 0m 5s) - ---------------- T H R E A D --------------- - -Current thread (0x000000013601ac00): JavaThread "main" [_thread_in_native, id=5379, stack(0x000000016b250000,0x000000016b453000)] - -Stack: [0x000000016b250000,0x000000016b453000], sp=0x000000016b450260, free space=2048k -Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) -C [libzip.dylib+0x64c0] newEntry+0x68 -C [libzip.dylib+0x6390] ZIP_GetEntry2+0x14c -C [libzip.dylib+0x6d78] ZIP_FindEntry+0x3c -V [libjvm.dylib+0x25a408] ClassPathZipEntry::open_entry(JavaThread*, char const*, int*, bool)+0xb4 -V [libjvm.dylib+0x25a53c] ClassPathZipEntry::open_stream(JavaThread*, char const*)+0x20 -V [libjvm.dylib+0x25d918] ClassLoader::load_class(Symbol*, bool, JavaThread*)+0x150 -V [libjvm.dylib+0x981d60] SystemDictionary::load_instance_class_impl(Symbol*, Handle, JavaThread*)+0x2d0 -V [libjvm.dylib+0x98063c] SystemDictionary::load_instance_class(unsigned int, Symbol*, Handle, JavaThread*)+0x30 -V [libjvm.dylib+0x97fd48] SystemDictionary::resolve_instance_class_or_null(Symbol*, Handle, Handle, JavaThread*)+0x4dc -V [libjvm.dylib+0x97f334] SystemDictionary::resolve_or_fail(Symbol*, Handle, Handle, bool, JavaThread*)+0x80 -V [libjvm.dylib+0x2beb54] ConstantPool::klass_at_impl(constantPoolHandle const&, int, JavaThread*)+0x1e0 -V [libjvm.dylib+0x46d6f0] InterpreterRuntime::_new(JavaThread*, ConstantPool*, int)+0x94 -j com.intellij.rt.debugger.agent.CaptureStorage.createCapturedStack(Ljava/lang/Throwable;Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;)Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;+14 -j com.intellij.rt.debugger.agent.CaptureStorage.access$500(Ljava/lang/Throwable;Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;)Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$3.run()V+75 -j com.intellij.rt.debugger.agent.OverheadDetector$PerThread.runIfNoOverhead(Ljava/lang/Runnable;)Z+62 -j com.intellij.rt.debugger.agent.CaptureStorage.runWithOverheadTrackingAndWithoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$ThreadLocalContext;Ljava/lang/Runnable;)Z+15 -j com.intellij.rt.debugger.agent.CaptureStorage.capture(Ljava/lang/Object;)V+26 -j java.util.concurrent.FutureTask.(Ljava/util/concurrent/Callable;)V+5 java.base@17.0.8 -j org.springframework.cglib.core.internal.LoadingCache.createEntry(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;+27 -j org.springframework.cglib.core.internal.LoadingCache.get(Ljava/lang/Object;)Ljava/lang/Object;+39 -j org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.get(Lorg/springframework/cglib/core/AbstractClassGenerator;Z)Ljava/lang/Object;+11 -j org.springframework.cglib.core.AbstractClassGenerator.create(Ljava/lang/Object;)Ljava/lang/Object;+115 -j org.springframework.cglib.reflect.FastClass$Generator.create()Lorg/springframework/cglib/reflect/FastClass;+19 -j org.springframework.cglib.proxy.MethodProxy.helper(Lorg/springframework/cglib/proxy/MethodProxy$CreateInfo;Ljava/lang/Class;)Lorg/springframework/cglib/reflect/FastClass;+54 -j org.springframework.cglib.proxy.MethodProxy.init()V+40 -j org.springframework.cglib.proxy.MethodProxy.create(Ljava/lang/Class;Ljava/lang/Class;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/springframework/cglib/proxy/MethodProxy;+80 -j org.springframework.cloud.loadbalancer.config.BlockingLoadBalancerClientAutoConfiguration$BlockingLoadBalancerRetryConfig$$SpringCGLIB$$0.CGLIB$STATICHOOK1()V+112 -j org.springframework.cloud.loadbalancer.config.BlockingLoadBalancerClientAutoConfiguration$BlockingLoadBalancerRetryConfig$$SpringCGLIB$$0.()V+3 -v ~StubRoutines::call_stub -V [libjvm.dylib+0x4781f8] JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, JavaThread*)+0x394 -V [libjvm.dylib+0x457c84] InstanceKlass::call_class_initializer(JavaThread*)+0x1e8 -V [libjvm.dylib+0x456f48] InstanceKlass::initialize_impl(JavaThread*)+0x65c -V [libjvm.dylib+0x51e020] JVM_FindClassFromCaller+0x340 -C [libjava.dylib+0x3a6c] Java_java_lang_Class_forName0+0x138 -J 1317 java.lang.Class.forName0(Ljava/lang/String;ZLjava/lang/ClassLoader;Ljava/lang/Class;)Ljava/lang/Class; java.base@17.0.8 (0 bytes) @ 0x000000010ed5306c [0x000000010ed52fc0+0x00000000000000ac] -V [libjvm.dylib+0x4715a0] InterpreterRuntime::resolve_from_cache(JavaThread*, Bytecodes::Code)+0x98 - -Java frames: (J=compiled Java code, j=interpreted, Vv=VM code) -j com.intellij.rt.debugger.agent.CaptureStorage.createCapturedStack(Ljava/lang/Throwable;Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;)Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;+14 -j com.intellij.rt.debugger.agent.CaptureStorage.access$500(Ljava/lang/Throwable;Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;)Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$3.run()V+75 -j com.intellij.rt.debugger.agent.OverheadDetector$PerThread.runIfNoOverhead(Ljava/lang/Runnable;)Z+62 -j com.intellij.rt.debugger.agent.CaptureStorage.runWithOverheadTrackingAndWithoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$ThreadLocalContext;Ljava/lang/Runnable;)Z+15 -j com.intellij.rt.debugger.agent.CaptureStorage.capture(Ljava/lang/Object;)V+26 -j java.util.concurrent.FutureTask.(Ljava/util/concurrent/Callable;)V+5 java.base@17.0.8 -j org.springframework.cglib.core.internal.LoadingCache.createEntry(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;+27 -j org.springframework.cglib.core.internal.LoadingCache.get(Ljava/lang/Object;)Ljava/lang/Object;+39 -j org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.get(Lorg/springframework/cglib/core/AbstractClassGenerator;Z)Ljava/lang/Object;+11 -j org.springframework.cglib.core.AbstractClassGenerator.create(Ljava/lang/Object;)Ljava/lang/Object;+115 -j org.springframework.cglib.reflect.FastClass$Generator.create()Lorg/springframework/cglib/reflect/FastClass;+19 -j org.springframework.cglib.proxy.MethodProxy.helper(Lorg/springframework/cglib/proxy/MethodProxy$CreateInfo;Ljava/lang/Class;)Lorg/springframework/cglib/reflect/FastClass;+54 -j org.springframework.cglib.proxy.MethodProxy.init()V+40 -j org.springframework.cglib.proxy.MethodProxy.create(Ljava/lang/Class;Ljava/lang/Class;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/springframework/cglib/proxy/MethodProxy;+80 -j org.springframework.cloud.loadbalancer.config.BlockingLoadBalancerClientAutoConfiguration$BlockingLoadBalancerRetryConfig$$SpringCGLIB$$0.CGLIB$STATICHOOK1()V+112 -j org.springframework.cloud.loadbalancer.config.BlockingLoadBalancerClientAutoConfiguration$BlockingLoadBalancerRetryConfig$$SpringCGLIB$$0.()V+3 -v ~StubRoutines::call_stub -J 1317 java.lang.Class.forName0(Ljava/lang/String;ZLjava/lang/ClassLoader;Ljava/lang/Class;)Ljava/lang/Class; java.base@17.0.8 (0 bytes) @ 0x000000010ed5306c [0x000000010ed52fc0+0x00000000000000ac] -J 1432 c1 java.lang.Class.forName(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class; java.base@17.0.8 (47 bytes) @ 0x000000010ed807c8 [0x000000010ed806c0+0x0000000000000108] -j org.springframework.cglib.core.ReflectUtils.defineClass(Ljava/lang/String;[BLjava/lang/ClassLoader;Ljava/security/ProtectionDomain;Ljava/lang/Class;)Ljava/lang/Class;+460 -j org.springframework.cglib.core.AbstractClassGenerator.generate(Lorg/springframework/cglib/core/AbstractClassGenerator$ClassLoaderData;)Ljava/lang/Class;+209 -j org.springframework.cglib.proxy.Enhancer.generate(Lorg/springframework/cglib/core/AbstractClassGenerator$ClassLoaderData;)Ljava/lang/Class;+53 -j org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.lambda$new$1(Lorg/springframework/cglib/core/AbstractClassGenerator;)Ljava/lang/Object;+2 -j org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData$$Lambda$857+0x00000070016c9418.apply(Ljava/lang/Object;)Ljava/lang/Object;+8 -j org.springframework.cglib.core.internal.LoadingCache.lambda$createEntry$1(Ljava/lang/Object;)Ljava/lang/Object;+5 -j org.springframework.cglib.core.internal.LoadingCache$$Lambda$859+0x00000070016c9a98.call()Ljava/lang/Object;+8 -j java.util.concurrent.FutureTask.run$$$capture()V+39 java.base@17.0.8 -j java.util.concurrent.FutureTask.run()V+5 java.base@17.0.8 -j org.springframework.cglib.core.internal.LoadingCache.createEntry(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;+56 -j org.springframework.cglib.core.internal.LoadingCache.get(Ljava/lang/Object;)Ljava/lang/Object;+39 -j org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.get(Lorg/springframework/cglib/core/AbstractClassGenerator;Z)Ljava/lang/Object;+11 -j org.springframework.cglib.core.AbstractClassGenerator.create(Ljava/lang/Object;)Ljava/lang/Object;+115 -j org.springframework.cglib.proxy.Enhancer.createHelper()Ljava/lang/Object;+102 -j org.springframework.cglib.proxy.Enhancer.createClass()Ljava/lang/Class;+6 -j org.springframework.context.annotation.ConfigurationClassEnhancer.createClass(Lorg/springframework/cglib/proxy/Enhancer;Z)Ljava/lang/Class;+1 -j org.springframework.context.annotation.ConfigurationClassEnhancer.enhance(Ljava/lang/Class;Ljava/lang/ClassLoader;)Ljava/lang/Class;+134 -j org.springframework.context.annotation.ConfigurationClassPostProcessor.enhanceConfigurationClasses(Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;)V+418 -j org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanFactory(Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;)V+78 -j org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(Ljava/util/Collection;Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;)V+61 -j org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;Ljava/util/List;)V+521 -j org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;)V+5 -j org.springframework.context.support.AbstractApplicationContext.refresh()V+62 -j org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext.refresh()V+1 -j org.springframework.boot.SpringApplication.refresh(Lorg/springframework/context/ConfigurableApplicationContext;)V+1 -j org.springframework.boot.SpringApplication.refreshContext(Lorg/springframework/context/ConfigurableApplicationContext;)V+19 -j org.springframework.boot.SpringApplication.run([Ljava/lang/String;)Lorg/springframework/context/ConfigurableApplicationContext;+113 -j org.springframework.boot.builder.SpringApplicationBuilder.run([Ljava/lang/String;)Lorg/springframework/context/ConfigurableApplicationContext;+38 -j org.springblade.core.launch.BladeApplication.run(Ljava/lang/String;Ljava/lang/Class;[Ljava/lang/String;)Lorg/springframework/context/ConfigurableApplicationContext;+9 -j org.springblade.admin.AdminApplication.main([Ljava/lang/String;)V+5 -v ~StubRoutines::call_stub - -siginfo: si_signo: 10 (SIGBUS), si_code: 1 (BUS_ADRALN), si_addr: 0x0000000104cfe958 - -Register to memory mapping: - - x0=0x0000600001ebc820 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x1=0x0 is NULL - x2=0xfffffffffffffff0 is an unknown value - x3=0x0000600001ebc830 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x4=0x0000600001ebc880 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x5=0x000000009b858ffb is an unknown value - x6=0x000000001ba00000 is an unknown value - x7=0x00000005c0183f80 is an oop: com.intellij.rt.debugger.agent.CaptureStorage$ConcurrentIdentityWeakHashMap$WeakKey -{0x00000005c0183f80} - klass: 'com/intellij/rt/debugger/agent/CaptureStorage$ConcurrentIdentityWeakHashMap$WeakKey' - - ---- fields (total size 4 words): - - private 'referent' 'Ljava/lang/Object;' @12 a 'java/lang/Thread'{0x00000005c0203dc0} (b80407b8) - - volatile 'queue' 'Ljava/lang/ref/ReferenceQueue;' @16 a 'java/lang/ref/ReferenceQueue'{0x00000005c0183ea0} (b80307d4) - - volatile 'next' 'Ljava/lang/ref/Reference;' @20 NULL (0) - - private transient 'discovered' 'Ljava/lang/ref/Reference;' @24 NULL (0) - - private final 'myHash' 'I' @28 897913732 (35851384) - x8=0x0000000104e2693c: getProcessHandle.procHandle+0x971c in /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjava.dylib at 0x0000000104e00000 - x9=0x0000000000128000 is an unknown value -x10=0x0000600001ebc000 points into unknown readable memory: 0x4c2800498af20001 | 01 00 f2 8a 49 00 28 4c -x11=0x0000000000000820 is an unknown value -x12=0x0000000000000050 is an unknown value -x13=0x0000000000000001 is an unknown value -x14=0x00000000ffffff4e is an unknown value -x15=0x00000000000007fb is an unknown value -x16=0x00000001829fd030: __bzero+0 in /usr/lib/system/libsystem_platform.dylib at 0x00000001829fa000 -x17=0x00000001f0a754a8 points into unknown readable memory: 0x00000001829fd030 | 30 d0 9f 82 01 00 00 00 -x18=0x0 is NULL -x19=0x0000600001ebc820 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x20=0x0 is NULL -x21=0x0000600000bf4240 points into unknown readable memory: 0x0000600001af00c0 | c0 00 af 01 00 60 00 00 -x22=0x0000000104cfe93c points into unknown readable memory: 50 4b 01 02 -x23= \ No newline at end of file diff --git a/hs_err_pid25098.log b/hs_err_pid25098.log deleted file mode 100644 index c5bda445..00000000 --- a/hs_err_pid25098.log +++ /dev/null @@ -1,1375 +0,0 @@ -# -# A fatal error has been detected by the Java Runtime Environment: -# -# SIGBUS (0xa) at pc=0x0000000104a124c0, pid=25098, tid=36379 -# -# JRE version: OpenJDK Runtime Environment Zulu17.44+15-CA (17.0.8+7) (build 17.0.8+7-LTS) -# Java VM: OpenJDK 64-Bit Server VM Zulu17.44+15-CA (17.0.8+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64) -# Problematic frame: -# C [libzip.dylib+0x64c0] newEntry+0x68 -# -# No core dump will be written. Core dumps have been disabled. To enable core dumping, try "ulimit -c unlimited" before starting Java again -# -# If you would like to submit a bug report, please visit: -# http://www.azul.com/support/ -# The crash happened outside the Java Virtual Machine in native code. -# See problematic frame for where to report the bug. -# - ---------------- S U M M A R Y ------------ - -Command Line: -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:57772,suspend=y,server=n -javaagent:/Users/liangxin/Library/Caches/JetBrains/IntelliJIdea2026.1/captureAgent/debugger-agent.jar=file:///var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/capture2936228657643338079.props -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dkotlinx.coroutines.debug.enable.creation.stack.trace=false -Ddebugger.agent.enable.coroutines=true -Dkotlinx.coroutines.debug.enable.flows.stack.trace=true -Dkotlinx.coroutines.debug.enable.mutable.state.flows.stack.trace=true -Ddebugger.async.stack.trace.for.all.threads=true -Dfile.encoding=UTF-8 org.springblade.transport.TransportApplication - -Host: "Mac15,6" arm64, 12 cores, 36G, Darwin 25.6.0, macOS 26.6.2 (25G83) -Time: Mon Sep 14 01:37:22 2026 CST elapsed time: 8.929714 seconds (0d 0h 0m 8s) - ---------------- T H R E A D --------------- - -Current thread (0x00000001178ff000): JavaThread "sentinel-datafile-log-executor-thread-1" daemon [_thread_in_native, id=36379, stack(0x000000031147c000,0x000000031167f000)] - -Stack: [0x000000031147c000,0x000000031167f000], sp=0x000000031167d9d0, free space=2054k -Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) -C [libzip.dylib+0x64c0] newEntry+0x68 -C [libzip.dylib+0x6390] ZIP_GetEntry2+0x14c -C [libzip.dylib+0x6d78] ZIP_FindEntry+0x3c -V [libjvm.dylib+0x25a408] ClassPathZipEntry::open_entry(JavaThread*, char const*, int*, bool)+0xb4 -V [libjvm.dylib+0x25a53c] ClassPathZipEntry::open_stream(JavaThread*, char const*)+0x20 -V [libjvm.dylib+0x25d918] ClassLoader::load_class(Symbol*, bool, JavaThread*)+0x150 -V [libjvm.dylib+0x981d60] SystemDictionary::load_instance_class_impl(Symbol*, Handle, JavaThread*)+0x2d0 -V [libjvm.dylib+0x98063c] SystemDictionary::load_instance_class(unsigned int, Symbol*, Handle, JavaThread*)+0x30 -V [libjvm.dylib+0x97fd48] SystemDictionary::resolve_instance_class_or_null(Symbol*, Handle, Handle, JavaThread*)+0x4dc -V [libjvm.dylib+0x97f334] SystemDictionary::resolve_or_fail(Symbol*, Handle, Handle, bool, JavaThread*)+0x80 -V [libjvm.dylib+0x2beb54] ConstantPool::klass_at_impl(constantPoolHandle const&, int, JavaThread*)+0x1e0 -V [libjvm.dylib+0x46d6f0] InterpreterRuntime::_new(JavaThread*, ConstantPool*, int)+0x94 -j com.intellij.rt.debugger.agent.CaptureStorage$DeepCapturedStack.collectStacks(Ljava/util/List;)Lcom/intellij/rt/debugger/agent/CaptureStorage$StackData;+89 -j com.intellij.rt.debugger.agent.CaptureStorage.getStackTrace(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+30 -j com.intellij.rt.debugger.agent.CaptureStorage.access$1000(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()[Ljava/lang/StackTraceElement;+31 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()Ljava/lang/Object;+1 -j com.intellij.rt.debugger.agent.CaptureStorage.withoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$Callable;)Ljava/lang/Object;+21 -j com.intellij.rt.debugger.agent.CaptureStorage.getAsyncStackTrace(Ljava/lang/Throwable;)[Ljava/lang/StackTraceElement;+19 -j java.lang.Throwable.printStackTrace(Ljava/lang/Throwable$PrintStreamOrWriter;)V+32 java.base@17.0.8 -j java.lang.Throwable.printStackTrace(Ljava/io/PrintWriter;)V+9 java.base@17.0.8 -j com.alibaba.csp.sentinel.log.jul.CspFormatter.format(Ljava/util/logging/LogRecord;)Ljava/lang/String;+103 -j java.util.logging.StreamHandler.publish(Ljava/util/logging/LogRecord;)V+14 java.logging@17.0.8 -j java.util.logging.FileHandler.publish(Ljava/util/logging/LogRecord;)V+11 java.logging@17.0.8 -j com.alibaba.csp.sentinel.log.jul.DateFileLogHandler$LogTask.run()V+8 -j java.util.concurrent.ThreadPoolExecutor.runWorker(Ljava/util/concurrent/ThreadPoolExecutor$Worker;)V+92 java.base@17.0.8 -j java.util.concurrent.ThreadPoolExecutor$Worker.run()V+5 java.base@17.0.8 -j java.lang.Thread.run()V+11 java.base@17.0.8 -v ~StubRoutines::call_stub -V [libjvm.dylib+0x4781f8] JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, JavaThread*)+0x394 -V [libjvm.dylib+0x47720c] JavaCalls::call_virtual(JavaValue*, Klass*, Symbol*, Symbol*, JavaCallArguments*, JavaThread*)+0x11c -V [libjvm.dylib+0x4772d8] JavaCalls::call_virtual(JavaValue*, Handle, Klass*, Symbol*, Symbol*, JavaThread*)+0x64 -V [libjvm.dylib+0x52ebfc] thread_entry(JavaThread*, JavaThread*)+0xc4 -V [libjvm.dylib+0x9b22e8] JavaThread::thread_main_inner()+0x150 -V [libjvm.dylib+0x9b0990] Thread::call_run()+0xe0 -V [libjvm.dylib+0x7d0364] thread_native_entry(Thread*)+0x158 -C [libsystem_pthread.dylib+0x6c58] _pthread_start+0x88 - -Java frames: (J=compiled Java code, j=interpreted, Vv=VM code) -j com.intellij.rt.debugger.agent.CaptureStorage$DeepCapturedStack.collectStacks(Ljava/util/List;)Lcom/intellij/rt/debugger/agent/CaptureStorage$StackData;+89 -j com.intellij.rt.debugger.agent.CaptureStorage.getStackTrace(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+30 -j com.intellij.rt.debugger.agent.CaptureStorage.access$1000(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()[Ljava/lang/StackTraceElement;+31 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()Ljava/lang/Object;+1 -j com.intellij.rt.debugger.agent.CaptureStorage.withoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$Callable;)Ljava/lang/Object;+21 -j com.intellij.rt.debugger.agent.CaptureStorage.getAsyncStackTrace(Ljava/lang/Throwable;)[Ljava/lang/StackTraceElement;+19 -j java.lang.Throwable.printStackTrace(Ljava/lang/Throwable$PrintStreamOrWriter;)V+32 java.base@17.0.8 -j java.lang.Throwable.printStackTrace(Ljava/io/PrintWriter;)V+9 java.base@17.0.8 -j com.alibaba.csp.sentinel.log.jul.CspFormatter.format(Ljava/util/logging/LogRecord;)Ljava/lang/String;+103 -j java.util.logging.StreamHandler.publish(Ljava/util/logging/LogRecord;)V+14 java.logging@17.0.8 -j java.util.logging.FileHandler.publish(Ljava/util/logging/LogRecord;)V+11 java.logging@17.0.8 -j com.alibaba.csp.sentinel.log.jul.DateFileLogHandler$LogTask.run()V+8 -j java.util.concurrent.ThreadPoolExecutor.runWorker(Ljava/util/concurrent/ThreadPoolExecutor$Worker;)V+92 java.base@17.0.8 -j java.util.concurrent.ThreadPoolExecutor$Worker.run()V+5 java.base@17.0.8 -j java.lang.Thread.run()V+11 java.base@17.0.8 -v ~StubRoutines::call_stub - -siginfo: si_signo: 10 (SIGBUS), si_code: 1 (BUS_ADRALN), si_addr: 0x000000010490de7b - -Register to memory mapping: - - x0=0x0000600001c38c80 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x1=0x0 is NULL - x2=0xffffffffffffffd0 is an unknown value - x3=0x0000600001c38c90 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x4=0x0000600001c38d00 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x5=0x000000008cc51ffb is an unknown value - x6=0x000000000ce00000 is an unknown value - x7=0x000000000000000a is an unknown value - x8=0x0000000104a35e5f points into unknown readable memory: 0a - x9=0x0000000000128000 is an unknown value -x10=0x0000600001c38000 points into unknown readable memory: 0x726f0040f29d0001 | 01 00 9d f2 40 00 6f 72 -x11=0x0000000000000c80 is an unknown value -x12=0x0000000000000050 is an unknown value -x13=0x0000000000000001 is an unknown value -x14=0x00000000ffffff5c is an unknown value -x15=0x00000000000007fb is an unknown value -x16=0x0000000185ab9030: __bzero+0 in /usr/lib/system/libsystem_platform.dylib at 0x0000000185ab6000 -x17=0x00000001f3b314a8 points into unknown readable memory: 0x0000000185ab9030 | 30 90 ab 85 01 00 00 00 -x18=0x0 is NULL -x19=0x0000600001c38c80 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x20=0x0 is NULL -x21=0x00006000009a8000 points into unknown readable memory: 0x00006000018b0180 | 80 01 8b 01 00 60 00 00 -x22=0x000000010490de5f points into unknown readable memory: 50 -x23=0x00000000d3a18b02 is an unknown value -x24=0x000000000000002f is an unknown value -x25=0x000000000000003d is an unknown value -x26=0x00000000000000cd is an unknown value -x27=0x0000600001c38ca8 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x28=0x0000000146e163d0 points into unknown readable memory: 0x65746e692f6d6f63 | 63 6f 6d 2f 69 6e 74 65 - - -Registers: - x0=0x0000600001c38c80 x1=0x0000000000000000 x2=0xffffffffffffffd0 x3=0x0000600001c38c90 - x4=0x0000600001c38d00 x5=0x000000008cc51ffb x6=0x000000000ce00000 x7=0x000000000000000a - x8=0x0000000104a35e5f x9=0x0000000000128000 x10=0x0000600001c38000 x11=0x0000000000000c80 -x12=0x0000000000000050 x13=0x0000000000000001 x14=0x00000000ffffff5c x15=0x00000000000007fb -x16=0x0000000185ab9030 x17=0x00000001f3b314a8 x18=0x0000000000000000 x19=0x0000600001c38c80 -x20=0x0000000000000000 x21=0x00006000009a8000 x22=0x000000010490de5f x23=0x00000000d3a18b02 -x24=0x000000000000002f x25=0x000000000000003d x26=0x00000000000000cd x27=0x0000600001c38ca8 -x28=0x0000000146e163d0 fp=0x000000031167da50 lr=0x0000000104a1248c sp=0x000000031167d9d0 -pc=0x0000000104a124c0 cpsr=0x0000000080001000 -Top of Stack: (sp=0x000000031167d9d0) -0x000000031167d9d0: 0000000000000000 0000000000000000 -0x000000031167d9e0: 0000000000000000 0000000000000000 -0x000000031167d9f0: 0000000000000000 0000000000000000 -0x000000031167da00: 0000000146e163d0 0000000159809800 -0x000000031167da10: 00000000000000cd 000000000000003d -0x000000031167da20: 000000000000002f 00000000d3a18b02 -0x000000031167da30: 0000600001c2e120 0000000000000000 -0x000000031167da40: 0000000146e16410 00006000009a8000 -0x000000031167da50: 000000031167dab0 0000000104a12390 -0x000000031167da60: 0000000146e163d0 0000000000000037 -0x000000031167da70: 0000000000000001 0000000146e16410 -0x000000031167da80: 00000001178ff348 0000000146e16410 -0x000000031167da90: 00006000009a8000 0000000146e16410 -0x000000031167daa0: 000000031167dbdc 000000031167daf4 -0x000000031167dab0: 000000031167dae0 0000000104a12d78 -0x000000031167dac0: 000000031167dbdc 00006000032b4150 -0x000000031167dad0: 0000000000000000 00000001178ff000 -0x000000031167dae0: 000000031167dbc0 0000000105d96408 -0x000000031167daf0: 00000001065f4388 0000000000000100 -0x000000031167db00: 000000031167db20 000000010606a5dc -0x000000031167db10: 00000001065f4388 000000031167dba0 -0x000000031167db20: 000000031167db70 0000000105e1c96c -0x000000031167db30: 0000000000000000 0000000000000000 -0x000000031167db40: 0000000000000001 00000001280a0290 -0x000000031167db50: 00000001178ff000 0000000146e167a8 -0x000000031167db60: 00000001066091e2 000000031167dc68 -0x000000031167db70: 000000031167db90 dde20fe0350d0029 -0x000000031167db80: 0000000000000001 0000000146e16410 -0x000000031167db90: 00006000032b4150 00000001280a0290 -0x000000031167dba0: 00000001178ff000 0000000146e167a8 -0x000000031167dbb0: 0000000146e163c0 00006000032b4150 -0x000000031167dbc0: 000000031167dbf0 0000000105d9653c - -Instructions: (pc=0x0000000104a124c0) -0x0000000104a123c0: 6b0c017f 54ffff60 17ffffde d2800016 -0x0000000104a123d0: 72001ebf 54000160 b5000156 f100073f -0x0000000104a123e0: 54fff7cb 8b140328 385ff108 7100bd1f -0x0000000104a123f0: 54fff741 d2800016 14000002 f9004e7f -0x0000000104a12400: f9402a60 94000451 aa1603e0 a9457bfd -0x0000000104a12410: a9444ff4 a94357f6 a9425ff8 a94167fa -0x0000000104a12420: a8c66ffc d65f03c0 6b03003f 540000e1 -0x0000000104a12430: 71000421 540000eb 38401408 38401449 -0x0000000104a12440: 6b09011f 54ffff60 52800000 d65f03c0 -0x0000000104a12450: 52800020 d65f03c0 d10243ff a9036ffc -0x0000000104a12460: a90467fa a9055ff8 a90657f6 a9074ff4 -0x0000000104a12470: a9087bfd 910203fd aa0203f4 aa0103f6 -0x0000000104a12480: aa0003f5 52800900 94000481 aa0003f3 -0x0000000104a12490: b4001320 f900027f aa1303fb f8028f7f -0x0000000104a124a0: f9001a7f 3940c2a8 34000288 f9400ea8 -0x0000000104a124b0: f94006c9 8b090108 f94016a9 cb090116 -0x0000000104a124c0: 79403ad8 39407ada 39407edc 794042c8 -0x0000000104a124d0: f90017e8 b9400ec8 f9000668 b9401ac8 -0x0000000104a124e0: f9000fe8 f9000a68 794016c8 34000488 -0x0000000104a124f0: b94016c8 14000023 f94006d7 34000d54 -0x0000000104a12500: f9401ea8 b4000288 f94022a9 eb17013f -0x0000000104a12510: 5400022c 5283fa4a 8b0a012a eb17015f -0x0000000104a12520: 540001ab 9140092a 8b170108 cb090116 -0x0000000104a12530: 79403ac8 79403ec9 794042cb 8b0802e8 -0x0000000104a12540: 8b090108 8b0b0108 9100b908 eb0a011f -0x0000000104a12550: 54000b4d aa1503e0 aa1703e1 52840002 -0x0000000104a12560: 94000384 aa0003f6 b4000aa0 f9401ea0 -0x0000000104a12570: 94000429 a903deb6 17ffffd2 d2800008 -0x0000000104a12580: aa0803f7 f9000e68 b94012c8 b9002268 -0x0000000104a12590: b842a2c9 f9405ea8 f9000be9 8b090108 -0x0000000104a125a0: cb0803e8 f9001e68 794012c8 b9004268 -0x0000000104a125b0: 91000700 94000436 aa0003f9 f9000260 - - -Stack slot to memory mapping: -stack at sp + 0 slots: 0x0 is NULL -stack at sp + 1 slots: 0x0 is NULL -stack at sp + 2 slots: 0x0 is NULL -stack at sp + 3 slots: 0x0 is NULL -stack at sp + 4 slots: 0x0 is NULL -stack at sp + 5 slots: 0x0 is NULL -stack at sp + 6 slots: 0x0000000146e163d0 points into unknown readable memory: 0x65746e692f6d6f63 | 63 6f 6d 2f 69 6e 74 65 -stack at sp + 7 slots: 0x0000000159809800 points into unknown readable memory: 0xffffffff5bbd78a2 | a2 78 bd 5b ff ff ff ff - - ---------------- P R O C E S S --------------- - -Threads class SMR info: -_java_thread_list=0x0000600003a5aec0, length=73, elements={ -0x0000000127008600, 0x000000012701a800, 0x0000000159809200, 0x0000000117008200, -0x000000011700ba00, 0x000000012701da00, 0x000000012701e000, 0x0000000117014000, -0x0000000105010a00, 0x000000015701ba00, 0x0000000137023800, 0x000000013701c200, -0x0000000105008200, 0x00000001278bcc00, 0x0000000157011e00, 0x0000000127900000, -0x0000000137023e00, 0x0000000117820600, 0x0000000117440400, 0x0000000127b6c800, -0x000000011752ae00, 0x00000001590ed600, 0x000000010542d200, 0x0000000117582c00, -0x00000001473b0800, 0x0000000157442800, 0x0000000157469a00, 0x0000000158186a00, -0x0000000127070600, 0x0000000127c83600, 0x00000001588e8600, 0x0000000117895e00, -0x000000015910e000, 0x00000001176b1800, 0x00000001054d9000, 0x0000000127cff600, -0x00000001176d7800, 0x000000011768a200, 0x00000001176f7c00, 0x0000000159134a00, -0x00000001178c8a00, 0x00000001574f1600, 0x0000000157557000, 0x00000001588c5a00, -0x0000000105539e00, 0x0000000117724c00, 0x000000015890a200, 0x00000001270c2a00, -0x0000000158924a00, 0x00000001598d1600, 0x0000000127d62e00, 0x00000001581bee00, -0x0000000117718c00, 0x0000000147538000, 0x0000000127da4a00, 0x0000000137555e00, -0x0000000147537a00, 0x0000000117731c00, 0x00000001374c1800, 0x0000000159930000, -0x00000001178ff000, 0x00000001055f5200, 0x00000001055e8200, 0x0000000147613600, -0x0000000157615000, 0x000000015762ea00, 0x0000000127e76400, 0x0000000127171400, -0x000000015765fe00, 0x0000000159961c00, 0x00000001300bb200, 0x000000010576e600, -0x000000012717f600 -} - -Java Threads: ( => current thread ) - 0x0000000127008600 JavaThread "main" [_thread_in_native, id=4099, stack(0x000000016b640000,0x000000016b843000)] - 0x000000012701a800 JavaThread "Reference Handler" daemon [_thread_blocked, id=20483, stack(0x0000000175494000,0x0000000175697000)] - 0x0000000159809200 JavaThread "Finalizer" daemon [_thread_blocked, id=20227, stack(0x00000001756a0000,0x00000001758a3000)] - 0x0000000117008200 JavaThread "Signal Dispatcher" daemon [_thread_blocked, id=30467, stack(0x00000001759c4000,0x0000000175bc7000)] - 0x000000011700ba00 JavaThread "Service Thread" daemon [_thread_blocked, id=30211, stack(0x0000000175bd0000,0x0000000175dd3000)] - 0x000000012701da00 JavaThread "Monitor Deflation Thread" daemon [_thread_blocked, id=29955, stack(0x0000000175ddc000,0x0000000175fdf000)] - 0x000000012701e000 JavaThread "C1 CompilerThread0" daemon [_thread_blocked, id=23043, stack(0x0000000175fe8000,0x00000001761eb000)] - 0x0000000117014000 JavaThread "Sweeper thread" daemon [_thread_blocked, id=29187, stack(0x00000001761f4000,0x00000001763f7000)] - 0x0000000105010a00 JavaThread "C1 CompilerThread1" daemon [_thread_blocked, id=23555, stack(0x0000000176400000,0x0000000176603000)] - 0x000000015701ba00 JavaThread "Common-Cleaner" daemon [_thread_blocked, id=23811, stack(0x000000017660c000,0x000000017680f000)] - 0x0000000137023800 JavaThread "JDWP Transport Listener: dt_socket" daemon [_thread_blocked, id=24323, stack(0x0000000176818000,0x0000000176a1b000)] - 0x000000013701c200 JavaThread "JDWP Event Helper Thread" daemon [_thread_blocked, id=28675, stack(0x0000000176a24000,0x0000000176c27000)] - 0x0000000105008200 JavaThread "JDWP Command Reader" daemon [_thread_in_native, id=28419, stack(0x0000000176c30000,0x0000000176e33000)] - 0x00000001278bcc00 JavaThread "IntelliJ Suspend Helper" daemon [_thread_blocked, id=27907, stack(0x0000000176e3c000,0x000000017703f000)] - 0x0000000157011e00 JavaThread "Notification Thread" daemon [_thread_blocked, id=25091, stack(0x0000000177048000,0x000000017724b000)] - 0x0000000127900000 JavaThread "CoarseTimer" daemon [_thread_blocked, id=25347, stack(0x0000000177254000,0x0000000177457000)] - 0x0000000137023e00 JavaThread "C1 CompilerThread2" daemon [_thread_blocked, id=25603, stack(0x0000000177460000,0x0000000177663000)] - 0x0000000117820600 JavaThread "C1 CompilerThread3" daemon [_thread_blocked, id=25859, stack(0x000000017766c000,0x000000017786f000)] - 0x0000000117440400 JavaThread "RMI TCP Accept-0" daemon [_thread_in_native, id=35587, stack(0x0000000310c4c000,0x0000000310e4f000)] - 0x0000000127b6c800 JavaThread "com.alibaba.nacos.client.logging.0" daemon [_thread_blocked, id=35843, stack(0x0000000311064000,0x0000000311267000)] - 0x000000011752ae00 JavaThread "Attach Listener" daemon [_thread_blocked, id=40195, stack(0x0000000311cac000,0x0000000311eaf000)] - 0x00000001590ed600 JavaThread "nacos.publisher-com.alibaba.nacos.common.notify.SlowEvent" daemon [_thread_blocked, id=38915, stack(0x00000003122d0000,0x00000003124d3000)] - 0x000000010542d200 JavaThread "nacos.publisher-com.alibaba.nacos.client.config.impl.ConfigFuzzyWatchNotifyEvent" daemon [_thread_blocked, id=39683, stack(0x00000003124dc000,0x00000003126df000)] - 0x0000000117582c00 JavaThread "nacos.publisher-com.alibaba.nacos.client.config.impl.ConfigFuzzyWatchLoadEvent" daemon [_thread_blocked, id=43523, stack(0x00000003126e8000,0x00000003128eb000)] - 0x00000001473b0800 JavaThread "RMI TCP Connection(idle)" daemon [_thread_blocked, id=65283, stack(0x00000003128f4000,0x0000000312af7000)] - 0x0000000157442800 JavaThread "RMI Scheduler(0)" daemon [_thread_blocked, id=65027, stack(0x0000000312b00000,0x0000000312d03000)] - 0x0000000157469a00 JavaThread "com.alibaba.nacos.client.auth.ram.identify.watcher.0" daemon [_thread_blocked, id=44559, stack(0x0000000312d0c000,0x0000000312f0f000)] - 0x0000000158186a00 JavaThread "com.alibaba.nacos.client.login-executor.0" daemon [_thread_blocked, id=37647, stack(0x0000000311aa0000,0x0000000311ca3000)] - 0x0000000127070600 JavaThread "com.alibaba.nacos.client.listen-executor.0" daemon [_thread_blocked, id=64771, stack(0x0000000312f18000,0x000000031311b000)] - 0x0000000127c83600 JavaThread "com.alibaba.nacos.client.fuzzy-watcher-executor.0" daemon [_thread_blocked, id=64259, stack(0x0000000313124000,0x0000000313327000)] - 0x00000001588e8600 JavaThread "com.alibaba.nacos.client.remote.worker.0" daemon [_thread_blocked, id=63747, stack(0x0000000313330000,0x0000000313533000)] - 0x0000000117895e00 JavaThread "com.alibaba.nacos.client.remote.worker.1" daemon [_thread_blocked, id=45059, stack(0x000000031353c000,0x000000031373f000)] - 0x000000015910e000 JavaThread "grpc-nio-worker-ELG-1-1" daemon [_thread_in_native, id=63247, stack(0x0000000313748000,0x000000031394b000)] - 0x00000001176b1800 JavaThread "grpc-default-executor-0" daemon [_thread_blocked, id=45571, stack(0x0000000313954000,0x0000000313b57000)] - 0x00000001054d9000 JavaThread "nacos-grpc-client-executor-127.0.0.1-0" daemon [_thread_blocked, id=46083, stack(0x0000000313b60000,0x0000000313d63000)] - 0x0000000127cff600 JavaThread "nacos-grpc-client-executor-127.0.0.1-1" daemon [_thread_blocked, id=46339, stack(0x0000000313d6c000,0x0000000313f6f000)] - 0x00000001176d7800 JavaThread "RMI TCP Connection(4)-127.0.0.1" daemon [_thread_in_native, id=46595, stack(0x0000000313f78000,0x000000031417b000)] - 0x000000011768a200 JavaThread "grpc-nio-worker-ELG-1-2" daemon [_thread_in_native, id=61967, stack(0x0000000314184000,0x0000000314387000)] - 0x00000001176f7c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-2" daemon [_thread_blocked, id=46851, stack(0x0000000314390000,0x0000000314593000)] - 0x0000000159134a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-3" daemon [_thread_blocked, id=61187, stack(0x000000031459c000,0x000000031479f000)] - 0x00000001178c8a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-4" daemon [_thread_blocked, id=60675, stack(0x00000003147a8000,0x00000003149ab000)] - 0x00000001574f1600 JavaThread "nacos-grpc-client-executor-127.0.0.1-5" daemon [_thread_blocked, id=60163, stack(0x00000003149b4000,0x0000000314bb7000)] - 0x0000000157557000 JavaThread "nacos-grpc-client-executor-127.0.0.1-6" daemon [_thread_blocked, id=47107, stack(0x0000000314bc0000,0x0000000314dc3000)] - 0x00000001588c5a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-7" daemon [_thread_blocked, id=59651, stack(0x0000000314dcc000,0x0000000314fcf000)] - 0x0000000105539e00 JavaThread "nacos.publisher-com.alibaba.nacos.common.ability.AbstractAbilityControlManager$AbilityUpdateEvent" daemon [_thread_blocked, id=59139, stack(0x0000000314fd8000,0x00000003151db000)] - 0x0000000117724c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-8" daemon [_thread_blocked, id=47619, stack(0x00000003151e4000,0x00000003153e7000)] - 0x000000015890a200 JavaThread "nacos-grpc-client-executor-127.0.0.1-9" daemon [_thread_blocked, id=58371, stack(0x00000003153f0000,0x00000003155f3000)] - 0x00000001270c2a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-10" daemon [_thread_blocked, id=57859, stack(0x00000003155fc000,0x00000003157ff000)] - 0x0000000158924a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-11" daemon [_thread_blocked, id=47875, stack(0x0000000315808000,0x0000000315a0b000)] - 0x00000001598d1600 JavaThread "nacos-grpc-client-executor-127.0.0.1-12" daemon [_thread_blocked, id=48387, stack(0x0000000315a14000,0x0000000315c17000)] - 0x0000000127d62e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-13" daemon [_thread_blocked, id=48899, stack(0x0000000315c20000,0x0000000315e23000)] - 0x00000001581bee00 JavaThread "nacos-grpc-client-executor-127.0.0.1-14" daemon [_thread_blocked, id=57091, stack(0x0000000315e2c000,0x000000031602f000)] - 0x0000000117718c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-15" daemon [_thread_blocked, id=56579, stack(0x0000000316038000,0x000000031623b000)] - 0x0000000147538000 JavaThread "nacos-grpc-client-executor-127.0.0.1-16" daemon [_thread_blocked, id=56067, stack(0x0000000316244000,0x0000000316447000)] - 0x0000000127da4a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-17" daemon [_thread_blocked, id=55555, stack(0x0000000316450000,0x0000000316653000)] - 0x0000000137555e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-18" daemon [_thread_blocked, id=49155, stack(0x000000031665c000,0x000000031685f000)] - 0x0000000147537a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-19" daemon [_thread_blocked, id=55055, stack(0x0000000316868000,0x0000000316a6b000)] - 0x0000000117731c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-20" daemon [_thread_blocked, id=54787, stack(0x0000000316a74000,0x0000000316c77000)] - 0x00000001374c1800 JavaThread "nacos-grpc-client-executor-127.0.0.1-21" daemon [_thread_blocked, id=54275, stack(0x0000000316c80000,0x0000000316e83000)] - 0x0000000159930000 JavaThread "nacos-grpc-client-executor-127.0.0.1-22" daemon [_thread_blocked, id=54019, stack(0x0000000316e8c000,0x000000031708f000)] -=>0x00000001178ff000 JavaThread "sentinel-datafile-log-executor-thread-1" daemon [_thread_in_native, id=36379, stack(0x000000031147c000,0x000000031167f000)] - 0x00000001055f5200 JavaThread "sentinel-command-center-executor-thread-1" daemon [_thread_in_native, id=40979, stack(0x0000000311894000,0x0000000311a97000)] - 0x00000001055e8200 JavaThread "sentinel-heartbeat-send-task-thread-1" daemon [_thread_blocked, id=40743, stack(0x0000000311688000,0x000000031188b000)] - 0x0000000147613600 JavaThread "nacos-grpc-client-executor-127.0.0.1-23" daemon [_thread_blocked, id=36651, stack(0x0000000311270000,0x0000000311473000)] - 0x0000000157615000 JavaThread "nacos-grpc-client-executor-127.0.0.1-24" daemon [_thread_blocked, id=50195, stack(0x0000000317098000,0x000000031729b000)] - 0x000000015762ea00 JavaThread "nacos-grpc-client-executor-127.0.0.1-25" daemon [_thread_blocked, id=50443, stack(0x00000003172a4000,0x00000003174a7000)] - 0x0000000127e76400 JavaThread "nacos-grpc-client-executor-127.0.0.1-26" daemon [_thread_blocked, id=50691, stack(0x00000003174b0000,0x00000003176b3000)] - 0x0000000127171400 JavaThread "nacos-grpc-client-executor-127.0.0.1-27" daemon [_thread_blocked, id=51203, stack(0x00000003176bc000,0x00000003178bf000)] - 0x000000015765fe00 JavaThread "nacos-grpc-client-executor-127.0.0.1-28" daemon [_thread_blocked, id=52995, stack(0x00000003178c8000,0x0000000317acb000)] - 0x0000000159961c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-29" daemon [_thread_blocked, id=52739, stack(0x0000000317ad4000,0x0000000317cd7000)] - 0x00000001300bb200 JavaThread "RMI TCP Connection(3)-127.0.0.1" daemon [_thread_in_native, id=32067, stack(0x0000000317ce0000,0x0000000317ee3000)] - 0x000000010576e600 JavaThread "sentinel-time-tick-thread" daemon [_thread_blocked, id=52099, stack(0x0000000340004000,0x0000000340207000)] - 0x000000012717f600 JavaThread "sentinel-heartbeat-send-task-thread-2" daemon [_thread_blocked, id=87043, stack(0x0000000340210000,0x0000000340413000)] - -Other Threads: - 0x0000000136e072e0 VMThread "VM Thread" [stack: 0x0000000175288000,0x000000017548b000] [id=18947] - 0x0000000104e18360 WatcherThread [stack: 0x0000000310e58000,0x000000031105b000] [id=41731] - 0x0000000126f05a10 GCTaskThread "GC Thread#0" [stack: 0x000000017484c000,0x0000000174a4f000] [id=14595] - 0x0000000104c0b500 GCTaskThread "GC Thread#1" [stack: 0x0000000177878000,0x0000000177a7b000] [id=26115] - 0x0000000156f07940 GCTaskThread "GC Thread#2" [stack: 0x0000000177a84000,0x0000000177c87000] [id=32771] - 0x0000000146e06230 GCTaskThread "GC Thread#3" [stack: 0x0000000177c90000,0x0000000177e93000] [id=43267] - 0x0000000104d05320 GCTaskThread "GC Thread#4" [stack: 0x0000000310004000,0x0000000310207000] [id=43011] - 0x0000000156f07dd0 GCTaskThread "GC Thread#5" [stack: 0x0000000310210000,0x0000000310413000] [id=42755] - 0x0000000104e103b0 GCTaskThread "GC Thread#6" [stack: 0x000000031041c000,0x000000031061f000] [id=34051] - 0x0000000104e10c30 GCTaskThread "GC Thread#7" [stack: 0x0000000310628000,0x000000031082b000] [id=42499] - 0x0000000104e114b0 GCTaskThread "GC Thread#8" [stack: 0x0000000310834000,0x0000000310a37000] [id=42243] - 0x0000000104e11d30 GCTaskThread "GC Thread#9" [stack: 0x0000000310a40000,0x0000000310c43000] [id=35075] - 0x0000000126e04ca0 ConcurrentGCThread "G1 Main Marker" [stack: 0x0000000174a58000,0x0000000174c5b000] [id=14339] - 0x0000000126e05530 ConcurrentGCThread "G1 Conc#0" [stack: 0x0000000174c64000,0x0000000174e67000] [id=13571] - 0x0000000104c2a5c0 ConcurrentGCThread "G1 Conc#1" [stack: 0x0000000311eb8000,0x00000003120bb000] [id=38147] - 0x0000000136e2f540 ConcurrentGCThread "G1 Conc#2" [stack: 0x00000003120c4000,0x00000003122c7000] [id=38659] - 0x0000000157806120 ConcurrentGCThread "G1 Refine#0" [stack: 0x0000000174e70000,0x0000000175073000] [id=21507] - 0x0000000126e05db0 ConcurrentGCThread "G1 Service" [stack: 0x000000017507c000,0x000000017527f000] [id=16899] - -Threads with active compile tasks: - -VM state: not at safepoint (normal execution) - -VM Mutex/Monitor currently owned by a thread: None - -Heap address: 0x00000005c0000000, size: 9216 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 - -CDS archive(s) mapped at: [0x000000f800000000-0x000000f800c14000-0x000000f800c14000), size 12664832, SharedBaseAddress: 0x000000f800000000, ArchiveRelocationMode: 1. -Compressed class space mapped at: 0x000000f801000000-0x000000f841000000, reserved size: 1073741824 -Narrow klass base: 0x000000f800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000 - -GC Precious Log: - CPUs: 12 total, 12 available - Memory: 36864M - Large Page Support: Disabled - NUMA Support: Disabled - Compressed Oops: Enabled (Zero based) - Heap Region Size: 8M - Heap Min Capacity: 8M - Heap Initial Capacity: 576M - Heap Max Capacity: 9G - Pre-touch: Disabled - Parallel Workers: 10 - Concurrent Workers: 3 - Concurrent Refinement Workers: 10 - Periodic GC: Disabled - -Heap: - garbage-first heap total 221184K, used 70377K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 4 young (32768K), 2 survivors (16384K) - Metaspace used 60876K, committed 61312K, reserved 1114112K - class space used 7924K, committed 8128K, reserved 1048576K - -Heap Regions: E=young(eden), S=young(survivor), O=old, HS=humongous(starts), HC=humongous(continues), CS=collection set, F=free, OA=open archive, CA=closed archive, TAMS=top-at-mark-start (previous, next) -| 0|0x00000005c0000000, 0x00000005c0800000, 0x00000005c0800000|100%| O| |TAMS 0x00000005c0800000, 0x00000005c0000000| Untracked -| 1|0x00000005c0800000, 0x00000005c1000000, 0x00000005c1000000|100%| O| |TAMS 0x00000005c1000000, 0x00000005c0800000| Untracked -| 2|0x00000005c1000000, 0x00000005c1782e00, 0x00000005c1800000| 93%| O| |TAMS 0x00000005c1782e00, 0x00000005c1000000| Untracked -| 3|0x00000005c1800000, 0x00000005c2000000, 0x00000005c2000000|100%| O| |TAMS 0x00000005c2000000, 0x00000005c1800000| Untracked -| 4|0x00000005c2000000, 0x00000005c2800000, 0x00000005c2800000|100%| O| |TAMS 0x00000005c2800000, 0x00000005c2000000| Untracked -| 5|0x00000005c2800000, 0x00000005c2b4da00, 0x00000005c3000000| 41%| O| |TAMS 0x00000005c2b4da00, 0x00000005c2800000| Untracked -| 6|0x00000005c3000000, 0x00000005c3000000, 0x00000005c3800000| 0%| F| |TAMS 0x00000005c3000000, 0x00000005c3000000| Untracked -| 7|0x00000005c3800000, 0x00000005c3800000, 0x00000005c4000000| 0%| F| |TAMS 0x00000005c3800000, 0x00000005c3800000| Untracked -| 8|0x00000005c4000000, 0x00000005c4000000, 0x00000005c4800000| 0%| F| |TAMS 0x00000005c4000000, 0x00000005c4000000| Untracked -| 9|0x00000005c4800000, 0x00000005c4800000, 0x00000005c5000000| 0%| F| |TAMS 0x00000005c4800000, 0x00000005c4800000| Untracked -| 10|0x00000005c5000000, 0x00000005c5000000, 0x00000005c5800000| 0%| F| |TAMS 0x00000005c5000000, 0x00000005c5000000| Untracked -| 11|0x00000005c5800000, 0x00000005c5800000, 0x00000005c6000000| 0%| F| |TAMS 0x00000005c5800000, 0x00000005c5800000| Untracked -| 12|0x00000005c6000000, 0x00000005c6000000, 0x00000005c6800000| 0%| F| |TAMS 0x00000005c6000000, 0x00000005c6000000| Untracked -| 13|0x00000005c6800000, 0x00000005c6800000, 0x00000005c7000000| 0%| F| |TAMS 0x00000005c6800000, 0x00000005c6800000| Untracked -| 14|0x00000005c7000000, 0x00000005c7000000, 0x00000005c7800000| 0%| F| |TAMS 0x00000005c7000000, 0x00000005c7000000| Untracked -| 15|0x00000005c7800000, 0x00000005c7800000, 0x00000005c8000000| 0%| F| |TAMS 0x00000005c7800000, 0x00000005c7800000| Untracked -| 16|0x00000005c8000000, 0x00000005c81f1d58, 0x00000005c8800000| 24%| S|CS|TAMS 0x00000005c8000000, 0x00000005c8000000| Complete -| 17|0x00000005c8800000, 0x00000005c9000000, 0x00000005c9000000|100%| S|CS|TAMS 0x00000005c8800000, 0x00000005c8800000| Complete -| 18|0x00000005c9000000, 0x00000005c9000000, 0x00000005c9800000| 0%| F| |TAMS 0x00000005c9000000, 0x00000005c9000000| Untracked -| 19|0x00000005c9800000, 0x00000005c9800000, 0x00000005ca000000| 0%| F| |TAMS 0x00000005c9800000, 0x00000005c9800000| Untracked -| 20|0x00000005ca000000, 0x00000005ca000000, 0x00000005ca800000| 0%| F| |TAMS 0x00000005ca000000, 0x00000005ca000000| Untracked -| 21|0x00000005ca800000, 0x00000005ca800000, 0x00000005cb000000| 0%| F| |TAMS 0x00000005ca800000, 0x00000005ca800000| Untracked -| 22|0x00000005cb000000, 0x00000005cb000000, 0x00000005cb800000| 0%| F| |TAMS 0x00000005cb000000, 0x00000005cb000000| Untracked -| 23|0x00000005cb800000, 0x00000005cbe5add0, 0x00000005cc000000| 79%| E| |TAMS 0x00000005cb800000, 0x00000005cb800000| Complete -| 71|0x00000005e3800000, 0x00000005e4000000, 0x00000005e4000000|100%| E|CS|TAMS 0x00000005e3800000, 0x00000005e3800000| Complete -|1150|0x00000007ff000000, 0x00000007ff778000, 0x00000007ff800000| 93%|OA| |TAMS 0x00000007ff778000, 0x00000007ff000000| Untracked -|1151|0x00000007ff800000, 0x00000007ff880000, 0x0000000800000000| 6%|CA| |TAMS 0x00000007ff880000, 0x00000007ff800000| Untracked - -Card table byte_map: [0x000000011289c000,0x0000000113a9c000] _byte_map_base: 0x000000010fa9c000 - -Marking Bits (Prev, Next): (CMBitMap*) 0x0000000127011250, (CMBitMap*) 0x0000000127011210 - Prev Bits: [0x000000016b848000, 0x0000000174848000) - Next Bits: [0x000000015a000000, 0x0000000163000000) - -Polling page: 0x00000001048e0000 - -Metaspace: - -Usage: - Non-class: 51.71 MB used. - Class: 7.74 MB used. - Both: 59.45 MB used. - -Virtual space: - Non-class space: 64.00 MB reserved, 51.94 MB ( 81%) committed, 1 nodes. - Class space: 1.00 GB reserved, 7.94 MB ( <1%) committed, 1 nodes. - Both: 1.06 GB reserved, 59.88 MB ( 6%) committed. - -Chunk freelists: - Non-Class: 11.69 MB - Class: 8.03 MB - Both: 19.72 MB - -MaxMetaspaceSize: unlimited -CompressedClassSpaceSize: 1.00 GB -Initial GC threshold: 21.00 MB -Current GC threshold: 98.25 MB -CDS: on -MetaspaceReclaimPolicy: balanced - - commit_granule_bytes: 65536. - - commit_granule_words: 8192. - - virtual_space_node_default_size: 8388608. - - enlarge_chunks_in_place: 1. - - new_chunks_are_fully_committed: 0. - - uncommit_free_chunks: 1. - - use_allocation_guard: 0. - - handle_deallocations: 1. - - -Internal statistics: - -num_allocs_failed_limit: 9. -num_arena_births: 620. -num_arena_deaths: 4. -num_vsnodes_births: 2. -num_vsnodes_deaths: 0. -num_space_committed: 958. -num_space_uncommitted: 0. -num_chunks_returned_to_freelist: 13. -num_chunks_taken_from_freelist: 2511. -num_chunk_merges: 9. -num_chunk_splits: 1878. -num_chunks_enlarged: 1524. -num_inconsistent_stats: 0. - -CodeCache: size=49152Kb used=12288Kb max_used=12288Kb free=36863Kb - bounds [0x000000010e69c000, 0x000000010f2ac000, 0x000000011169c000] - total_blobs=6256 nmethods=5630 adapters=553 - compilation: enabled - stopped_count=0, restarted_count=0 - full_count=0 - -Compilation events (20 events): -Event: 8.861 Thread 0x000000012701e000 5881 1 org.springframework.beans.factory.support.DefaultListableBeanFactory::getBeanNamesForType (101 bytes) -Event: 8.861 Thread 0x000000012701e000 nmethod 5881 0x000000010f296b10 code [0x000000010f296d00, 0x000000010f297178] -Event: 8.873 Thread 0x0000000137023e00 5882 1 org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec::getStrategy (18 bytes) -Event: 8.873 Thread 0x0000000137023e00 nmethod 5882 0x000000010f297410 code [0x000000010f297580, 0x000000010f297658] -Event: 8.873 Thread 0x000000012701e000 5883 1 org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec::getParameterizedContainers (5 bytes) -Event: 8.873 Thread 0x0000000105010a00 5884 1 org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec::getIgnoredTypes (5 bytes) -Event: 8.873 Thread 0x000000012701e000 nmethod 5883 0x000000010f297710 code [0x000000010f297880, 0x000000010f297918] -Event: 8.873 Thread 0x0000000105010a00 nmethod 5884 0x000000010f297a10 code [0x000000010f297b80, 0x000000010f297c18] -Event: 8.884 Thread 0x0000000117820600 5885 1 java.security.BasicPermission::init (132 bytes) -Event: 8.884 Thread 0x0000000117820600 nmethod 5885 0x000000010f297d10 code [0x000000010f297f80, 0x000000010f298658] -Event: 8.909 Thread 0x0000000137023e00 5887 1 org.springframework.context.annotation.AnnotationScopeMetadataResolver::resolveScopeMetadata (85 bytes) -Event: 8.909 Thread 0x0000000105010a00 5888 1 org.springframework.context.annotation.ScopeMetadata:: (18 bytes) -Event: 8.909 Thread 0x0000000105010a00 nmethod 5888 0x000000010f298b10 code [0x000000010f298cc0, 0x000000010f298e58] -Event: 8.909 Thread 0x000000012701e000 5889 1 org.springframework.core.annotation.TypeMappedAnnotation::getClassLoader (70 bytes) -Event: 8.909 Thread 0x0000000137023e00 nmethod 5887 0x000000010f298f10 code [0x000000010f299180, 0x000000010f299818] -Event: 8.910 Thread 0x000000012701e000 nmethod 5889 0x000000010f299c90 code [0x000000010f299ec0, 0x000000010f29a478] -Event: 8.911 Thread 0x0000000137023e00 5890 1 org.springframework.beans.factory.support.DefaultListableBeanFactory::getBeanNamesForType (35 bytes) -Event: 8.912 Thread 0x0000000137023e00 nmethod 5890 0x000000010f29a890 code [0x000000010f29aa40, 0x000000010f29ac38] -Event: 8.927 Thread 0x000000012701e000 5893 1 java.util.regex.Pattern::qtype (39 bytes) -Event: 8.927 Thread 0x000000012701e000 nmethod 5893 0x000000010f29b990 code [0x000000010f29bb80, 0x000000010f29beb8] - -GC Heap History (20 events): -Event: 1.161 GC heap before -{Heap before GC invocations=3 (full 0): - garbage-first heap total 606208K, used 83747K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 7 young (57344K), 1 survivors (8192K) - Metaspace used 15618K, committed 15936K, reserved 1114112K - class space used 1924K, committed 2048K, reserved 1048576K -} -Event: 1.164 GC heap after -{Heap after GC invocations=4 (full 0): - garbage-first heap total 606208K, used 35635K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 15618K, committed 15936K, reserved 1114112K - class space used 1924K, committed 2048K, reserved 1048576K -} -Event: 1.392 GC heap before -{Heap before GC invocations=4 (full 0): - garbage-first heap total 606208K, used 76595K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 7 young (57344K), 1 survivors (8192K) - Metaspace used 21245K, committed 21504K, reserved 1114112K - class space used 2660K, committed 2752K, reserved 1048576K -} -Event: 1.393 GC heap after -{Heap after GC invocations=5 (full 0): - garbage-first heap total 606208K, used 37226K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 21245K, committed 21504K, reserved 1114112K - class space used 2660K, committed 2752K, reserved 1048576K -} -Event: 2.427 GC heap before -{Heap before GC invocations=6 (full 0): - garbage-first heap total 196608K, used 143722K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 14 young (114688K), 1 survivors (8192K) - Metaspace used 31866K, committed 32256K, reserved 1114112K - class space used 3982K, committed 4160K, reserved 1048576K -} -Event: 2.441 GC heap after -{Heap after GC invocations=7 (full 0): - garbage-first heap total 196608K, used 39539K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 31866K, committed 32256K, reserved 1114112K - class space used 3982K, committed 4160K, reserved 1048576K -} -Event: 2.521 GC heap before -{Heap before GC invocations=7 (full 0): - garbage-first heap total 196608K, used 47731K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 1 survivors (8192K) - Metaspace used 32583K, committed 33024K, reserved 1114112K - class space used 4074K, committed 4288K, reserved 1048576K -} -Event: 2.527 GC heap after -{Heap after GC invocations=8 (full 0): - garbage-first heap total 196608K, used 37869K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 32583K, committed 33024K, reserved 1114112K - class space used 4074K, committed 4288K, reserved 1048576K -} -Event: 2.683 GC heap before -{Heap before GC invocations=8 (full 0): - garbage-first heap total 196608K, used 62445K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 5 young (40960K), 1 survivors (8192K) - Metaspace used 35765K, committed 36096K, reserved 1114112K - class space used 4463K, committed 4608K, reserved 1048576K -} -Event: 2.686 GC heap after -{Heap after GC invocations=9 (full 0): - garbage-first heap total 245760K, used 38769K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 35765K, committed 36096K, reserved 1114112K - class space used 4463K, committed 4608K, reserved 1048576K -} -Event: 4.142 GC heap before -{Heap before GC invocations=10 (full 0): - garbage-first heap total 221184K, used 161649K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 16 young (131072K), 1 survivors (8192K) - Metaspace used 47506K, committed 47936K, reserved 1114112K - class space used 6180K, committed 6400K, reserved 1048576K -} -Event: 4.163 GC heap after -{Heap after GC invocations=11 (full 0): - garbage-first heap total 221184K, used 42475K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 2 survivors (16384K) - Metaspace used 47506K, committed 47936K, reserved 1114112K - class space used 6180K, committed 6400K, reserved 1048576K -} -Event: 4.432 GC heap before -{Heap before GC invocations=11 (full 0): - garbage-first heap total 221184K, used 50667K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 3 young (24576K), 2 survivors (16384K) - Metaspace used 48268K, committed 48704K, reserved 1114112K - class space used 6299K, committed 6528K, reserved 1048576K -} -Event: 4.450 GC heap after -{Heap after GC invocations=12 (full 0): - garbage-first heap total 221184K, used 43614K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 48268K, committed 48704K, reserved 1114112K - class space used 6299K, committed 6528K, reserved 1048576K -} -Event: 5.741 GC heap before -{Heap before GC invocations=12 (full 0): - garbage-first heap total 221184K, used 150110K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 15 young (122880K), 1 survivors (8192K) - Metaspace used 51028K, committed 51456K, reserved 1114112K - class space used 6663K, committed 6848K, reserved 1048576K -} -Event: 5.746 GC heap after -{Heap after GC invocations=13 (full 0): - garbage-first heap total 221184K, used 53612K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 2 survivors (16384K) - Metaspace used 51028K, committed 51456K, reserved 1114112K - class space used 6663K, committed 6848K, reserved 1048576K -} -Event: 7.882 GC heap before -{Heap before GC invocations=13 (full 0): - garbage-first heap total 221184K, used 160108K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 15 young (122880K), 2 survivors (16384K) - Metaspace used 58340K, committed 58816K, reserved 1114112K - class space used 7527K, committed 7744K, reserved 1048576K -} -Event: 7.898 GC heap after -{Heap after GC invocations=14 (full 0): - garbage-first heap total 221184K, used 58444K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 58340K, committed 58816K, reserved 1114112K - class space used 7527K, committed 7744K, reserved 1048576K -} -Event: 8.518 GC heap before -{Heap before GC invocations=14 (full 0): - garbage-first heap total 221184K, used 107596K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 8 young (65536K), 1 survivors (8192K) - Metaspace used 59896K, committed 60288K, reserved 1114112K - class space used 7761K, committed 7936K, reserved 1048576K -} -Event: 8.521 GC heap after -{Heap after GC invocations=15 (full 0): - garbage-first heap total 221184K, used 62185K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 2 survivors (16384K) - Metaspace used 59896K, committed 60288K, reserved 1114112K - class space used 7761K, committed 7936K, reserved 1048576K -} - -Dll operation events (11 events): -Event: 0.018 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjava.dylib -Event: 0.019 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -Event: 0.090 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libinstrument.dylib -Event: 0.092 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnio.dylib -Event: 0.095 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnet.dylib -Event: 0.119 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjimage.dylib -Event: 0.128 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -Event: 0.236 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement.dylib -Event: 0.245 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement_ext.dylib -Event: 0.377 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libextnet.dylib -Event: 7.955 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libverify.dylib - -Deoptimization events (20 events): -Event: 8.649 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010eb95348 sp=0x000000016b841cf0 -Event: 8.649 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b8419d0 mode 1 -Event: 8.649 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010ef28ad0 sp=0x000000016b841d90 -Event: 8.649 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b841b10 mode 1 -Event: 8.650 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010eb078a8 sp=0x000000016b841480 -Event: 8.651 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b841120 mode 1 -Event: 8.651 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010eb06b3c sp=0x000000016b841550 -Event: 8.651 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b841220 mode 1 -Event: 8.651 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010eb95348 sp=0x000000016b841cf0 -Event: 8.651 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b8419d0 mode 1 -Event: 8.651 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010ef28ad0 sp=0x000000016b841d90 -Event: 8.651 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b841b10 mode 1 -Event: 8.652 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010eb078a8 sp=0x000000016b841480 -Event: 8.652 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b841120 mode 1 -Event: 8.652 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010eb06b3c sp=0x000000016b841550 -Event: 8.652 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b841220 mode 1 -Event: 8.652 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010eb95348 sp=0x000000016b841cf0 -Event: 8.652 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b8419d0 mode 1 -Event: 8.652 Thread 0x0000000127008600 DEOPT PACKING pc=0x000000010ef28ad0 sp=0x000000016b841d90 -Event: 8.652 Thread 0x0000000127008600 DEOPT UNPACKING pc=0x000000010e6e377c sp=0x000000016b841b10 mode 1 - -Classes unloaded (2 events): -Event: 8.535 Thread 0x0000000136e072e0 Unloading class 0x000000f801664800 'SC' -Event: 8.535 Thread 0x0000000136e072e0 Unloading class 0x000000f801554000 'SC' - -Classes redefined (1 events): -Event: 0.110 Thread 0x0000000136e072e0 redefined class name=java.lang.Throwable, count=1 - -Internal exceptions (20 events): -Event: 4.426 Thread 0x0000000127008600 Exception (0x00000005e3fa79a8) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 4.563 Thread 0x0000000147623c00 Exception (0x00000005cb43d600) -thrown [src/hotspot/share/prims/jni.cpp, line 535] -Event: 4.599 Thread 0x00000001473b0800 Exception (0x00000005cb7d5300) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 4.677 Thread 0x0000000127008600 Exception (0x00000005ca98d140) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 826] -Event: 4.697 Thread 0x0000000127008600 Exception (0x00000005caa29b78) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 5.103 Thread 0x00000001473b0800 Exception (0x00000005cb7e1d70) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 5.448 Thread 0x0000000127008600 Exception (0x00000005c58f6100) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 826] -Event: 5.616 Thread 0x00000001473b0800 Exception (0x00000005c5541730) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 6.121 Thread 0x00000001473b0800 Exception (0x00000005caee8ad8) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 6.550 Thread 0x0000000127008600 Exception (0x00000005c90aba90) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 826] -Event: 6.627 Thread 0x00000001473b0800 Exception (0x00000005c952e2e8) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 7.132 Thread 0x00000001176d7800 Exception (0x00000005c89f7490) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 7.198 Thread 0x0000000127008600 Exception (0x00000005c84b3350) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.198 Thread 0x0000000127008600 Exception (0x00000005c84b9da0) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.199 Thread 0x0000000127008600 Exception (0x00000005c84bdd98) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.211 Thread 0x0000000127008600 Exception (0x00000005c8555d28) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.637 Thread 0x00000001176d7800 Exception (0x00000005c8a03c48) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 8.139 Thread 0x00000001176d7800 Exception (0x00000005c980a0c0) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 8.643 Thread 0x00000001176d7800 Exception (0x00000005e3e4d110) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 8.927 Thread 0x00000001055e8200 Exception (0x00000005cbe046d0) -thrown [src/hotspot/share/prims/jni.cpp, line 516] - -VM Operations (20 events): -Event: 5.825 Executing VM operation: HandshakeAllThreads -Event: 5.825 Executing VM operation: HandshakeAllThreads done -Event: 6.540 Executing VM operation: ICBufferFull -Event: 6.540 Executing VM operation: ICBufferFull done -Event: 6.580 Executing VM operation: HandshakeAllThreads -Event: 6.580 Executing VM operation: HandshakeAllThreads done -Event: 7.253 Executing VM operation: HandshakeAllThreads -Event: 7.253 Executing VM operation: HandshakeAllThreads done -Event: 7.456 Executing VM operation: HandshakeAllThreads -Event: 7.456 Executing VM operation: HandshakeAllThreads done -Event: 7.704 Executing VM operation: ICBufferFull -Event: 7.704 Executing VM operation: ICBufferFull done -Event: 7.881 Executing VM operation: G1CollectForAllocation -Event: 7.898 Executing VM operation: G1CollectForAllocation done -Event: 8.517 Executing VM operation: CollectForMetadataAllocation -Event: 8.521 Executing VM operation: CollectForMetadataAllocation done -Event: 8.534 Executing VM operation: G1PauseRemark -Event: 8.542 Executing VM operation: G1PauseRemark done -Event: 8.548 Executing VM operation: G1PauseCleanup -Event: 8.549 Executing VM operation: G1PauseCleanup done - -Events (20 events): -Event: 8.927 loading class sun/net/util/SocketExceptions done -Event: 8.928 Thread 0x000000012717f600 Thread added: 0x000000012717f600 -Event: 8.928 Protecting memory [0x0000000340210000,0x000000034021c000] with protection modes 0 -Event: 8.928 loading class java/lang/Throwable$WrappedPrintWriter -Event: 8.928 loading class java/lang/Throwable$WrappedPrintWriter done -Event: 8.928 loading class com/intellij/rt/debugger/agent/CaptureStorage$9 -Event: 8.928 loading class com/intellij/rt/debugger/agent/CaptureStorage$Callable -Event: 8.928 loading class com/intellij/rt/debugger/agent/CaptureStorage$Callable done -Event: 8.928 loading class com/intellij/rt/debugger/agent/CaptureStorage$9 done -Event: 8.928 loading class com/intellij/rt/debugger/agent/CaptureStorage$DeepCapturedStack -Event: 8.928 loading class com/intellij/rt/debugger/agent/CaptureStorage$DeepCapturedStack done -Event: 8.928 loading class jdk/internal/loader/BootLoader$PackageHelper$1 -Event: 8.928 loading class jdk/internal/loader/BootLoader$PackageHelper$1 done -Event: 8.928 loading class jdk/internal/loader/BootLoader$PackageHelper$2 -Event: 8.928 loading class jdk/internal/loader/BootLoader$PackageHelper$2 done -Event: 8.928 loading class java/util/jar/JarInputStream -Event: 8.928 loading class java/util/zip/ZipInputStream -Event: 8.929 loading class java/util/zip/ZipInputStream done -Event: 8.929 loading class java/util/jar/JarInputStream done -Event: 8.929 loading class com/intellij/rt/debugger/agent/CaptureStorage$StackData - - -Dynamic libraries: -0x0000000104880000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjli.dylib -0x0000000195854000 /usr/lib/libz.1.dylib -0x000000019590a000 /usr/lib/libSystem.B.dylib -0x0000000195904000 /usr/lib/system/libcache.dylib -0x00000001958bf000 /usr/lib/system/libcommonCrypto.dylib -0x00000001958ea000 /usr/lib/system/libcompiler_rt.dylib -0x00000001958df000 /usr/lib/system/libcopyfile.dylib -0x00000001857f2000 /usr/lib/system/libcorecrypto.dylib -0x00000001858f2000 /usr/lib/system/libdispatch.dylib -0x000000018568f000 /usr/lib/system/libdyld.dylib -0x00000001958fa000 /usr/lib/system/libkeymgr.dylib -0x00000001958a2000 /usr/lib/system/libmacho.dylib -0x0000000194b35000 /usr/lib/system/libquarantine.dylib -0x00000001958f7000 /usr/lib/system/libremovefile.dylib -0x000000018c265000 /usr/lib/system/libsystem_asl.dylib -0x0000000185778000 /usr/lib/system/libsystem_blocks.dylib -0x000000018593d000 /usr/lib/system/libsystem_c.dylib -0x00000001958ee000 /usr/lib/system/libsystem_collections.dylib -0x00000001934d5000 /usr/lib/system/libsystem_configuration.dylib -0x00000001920c3000 /usr/lib/system/libsystem_containermanager.dylib -0x00000001952d4000 /usr/lib/system/libsystem_coreservices.dylib -0x0000000189a8c000 /usr/lib/system/libsystem_darwin.dylib -0x000000028b4e0000 /usr/lib/system/libsystem_darwindirectory.dylib -0x00000001958fb000 /usr/lib/system/libsystem_dnssd.dylib -0x000000028b4e4000 /usr/lib/system/libsystem_eligibility.dylib -0x000000018593a000 /usr/lib/system/libsystem_featureflags.dylib -0x0000000185abf000 /usr/lib/system/libsystem_info.dylib -0x0000000195863000 /usr/lib/system/libsystem_m.dylib -0x00000001858a1000 /usr/lib/system/libsystem_malloc.dylib -0x000000018c1c8000 /usr/lib/system/libsystem_networkextension.dylib -0x0000000189ef7000 /usr/lib/system/libsystem_notify.dylib -0x00000001934da000 /usr/lib/system/libsystem_sandbox.dylib -0x000000028b4ef000 /usr/lib/system/libsystem_sanitizers.dylib -0x00000001958f3000 /usr/lib/system/libsystem_secinit.dylib -0x0000000185a6b000 /usr/lib/system/libsystem_kernel.dylib -0x0000000185ab6000 /usr/lib/system/libsystem_platform.dylib -0x0000000185aa9000 /usr/lib/system/libsystem_pthread.dylib -0x000000018de1e000 /usr/lib/system/libsystem_symptoms.dylib -0x00000001857d1000 /usr/lib/system/libsystem_trace.dylib -0x000000028b4f7000 /usr/lib/system/libsystem_trial.dylib -0x00000001958cd000 /usr/lib/system/libunwind.dylib -0x000000018577c000 /usr/lib/system/libxpc.dylib -0x000000018563c000 /usr/lib/libobjc.A.dylib -0x0000000185aef000 /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation -0x00000001991ff000 /usr/lib/swift/libswiftCore.dylib -0x0000000185a50000 /usr/lib/libc++abi.dylib -0x00000002898cd000 /usr/lib/libRosetta.dylib -0x00000001859bf000 /usr/lib/libc++.1.dylib -0x000000018735e000 /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation -0x00000001a2ddf000 /usr/lib/swift/libswiftObjectiveC.dylib -0x000000028ad49000 /usr/lib/libswiftPrespecialized.dylib -0x0000000186fcd000 /System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration -0x000000019033f000 /System/Library/PrivateFrameworks/CoreAutoLayout.framework/Versions/A/CoreAutoLayout -0x000000019590c000 /usr/lib/libfakelink.dylib -0x0000000195bb5000 /usr/lib/libcompression.dylib -0x000000018be12000 /System/Library/Frameworks/CFNetwork.framework/Versions/A/CFNetwork -0x000000018f770000 /System/Library/Frameworks/DiskArbitration.framework/Versions/A/DiskArbitration -0x000000019595f000 /usr/lib/libarchive.2.dylib -0x000000018f675000 /usr/lib/libDiagnosticMessagesClient.dylib -0x00000001897b6000 /usr/lib/libicucore.A.dylib -0x0000000190388000 /usr/lib/libxml2.2.dylib -0x000000019e08e000 /System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices -0x00000001934e8000 /usr/lib/liblangid.dylib -0x0000000189e0e000 /System/Library/Frameworks/IOKit.framework/Versions/A/IOKit -0x000000019bd00000 /System/Library/Frameworks/Combine.framework/Versions/A/Combine -0x000000023ec2f000 /System/Library/PrivateFrameworks/CollectionsInternal.framework/Versions/A/CollectionsInternal -0x000000026ac75000 /System/Library/PrivateFrameworks/ReflectionInternal.framework/Versions/A/ReflectionInternal -0x000000026bbc9000 /System/Library/PrivateFrameworks/RuntimeInternal.framework/Versions/A/RuntimeInternal -0x000000019590e000 /System/Library/PrivateFrameworks/SoftLinking.framework/Versions/A/SoftLinking -0x00000001b34c8000 /usr/lib/swift/libswiftCoreFoundation.dylib -0x00000001b028b000 /usr/lib/swift/libswiftDarwin.dylib -0x000000019fe05000 /usr/lib/swift/libswiftDispatch.dylib -0x00000001b3529000 /usr/lib/swift/libswiftIOKit.dylib -0x000000028b18c000 /usr/lib/swift/libswiftSystem.dylib -0x00000001b34db000 /usr/lib/swift/libswiftXPC.dylib -0x000000028b1be000 /usr/lib/swift/libswift_Builtin_float.dylib -0x000000028b1bf000 /usr/lib/swift/libswift_Concurrency.dylib -0x000000028b24b000 /usr/lib/swift/libswift_DarwinFoundation1.dylib -0x000000028b2ef000 /usr/lib/swift/libswift_StringProcessing.dylib -0x00000001a2de3000 /usr/lib/swift/libswiftos.dylib -0x0000000189d8e000 /System/Library/PrivateFrameworks/CoreServicesInternal.framework/Versions/A/CoreServicesInternal -0x00000001958d7000 /usr/lib/liboah.dylib -0x0000000189396000 /System/Library/Frameworks/Security.framework/Versions/A/Security -0x00000001a2213000 /System/Library/PrivateFrameworks/DiskImages.framework/Versions/A/DiskImages -0x00000001afd2f000 /System/Library/Frameworks/NetFS.framework/Versions/A/NetFS -0x0000000190304000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/FSEvents.framework/Versions/A/FSEvents -0x0000000189a96000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore -0x000000018f6e4000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/Metadata.framework/Versions/A/Metadata -0x00000001952db000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/OSServices.framework/Versions/A/OSServices -0x0000000195a57000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/SearchKit.framework/Versions/A/SearchKit -0x000000018dd98000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/AE.framework/Versions/A/AE -0x000000018604e000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices -0x0000000196e60000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/DictionaryServices.framework/Versions/A/DictionaryServices -0x0000000190311000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/SharedFileList.framework/Versions/A/SharedFileList -0x0000000195aea000 /usr/lib/libapple_nghttp2.dylib -0x000000018d9b4000 /usr/lib/libsqlite3.dylib -0x000000018db9d000 /System/Library/Frameworks/Accounts.framework/Versions/A/Accounts -0x00000001a2455000 /System/Library/PrivateFrameworks/AppSupport.framework/Versions/A/AppSupport -0x00000001b227a000 /System/Library/Frameworks/AVFoundation.framework/Versions/A/AVFoundation -0x000000018f645000 /System/Library/PrivateFrameworks/CoreAnalytics.framework/Versions/A/CoreAnalytics -0x000000018c858000 /System/Library/Frameworks/CoreGraphics.framework/Versions/A/CoreGraphics -0x0000000199f3a000 /System/Library/Frameworks/GSS.framework/Versions/A/GSS -0x0000000198322000 /System/Library/PrivateFrameworks/InternationalSupport.framework/Versions/A/InternationalSupport -0x000000018dd2c000 /System/Library/PrivateFrameworks/RunningBoardServices.framework/Versions/A/RunningBoardServices -0x00000001a2d67000 /System/Library/PrivateFrameworks/StreamingZip.framework/Versions/A/StreamingZip -0x000000018c1e3000 /usr/lib/libenergytrace.dylib -0x000000018de27000 /System/Library/Frameworks/Network.framework/Versions/A/Network -0x0000000194b5d000 /usr/lib/libbsm.0.dylib -0x00000001958a6000 /usr/lib/system/libkxld.dylib -0x000000023a201000 /System/Library/PrivateFrameworks/AppleKeyStore.framework/Versions/A/AppleKeyStore -0x00000002895cf000 /usr/lib/libCoreEntitlements.dylib -0x000000025f341000 /System/Library/PrivateFrameworks/MessageSecurity.framework/Versions/A/MessageSecurity -0x000000018d998000 /System/Library/PrivateFrameworks/ProtocolBuffer.framework/Versions/A/ProtocolBuffer -0x000000019f214000 /System/Library/PrivateFrameworks/SymptomDiagnosticReporter.framework/Versions/A/SymptomDiagnosticReporter -0x00000001970a5000 /System/Library/PrivateFrameworks/CrashReporterSupport.framework/Versions/A/CrashReporterSupport -0x000000018c1e5000 /usr/lib/libMobileGestalt.dylib -0x00000001952bb000 /System/Library/PrivateFrameworks/AppleFSCompression.framework/Versions/A/AppleFSCompression -0x0000000194b45000 /usr/lib/libcoretls.dylib -0x0000000196ed6000 /usr/lib/libcoretls_cfhelpers.dylib -0x0000000195baf000 /usr/lib/libpam.2.dylib -0x0000000196f4c000 /usr/lib/libxar.1.dylib -0x0000000196ed8000 /System/Library/PrivateFrameworks/APFS.framework/Versions/A/APFS -0x000000027734f000 /System/Library/PrivateFrameworks/SwiftASN1Internal.framework/Versions/A/SwiftASN1Internal -0x0000000196f5b000 /usr/lib/libutil.dylib -0x00000001934e3000 /System/Library/PrivateFrameworks/AppleSystemInfo.framework/Versions/A/AppleSystemInfo -0x000000019480c000 /System/Library/PrivateFrameworks/IOMobileFramebuffer.framework/Versions/A/IOMobileFramebuffer -0x00000001920fc000 /System/Library/Frameworks/IOSurface.framework/Versions/A/IOSurface -0x00000001a1b73000 /System/Library/PrivateFrameworks/CoreWiFi.framework/Versions/A/CoreWiFi -0x00000001b3388000 /System/Library/PrivateFrameworks/LoggingSupport.framework/Versions/A/LoggingSupport -0x0000000199f9d000 /System/Library/PrivateFrameworks/MobileAsset.framework/Versions/A/MobileAsset -0x000000019f224000 /System/Library/PrivateFrameworks/PowerLog.framework/Versions/A/PowerLog -0x00000001a06e6000 /System/Library/PrivateFrameworks/Rapport.framework/Versions/A/Rapport -0x0000000232daa000 /System/Library/Frameworks/SwiftData.framework/Versions/A/SwiftData -0x000000018b846000 /System/Library/Frameworks/UniformTypeIdentifiers.framework/Versions/A/UniformTypeIdentifiers -0x0000000190531000 /System/Library/PrivateFrameworks/UserManagement.framework/Versions/A/UserManagement -0x000000018bd39000 /usr/lib/libboringssl.dylib -0x000000018de0c000 /usr/lib/libdns_services.dylib -0x00000001b23ae000 /usr/lib/libquic.dylib -0x0000000199190000 /usr/lib/libusrtcp.dylib -0x000000023b0bb000 /System/Library/PrivateFrameworks/AtomicsInternal.framework/Versions/A/AtomicsInternal -0x00000001d976e000 /System/Library/PrivateFrameworks/InternalSwiftProtobuf.framework/Versions/A/InternalSwiftProtobuf -0x000000028b013000 /usr/lib/swift/libswiftDistributed.dylib -0x000000028b03c000 /usr/lib/swift/libswiftObservation.dylib -0x000000028b178000 /usr/lib/swift/libswiftSynchronization.dylib -0x00000001934e1000 /System/Library/PrivateFrameworks/AggregateDictionary.framework/Versions/A/AggregateDictionary -0x000000023b8eb000 /System/Library/PrivateFrameworks/BiomeLibrary.framework/Versions/A/BiomeLibrary -0x00000001c2511000 /System/Library/PrivateFrameworks/BiomeStreams.framework/Versions/A/BiomeStreams -0x00000001be560000 /System/Library/PrivateFrameworks/BiomeFoundation.framework/Versions/A/BiomeFoundation -0x00000001c87be000 /System/Library/PrivateFrameworks/BiomePubSub.framework/Versions/A/BiomePubSub -0x000000018d5aa000 /System/Library/Frameworks/CoreData.framework/Versions/A/CoreData -0x00000001a3d4a000 /System/Library/PrivateFrameworks/ProactiveSupport.framework/Versions/A/ProactiveSupport -0x0000000234e08000 /System/Library/Frameworks/_LocationEssentials.framework/Versions/A/_LocationEssentials -0x0000000196eb7000 /usr/lib/liblzma.5.dylib -0x000000019e30d000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Accelerate -0x0000000194a3e000 /System/Library/PrivateFrameworks/MobileKeyBag.framework/Versions/A/MobileKeyBag -0x00000001a2769000 /System/Library/PrivateFrameworks/InternationalTextSearch.framework/Versions/A/InternationalTextSearch -0x00000001baa33000 /System/Library/PrivateFrameworks/SoftwareUpdateCoreSupport.framework/Versions/A/SoftwareUpdateCoreSupport -0x00000001c2fb0000 /System/Library/PrivateFrameworks/SoftwareUpdateCoreConnect.framework/Versions/A/SoftwareUpdateCoreConnect -0x00000001a230d000 /System/Library/PrivateFrameworks/RemoteServiceDiscovery.framework/Versions/A/RemoteServiceDiscovery -0x00000001ba60a000 /System/Library/PrivateFrameworks/MSUDataAccessor.framework/Versions/A/MSUDataAccessor -0x00000001b555b000 /usr/lib/libbootpolicy.dylib -0x00000001a2324000 /System/Library/PrivateFrameworks/RemoteXPC.framework/Versions/A/RemoteXPC -0x00000001c23e5000 /usr/lib/libFDR.dylib -0x00000001c83c0000 /usr/lib/libamsupport.dylib -0x00000002898c5000 /usr/lib/libReverseProxyDevice.dylib -0x0000000239a6f000 /System/Library/PrivateFrameworks/AppleDeviceQuerySupport.framework/Versions/A/AppleDeviceQuerySupport -0x00000001cb58a000 /usr/lib/libpartition2_dynamic.dylib -0x0000000195ac6000 /System/Library/PrivateFrameworks/AppleSauce.framework/Versions/A/AppleSauce -0x000000028947a000 /usr/lib/libAppleArchive.dylib -0x00000001952c7000 /usr/lib/libbz2.1.0.dylib -0x000000018f77a000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vImage.framework/Versions/A/vImage -0x000000019e069000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/vecLib -0x0000000196f92000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvMisc.dylib -0x0000000186552000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib -0x00000001a2768000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices -0x000000019046f000 /System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo -0x000000018cfae000 /System/Library/Frameworks/ColorSync.framework/Versions/A/ColorSync -0x00000001889d6000 /System/Library/Frameworks/CoreText.framework/Versions/A/CoreText -0x0000000192bef000 /System/Library/Frameworks/ImageIO.framework/Versions/A/ImageIO -0x0000000199b4a000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATS.framework/Versions/A/ATS -0x000000018d156000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/HIServices -0x0000000198218000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Versions/A/PrintCore -0x0000000199f03000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/QD.framework/Versions/A/QD -0x0000000199efe000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ColorSyncLegacy.framework/Versions/A/ColorSyncLegacy -0x0000000199b1c000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/SpeechSynthesis.framework/Versions/A/SpeechSynthesis -0x000000018c2a1000 /System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight -0x00000001925ca000 /System/Library/PrivateFrameworks/FontServices.framework/libFontParser.dylib -0x000000018dc3a000 /System/Library/PrivateFrameworks/BaseBoard.framework/Versions/A/BaseBoard -0x00000001a0166000 /System/Library/PrivateFrameworks/BoardServices.framework/Versions/A/BoardServices -0x00000001a2035000 /System/Library/PrivateFrameworks/BackBoardServices.framework/Versions/A/BackBoardServices -0x000000023b7f0000 /System/Library/PrivateFrameworks/BackBoardHIDEventFoundation.framework/Versions/A/BackBoardHIDEventFoundation -0x00000001884f2000 /System/Library/Frameworks/CoreDisplay.framework/Versions/A/CoreDisplay -0x0000000197b71000 /System/Library/Frameworks/VideoToolbox.framework/Versions/A/VideoToolbox -0x0000000195bad000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/MetalPerformanceShaders -0x0000000269b7f000 /System/Library/PrivateFrameworks/ProDisplayLibrary.framework/Versions/A/ProDisplayLibrary -0x00000001a5e6a000 /System/Library/PrivateFrameworks/IOSurfaceAccelerator.framework/Versions/A/IOSurfaceAccelerator -0x0000000192124000 /System/Library/Frameworks/Metal.framework/Versions/A/Metal -0x0000000192119000 /System/Library/PrivateFrameworks/IOAccelerator.framework/Versions/A/IOAccelerator -0x0000000192428000 /System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia -0x000000018c27d000 /System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC -0x0000000197b29000 /System/Library/PrivateFrameworks/WatchdogClient.framework/Versions/A/WatchdogClient -0x000000018fb9f000 /System/Library/Frameworks/QuartzCore.framework/Versions/A/QuartzCore -0x0000000197b2b000 /System/Library/PrivateFrameworks/MultitouchSupport.framework/Versions/A/MultitouchSupport -0x00000001cb36c000 /usr/lib/swift/libswiftAccelerate.dylib -0x00000001b34a8000 /usr/lib/swift/libswiftCoreAudio.dylib -0x00000001cf4fd000 /usr/lib/swift/libswiftCoreMedia.dylib -0x00000001c149e000 /usr/lib/swift/libswiftMetal.dylib -0x00000001d0cb0000 /usr/lib/swift/libswiftOSLog.dylib -0x00000001c68c4000 /usr/lib/swift/libswiftQuartzCore.dylib -0x00000001cb35c000 /usr/lib/swift/libswiftUniformTypeIdentifiers.dylib -0x000000028b1a6000 /usr/lib/swift/libswiftVideoToolbox.dylib -0x00000001b7022000 /usr/lib/swift/libswiftsimd.dylib -0x00000001c881f000 /System/Library/PrivateFrameworks/BiomeStorage.framework/Versions/A/BiomeStorage -0x0000000258030000 /System/Library/PrivateFrameworks/IntelligencePlatformLibrary.framework/Versions/A/IntelligencePlatformLibrary -0x0000000268943000 /System/Library/PrivateFrameworks/PoirotSchematizer.framework/Versions/A/PoirotSchematizer -0x000000023c1a6000 /System/Library/PrivateFrameworks/BiomeSync.framework/Versions/A/BiomeSync -0x000000023b8d1000 /System/Library/PrivateFrameworks/BiomeDSL.framework/Versions/A/BiomeDSL -0x00000001e1979000 /System/Library/PrivateFrameworks/FeatureFlags.framework/Versions/A/FeatureFlags -0x00000002689b1000 /System/Library/PrivateFrameworks/PoirotUDFs.framework/Versions/A/PoirotUDFs -0x000000028b24e000 /usr/lib/swift/libswift_DarwinFoundation2.dylib -0x000000028b24f000 /usr/lib/swift/libswift_DarwinFoundation3.dylib -0x00000001a06db000 /System/Library/PrivateFrameworks/CoreTime.framework/Versions/A/CoreTime -0x0000000195944000 /usr/lib/libiconv.2.dylib -0x00000001958a1000 /usr/lib/libcharset.1.dylib -0x0000000268906000 /System/Library/PrivateFrameworks/PoirotSQLite.framework/Versions/A/PoirotSQLite -0x000000028b250000 /usr/lib/swift/libswift_RegexParser.dylib -0x000000023d7dc000 /System/Library/PrivateFrameworks/CascadeSets.framework/Versions/A/CascadeSets -0x000000019a114000 /System/Library/PrivateFrameworks/CorePhoneNumbers.framework/Versions/A/CorePhoneNumbers -0x0000000197923000 /System/Library/PrivateFrameworks/AppleJPEG.framework/Versions/A/AppleJPEG -0x00000001972fc000 /usr/lib/libexpat.1.dylib -0x00000001980ee000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libPng.dylib -0x0000000198119000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libTIFF.dylib -0x0000000198201000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libGIF.dylib -0x0000000197968000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJP2.dylib -0x000000019700c000 /usr/lib/libate.dylib -0x00000001981a8000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJPEG.dylib -0x000000019819f000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libRadiance.dylib -0x000000024dc80000 /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/libllvm-flatbuffers.dylib -0x00000002486df000 /System/Library/PrivateFrameworks/FramePacing.framework/Versions/A/FramePacing -0x000000022b7ef000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCoreFSCache.dylib -0x000000024980d000 /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/libGPUCompilerUtils.dylib -0x00000001a0233000 /System/Library/PrivateFrameworks/GraphicsServices.framework/Versions/A/GraphicsServices -0x000000022b7fd000 /System/Library/Frameworks/OpenGL.framework/Versions/A/OpenGL -0x000000022b84e000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGLU.dylib -0x000000022b811000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGFXShared.dylib -0x000000022b9de000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib -0x000000022b81a000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGLImage.dylib -0x000000022b80e000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCVMSPluginSupport.dylib -0x000000022b7f7000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCoreVMClient.dylib -0x000000019819a000 /System/Library/PrivateFrameworks/GPUWrangler.framework/Versions/A/GPUWrangler -0x000000019817a000 /System/Library/PrivateFrameworks/IOPresentment.framework/Versions/A/IOPresentment -0x00000001981a2000 /System/Library/PrivateFrameworks/DSExternalDisplay.framework/Versions/A/DSExternalDisplay -0x000000027f205000 /System/Library/PrivateFrameworks/VideoToolboxParavirtualizationSupport.framework/Versions/A/VideoToolboxParavirtualizationSupport -0x00000001972b3000 /System/Library/PrivateFrameworks/AppleVA.framework/Versions/A/AppleVA -0x000000022d87a000 /System/Library/Frameworks/ExtensionFoundation.framework/Versions/A/ExtensionFoundation -0x0000000198207000 /System/Library/PrivateFrameworks/CMCaptureCore.framework/Versions/A/CMCaptureCore -0x000000019758d000 /usr/lib/libspindump.dylib -0x0000000188c00000 /System/Library/Frameworks/CoreAudio.framework/Versions/A/CoreAudio -0x0000000197580000 /System/Library/PrivateFrameworks/AppServerSupport.framework/Versions/A/AppServerSupport -0x0000000199f0c000 /System/Library/PrivateFrameworks/perfdata.framework/Versions/A/perfdata -0x0000000188613000 /System/Library/PrivateFrameworks/AudioToolboxCore.framework/Versions/A/AudioToolboxCore -0x00000001923fe000 /System/Library/PrivateFrameworks/caulk.framework/Versions/A/caulk -0x0000000199b02000 /usr/lib/libAudioStatistics.dylib -0x00000001b24a3000 /System/Library/PrivateFrameworks/SystemPolicy.framework/Versions/A/SystemPolicy -0x0000000199db0000 /usr/lib/libSMC.dylib -0x00000001b9e19000 /usr/lib/swift/libswiftCoreMIDI.dylib -0x00000001a5159000 /System/Library/Frameworks/CoreMIDI.framework/Versions/A/CoreMIDI -0x00000001980c8000 /usr/lib/libAudioToolboxUtility.dylib -0x0000000199f1a000 /usr/lib/libperfcheck.dylib -0x000000023b18a000 /System/Library/PrivateFrameworks/AudioAnalytics.framework/Versions/A/AudioAnalytics -0x00000001d945a000 /System/Library/Frameworks/OSLog.framework/Versions/A/OSLog -0x00000002643b3000 /System/Library/PrivateFrameworks/OSEligibility.framework/Versions/A/OSEligibility -0x0000000197382000 /System/Library/PrivateFrameworks/IconServices.framework/Versions/A/IconServices -0x000000022ec61000 /System/Library/Frameworks/LightweightCodeRequirements.framework/Versions/A/LightweightCodeRequirements -0x00000001971fc000 /System/Library/PrivateFrameworks/PlugInKit.framework/Versions/A/PlugInKit -0x0000000194a56000 /System/Library/PrivateFrameworks/AssertionServices.framework/Versions/A/AssertionServices -0x0000000197321000 /System/Library/PrivateFrameworks/IconFoundation.framework/Versions/A/IconFoundation -0x0000000254bbc000 /System/Library/PrivateFrameworks/IconRendering.framework/Versions/A/IconRendering -0x0000000190006000 /System/Library/PrivateFrameworks/CoreUI.framework/Versions/A/CoreUI -0x0000000192f30000 /System/Library/Frameworks/CoreImage.framework/Versions/A/CoreImage -0x000000026bdae000 /System/Library/PrivateFrameworks/SFSymbols.framework/Versions/A/SFSymbols -0x000000022d730000 /System/Library/Frameworks/DeveloperToolsSupport.framework/Versions/A/DeveloperToolsSupport -0x00000001aa406000 /System/Library/PrivateFrameworks/RenderBox.framework/Versions/A/RenderBox -0x0000000192bb1000 /System/Library/PrivateFrameworks/CoreSVG.framework/Versions/A/CoreSVG -0x000000019828b000 /System/Library/PrivateFrameworks/TextureIO.framework/Versions/A/TextureIO -0x00000001b3528000 /usr/lib/swift/libswiftCoreImage.dylib -0x0000000197530000 /System/Library/PrivateFrameworks/GraphVisualizer.framework/Versions/A/GraphVisualizer -0x00000002485ea000 /System/Library/PrivateFrameworks/FontServices.framework/Versions/A/FontServices -0x0000000197540000 /System/Library/PrivateFrameworks/OTSVG.framework/Versions/A/OTSVG -0x000000018ffb5000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATS.framework/Versions/A/Resources/libFontRegistry.dylib -0x000000028a39f000 /usr/lib/libhvf.dylib -0x0000000265040000 /System/Library/PrivateFrameworks/ParsingInternal.framework/Versions/A/ParsingInternal -0x00000002485ee000 /System/Library/PrivateFrameworks/FontServices.framework/libXTFontStaticRegistryData.dylib -0x000000019341b000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSCore.framework/Versions/A/MPSCore -0x0000000195226000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSImage.framework/Versions/A/MPSImage -0x0000000194be5000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSNeuralNetwork.framework/Versions/A/MPSNeuralNetwork -0x0000000195024000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSMatrix.framework/Versions/A/MPSMatrix -0x0000000194e3c000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSRayIntersector.framework/Versions/A/MPSRayIntersector -0x0000000195056000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSNDArray.framework/Versions/A/MPSNDArray -0x000000022fae6000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSFunctions.framework/Versions/A/MPSFunctions -0x000000022fac7000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSBenchmarkLoop.framework/Versions/A/MPSBenchmarkLoop -0x000000022fafa000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSHost.framework/Versions/A/MPSHost -0x0000000186369000 /System/Library/PrivateFrameworks/MetalTools.framework/Versions/A/MetalTools -0x00000001b8749000 /System/Library/PrivateFrameworks/IOAccelMemoryInfo.framework/Versions/A/IOAccelMemoryInfo -0x00000001c6cb7000 /System/Library/PrivateFrameworks/kperf.framework/Versions/A/kperf -0x00000001b34a4000 /System/Library/PrivateFrameworks/GPURawCounter.framework/Versions/A/GPURawCounter -0x00000001a3ed5000 /System/Library/PrivateFrameworks/ASEProcessing.framework/Versions/A/ASEProcessing -0x00000001d4dc6000 /System/Library/PrivateFrameworks/Symbolication.framework/Versions/A/Symbolication -0x00000002684f1000 /System/Library/PrivateFrameworks/PhotosensitivityProcessing.framework/Versions/A/PhotosensitivityProcessing -0x000000026be34000 /System/Library/PrivateFrameworks/SILManager.framework/Versions/A/SILManager -0x00000001a057f000 /System/Library/PrivateFrameworks/CoreSymbolication.framework/Versions/A/CoreSymbolication -0x00000001b3417000 /System/Library/PrivateFrameworks/MallocStackLogging.framework/Versions/A/MallocStackLogging -0x00000001a055d000 /System/Library/PrivateFrameworks/DebugSymbols.framework/Versions/A/DebugSymbols -0x00000001c5408000 /System/Library/PrivateFrameworks/OSAnalytics.framework/Versions/A/OSAnalytics -0x0000000246345000 /System/Library/PrivateFrameworks/DeviceRecovery.framework/Versions/A/DeviceRecovery -0x000000027aa8d000 /System/Library/PrivateFrameworks/Tightbeam.framework/Versions/A/Tightbeam -0x00000001c14ac000 /usr/lib/swift/libswiftCompression.dylib -0x00000001cbaf9000 /System/Library/PrivateFrameworks/AFKUser.framework/Versions/A/AFKUser -0x00000001981d3000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATSUI.framework/Versions/A/ATSUI -0x00000001998ab000 /System/Library/Frameworks/AudioToolbox.framework/Versions/A/AudioToolbox -0x0000000195698000 /System/Library/Frameworks/UserNotifications.framework/Versions/A/UserNotifications -0x00000001b8ee0000 /System/Library/PrivateFrameworks/SiriInstrumentation.framework/Versions/A/SiriInstrumentation -0x000000026e2c4000 /System/Library/PrivateFrameworks/SiriAnalytics.framework/Versions/A/SiriAnalytics -0x00000001b663f000 /System/Library/PrivateFrameworks/FeedbackLogger.framework/Versions/A/FeedbackLogger -0x00000001d0f49000 /usr/lib/swift/libswiftAVFoundation.dylib -0x000000027d2b6000 /System/Library/PrivateFrameworks/UnifiedAssetFramework.framework/Versions/A/UnifiedAssetFramework -0x0000000199a81000 /System/Library/PrivateFrameworks/AudioSession.framework/Versions/A/AudioSession -0x0000000197441000 /System/Library/PrivateFrameworks/MediaExperience.framework/Versions/A/MediaExperience -0x0000000199855000 /System/Library/PrivateFrameworks/AudioSession.framework/libSessionUtility.dylib -0x000000019f109000 /System/Library/Frameworks/CoreBluetooth.framework/Versions/A/CoreBluetooth -0x00000001948c9000 /System/Library/PrivateFrameworks/CoreUtils.framework/Versions/A/CoreUtils -0x00000001ab13a000 /System/Library/PrivateFrameworks/HID.framework/Versions/A/HID -0x0000000245236000 /System/Library/PrivateFrameworks/CoreUtilsExtras.framework/Versions/A/CoreUtilsExtras -0x0000000254b11000 /System/Library/PrivateFrameworks/IO80211.framework/Versions/A/IO80211 -0x000000019ba4c000 /System/Library/Frameworks/IOBluetooth.framework/Versions/A/IOBluetooth -0x000000028b05b000 /usr/lib/swift/libswiftRegexBuilder.dylib -0x000000019709c000 /usr/lib/libIOReport.dylib -0x00000001e19fe000 /System/Library/PrivateFrameworks/WiFiPeerToPeer.framework/Versions/A/WiFiPeerToPeer -0x0000000194a65000 /System/Library/Frameworks/SecurityFoundation.framework/Versions/A/SecurityFoundation -0x000000023d890000 /System/Library/PrivateFrameworks/Centauri.framework/Versions/A/Centauri -0x0000000186d4d000 /System/Library/PrivateFrameworks/Lexicon.framework/Versions/A/Lexicon -0x000000028a7ef000 /usr/lib/libmrc.dylib -0x0000000254b7c000 /System/Library/PrivateFrameworks/IPConfiguration.framework/Versions/A/IPConfiguration -0x00000001d559d000 /System/Library/PrivateFrameworks/Netrb.framework/Versions/A/Netrb -0x00000001a008c000 /System/Library/PrivateFrameworks/FrontBoardServices.framework/Versions/A/FrontBoardServices -0x0000000194bce000 /usr/lib/libgermantok.dylib -0x000000019360a000 /System/Library/PrivateFrameworks/LinguisticData.framework/Versions/A/LinguisticData -0x000000019f312000 /System/Library/PrivateFrameworks/MediaKit.framework/Versions/A/MediaKit -0x000000019f260000 /System/Library/Frameworks/DiscRecording.framework/Versions/A/DiscRecording -0x0000000197317000 /usr/lib/libheimdal-asn1.dylib -0x00000001a2d3d000 /System/Library/Frameworks/AudioUnit.framework/Versions/A/AudioUnit -0x00000001902cc000 /System/Library/Frameworks/OpenDirectory.framework/Versions/A/OpenDirectory -0x00000001902da000 /System/Library/Frameworks/OpenDirectory.framework/Versions/A/Frameworks/CFOpenDirectory.framework/Versions/A/CFOpenDirectory -0x000000019bdf4000 /System/Library/PrivateFrameworks/GeoServices.framework/Versions/A/GeoServices -0x000000019981b000 /System/Library/PrivateFrameworks/LocationSupport.framework/Versions/A/LocationSupport -0x000000025183a000 /System/Library/PrivateFrameworks/GeoServicesCore.framework/Versions/A/GeoServicesCore -0x00000001ac03e000 /System/Library/PrivateFrameworks/PhoneNumbers.framework/Versions/A/PhoneNumbers -0x0000000259fc2000 /System/Library/PrivateFrameworks/LocationLogEncryption.framework/Versions/A/LocationLogEncryption -0x000000022bd6a000 /System/Library/Frameworks/AVFAudio.framework/Versions/A/AVFAudio -0x000000022beae000 /System/Library/Frameworks/AVRouting.framework/Versions/A/AVRouting -0x00000001ac156000 /usr/lib/libAccessibility.dylib -0x00000002589ac000 /System/Library/PrivateFrameworks/IsolatedCoreAudioClient.framework/Versions/A/IsolatedCoreAudioClient -0x000000024102a000 /System/Library/PrivateFrameworks/CoreAudioOrchestration.framework/Versions/A/CoreAudioOrchestration -0x00000001986ef000 /System/Library/Frameworks/MediaToolbox.framework/Versions/A/MediaToolbox -0x000000019f438000 /System/Library/PrivateFrameworks/CoreAVCHD.framework/Versions/A/CoreAVCHD -0x000000019e35c000 /System/Library/Frameworks/MediaAccessibility.framework/Versions/A/MediaAccessibility -0x000000019f434000 /System/Library/PrivateFrameworks/Mangrove.framework/Versions/A/Mangrove -0x000000023cecc000 /System/Library/PrivateFrameworks/CMPhoto.framework/Versions/A/CMPhoto -0x000000019fc01000 /System/Library/Frameworks/CoreTelephony.framework/Versions/A/CoreTelephony -0x000000019f427000 /System/Library/PrivateFrameworks/CoreAUC.framework/Versions/A/CoreAUC -0x000000023a032000 /System/Library/PrivateFrameworks/AppleJPEGXL.framework/Versions/A/AppleJPEGXL -0x000000019a124000 /usr/lib/libTelephonyUtilDynamic.dylib -0x00000001dc57b000 /System/Library/Frameworks/CryptoKit.framework/Versions/A/CryptoKit -0x00000001a2d38000 /System/Library/PrivateFrameworks/CryptoKitCBridging.framework/Versions/A/CryptoKitCBridging -0x00000001a0245000 /System/Library/Frameworks/CryptoTokenKit.framework/Versions/A/CryptoTokenKit -0x0000000245db5000 /System/Library/PrivateFrameworks/Dendrite.framework/Versions/A/Dendrite -0x00000001b3048000 /System/Library/Frameworks/NaturalLanguage.framework/Versions/A/NaturalLanguage -0x000000025153d000 /System/Library/PrivateFrameworks/GenerativeModels.framework/Versions/A/GenerativeModels -0x00000001e1985000 /usr/lib/swift/libswiftNaturalLanguage.dylib -0x000000023aba9000 /System/Library/PrivateFrameworks/AppleMobileFileIntegrity.framework/Versions/A/AppleMobileFileIntegrity -0x000000028993d000 /usr/lib/libTLE.dylib -0x00000001b3449000 /usr/lib/libmis.dylib -0x00000001eb0cd000 /System/Library/PrivateFrameworks/ConfigProfileHelper.framework/Versions/A/ConfigProfileHelper -0x00000001a2ec7000 /System/Library/PrivateFrameworks/Espresso.framework/Versions/A/Espresso -0x0000000190a5c000 /System/Library/Frameworks/CoreML.framework/Versions/A/CoreML -0x00000001df831000 /usr/lib/libedit.3.dylib -0x0000000228b78000 /System/Library/PrivateFrameworks/ANECompiler.framework/Versions/A/ANECompiler -0x00000001a4f9d000 /System/Library/PrivateFrameworks/AppleNeuralEngine.framework/Versions/A/AppleNeuralEngine -0x000000025a0e0000 /System/Library/PrivateFrameworks/MIL.framework/Versions/A/MIL -0x000000022fb00000 /System/Library/Frameworks/MetalPerformanceShadersGraph.framework/Versions/A/MetalPerformanceShadersGraph -0x000000025a84f000 /System/Library/PrivateFrameworks/MLCompilerServices.framework/Versions/A/MLCompilerServices -0x00000001a3d19000 /System/Library/PrivateFrameworks/ANEServices.framework/Versions/A/ANEServices -0x00000001b870c000 /usr/lib/libncurses.5.4.dylib -0x0000000189f0a000 /usr/lib/libsandbox.1.dylib -0x000000019723d000 /usr/lib/libMatch.1.dylib -0x0000000264135000 /System/Library/PrivateFrameworks/ODIE.framework/Versions/A/ODIE -0x000000025d4dc000 /System/Library/PrivateFrameworks/MLModelAsset.framework/Versions/A/MLModelAsset -0x000000025a7f5000 /System/Library/PrivateFrameworks/MLCompilerRuntime.framework/Versions/A/MLCompilerRuntime -0x0000000194e91000 /System/Library/Frameworks/MLCompute.framework/Versions/A/MLCompute -0x000000025a777000 /System/Library/PrivateFrameworks/MLAssetIO.framework/Versions/A/MLAssetIO -0x000000028b02d000 /usr/lib/swift/libswiftMLCompute.dylib -0x000000019fe1c000 /System/Library/PrivateFrameworks/AVFCore.framework/Versions/A/AVFCore -0x00000001a9a57000 /System/Library/PrivateFrameworks/AVFCapture.framework/Versions/A/AVFCapture -0x000000023ccc3000 /System/Library/PrivateFrameworks/CMImaging.framework/Versions/A/CMImaging -0x00000001a9c99000 /System/Library/PrivateFrameworks/Quagga.framework/Versions/A/Quagga -0x00000001a9dca000 /System/Library/PrivateFrameworks/CMCapture.framework/Versions/A/CMCapture -0x0000000199cad000 /System/Library/Frameworks/CoreMediaIO.framework/Versions/A/CoreMediaIO -0x000000023cbfe000 /System/Library/PrivateFrameworks/CMCaptureDevice.framework/Versions/A/CMCaptureDevice -0x0000000197679000 /System/Library/PrivateFrameworks/CoreBrightness.framework/Versions/A/CoreBrightness -0x000000023dd87000 /System/Library/PrivateFrameworks/CinematicFraming.framework/Versions/A/CinematicFraming -0x0000000260619000 /System/Library/PrivateFrameworks/ModelManagerServices.framework/Versions/A/ModelManagerServices -0x00000001cdfb1000 /System/Library/PrivateFrameworks/CPMS.framework/Versions/A/CPMS -0x00000002781bd000 /System/Library/PrivateFrameworks/SystemStatus.framework/Versions/A/SystemStatus -0x00000001b1e78000 /System/Library/Frameworks/CoreMotion.framework/Versions/A/CoreMotion -0x00000001c2490000 /System/Library/PrivateFrameworks/TimeSync.framework/Versions/A/TimeSync -0x000000024677d000 /System/Library/PrivateFrameworks/DistributedSensing.framework/Versions/A/DistributedSensing -0x00000001bd86f000 /System/Library/PrivateFrameworks/MobileBluetooth.framework/Versions/A/MobileBluetooth -0x00000001c4154000 /System/Library/PrivateFrameworks/IOKitten.framework/Versions/A/IOKitten -0x0000000239eac000 /System/Library/PrivateFrameworks/AppleIntelligenceReporting.framework/Versions/A/AppleIntelligenceReporting -0x00000001947d8000 /System/Library/PrivateFrameworks/CoreEmoji.framework/Versions/A/CoreEmoji -0x0000000187061000 /usr/lib/libCRFSuite.dylib -0x0000000188342000 /System/Library/PrivateFrameworks/LanguageModeling.framework/Versions/A/LanguageModeling -0x00000001934ea000 /System/Library/PrivateFrameworks/CoreNLP.framework/Versions/A/CoreNLP -0x000000018d2bf000 /System/Library/PrivateFrameworks/Montreal.framework/Versions/A/Montreal -0x000000019594c000 /usr/lib/libcmph.dylib -0x0000000194b6f000 /usr/lib/libmecab.dylib -0x0000000195abd000 /usr/lib/libThaiTokenizer.dylib -0x000000025161f000 /System/Library/PrivateFrameworks/GenerativeModelsFoundation.framework/Versions/A/GenerativeModelsFoundation -0x000000027af92000 /System/Library/PrivateFrameworks/TokenGeneration.framework/Versions/A/TokenGeneration -0x00000002513f3000 /System/Library/PrivateFrameworks/GenerativeFunctions.framework/Versions/A/GenerativeFunctions -0x0000000251441000 /System/Library/PrivateFrameworks/GenerativeFunctionsFoundation.framework/Versions/A/GenerativeFunctionsFoundation -0x00000002602da000 /System/Library/PrivateFrameworks/ModelCatalog.framework/Versions/A/ModelCatalog -0x000000026d4de000 /System/Library/PrivateFrameworks/SensitiveContentAnalysisML.framework/Versions/A/SensitiveContentAnalysisML -0x00000002514d7000 /System/Library/PrivateFrameworks/GenerativeFunctionsInstrumentation.framework/Versions/A/GenerativeFunctionsInstrumentation -0x000000026a307000 /System/Library/PrivateFrameworks/PromptKit.framework/Versions/A/PromptKit -0x0000000269d1d000 /System/Library/PrivateFrameworks/ProactiveDaemonSupport.framework/Versions/A/ProactiveDaemonSupport -0x000000027b1ca000 /System/Library/PrivateFrameworks/TokenGenerationCore.framework/Versions/A/TokenGenerationCore -0x00000001b3f17000 /System/Library/PrivateFrameworks/Trial.framework/Versions/A/Trial -0x00000001b3e98000 /System/Library/PrivateFrameworks/TrialProto.framework/Versions/A/TrialProto -0x0000000239ad6000 /System/Library/PrivateFrameworks/AppleFlatBuffers.framework/Versions/A/AppleFlatBuffers -0x000000026d7ae000 /System/Library/PrivateFrameworks/SentencePieceInternal.framework/Versions/A/SentencePieceInternal -0x000000019e3b6000 /System/Library/Frameworks/Vision.framework/Versions/A/Vision -0x0000000244ed4000 /System/Library/PrivateFrameworks/CoreSceneUnderstanding.framework/Versions/A/CoreSceneUnderstanding -0x000000027fe1b000 /System/Library/PrivateFrameworks/VisionCore.framework/Versions/A/VisionCore -0x000000019862c000 /System/Library/PrivateFrameworks/DataDetectorsCore.framework/Versions/A/DataDetectorsCore -0x00000001bc7fd000 /System/Library/Frameworks/Vision.framework/libfaceCore.dylib -0x00000001bd317000 /System/Library/PrivateFrameworks/Futhark.framework/Versions/A/Futhark -0x00000001c1265000 /System/Library/PrivateFrameworks/InertiaCam.framework/Versions/A/InertiaCam -0x00000001bd0a4000 /System/Library/PrivateFrameworks/TextRecognition.framework/Versions/A/TextRecognition -0x000000022d719000 /System/Library/Frameworks/DataDetection.framework/Versions/A/DataDetection -0x00000001b72b0000 /System/Library/PrivateFrameworks/TextInput.framework/Versions/A/TextInput -0x00000001970da000 /System/Library/PrivateFrameworks/CVNLP.framework/Versions/A/CVNLP -0x00000001d9945000 /System/Library/PrivateFrameworks/HIDDisplay.framework/Versions/A/HIDDisplay -0x0000000199e91000 /usr/lib/libcups.2.dylib -0x0000000199f28000 /System/Library/Frameworks/Kerberos.framework/Versions/A/Kerberos -0x0000000199b98000 /usr/lib/libresolv.9.dylib -0x0000000197594000 /System/Library/PrivateFrameworks/Heimdal.framework/Versions/A/Heimdal -0x00000001a2d3c000 /System/Library/Frameworks/Kerberos.framework/Versions/A/Libraries/libHeimdalProxy.dylib -0x0000000199f8c000 /System/Library/PrivateFrameworks/CommonAuth.framework/Versions/A/CommonAuth -0x00000001ac04a000 /System/Library/PrivateFrameworks/AXCoreUtilities.framework/Versions/A/AXCoreUtilities -0x00000001bc646000 /System/Library/PrivateFrameworks/AttributeGraph.framework/Versions/A/AttributeGraph -0x000000028943d000 /usr/lib/libAXSafeCategoryBundle.dylib -0x0000000233e8e000 /System/Library/Frameworks/TabularData.framework/Versions/A/TabularData -0x000000023ad59000 /System/Library/PrivateFrameworks/ArgumentParserInternal.framework/Versions/A/ArgumentParserInternal -0x000000019469a000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib -0x0000000195c8f000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libLAPACK.dylib -0x0000000194bd1000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libLinearAlgebra.dylib -0x0000000195b03000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libSparseBLAS.dylib -0x0000000195c8a000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libQuadrature.dylib -0x0000000193611000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBNNS.dylib -0x0000000186e5d000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libSparse.dylib -0x000000022d1fe000 /System/Library/Frameworks/CoreTransferable.framework/Versions/A/CoreTransferable -0x0000000199ef0000 /System/Library/PrivateFrameworks/NetAuth.framework/Versions/A/NetAuth -0x00000001904f0000 /System/Library/PrivateFrameworks/login.framework/Versions/A/Frameworks/loginsupport.framework/Versions/A/loginsupport -0x000000018b68d000 /System/Library/PrivateFrameworks/UIFoundation.framework/Versions/A/UIFoundation -0x0000000194b39000 /usr/lib/libCheckFix.dylib -0x000000018f677000 /System/Library/PrivateFrameworks/MetadataUtilities.framework/Versions/A/MetadataUtilities -0x0000000255603000 /System/Library/PrivateFrameworks/InstalledContentLibrary.framework/Versions/A/InstalledContentLibrary -0x0000000189dce000 /System/Library/PrivateFrameworks/CoreServicesStore.framework/Versions/A/CoreServicesStore -0x000000019033b000 /usr/lib/libapp_launch_measurement.dylib -0x00000001c6dce000 /System/Library/PrivateFrameworks/MobileSystemServices.framework/Versions/A/MobileSystemServices -0x0000000196f5f000 /usr/lib/libxslt.1.dylib -0x0000000194af8000 /System/Library/PrivateFrameworks/BackgroundTaskManagement.framework/Versions/A/BackgroundTaskManagement -0x00000001a23ce000 /usr/lib/libcurl.4.dylib -0x000000028a153000 /usr/lib/libcrypto.46.dylib -0x000000028acd6000 /usr/lib/libssl.48.dylib -0x00000001a20a8000 /System/Library/Frameworks/LDAP.framework/Versions/A/LDAP -0x00000001a20e4000 /System/Library/PrivateFrameworks/TrustEvaluationAgent.framework/Versions/A/TrustEvaluationAgent -0x0000000199bb5000 /usr/lib/libsasl2.2.dylib -0x00000001a534c000 /System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa -0x0000000189f69000 /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit -0x000000023ec0a000 /System/Library/PrivateFrameworks/CollectionViewCore.framework/Versions/A/CollectionViewCore -0x0000000192bab000 /System/Library/PrivateFrameworks/XCTTargetBootstrap.framework/Versions/A/XCTTargetBootstrap -0x000000019867e000 /System/Library/PrivateFrameworks/UserActivity.framework/Versions/A/UserActivity -0x00000002486ec000 /System/Library/PrivateFrameworks/FrontBoard.framework/Versions/A/FrontBoard -0x000000027cbdb000 /System/Library/PrivateFrameworks/UIIntelligenceSupport.framework/Versions/A/UIIntelligenceSupport -0x0000000232f17000 /System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore -0x0000000283787000 /System/Library/PrivateFrameworks/WritingTools.framework/Versions/A/WritingTools -0x000000028257e000 /System/Library/PrivateFrameworks/WindowManagement.framework/Versions/A/WindowManagement -0x000000024841c000 /System/Library/PrivateFrameworks/FocusEngine.framework/Versions/A/FocusEngine -0x0000000245e25000 /System/Library/PrivateFrameworks/DesignLibrary.framework/Versions/A/DesignLibrary -0x0000000192b96000 /System/Library/PrivateFrameworks/DFRFoundation.framework/Versions/A/DFRFoundation -0x000000027db28000 /System/Library/PrivateFrameworks/UpdateCycle.framework/Versions/A/UpdateCycle -0x000000019289a000 /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox -0x000000019dc7c000 /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/SpeechRecognition.framework/Versions/A/SpeechRecognition -0x00000001902c2000 /System/Library/PrivateFrameworks/PerformanceAnalysis.framework/Versions/A/PerformanceAnalysis -0x000000019e00c000 /System/Library/Frameworks/Accessibility.framework/Versions/A/Accessibility -0x0000000233e74000 /System/Library/Frameworks/Symbols.framework/Versions/A/Symbols -0x00000002519e8000 /System/Library/PrivateFrameworks/Gestures.framework/Versions/A/Gestures -0x000000028b0fe000 /usr/lib/swift/libswiftSpatial.dylib -0x00000001b028a000 /usr/lib/swift/libswiftCoreGraphics.dylib -0x000000019ea50000 /usr/lib/swift/libswiftFoundation.dylib -0x00000001eaa6e000 /usr/lib/swift/libswiftSwiftOnoneSupport.dylib -0x000000028b384000 /usr/lib/swift/libswiftsys_time.dylib -0x00000001d55db000 /System/Library/PrivateFrameworks/CoreMaterial.framework/Versions/A/CoreMaterial -0x000000028993a000 /usr/lib/libSpatial.dylib -0x000000028935a000 /System/Library/SubFrameworks/UIUtilities.framework/Versions/A/UIUtilities -0x0000000105b3c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/server/libjvm.dylib -0x00000001048f4000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjimage.dylib -0x0000000104950000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjdwp.dylib -0x0000000104998000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjava.dylib -0x0000000104924000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libinstrument.dylib -0x0000000104a0c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -0x000000028942f000 /usr/lib/i18n/libiconv_std.dylib -0x0000000289425000 /usr/lib/i18n/libUTF8.dylib -0x0000000289434000 /usr/lib/i18n/libmapper_none.dylib -0x0000000104a98000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libdt_socket.dylib -0x0000000104b90000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnio.dylib -0x0000000104bd4000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnet.dylib -0x0000000104b70000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement.dylib -0x0000000104bb0000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement_ext.dylib -0x0000000104f00000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libextnet.dylib -0x0000000104f30000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libverify.dylib - - -VM Arguments: -jvm_args: -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:57772,suspend=y,server=n -javaagent:/Users/liangxin/Library/Caches/JetBrains/IntelliJIdea2026.1/captureAgent/debugger-agent.jar=file:///var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/capture2936228657643338079.props -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dkotlinx.coroutines.debug.enable.creation.stack.trace=false -Ddebugger.agent.enable.coroutines=true -Dkotlinx.coroutines.debug.enable.flows.stack.trace=true -Dkotlinx.coroutines.debug.enable.mutable.state.flows.stack.trace=true -Ddebugger.async.stack.trace.for.all.threads=true -Dfile.encoding=UTF-8 -java_command: org.springblade.transport.TransportApplication -java_class_path (initial): /Users/liangxin/Project/JAVA/tms-erp-api/blade-service/blade-transport/target/classes:/Users/liangxin/.m2/repository/org/springblade/blade-core-boot/4.10.0.BASE-SNAPSHOT/blade-core-boot-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-context/4.10.0.BASE-SNAPSHOT/blade-core-context-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-db/4.10.0.BASE-SNAPSHOT/blade-core-db-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/3.5.16/spring-boot-starter-jdbc-3.5.16.jar:/Users/liangxin/.m2/repository/com/zaxxer/HikariCP/6.3.3/HikariCP-6.3.3.jar:/Users/liangxin/.m2/repository/com/baomidou/mybatis-plus-spring-boot3-starter/3.5.16/mybatis-plus-spring-boot3-starter-3.5.16.jar:/Users/liangxin/.m2/repository/com/alibaba/druid-spring-boot-3-starter/1.2.28/druid-spring-boot-3-starter-1.2.28.jar:/Users/liangxin/.m2/repository/com/mysql/mysql-connector-j/9.4.0/mysql-connector-j-9.4.0.jar:/Users/liangxin/.m2/repository/com/google/protobuf/protobuf-java/4.31.1/protobuf-java-4.31.1.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-secure/4.10.0.BASE-SNAPSHOT/blade-core-secure-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springblade/blade-core-cloud/4.10.0.BASE-SNAPSHOT/blade-core-cloud-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/de/codecentric/spring-boot-admin-starter-client/3.5.9/spring-boot-admin-starter-client-3.5.9.jar:/Users/liangxin/.m2/repository/de/codecentric/spring-boot-admin-client/3.5.9/spring-boot-admin-client-3.5.9.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-starter-actuator/3.5.16/spring-boot-starter-actuator-3.5.16.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-actuator-autoconfigure/3.5.16/spring-boot-actuator-autoconfigure-3.5.16.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-actuator/3.5.16/spring-boot-act -Launcher Type: SUN_STANDARD - -[Global flags] - intx CICompilerCount = 4 {product} {ergonomic} - uint ConcGCThreads = 3 {product} {ergonomic} - uint G1ConcRefinementThreads = 10 {product} {ergonomic} - size_t G1HeapRegionSize = 8388608 {product} {ergonomic} - uintx GCDrainStackTargetSize = 64 {product} {ergonomic} - size_t InitialHeapSize = 603979776 {product} {ergonomic} - bool ManagementServer = true {product} {command line} - size_t MarkStackSize = 4194304 {product} {ergonomic} - size_t MaxHeapSize = 9663676416 {product} {ergonomic} - size_t MaxNewSize = 5796528128 {product} {ergonomic} - size_t MinHeapDeltaBytes = 8388608 {product} {ergonomic} - size_t MinHeapSize = 8388608 {product} {ergonomic} - uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} - bool ProfileInterpreter = false {pd product} {command line} - uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} - size_t SoftMaxHeapSize = 9663676416 {manageable} {ergonomic} - intx TieredStopAtLevel = 1 {product} {command line} - bool UseCompressedClassPointers = true {product lp64_product} {ergonomic} - bool UseCompressedOops = true {product lp64_product} {ergonomic} - bool UseG1GC = true {product} {ergonomic} - bool UseNUMA = false {product} {ergonomic} - bool UseNUMAInterleaving = false {product} {ergonomic} - -Logging: -Log output configuration: - #0: stdout all=warning uptime,level,tags - #1: stderr all=off uptime,level,tags - -Environment Variables: -JAVA_HOME=/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home -PATH=/Users/liangxin/ai-infra/.venv/bin:/Users/liangxin/.nacos/bin:/Applications/Docker.app/Contents/Resources/bin:/Users/liangxin/Library/pnpm:/opt/homebrew/opt/ruby@3.2/bin:/opt/homebrew/opt/openssl@3/bin:/opt/miniconda3/bin:/opt/miniconda3/condabin:/usr/local/sbin:/usr/local/bin:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Users/liangxin/fvm/default/bin:/Applications/MAMP/bin/php/php8.1.13/bin:/opt/homebrew/opt/ruby@3.2/bin:/Users/liangxin/.nvm/versions/node/v20.18.3/bin:/Applications/apache-tomcat-9.0.78:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Users/liangxin/fvm/default/bin:/opt/homebrew/opt/libpng/bin:/Applications/pngquant:/Users/liangxin/AndroidSDK/platform-tools:/Users/liangxin/Library/Android/sdk/platform-tools:/Users/liangxin/Library/Andriod/sdk/cmdline-tools/latest/bin:/Users/liangxin/Library/Andriod/sdk:/Applications/apache-maven-3.8.1/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/local/bin:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Library/Frameworks/Python.framework/Versions/3.9/bin:/Users/liangxin/.local/bin:/Users/liangxin/fvm/default/bin:/Applications/MAMP/bin/php/php8.1.13/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/Library/Apple/usr/bin:/usr/local/share/dotnet:~/.dotnet/tools:/Library/Frameworks/Mono.framework/Versions/Current/Commands:/Users/liangxin/.cargo/bin:true:/Applications/极空间.app/Contents/Resources/app.asar.unpacked/bin/platform-tools -SHELL=/bin/zsh -LANG=C.UTF-8 -TMPDIR=/var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/ - -Active Locale: -LC_ALL=C.UTF-8 -LC_COLLATE=C.UTF-8 -LC_CTYPE=C.UTF-8 -LC_MESSAGES=C.UTF-8 -LC_MONETARY=C.UTF-8 -LC_NUMERIC=C.UTF-8 -LC_TIME=C.UTF-8 - -Signal Handlers: - SIGSEGV: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGBUS: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGFPE: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGPIPE: javaSignalHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGXFSZ: javaSignalHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGILL: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGUSR2: SR_handler in libjvm.dylib, mask=00000000000000000000000000000000, flags=SA_RESTART|SA_SIGINFO, blocked - SIGHUP: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGINT: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGTERM: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGQUIT: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGTRAP: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - - ---------------- S Y S T E M --------------- - -OS: -uname: Darwin 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:16:36 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6030 arm64 -OS uptime: 3 days 11:55 hours -rlimit (soft/hard): STACK 8176k/65520k , CORE 0k/infinity , NPROC 6000/9000 , NOFILE 10240/65536 , AS infinity/infinity , CPU infinity/infinity , DATA infinity/infinity , FSIZE infinity/infinity , MEMLOCK infinity/infinity , RSS infinity/infinity -load average: 13.76 8.45 9.16 - -CPU: total 12 (initial active 12) 0x61:0x0:0x5f4dea93:0, fp, simd, crc, lse -machdep.cpu.brand_string:Apple M3 Pro -hw.cachelinesize:128 -hw.l1icachesize:131072 -hw.l1dcachesize:65536 -hw.l2cachesize:4194304 - -Memory: 16k page, physical 37748736k(1387216k free), swap 12582912k(791104k free) - -vm_info: OpenJDK 64-Bit Server VM (17.0.8+7-LTS) for bsd-aarch64 JRE (17.0.8+7-LTS) (Zulu17.44+15-CA), built on Jul 5 2023 00:50:04 by "zulu_re" with clang Apple LLVM 12.0.0 (clang-1200.0.32.28) - -END. diff --git a/hs_err_pid40988.log b/hs_err_pid40988.log deleted file mode 100644 index 52e4b4ea..00000000 --- a/hs_err_pid40988.log +++ /dev/null @@ -1,1368 +0,0 @@ -# -# A fatal error has been detected by the Java Runtime Environment: -# -# SIGBUS (0xa) at pc=0x00000001046de4c0, pid=40988, tid=36131 -# -# JRE version: OpenJDK Runtime Environment Zulu17.44+15-CA (17.0.8+7) (build 17.0.8+7-LTS) -# Java VM: OpenJDK 64-Bit Server VM Zulu17.44+15-CA (17.0.8+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64) -# Problematic frame: -# C [libzip.dylib+0x64c0] newEntry+0x68 -# -# No core dump will be written. Core dumps have been disabled. To enable core dumping, try "ulimit -c unlimited" before starting Java again -# -# If you would like to submit a bug report, please visit: -# http://www.azul.com/support/ -# The crash happened outside the Java Virtual Machine in native code. -# See problematic frame for where to report the bug. -# - ---------------- S U M M A R Y ------------ - -Command Line: -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:49167,suspend=y,server=n -javaagent:/Users/liangxin/Library/Caches/JetBrains/IntelliJIdea2026.1/captureAgent/debugger-agent.jar=file:///var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/capture18007844485071508468.props -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dkotlinx.coroutines.debug.enable.creation.stack.trace=false -Ddebugger.agent.enable.coroutines=true -Dkotlinx.coroutines.debug.enable.flows.stack.trace=true -Dkotlinx.coroutines.debug.enable.mutable.state.flows.stack.trace=true -Ddebugger.async.stack.trace.for.all.threads=true -Dfile.encoding=UTF-8 org.springblade.resource.ResourceApplication - -Host: "Mac15,6" arm64, 12 cores, 36G, Darwin 25.6.0, macOS 26.6.2 (25G83) -Time: Thu Sep 17 20:22:18 2026 CST elapsed time: 7.706668 seconds (0d 0h 0m 7s) - ---------------- T H R E A D --------------- - -Current thread (0x000000010fa93c00): JavaThread "sentinel-datafile-log-executor-thread-1" daemon [_thread_in_native, id=36131, stack(0x0000000318210000,0x0000000318413000)] - -Stack: [0x0000000318210000,0x0000000318413000], sp=0x00000003184119d0, free space=2054k -Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) -C [libzip.dylib+0x64c0] newEntry+0x68 -C [libzip.dylib+0x6390] ZIP_GetEntry2+0x14c -C [libzip.dylib+0x6d78] ZIP_FindEntry+0x3c -V [libjvm.dylib+0x25a408] ClassPathZipEntry::open_entry(JavaThread*, char const*, int*, bool)+0xb4 -V [libjvm.dylib+0x25a53c] ClassPathZipEntry::open_stream(JavaThread*, char const*)+0x20 -V [libjvm.dylib+0x25d918] ClassLoader::load_class(Symbol*, bool, JavaThread*)+0x150 -V [libjvm.dylib+0x981d60] SystemDictionary::load_instance_class_impl(Symbol*, Handle, JavaThread*)+0x2d0 -V [libjvm.dylib+0x98063c] SystemDictionary::load_instance_class(unsigned int, Symbol*, Handle, JavaThread*)+0x30 -V [libjvm.dylib+0x97fd48] SystemDictionary::resolve_instance_class_or_null(Symbol*, Handle, Handle, JavaThread*)+0x4dc -V [libjvm.dylib+0x97f334] SystemDictionary::resolve_or_fail(Symbol*, Handle, Handle, bool, JavaThread*)+0x80 -V [libjvm.dylib+0x2beb54] ConstantPool::klass_at_impl(constantPoolHandle const&, int, JavaThread*)+0x1e0 -V [libjvm.dylib+0x46d6f0] InterpreterRuntime::_new(JavaThread*, ConstantPool*, int)+0x94 -j com.intellij.rt.debugger.agent.CaptureStorage$DeepCapturedStack.collectStacks(Ljava/util/List;)Lcom/intellij/rt/debugger/agent/CaptureStorage$StackData;+89 -j com.intellij.rt.debugger.agent.CaptureStorage.getStackTrace(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+30 -j com.intellij.rt.debugger.agent.CaptureStorage.access$1000(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()[Ljava/lang/StackTraceElement;+31 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()Ljava/lang/Object;+1 -j com.intellij.rt.debugger.agent.CaptureStorage.withoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$Callable;)Ljava/lang/Object;+21 -j com.intellij.rt.debugger.agent.CaptureStorage.getAsyncStackTrace(Ljava/lang/Throwable;)[Ljava/lang/StackTraceElement;+19 -j java.lang.Throwable.printStackTrace(Ljava/lang/Throwable$PrintStreamOrWriter;)V+32 java.base@17.0.8 -j java.lang.Throwable.printStackTrace(Ljava/io/PrintWriter;)V+9 java.base@17.0.8 -j com.alibaba.csp.sentinel.log.jul.CspFormatter.format(Ljava/util/logging/LogRecord;)Ljava/lang/String;+103 -j java.util.logging.StreamHandler.publish(Ljava/util/logging/LogRecord;)V+14 java.logging@17.0.8 -j java.util.logging.FileHandler.publish(Ljava/util/logging/LogRecord;)V+11 java.logging@17.0.8 -j com.alibaba.csp.sentinel.log.jul.DateFileLogHandler$LogTask.run()V+8 -j java.util.concurrent.ThreadPoolExecutor.runWorker(Ljava/util/concurrent/ThreadPoolExecutor$Worker;)V+92 java.base@17.0.8 -j java.util.concurrent.ThreadPoolExecutor$Worker.run()V+5 java.base@17.0.8 -j java.lang.Thread.run()V+11 java.base@17.0.8 -v ~StubRoutines::call_stub -V [libjvm.dylib+0x4781f8] JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, JavaThread*)+0x394 -V [libjvm.dylib+0x47720c] JavaCalls::call_virtual(JavaValue*, Klass*, Symbol*, Symbol*, JavaCallArguments*, JavaThread*)+0x11c -V [libjvm.dylib+0x4772d8] JavaCalls::call_virtual(JavaValue*, Handle, Klass*, Symbol*, Symbol*, JavaThread*)+0x64 -V [libjvm.dylib+0x52ebfc] thread_entry(JavaThread*, JavaThread*)+0xc4 -V [libjvm.dylib+0x9b22e8] JavaThread::thread_main_inner()+0x150 -V [libjvm.dylib+0x9b0990] Thread::call_run()+0xe0 -V [libjvm.dylib+0x7d0364] thread_native_entry(Thread*)+0x158 -C [libsystem_pthread.dylib+0x6c58] _pthread_start+0x88 - -Java frames: (J=compiled Java code, j=interpreted, Vv=VM code) -j com.intellij.rt.debugger.agent.CaptureStorage$DeepCapturedStack.collectStacks(Ljava/util/List;)Lcom/intellij/rt/debugger/agent/CaptureStorage$StackData;+89 -j com.intellij.rt.debugger.agent.CaptureStorage.getStackTrace(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+30 -j com.intellij.rt.debugger.agent.CaptureStorage.access$1000(Lcom/intellij/rt/debugger/agent/CaptureStorage$CapturedStack;I)Ljava/util/ArrayList;+2 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()[Ljava/lang/StackTraceElement;+31 -j com.intellij.rt.debugger.agent.CaptureStorage$9.call()Ljava/lang/Object;+1 -j com.intellij.rt.debugger.agent.CaptureStorage.withoutThrowableCapture(Lcom/intellij/rt/debugger/agent/CaptureStorage$Callable;)Ljava/lang/Object;+21 -j com.intellij.rt.debugger.agent.CaptureStorage.getAsyncStackTrace(Ljava/lang/Throwable;)[Ljava/lang/StackTraceElement;+19 -j java.lang.Throwable.printStackTrace(Ljava/lang/Throwable$PrintStreamOrWriter;)V+32 java.base@17.0.8 -j java.lang.Throwable.printStackTrace(Ljava/io/PrintWriter;)V+9 java.base@17.0.8 -j com.alibaba.csp.sentinel.log.jul.CspFormatter.format(Ljava/util/logging/LogRecord;)Ljava/lang/String;+103 -j java.util.logging.StreamHandler.publish(Ljava/util/logging/LogRecord;)V+14 java.logging@17.0.8 -j java.util.logging.FileHandler.publish(Ljava/util/logging/LogRecord;)V+11 java.logging@17.0.8 -j com.alibaba.csp.sentinel.log.jul.DateFileLogHandler$LogTask.run()V+8 -j java.util.concurrent.ThreadPoolExecutor.runWorker(Ljava/util/concurrent/ThreadPoolExecutor$Worker;)V+92 java.base@17.0.8 -j java.util.concurrent.ThreadPoolExecutor$Worker.run()V+5 java.base@17.0.8 -j java.lang.Thread.run()V+11 java.base@17.0.8 -v ~StubRoutines::call_stub - -siginfo: si_signo: 10 (SIGBUS), si_code: 1 (BUS_ADRALN), si_addr: 0x00000001046c5e7b - -Register to memory mapping: - - x0=0x0000600000694eb0 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x1=0x0 is NULL - x2=0x0 is NULL - x3=0x0000600000694ec0 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 - x4=0x0000600000694f00 points into unknown readable memory: 0x0000600003c95440 | 40 54 c9 03 00 60 00 00 - x5=0x000000009d64e030 is an unknown value - x6=0x0000000000000eb0 is an unknown value - x7=0x000000000000000a is an unknown value - x8=0x00000001047ede5f: gdata+0xcbf7 in /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjdwp.dylib at 0x00000001047a8000 - x9=0x0000000000128000 is an unknown value -x10=0x0000600000694eb0 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x11=0x000066d8c3c50000 is an unknown value -x12=0x0000000000000050 is an unknown value -x13=0x000060000242e518 points into unknown readable memory: 0x000000009d84d82f | 2f d8 84 9d 00 00 00 00 -x14=0x00000000001ff800 is an unknown value -x15=0x00000000000007fb is an unknown value -x16=0x0000000186e7d030: __bzero+0 in /usr/lib/system/libsystem_platform.dylib at 0x0000000186e7a000 -x17=0x00000001f4ef54a8 points into unknown readable memory: 0x0000000186e7d030 | 30 d0 e7 86 01 00 00 00 -x18=0x0 is NULL -x19=0x0000600000694eb0 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x20=0x0 is NULL -x21=0x0000600001324000 points into unknown readable memory: 0x00006000002382a0 | a0 82 23 00 00 60 00 00 -x22=0x00000001046c5e5f points into unknown readable memory: 50 -x23=0x00000000d3a18b02 is an unknown value -x24=0x000000000000002f is an unknown value -x25=0x000000000000003d is an unknown value -x26=0x00000000000000cd is an unknown value -x27=0x0000600000694ed8 points into unknown readable memory: 0x0000000000000000 | 00 00 00 00 00 00 00 00 -x28=0x0000000105127d40 points into unknown readable memory: 0x65746e692f6d6f63 | 63 6f 6d 2f 69 6e 74 65 - - -Registers: - x0=0x0000600000694eb0 x1=0x0000000000000000 x2=0x0000000000000000 x3=0x0000600000694ec0 - x4=0x0000600000694f00 x5=0x000000009d64e030 x6=0x0000000000000eb0 x7=0x000000000000000a - x8=0x00000001047ede5f x9=0x0000000000128000 x10=0x0000600000694eb0 x11=0x000066d8c3c50000 -x12=0x0000000000000050 x13=0x000060000242e518 x14=0x00000000001ff800 x15=0x00000000000007fb -x16=0x0000000186e7d030 x17=0x00000001f4ef54a8 x18=0x0000000000000000 x19=0x0000600000694eb0 -x20=0x0000000000000000 x21=0x0000600001324000 x22=0x00000001046c5e5f x23=0x00000000d3a18b02 -x24=0x000000000000002f x25=0x000000000000003d x26=0x00000000000000cd x27=0x0000600000694ed8 -x28=0x0000000105127d40 fp=0x0000000318411a50 lr=0x00000001046de48c sp=0x00000003184119d0 -pc=0x00000001046de4c0 cpsr=0x0000000060001000 -Top of Stack: (sp=0x00000003184119d0) -0x00000003184119d0: 0000000000000000 0000000000000000 -0x00000003184119e0: 0000000000000000 0000000000000000 -0x00000003184119f0: 0000000000000000 0000000000000000 -0x0000000318411a00: 0000000105127d40 0000000107809600 -0x0000000318411a10: 00000000000000cd 000000000000003d -0x0000000318411a20: 000000000000002f 00000000d3a18b02 -0x0000000318411a30: 0000600000694f00 0000000000000000 -0x0000000318411a40: 0000000105127d80 0000600001324000 -0x0000000318411a50: 0000000318411ab0 00000001046de390 -0x0000000318411a60: 0000000105127d40 0000000000000037 -0x0000000318411a70: 0000000000000001 0000000105127d80 -0x0000000318411a80: 000000010fa93f48 0000000105127d80 -0x0000000318411a90: 0000600001324000 0000000105127d80 -0x0000000318411aa0: 0000000318411bdc 0000000318411af4 -0x0000000318411ab0: 0000000318411ae0 00000001046ded78 -0x0000000318411ac0: 0000000318411bdc 00006000028341e0 -0x0000000318411ad0: 0000000000000000 000000010fa93c00 -0x0000000318411ae0: 0000000318411bc0 0000000105a1a408 -0x0000000318411af0: 0000000106278388 0000000000000100 -0x0000000318411b00: 0000000318411b20 0000000105cee5dc -0x0000000318411b10: 0000000106278388 0000000318411ba0 -0x0000000318411b20: 0000000318411b70 0000000105aa096c -0x0000000318411b30: 0000000000000000 0000000000000000 -0x0000000318411b40: 0000000000000001 0000000158a50290 -0x0000000318411b50: 000000010fa93c00 0000000105128118 -0x0000000318411b60: 000000010628d1e2 0000000318411c68 -0x0000000318411b70: 0000000318411b90 43eef1f7fdfa0026 -0x0000000318411b80: 0000000000000001 0000000105127d80 -0x0000000318411b90: 00006000028341e0 0000000158a50290 -0x0000000318411ba0: 000000010fa93c00 0000000105128118 -0x0000000318411bb0: 0000000105127d30 00006000028341e0 -0x0000000318411bc0: 0000000318411bf0 0000000105a1a53c - -Instructions: (pc=0x00000001046de4c0) -0x00000001046de3c0: 6b0c017f 54ffff60 17ffffde d2800016 -0x00000001046de3d0: 72001ebf 54000160 b5000156 f100073f -0x00000001046de3e0: 54fff7cb 8b140328 385ff108 7100bd1f -0x00000001046de3f0: 54fff741 d2800016 14000002 f9004e7f -0x00000001046de400: f9402a60 94000451 aa1603e0 a9457bfd -0x00000001046de410: a9444ff4 a94357f6 a9425ff8 a94167fa -0x00000001046de420: a8c66ffc d65f03c0 6b03003f 540000e1 -0x00000001046de430: 71000421 540000eb 38401408 38401449 -0x00000001046de440: 6b09011f 54ffff60 52800000 d65f03c0 -0x00000001046de450: 52800020 d65f03c0 d10243ff a9036ffc -0x00000001046de460: a90467fa a9055ff8 a90657f6 a9074ff4 -0x00000001046de470: a9087bfd 910203fd aa0203f4 aa0103f6 -0x00000001046de480: aa0003f5 52800900 94000481 aa0003f3 -0x00000001046de490: b4001320 f900027f aa1303fb f8028f7f -0x00000001046de4a0: f9001a7f 3940c2a8 34000288 f9400ea8 -0x00000001046de4b0: f94006c9 8b090108 f94016a9 cb090116 -0x00000001046de4c0: 79403ad8 39407ada 39407edc 794042c8 -0x00000001046de4d0: f90017e8 b9400ec8 f9000668 b9401ac8 -0x00000001046de4e0: f9000fe8 f9000a68 794016c8 34000488 -0x00000001046de4f0: b94016c8 14000023 f94006d7 34000d54 -0x00000001046de500: f9401ea8 b4000288 f94022a9 eb17013f -0x00000001046de510: 5400022c 5283fa4a 8b0a012a eb17015f -0x00000001046de520: 540001ab 9140092a 8b170108 cb090116 -0x00000001046de530: 79403ac8 79403ec9 794042cb 8b0802e8 -0x00000001046de540: 8b090108 8b0b0108 9100b908 eb0a011f -0x00000001046de550: 54000b4d aa1503e0 aa1703e1 52840002 -0x00000001046de560: 94000384 aa0003f6 b4000aa0 f9401ea0 -0x00000001046de570: 94000429 a903deb6 17ffffd2 d2800008 -0x00000001046de580: aa0803f7 f9000e68 b94012c8 b9002268 -0x00000001046de590: b842a2c9 f9405ea8 f9000be9 8b090108 -0x00000001046de5a0: cb0803e8 f9001e68 794012c8 b9004268 -0x00000001046de5b0: 91000700 94000436 aa0003f9 f9000260 - - -Stack slot to memory mapping: -stack at sp + 0 slots: 0x0 is NULL -stack at sp + 1 slots: 0x0 is NULL -stack at sp + 2 slots: 0x0 is NULL -stack at sp + 3 slots: 0x0 is NULL -stack at sp + 4 slots: 0x0 is NULL -stack at sp + 5 slots: 0x0 is NULL -stack at sp + 6 slots: 0x0000000105127d40 points into unknown readable memory: 0x65746e692f6d6f63 | 63 6f 6d 2f 69 6e 74 65 -stack at sp + 7 slots: 0x0000000107809600 points into unknown readable memory: 0xffffffff5bbd78a2 | a2 78 bd 5b ff ff ff ff - - ---------------- P R O C E S S --------------- - -Threads class SMR info: -_java_thread_list=0x000060000217cce0, length=71, elements={ -0x0000000107808a00, 0x000000011b808200, 0x000000010f813a00, 0x0000000107009200, -0x0000000107009800, 0x000000011e014a00, 0x000000011b00c400, 0x0000000107823800, -0x000000011c00a200, 0x000000011a813c00, 0x000000011a816400, 0x000000011e80fa00, -0x000000011a0d9600, 0x000000010f812c00, 0x000000011e02fe00, 0x000000011b1b6800, -0x000000011a573600, 0x000000011c8e8800, 0x000000011b960000, 0x0000000107024a00, -0x000000011f3d8600, 0x000000011eba7600, 0x000000010f9e0600, 0x000000011f41b600, -0x000000011c068400, 0x000000011f47fc00, 0x000000011c047c00, 0x000000011f440200, -0x000000011b404800, 0x000000011f4a3a00, 0x000000011c07bc00, 0x000000011c095c00, -0x000000011b95a800, 0x000000011e499200, 0x000000011e4bd000, 0x000000011b4a8800, -0x000000011a66aa00, 0x000000011f504600, 0x000000011ad0cc00, 0x000000011f598a00, -0x000000011b9bb000, 0x000000011a693400, 0x000000011f3e9600, 0x0000000107978800, -0x0000000107973c00, 0x0000000107934000, 0x0000000107961000, 0x000000011b989600, -0x000000011ad8e400, 0x000000011ad93000, 0x000000011e4a8000, 0x000000011b52f200, -0x000000011b9a0a00, 0x000000011a69d400, 0x000000011b563c00, 0x000000010fa93c00, -0x000000011b5a3a00, 0x000000011e51f600, 0x000000011f65ba00, 0x000000011b62e600, -0x000000011edd6200, 0x000000011edf8e00, 0x000000011f67a800, 0x000000011ae25600, -0x000000011ae41800, 0x000000011ae41e00, 0x000000011edf4400, 0x000000011edef000, -0x000000011b3b4400, 0x0000000107ada400, 0x000000011b77a400 -} - -Java Threads: ( => current thread ) - 0x0000000107808a00 JavaThread "main" [_thread_in_native, id=4355, stack(0x000000016b888000,0x000000016ba8b000)] - 0x000000011b808200 JavaThread "Reference Handler" daemon [_thread_blocked, id=18179, stack(0x000000016c6dc000,0x000000016c8df000)] - 0x000000010f813a00 JavaThread "Finalizer" daemon [_thread_blocked, id=18947, stack(0x000000016c8e8000,0x000000016caeb000)] - 0x0000000107009200 JavaThread "Signal Dispatcher" daemon [_thread_blocked, id=31235, stack(0x000000016cc0c000,0x000000016ce0f000)] - 0x0000000107009800 JavaThread "Service Thread" daemon [_thread_blocked, id=30723, stack(0x000000016ce18000,0x000000016d01b000)] - 0x000000011e014a00 JavaThread "Monitor Deflation Thread" daemon [_thread_blocked, id=23299, stack(0x000000016d024000,0x000000016d227000)] - 0x000000011b00c400 JavaThread "C1 CompilerThread0" daemon [_thread_blocked, id=30211, stack(0x000000016d230000,0x000000016d433000)] - 0x0000000107823800 JavaThread "Sweeper thread" daemon [_thread_blocked, id=24067, stack(0x000000016d43c000,0x000000016d63f000)] - 0x000000011c00a200 JavaThread "Common-Cleaner" daemon [_thread_blocked, id=24579, stack(0x000000016d648000,0x000000016d84b000)] - 0x000000011a813c00 JavaThread "JDWP Transport Listener: dt_socket" daemon [_thread_blocked, id=25091, stack(0x000000016d854000,0x000000016da57000)] - 0x000000011a816400 JavaThread "JDWP Event Helper Thread" daemon [_thread_blocked, id=29699, stack(0x000000016da60000,0x000000016dc63000)] - 0x000000011e80fa00 JavaThread "JDWP Command Reader" daemon [_thread_in_native, id=29443, stack(0x000000016dc6c000,0x000000016de6f000)] - 0x000000011a0d9600 JavaThread "IntelliJ Suspend Helper" daemon [_thread_blocked, id=28931, stack(0x000000016de78000,0x000000016e07b000)] - 0x000000010f812c00 JavaThread "Notification Thread" daemon [_thread_blocked, id=25603, stack(0x000000016e084000,0x000000016e287000)] - 0x000000011e02fe00 JavaThread "CoarseTimer" daemon [_thread_blocked, id=28163, stack(0x000000016e290000,0x000000016e493000)] - 0x000000011b1b6800 JavaThread "RMI TCP Accept-0" daemon [_thread_in_native, id=34307, stack(0x000000016f708000,0x000000016f90b000)] - 0x000000011a573600 JavaThread "com.alibaba.nacos.client.logging.0" daemon [_thread_blocked, id=35331, stack(0x000000016fb20000,0x000000016fd23000)] - 0x000000011c8e8800 JavaThread "Attach Listener" daemon [_thread_blocked, id=42243, stack(0x000000016fd2c000,0x000000016ff2f000)] - 0x000000011b960000 JavaThread "RMI TCP Connection(1)-127.0.0.1" daemon [_thread_in_native, id=40963, stack(0x0000000318a40000,0x0000000318c43000)] - 0x0000000107024a00 JavaThread "nacos.publisher-com.alibaba.nacos.common.notify.SlowEvent" daemon [_thread_blocked, id=37635, stack(0x0000000318c4c000,0x0000000318e4f000)] - 0x000000011f3d8600 JavaThread "nacos.publisher-com.alibaba.nacos.client.config.impl.ConfigFuzzyWatchNotifyEvent" daemon [_thread_blocked, id=40451, stack(0x0000000318e58000,0x000000031905b000)] - 0x000000011eba7600 JavaThread "nacos.publisher-com.alibaba.nacos.client.config.impl.ConfigFuzzyWatchLoadEvent" daemon [_thread_blocked, id=39939, stack(0x0000000319064000,0x0000000319267000)] - 0x000000010f9e0600 JavaThread "RMI Scheduler(0)" daemon [_thread_blocked, id=38915, stack(0x0000000319688000,0x000000031988b000)] - 0x000000011f41b600 JavaThread "com.alibaba.nacos.client.auth.ram.identify.watcher.0" daemon [_thread_blocked, id=43527, stack(0x0000000319894000,0x0000000319a97000)] - 0x000000011c068400 JavaThread "com.alibaba.nacos.client.login-executor.0" daemon [_thread_blocked, id=37127, stack(0x0000000318834000,0x0000000318a37000)] - 0x000000011f47fc00 JavaThread "com.alibaba.nacos.client.listen-executor.0" daemon [_thread_blocked, id=43779, stack(0x0000000319aa0000,0x0000000319ca3000)] - 0x000000011c047c00 JavaThread "com.alibaba.nacos.client.fuzzy-watcher-executor.0" daemon [_thread_blocked, id=44291, stack(0x0000000319cac000,0x0000000319eaf000)] - 0x000000011f440200 JavaThread "com.alibaba.nacos.client.remote.worker.0" daemon [_thread_blocked, id=44803, stack(0x0000000319eb8000,0x000000031a0bb000)] - 0x000000011b404800 JavaThread "com.alibaba.nacos.client.remote.worker.1" daemon [_thread_blocked, id=64771, stack(0x000000031a0c4000,0x000000031a2c7000)] - 0x000000011f4a3a00 JavaThread "grpc-nio-worker-ELG-1-1" daemon [_thread_in_native, id=64531, stack(0x000000031a2d0000,0x000000031a4d3000)] - 0x000000011c07bc00 JavaThread "grpc-default-executor-0" daemon [_thread_blocked, id=64003, stack(0x000000031a4dc000,0x000000031a6df000)] - 0x000000011c095c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-0" daemon [_thread_blocked, id=45315, stack(0x000000031a6e8000,0x000000031a8eb000)] - 0x000000011b95a800 JavaThread "nacos-grpc-client-executor-127.0.0.1-1" daemon [_thread_blocked, id=45827, stack(0x000000031a8f4000,0x000000031aaf7000)] - 0x000000011e499200 JavaThread "grpc-nio-worker-ELG-1-2" daemon [_thread_in_native, id=46083, stack(0x000000031ab00000,0x000000031ad03000)] - 0x000000011e4bd000 JavaThread "nacos-grpc-client-executor-127.0.0.1-2" daemon [_thread_blocked, id=46339, stack(0x000000031ad0c000,0x000000031af0f000)] - 0x000000011b4a8800 JavaThread "nacos-grpc-client-executor-127.0.0.1-3" daemon [_thread_blocked, id=62979, stack(0x000000031af18000,0x000000031b11b000)] - 0x000000011a66aa00 JavaThread "nacos-grpc-client-executor-127.0.0.1-4" daemon [_thread_blocked, id=62467, stack(0x000000031b124000,0x000000031b327000)] - 0x000000011f504600 JavaThread "nacos-grpc-client-executor-127.0.0.1-5" daemon [_thread_blocked, id=46851, stack(0x000000031b330000,0x000000031b533000)] - 0x000000011ad0cc00 JavaThread "nacos-grpc-client-executor-127.0.0.1-6" daemon [_thread_blocked, id=47363, stack(0x000000031b53c000,0x000000031b73f000)] - 0x000000011f598a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-7" daemon [_thread_blocked, id=61955, stack(0x000000031b748000,0x000000031b94b000)] - 0x000000011b9bb000 JavaThread "nacos.publisher-com.alibaba.nacos.common.ability.AbstractAbilityControlManager$AbilityUpdateEvent" daemon [_thread_blocked, id=61443, stack(0x000000031b954000,0x000000031bb57000)] - 0x000000011a693400 JavaThread "nacos-grpc-client-executor-127.0.0.1-8" daemon [_thread_blocked, id=47875, stack(0x000000031bb60000,0x000000031bd63000)] - 0x000000011f3e9600 JavaThread "nacos-grpc-client-executor-127.0.0.1-9" daemon [_thread_blocked, id=60931, stack(0x000000031bd6c000,0x000000031bf6f000)] - 0x0000000107978800 JavaThread "nacos-grpc-client-executor-127.0.0.1-10" daemon [_thread_blocked, id=48387, stack(0x000000031bf78000,0x000000031c17b000)] - 0x0000000107973c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-11" daemon [_thread_blocked, id=48643, stack(0x000000031c184000,0x000000031c387000)] - 0x0000000107934000 JavaThread "nacos-grpc-client-executor-127.0.0.1-12" daemon [_thread_blocked, id=48899, stack(0x000000031c390000,0x000000031c593000)] - 0x0000000107961000 JavaThread "nacos-grpc-client-executor-127.0.0.1-13" daemon [_thread_blocked, id=49155, stack(0x000000031c59c000,0x000000031c79f000)] - 0x000000011b989600 JavaThread "nacos-grpc-client-executor-127.0.0.1-14" daemon [_thread_blocked, id=59395, stack(0x000000031c7a8000,0x000000031c9ab000)] - 0x000000011ad8e400 JavaThread "nacos-grpc-client-executor-127.0.0.1-15" daemon [_thread_blocked, id=49667, stack(0x000000031c9b4000,0x000000031cbb7000)] - 0x000000011ad93000 JavaThread "nacos-grpc-client-executor-127.0.0.1-16" daemon [_thread_blocked, id=58883, stack(0x000000031cbc0000,0x000000031cdc3000)] - 0x000000011e4a8000 JavaThread "nacos-grpc-client-executor-127.0.0.1-17" daemon [_thread_blocked, id=58627, stack(0x000000031cdcc000,0x000000031cfcf000)] - 0x000000011b52f200 JavaThread "nacos-grpc-client-executor-127.0.0.1-18" daemon [_thread_blocked, id=50435, stack(0x000000031cfd8000,0x000000031d1db000)] - 0x000000011b9a0a00 JavaThread "nacos-grpc-client-executor-127.0.0.1-19" daemon [_thread_blocked, id=50955, stack(0x000000031d1e4000,0x000000031d3e7000)] - 0x000000011a69d400 JavaThread "nacos-grpc-client-executor-127.0.0.1-20" daemon [_thread_blocked, id=51203, stack(0x000000031d3f0000,0x000000031d5f3000)] - 0x000000011b563c00 JavaThread "nacos-grpc-client-executor-127.0.0.1-21" daemon [_thread_blocked, id=51459, stack(0x000000031d5fc000,0x000000031d7ff000)] -=>0x000000010fa93c00 JavaThread "sentinel-datafile-log-executor-thread-1" daemon [_thread_in_native, id=36131, stack(0x0000000318210000,0x0000000318413000)] - 0x000000011b5a3a00 JavaThread "sentinel-command-center-executor-thread-1" daemon [_thread_in_native, id=36635, stack(0x0000000318628000,0x000000031882b000)] - 0x000000011e51f600 JavaThread "sentinel-heartbeat-send-task-thread-1" daemon [_thread_blocked, id=41523, stack(0x000000031841c000,0x000000031861f000)] - 0x000000011f65ba00 JavaThread "C1 CompilerThread1" daemon [_thread_blocked, id=36403, stack(0x0000000318004000,0x0000000318207000)] - 0x000000011b62e600 JavaThread "nacos-grpc-client-executor-127.0.0.1-22" daemon [_thread_blocked, id=57635, stack(0x000000031d808000,0x000000031da0b000)] - 0x000000011edd6200 JavaThread "nacos-grpc-client-executor-127.0.0.1-23" daemon [_thread_blocked, id=57095, stack(0x000000031da14000,0x000000031dc17000)] - 0x000000011edf8e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-24" daemon [_thread_blocked, id=56579, stack(0x000000031dc20000,0x000000031de23000)] - 0x000000011f67a800 JavaThread "nacos-grpc-client-executor-127.0.0.1-25" daemon [_thread_blocked, id=51715, stack(0x000000031de2c000,0x000000031e02f000)] - 0x000000011ae25600 JavaThread "nacos-grpc-client-executor-127.0.0.1-26" daemon [_thread_blocked, id=56067, stack(0x000000031e038000,0x000000031e23b000)] - 0x000000011ae41800 JavaThread "nacos-grpc-client-executor-127.0.0.1-27" daemon [_thread_blocked, id=55555, stack(0x000000031e244000,0x000000031e447000)] - 0x000000011ae41e00 JavaThread "nacos-grpc-client-executor-127.0.0.1-28" daemon [_thread_blocked, id=52483, stack(0x000000031e450000,0x000000031e653000)] - 0x000000011edf4400 JavaThread "nacos-grpc-client-executor-127.0.0.1-29" daemon [_thread_blocked, id=55043, stack(0x000000031e65c000,0x000000031e85f000)] - 0x000000011edef000 JavaThread "nacos-grpc-client-executor-127.0.0.1-30" daemon [_thread_blocked, id=54787, stack(0x000000031e868000,0x000000031ea6b000)] - 0x000000011b3b4400 JavaThread "nacos-grpc-client-executor-127.0.0.1-31" daemon [_thread_blocked, id=54531, stack(0x000000031ea74000,0x000000031ec77000)] - 0x0000000107ada400 JavaThread "sentinel-time-tick-thread" daemon [_thread_blocked, id=32027, stack(0x000000031ec80000,0x000000031ee83000)] - 0x000000011b77a400 JavaThread "sentinel-heartbeat-send-task-thread-2" daemon [_thread_blocked, id=53555, stack(0x000000031ee8c000,0x000000031f08f000)] - -Other Threads: - 0x0000000104d04d00 VMThread "VM Thread" [stack: 0x000000016c4d0000,0x000000016c6d3000] [id=19715] - 0x0000000104c0a4f0 WatcherThread [stack: 0x000000016f914000,0x000000016fb17000] [id=34819] - 0x0000000104e067f0 GCTaskThread "GC Thread#0" [stack: 0x000000016ba94000,0x000000016bc97000] [id=12547] - 0x00000001051109e0 GCTaskThread "GC Thread#1" [stack: 0x000000016e49c000,0x000000016e69f000] [id=25859] - 0x0000000105214660 GCTaskThread "GC Thread#2" [stack: 0x000000016e6a8000,0x000000016e8ab000] [id=27395] - 0x000000010de05990 GCTaskThread "GC Thread#3" [stack: 0x000000016e8b4000,0x000000016eab7000] [id=26883] - 0x0000000105110e70 GCTaskThread "GC Thread#4" [stack: 0x000000016eac0000,0x000000016ecc3000] [id=26115] - 0x00000001051116f0 GCTaskThread "GC Thread#5" [stack: 0x000000016eccc000,0x000000016eecf000] [id=32771] - 0x0000000105111f70 GCTaskThread "GC Thread#6" [stack: 0x000000016eed8000,0x000000016f0db000] [id=43011] - 0x00000001051127f0 GCTaskThread "GC Thread#7" [stack: 0x000000016f0e4000,0x000000016f2e7000] [id=33283] - 0x0000000105113070 GCTaskThread "GC Thread#8" [stack: 0x000000016f2f0000,0x000000016f4f3000] [id=33795] - 0x0000000104809f30 GCTaskThread "GC Thread#9" [stack: 0x000000016f4fc000,0x000000016f6ff000] [id=42755] - 0x00000001050042b0 ConcurrentGCThread "G1 Main Marker" [stack: 0x000000016bca0000,0x000000016bea3000] [id=14083] - 0x000000011df045c0 ConcurrentGCThread "G1 Conc#0" [stack: 0x000000016beac000,0x000000016c0af000] [id=13827] - 0x000000010512ad30 ConcurrentGCThread "G1 Conc#1" [stack: 0x0000000319270000,0x0000000319473000] [id=39427] - 0x0000000104e11120 ConcurrentGCThread "G1 Conc#2" [stack: 0x000000031947c000,0x000000031967f000] [id=38403] - 0x0000000105105c00 ConcurrentGCThread "G1 Refine#0" [stack: 0x000000016c0b8000,0x000000016c2bb000] [id=16643] - 0x0000000105204080 ConcurrentGCThread "G1 Service" [stack: 0x000000016c2c4000,0x000000016c4c7000] [id=21251] - -Threads with active compile tasks: - -VM state: not at safepoint (normal execution) - -VM Mutex/Monitor currently owned by a thread: None - -Heap address: 0x00000005c0000000, size: 9216 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 - -CDS archive(s) mapped at: [0x000000e000000000-0x000000e000c14000-0x000000e000c14000), size 12664832, SharedBaseAddress: 0x000000e000000000, ArchiveRelocationMode: 1. -Compressed class space mapped at: 0x000000e001000000-0x000000e041000000, reserved size: 1073741824 -Narrow klass base: 0x000000e000000000, Narrow klass shift: 0, Narrow klass range: 0x100000000 - -GC Precious Log: - CPUs: 12 total, 12 available - Memory: 36864M - Large Page Support: Disabled - NUMA Support: Disabled - Compressed Oops: Enabled (Zero based) - Heap Region Size: 8M - Heap Min Capacity: 8M - Heap Initial Capacity: 576M - Heap Max Capacity: 9G - Pre-touch: Disabled - Parallel Workers: 10 - Concurrent Workers: 3 - Concurrent Refinement Workers: 10 - Periodic GC: Disabled - -Heap: - garbage-first heap total 196608K, used 119331K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 10 young (81920K), 1 survivors (8192K) - Metaspace used 65956K, committed 66496K, reserved 1114112K - class space used 8750K, committed 9024K, reserved 1048576K - -Heap Regions: E=young(eden), S=young(survivor), O=old, HS=humongous(starts), HC=humongous(continues), CS=collection set, F=free, OA=open archive, CA=closed archive, TAMS=top-at-mark-start (previous, next) -| 0|0x00000005c0000000, 0x00000005c0800000, 0x00000005c0800000|100%| O| |TAMS 0x00000005c0800000, 0x00000005c0000000| Untracked -| 1|0x00000005c0800000, 0x00000005c1000000, 0x00000005c1000000|100%| O| |TAMS 0x00000005c1000000, 0x00000005c0800000| Untracked -| 2|0x00000005c1000000, 0x00000005c1800000, 0x00000005c1800000|100%| O| |TAMS 0x00000005c178d800, 0x00000005c1000000| Untracked -| 3|0x00000005c1800000, 0x00000005c1f6fc00, 0x00000005c2000000| 92%| O| |TAMS 0x00000005c1f6fc00, 0x00000005c1800000| Untracked -| 4|0x00000005c2000000, 0x00000005c2521200, 0x00000005c2800000| 64%| O| |TAMS 0x00000005c2000000, 0x00000005c2000000| Untracked -| 5|0x00000005c2800000, 0x00000005c2800000, 0x00000005c3000000| 0%| F| |TAMS 0x00000005c2800000, 0x00000005c2800000| Untracked -| 6|0x00000005c3000000, 0x00000005c3000000, 0x00000005c3800000| 0%| F| |TAMS 0x00000005c3000000, 0x00000005c3000000| Untracked -| 7|0x00000005c3800000, 0x00000005c3800000, 0x00000005c4000000| 0%| F| |TAMS 0x00000005c3800000, 0x00000005c3800000| Untracked -| 8|0x00000005c4000000, 0x00000005c4000000, 0x00000005c4800000| 0%| F| |TAMS 0x00000005c4000000, 0x00000005c4000000| Untracked -| 9|0x00000005c4800000, 0x00000005c4800000, 0x00000005c5000000| 0%| F| |TAMS 0x00000005c4800000, 0x00000005c4800000| Untracked -| 10|0x00000005c5000000, 0x00000005c5000000, 0x00000005c5800000| 0%| F| |TAMS 0x00000005c5000000, 0x00000005c5000000| Untracked -| 11|0x00000005c5800000, 0x00000005c5800000, 0x00000005c6000000| 0%| F| |TAMS 0x00000005c5800000, 0x00000005c5800000| Untracked -| 12|0x00000005c6000000, 0x00000005c64df298, 0x00000005c6800000| 60%| E| |TAMS 0x00000005c6000000, 0x00000005c6000000| Complete -| 13|0x00000005c6800000, 0x00000005c7000000, 0x00000005c7000000|100%| E|CS|TAMS 0x00000005c6800000, 0x00000005c6800000| Complete -| 14|0x00000005c7000000, 0x00000005c7800000, 0x00000005c7800000|100%| E|CS|TAMS 0x00000005c7000000, 0x00000005c7000000| Complete -| 15|0x00000005c7800000, 0x00000005c8000000, 0x00000005c8000000|100%| E|CS|TAMS 0x00000005c7800000, 0x00000005c7800000| Complete -| 16|0x00000005c8000000, 0x00000005c8800000, 0x00000005c8800000|100%| S|CS|TAMS 0x00000005c8000000, 0x00000005c8000000| Complete -| 17|0x00000005c8800000, 0x00000005c9000000, 0x00000005c9000000|100%| E|CS|TAMS 0x00000005c8800000, 0x00000005c8800000| Complete -| 18|0x00000005c9000000, 0x00000005c9800000, 0x00000005c9800000|100%| E|CS|TAMS 0x00000005c9000000, 0x00000005c9000000| Complete -| 19|0x00000005c9800000, 0x00000005ca000000, 0x00000005ca000000|100%| E|CS|TAMS 0x00000005c9800000, 0x00000005c9800000| Complete -| 68|0x00000005e2000000, 0x00000005e2800000, 0x00000005e2800000|100%| E|CS|TAMS 0x00000005e2000000, 0x00000005e2000000| Complete -| 71|0x00000005e3800000, 0x00000005e4000000, 0x00000005e4000000|100%| E|CS|TAMS 0x00000005e3800000, 0x00000005e3800000| Complete -|1150|0x00000007ff000000, 0x00000007ff778000, 0x00000007ff800000| 93%|OA| |TAMS 0x00000007ff778000, 0x00000007ff000000| Untracked -|1151|0x00000007ff800000, 0x00000007ff880000, 0x0000000800000000| 6%|CA| |TAMS 0x00000007ff880000, 0x00000007ff800000| Untracked - -Card table byte_map: [0x000000010c200000,0x000000010d400000] _byte_map_base: 0x0000000109400000 - -Marking Bits (Prev, Next): (CMBitMap*) 0x000000010f808250, (CMBitMap*) 0x000000010f808210 - Prev Bits: [0x0000000149000000, 0x0000000152000000) - Next Bits: [0x0000000140000000, 0x0000000149000000) - -Polling page: 0x0000000104698000 - -Metaspace: - -Usage: - Non-class: 55.87 MB used. - Class: 8.55 MB used. - Both: 64.41 MB used. - -Virtual space: - Non-class space: 64.00 MB reserved, 56.12 MB ( 88%) committed, 1 nodes. - Class space: 1.00 GB reserved, 8.81 MB ( <1%) committed, 1 nodes. - Both: 1.06 GB reserved, 64.94 MB ( 6%) committed. - -Chunk freelists: - Non-Class: 7.80 MB - Class: 7.22 MB - Both: 15.02 MB - -MaxMetaspaceSize: unlimited -CompressedClassSpaceSize: 1.00 GB -Initial GC threshold: 21.00 MB -Current GC threshold: 100.31 MB -CDS: on -MetaspaceReclaimPolicy: balanced - - commit_granule_bytes: 65536. - - commit_granule_words: 8192. - - virtual_space_node_default_size: 8388608. - - enlarge_chunks_in_place: 1. - - new_chunks_are_fully_committed: 0. - - uncommit_free_chunks: 1. - - use_allocation_guard: 0. - - handle_deallocations: 1. - - -Internal statistics: - -num_allocs_failed_limit: 17. -num_arena_births: 702. -num_arena_deaths: 2. -num_vsnodes_births: 2. -num_vsnodes_deaths: 0. -num_space_committed: 1039. -num_space_uncommitted: 0. -num_chunks_returned_to_freelist: 19. -num_chunks_taken_from_freelist: 2813. -num_chunk_merges: 12. -num_chunk_splits: 2108. -num_chunks_enlarged: 1687. -num_inconsistent_stats: 0. - -CodeCache: size=49152Kb used=11912Kb max_used=11912Kb free=37239Kb - bounds [0x0000000108000000, 0x0000000108bb0000, 0x000000010b000000] - total_blobs=6643 nmethods=6019 adapters=554 - compilation: enabled - stopped_count=0, restarted_count=0 - full_count=0 - -Compilation events (20 events): -Event: 7.622 Thread 0x000000011b00c400 6308 1 org.aspectj.internal.lang.reflect.PerClauseImpl::getKind (5 bytes) -Event: 7.622 Thread 0x000000011b00c400 nmethod 6308 0x0000000108b9ea10 code [0x0000000108b9eb80, 0x0000000108b9ec18] -Event: 7.622 Thread 0x000000011b00c400 6310 ! 1 jdk.proxy2.$Proxy142::annotationType (29 bytes) -Event: 7.622 Thread 0x000000011b00c400 nmethod 6310 0x0000000108b9ed10 code [0x0000000108b9eec0, 0x0000000108b9f0d8] -Event: 7.623 Thread 0x000000011b00c400 6311 1 java.lang.reflect.Field::getAnnotation (23 bytes) -Event: 7.623 Thread 0x000000011f65ba00 6312 1 org.springframework.aop.aspectj.annotation.BeanFactoryAspectInstanceFactory::getAspectMetadata (5 bytes) -Event: 7.623 Thread 0x000000011b00c400 nmethod 6311 0x0000000108b9f290 code [0x0000000108b9f480, 0x0000000108b9f778] -Event: 7.623 Thread 0x000000011f65ba00 nmethod 6312 0x0000000108b9f990 code [0x0000000108b9fb00, 0x0000000108b9fb98] -Event: 7.623 Thread 0x000000011b00c400 6313 1 org.springframework.aop.aspectj.annotation.AbstractAspectJAdvisorFactory::findAspectJAnnotationOnMethod (43 bytes) -Event: 7.623 Thread 0x000000011b00c400 nmethod 6313 0x0000000108b9fc90 code [0x0000000108b9fe40, 0x0000000108ba0018] -Event: 7.627 Thread 0x000000011b00c400 6314 ! 1 jdk.proxy2.$Proxy84::annotationType (29 bytes) -Event: 7.627 Thread 0x000000011b00c400 nmethod 6314 0x0000000108ba0190 code [0x0000000108ba0340, 0x0000000108ba0558] -Event: 7.627 Thread 0x000000011b00c400 6315 1 java.util.concurrent.atomic.AtomicInteger::getAndAdd (12 bytes) -Event: 7.627 Thread 0x000000011b00c400 nmethod 6315 0x0000000108ba0710 code [0x0000000108ba0880, 0x0000000108ba0958] -Event: 7.703 Thread 0x000000011f65ba00 6320 1 java.util.regex.Pattern::unread (11 bytes) -Event: 7.703 Thread 0x000000011b00c400 6321 1 java.util.regex.Pattern::qtype (39 bytes) -Event: 7.704 Thread 0x000000011f65ba00 nmethod 6320 0x0000000108ba1610 code [0x0000000108ba1780, 0x0000000108ba1858] -Event: 7.704 Thread 0x000000011b00c400 nmethod 6321 0x0000000108ba1910 code [0x0000000108ba1b00, 0x0000000108ba1e38] -Event: 7.704 Thread 0x000000011b00c400 6322 1 jdk.internal.misc.Unsafe::putReferenceOpaque (9 bytes) -Event: 7.704 Thread 0x000000011b00c400 nmethod 6322 0x0000000108ba2010 code [0x0000000108ba2180, 0x0000000108ba2298] - -GC Heap History (20 events): -Event: 0.685 GC heap before -{Heap before GC invocations=2 (full 0): - garbage-first heap total 606208K, used 49439K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 4 young (32768K), 1 survivors (8192K) - Metaspace used 11758K, committed 11968K, reserved 1114112K - class space used 1313K, committed 1408K, reserved 1048576K -} -Event: 0.687 GC heap after -{Heap after GC invocations=3 (full 0): - garbage-first heap total 606208K, used 28495K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 11758K, committed 11968K, reserved 1114112K - class space used 1313K, committed 1408K, reserved 1048576K -} -Event: 1.150 GC heap before -{Heap before GC invocations=3 (full 0): - garbage-first heap total 606208K, used 77647K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 7 young (57344K), 1 survivors (8192K) - Metaspace used 19291K, committed 19584K, reserved 1114112K - class space used 2462K, committed 2560K, reserved 1048576K -} -Event: 1.153 GC heap after -{Heap after GC invocations=4 (full 0): - garbage-first heap total 606208K, used 31366K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 19291K, committed 19584K, reserved 1114112K - class space used 2462K, committed 2560K, reserved 1048576K -} -Event: 1.228 GC heap before -{Heap before GC invocations=4 (full 0): - garbage-first heap total 606208K, used 47750K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 4 young (32768K), 1 survivors (8192K) - Metaspace used 21191K, committed 21504K, reserved 1114112K - class space used 2665K, committed 2816K, reserved 1048576K -} -Event: 1.231 GC heap after -{Heap after GC invocations=5 (full 0): - garbage-first heap total 606208K, used 32744K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 21191K, committed 21504K, reserved 1114112K - class space used 2665K, committed 2816K, reserved 1048576K -} -Event: 2.119 GC heap before -{Heap before GC invocations=6 (full 0): - garbage-first heap total 221184K, used 106472K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 12 young (98304K), 1 survivors (8192K) - Metaspace used 35766K, committed 36096K, reserved 1114112K - class space used 4465K, committed 4608K, reserved 1048576K -} -Event: 2.122 GC heap after -{Heap after GC invocations=7 (full 0): - garbage-first heap total 221184K, used 35490K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 2 survivors (16384K) - Metaspace used 35766K, committed 36096K, reserved 1114112K - class space used 4465K, committed 4608K, reserved 1048576K -} -Event: 2.130 GC heap before -{Heap before GC invocations=7 (full 0): - garbage-first heap total 221184K, used 35490K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 3 young (24576K), 2 survivors (16384K) - Metaspace used 36067K, committed 36416K, reserved 1114112K - class space used 4504K, committed 4672K, reserved 1048576K -} -Event: 2.134 GC heap after -{Heap after GC invocations=8 (full 0): - garbage-first heap total 221184K, used 36545K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 36067K, committed 36416K, reserved 1114112K - class space used 4504K, committed 4672K, reserved 1048576K -} -Event: 2.147 GC heap before -{Heap before GC invocations=8 (full 0): - garbage-first heap total 221184K, used 36545K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 1 survivors (8192K) - Metaspace used 36418K, committed 36736K, reserved 1114112K - class space used 4549K, committed 4672K, reserved 1048576K -} -Event: 2.148 GC heap after -{Heap after GC invocations=9 (full 0): - garbage-first heap total 442368K, used 35602K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 36418K, committed 36736K, reserved 1114112K - class space used 4549K, committed 4672K, reserved 1048576K -} -Event: 3.154 GC heap before -{Heap before GC invocations=10 (full 0): - garbage-first heap total 196608K, used 158482K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 17 young (139264K), 1 survivors (8192K) - Metaspace used 48508K, committed 48960K, reserved 1114112K - class space used 6329K, committed 6528K, reserved 1048576K -} -Event: 3.158 GC heap after -{Heap after GC invocations=11 (full 0): - garbage-first heap total 196608K, used 40351K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 48508K, committed 48960K, reserved 1114112K - class space used 6329K, committed 6528K, reserved 1048576K -} -Event: 4.257 GC heap before -{Heap before GC invocations=11 (full 0): - garbage-first heap total 196608K, used 138655K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 13 young (106496K), 1 survivors (8192K) - Metaspace used 54014K, committed 54464K, reserved 1114112K - class space used 7010K, committed 7232K, reserved 1048576K -} -Event: 4.263 GC heap after -{Heap after GC invocations=12 (full 0): - garbage-first heap total 196608K, used 46705K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 2 survivors (16384K) - Metaspace used 54014K, committed 54464K, reserved 1114112K - class space used 7010K, committed 7232K, reserved 1048576K -} -Event: 5.515 GC heap before -{Heap before GC invocations=12 (full 0): - garbage-first heap total 196608K, used 136817K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 13 young (106496K), 2 survivors (16384K) - Metaspace used 58257K, committed 58624K, reserved 1114112K - class space used 7651K, committed 7808K, reserved 1048576K -} -Event: 5.523 GC heap after -{Heap after GC invocations=13 (full 0): - garbage-first heap total 196608K, used 52335K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 2 young (16384K), 2 survivors (16384K) - Metaspace used 58257K, committed 58624K, reserved 1114112K - class space used 7651K, committed 7808K, reserved 1048576K -} -Event: 6.372 GC heap before -{Heap before GC invocations=13 (full 0): - garbage-first heap total 196608K, used 85103K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 7 young (57344K), 2 survivors (16384K) - Metaspace used 61127K, committed 61568K, reserved 1114112K - class space used 8029K, committed 8256K, reserved 1048576K -} -Event: 6.376 GC heap after -{Heap after GC invocations=14 (full 0): - garbage-first heap total 196608K, used 53795K [0x00000005c0000000, 0x0000000800000000) - region size 8192K, 1 young (8192K), 1 survivors (8192K) - Metaspace used 61127K, committed 61568K, reserved 1114112K - class space used 8029K, committed 8256K, reserved 1048576K -} - -Dll operation events (11 events): -Event: 0.042 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjava.dylib -Event: 0.043 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -Event: 0.145 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libinstrument.dylib -Event: 0.147 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnio.dylib -Event: 0.149 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnet.dylib -Event: 0.169 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjimage.dylib -Event: 0.179 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -Event: 0.296 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement.dylib -Event: 0.309 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement_ext.dylib -Event: 0.423 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libextnet.dylib -Event: 5.147 Loaded shared library /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libverify.dylib - -Deoptimization events (20 events): -Event: 7.531 Thread 0x0000000107808a00 DEOPT PACKING pc=0x0000000108333ac8 sp=0x000000016ba89ac0 -Event: 7.531 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba897a0 mode 1 -Event: 7.531 Thread 0x0000000107808a00 DEOPT PACKING pc=0x00000001086c7750 sp=0x000000016ba89b60 -Event: 7.531 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba898e0 mode 1 -Event: 7.531 Thread 0x0000000107808a00 DEOPT PACKING pc=0x00000001082b8728 sp=0x000000016ba89240 -Event: 7.531 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba88ee0 mode 1 -Event: 7.531 Thread 0x0000000107808a00 DEOPT PACKING pc=0x00000001082b823c sp=0x000000016ba89310 -Event: 7.531 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba88fe0 mode 1 -Event: 7.531 Thread 0x0000000107808a00 DEOPT PACKING pc=0x0000000108333ac8 sp=0x000000016ba89ab0 -Event: 7.531 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba89790 mode 1 -Event: 7.531 Thread 0x0000000107808a00 DEOPT PACKING pc=0x00000001086c7750 sp=0x000000016ba89b50 -Event: 7.531 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba898d0 mode 1 -Event: 7.543 Thread 0x0000000107808a00 DEOPT PACKING pc=0x0000000108535ad4 sp=0x000000016ba89bb0 -Event: 7.543 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba89930 mode 1 -Event: 7.545 Thread 0x0000000107808a00 DEOPT PACKING pc=0x0000000108535ad4 sp=0x000000016ba89ba0 -Event: 7.545 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba89920 mode 1 -Event: 7.572 Thread 0x0000000107808a00 DEOPT PACKING pc=0x0000000108535ad4 sp=0x000000016ba89bb0 -Event: 7.572 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba89930 mode 1 -Event: 7.572 Thread 0x0000000107808a00 DEOPT PACKING pc=0x0000000108535ad4 sp=0x000000016ba89ba0 -Event: 7.572 Thread 0x0000000107808a00 DEOPT UNPACKING pc=0x000000010804777c sp=0x000000016ba89920 mode 1 - -Classes unloaded (1 events): -Event: 6.390 Thread 0x0000000104d04d00 Unloading class 0x000000e001554000 'SC' - -Classes redefined (1 events): -Event: 0.160 Thread 0x0000000104d04d00 redefined class name=java.lang.Throwable, count=1 - -Internal exceptions (20 events): -Event: 5.854 Thread 0x000000011b960000 Exception (0x00000005c9c7a940) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 6.357 Thread 0x000000011b960000 Exception (0x00000005c8b001d0) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 6.865 Thread 0x000000011b960000 Exception (0x00000005c9009658) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 7.070 Thread 0x0000000107808a00 Exception (0x00000005c8d01110) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.070 Thread 0x0000000107808a00 Exception (0x00000005c8d05e08) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.071 Thread 0x0000000107808a00 Exception (0x00000005c8d0b6a8) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.072 Thread 0x0000000107808a00 Exception (0x00000005c8d17440) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.177 Thread 0x0000000107808a00 Exception (0x00000005c7aaa528) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 7.178 Thread 0x0000000107808a00 Exception (0x00000005c7ab2338) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 7.187 Thread 0x0000000107808a00 Exception (0x00000005c7b50300) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 7.187 Thread 0x0000000107808a00 Exception (0x00000005c7b577f8) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 7.187 Thread 0x0000000107808a00 Exception (0x00000005c7b5fdb8) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 7.207 Thread 0x0000000107808a00 Exception (0x00000005c7b914a8) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 7.216 Thread 0x0000000107808a00 Exception (0x00000005c7b9a268) -thrown [src/hotspot/share/classfile/systemDictionary.cpp, line 248] -Event: 7.342 Thread 0x0000000107808a00 Exception (0x00000005c72e5358) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 759] -Event: 7.368 Thread 0x000000011b960000 Exception (0x00000005c7479150) -thrown [src/hotspot/share/runtime/reflection.cpp, line 1121] -Event: 7.479 Thread 0x0000000107808a00 Exception (0x00000005c69c4500) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 826] -Event: 7.525 Thread 0x0000000107808a00 Exception (0x00000005c6bbd418) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 826] -Event: 7.526 Thread 0x0000000107808a00 Exception (0x00000005c6bc3d28) -thrown [src/hotspot/share/interpreter/linkResolver.cpp, line 826] -Event: 7.704 Thread 0x000000011e51f600 Exception (0x00000005c6477ae0) -thrown [src/hotspot/share/prims/jni.cpp, line 516] - -VM Operations (20 events): -Event: 6.389 Executing VM operation: G1PauseRemark -Event: 6.394 Executing VM operation: G1PauseRemark done -Event: 6.401 Executing VM operation: G1PauseCleanup -Event: 6.401 Executing VM operation: G1PauseCleanup done -Event: 6.543 Executing VM operation: HandshakeAllThreads -Event: 6.543 Executing VM operation: HandshakeAllThreads done -Event: 6.555 Executing VM operation: HandshakeAllThreads -Event: 6.555 Executing VM operation: HandshakeAllThreads done -Event: 6.560 Executing VM operation: HandshakeAllThreads -Event: 6.560 Executing VM operation: HandshakeAllThreads done -Event: 7.076 Executing VM operation: HandshakeAllThreads -Event: 7.076 Executing VM operation: HandshakeAllThreads done -Event: 7.080 Executing VM operation: HandshakeAllThreads -Event: 7.080 Executing VM operation: HandshakeAllThreads done -Event: 7.096 Executing VM operation: HandshakeAllThreads -Event: 7.096 Executing VM operation: HandshakeAllThreads done -Event: 7.131 Executing VM operation: ICBufferFull -Event: 7.131 Executing VM operation: ICBufferFull done -Event: 7.466 Executing VM operation: ICBufferFull -Event: 7.466 Executing VM operation: ICBufferFull done - -Events (20 events): -Event: 7.703 loading class java/net/SocksSocketImpl$3 done -Event: 7.704 loading class sun/net/util/SocketExceptions -Event: 7.704 loading class sun/net/util/SocketExceptions done -Event: 7.704 Thread 0x000000011b77a400 Thread added: 0x000000011b77a400 -Event: 7.704 loading class java/lang/Throwable$WrappedPrintWriter -Event: 7.704 loading class java/lang/Throwable$WrappedPrintWriter done -Event: 7.704 Protecting memory [0x000000031ee8c000,0x000000031ee98000] with protection modes 0 -Event: 7.704 loading class com/intellij/rt/debugger/agent/CaptureStorage$9 -Event: 7.704 loading class com/intellij/rt/debugger/agent/CaptureStorage$Callable -Event: 7.704 loading class com/intellij/rt/debugger/agent/CaptureStorage$Callable done -Event: 7.704 loading class com/intellij/rt/debugger/agent/CaptureStorage$9 done -Event: 7.705 loading class jdk/internal/loader/BootLoader$PackageHelper$1 -Event: 7.705 loading class jdk/internal/loader/BootLoader$PackageHelper$1 done -Event: 7.705 loading class jdk/internal/loader/BootLoader$PackageHelper$2 -Event: 7.705 loading class jdk/internal/loader/BootLoader$PackageHelper$2 done -Event: 7.705 loading class java/util/jar/JarInputStream -Event: 7.705 loading class java/util/zip/ZipInputStream -Event: 7.705 loading class java/util/zip/ZipInputStream done -Event: 7.705 loading class java/util/jar/JarInputStream done -Event: 7.706 loading class com/intellij/rt/debugger/agent/CaptureStorage$StackData - - -Dynamic libraries: -0x0000000104638000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjli.dylib -0x0000000196c18000 /usr/lib/libz.1.dylib -0x0000000196cce000 /usr/lib/libSystem.B.dylib -0x0000000196cc8000 /usr/lib/system/libcache.dylib -0x0000000196c83000 /usr/lib/system/libcommonCrypto.dylib -0x0000000196cae000 /usr/lib/system/libcompiler_rt.dylib -0x0000000196ca3000 /usr/lib/system/libcopyfile.dylib -0x0000000186bb6000 /usr/lib/system/libcorecrypto.dylib -0x0000000186cb6000 /usr/lib/system/libdispatch.dylib -0x0000000186a53000 /usr/lib/system/libdyld.dylib -0x0000000196cbe000 /usr/lib/system/libkeymgr.dylib -0x0000000196c66000 /usr/lib/system/libmacho.dylib -0x0000000195ef9000 /usr/lib/system/libquarantine.dylib -0x0000000196cbb000 /usr/lib/system/libremovefile.dylib -0x000000018d629000 /usr/lib/system/libsystem_asl.dylib -0x0000000186b3c000 /usr/lib/system/libsystem_blocks.dylib -0x0000000186d01000 /usr/lib/system/libsystem_c.dylib -0x0000000196cb2000 /usr/lib/system/libsystem_collections.dylib -0x0000000194899000 /usr/lib/system/libsystem_configuration.dylib -0x0000000193487000 /usr/lib/system/libsystem_containermanager.dylib -0x0000000196698000 /usr/lib/system/libsystem_coreservices.dylib -0x000000018ae50000 /usr/lib/system/libsystem_darwin.dylib -0x000000028c8a4000 /usr/lib/system/libsystem_darwindirectory.dylib -0x0000000196cbf000 /usr/lib/system/libsystem_dnssd.dylib -0x000000028c8a8000 /usr/lib/system/libsystem_eligibility.dylib -0x0000000186cfe000 /usr/lib/system/libsystem_featureflags.dylib -0x0000000186e83000 /usr/lib/system/libsystem_info.dylib -0x0000000196c27000 /usr/lib/system/libsystem_m.dylib -0x0000000186c65000 /usr/lib/system/libsystem_malloc.dylib -0x000000018d58c000 /usr/lib/system/libsystem_networkextension.dylib -0x000000018b2bb000 /usr/lib/system/libsystem_notify.dylib -0x000000019489e000 /usr/lib/system/libsystem_sandbox.dylib -0x000000028c8b3000 /usr/lib/system/libsystem_sanitizers.dylib -0x0000000196cb7000 /usr/lib/system/libsystem_secinit.dylib -0x0000000186e2f000 /usr/lib/system/libsystem_kernel.dylib -0x0000000186e7a000 /usr/lib/system/libsystem_platform.dylib -0x0000000186e6d000 /usr/lib/system/libsystem_pthread.dylib -0x000000018f1e2000 /usr/lib/system/libsystem_symptoms.dylib -0x0000000186b95000 /usr/lib/system/libsystem_trace.dylib -0x000000028c8bb000 /usr/lib/system/libsystem_trial.dylib -0x0000000196c91000 /usr/lib/system/libunwind.dylib -0x0000000186b40000 /usr/lib/system/libxpc.dylib -0x0000000186a00000 /usr/lib/libobjc.A.dylib -0x0000000186eb3000 /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation -0x000000019a5c3000 /usr/lib/swift/libswiftCore.dylib -0x0000000186e14000 /usr/lib/libc++abi.dylib -0x000000028ac91000 /usr/lib/libRosetta.dylib -0x0000000186d83000 /usr/lib/libc++.1.dylib -0x0000000188722000 /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation -0x00000001a41a3000 /usr/lib/swift/libswiftObjectiveC.dylib -0x000000028c10d000 /usr/lib/libswiftPrespecialized.dylib -0x0000000188391000 /System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration -0x0000000191703000 /System/Library/PrivateFrameworks/CoreAutoLayout.framework/Versions/A/CoreAutoLayout -0x0000000196cd0000 /usr/lib/libfakelink.dylib -0x0000000196f79000 /usr/lib/libcompression.dylib -0x000000018d1d6000 /System/Library/Frameworks/CFNetwork.framework/Versions/A/CFNetwork -0x0000000190b34000 /System/Library/Frameworks/DiskArbitration.framework/Versions/A/DiskArbitration -0x0000000196d23000 /usr/lib/libarchive.2.dylib -0x0000000190a39000 /usr/lib/libDiagnosticMessagesClient.dylib -0x000000018ab7a000 /usr/lib/libicucore.A.dylib -0x000000019174c000 /usr/lib/libxml2.2.dylib -0x000000019f452000 /System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices -0x00000001948ac000 /usr/lib/liblangid.dylib -0x000000018b1d2000 /System/Library/Frameworks/IOKit.framework/Versions/A/IOKit -0x000000019d0c4000 /System/Library/Frameworks/Combine.framework/Versions/A/Combine -0x000000023fff3000 /System/Library/PrivateFrameworks/CollectionsInternal.framework/Versions/A/CollectionsInternal -0x000000026c039000 /System/Library/PrivateFrameworks/ReflectionInternal.framework/Versions/A/ReflectionInternal -0x000000026cf8d000 /System/Library/PrivateFrameworks/RuntimeInternal.framework/Versions/A/RuntimeInternal -0x0000000196cd2000 /System/Library/PrivateFrameworks/SoftLinking.framework/Versions/A/SoftLinking -0x00000001b488c000 /usr/lib/swift/libswiftCoreFoundation.dylib -0x00000001b164f000 /usr/lib/swift/libswiftDarwin.dylib -0x00000001a11c9000 /usr/lib/swift/libswiftDispatch.dylib -0x00000001b48ed000 /usr/lib/swift/libswiftIOKit.dylib -0x000000028c550000 /usr/lib/swift/libswiftSystem.dylib -0x00000001b489f000 /usr/lib/swift/libswiftXPC.dylib -0x000000028c582000 /usr/lib/swift/libswift_Builtin_float.dylib -0x000000028c583000 /usr/lib/swift/libswift_Concurrency.dylib -0x000000028c60f000 /usr/lib/swift/libswift_DarwinFoundation1.dylib -0x000000028c6b3000 /usr/lib/swift/libswift_StringProcessing.dylib -0x00000001a41a7000 /usr/lib/swift/libswiftos.dylib -0x000000018b152000 /System/Library/PrivateFrameworks/CoreServicesInternal.framework/Versions/A/CoreServicesInternal -0x0000000196c9b000 /usr/lib/liboah.dylib -0x000000018a75a000 /System/Library/Frameworks/Security.framework/Versions/A/Security -0x00000001a35d7000 /System/Library/PrivateFrameworks/DiskImages.framework/Versions/A/DiskImages -0x00000001b10f3000 /System/Library/Frameworks/NetFS.framework/Versions/A/NetFS -0x00000001916c8000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/FSEvents.framework/Versions/A/FSEvents -0x000000018ae5a000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore -0x0000000190aa8000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/Metadata.framework/Versions/A/Metadata -0x000000019669f000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/OSServices.framework/Versions/A/OSServices -0x0000000196e1b000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/SearchKit.framework/Versions/A/SearchKit -0x000000018f15c000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/AE.framework/Versions/A/AE -0x0000000187412000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices -0x0000000198224000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/DictionaryServices.framework/Versions/A/DictionaryServices -0x00000001916d5000 /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/SharedFileList.framework/Versions/A/SharedFileList -0x0000000196eae000 /usr/lib/libapple_nghttp2.dylib -0x000000018ed78000 /usr/lib/libsqlite3.dylib -0x000000018ef61000 /System/Library/Frameworks/Accounts.framework/Versions/A/Accounts -0x00000001a3819000 /System/Library/PrivateFrameworks/AppSupport.framework/Versions/A/AppSupport -0x00000001b363e000 /System/Library/Frameworks/AVFoundation.framework/Versions/A/AVFoundation -0x0000000190a09000 /System/Library/PrivateFrameworks/CoreAnalytics.framework/Versions/A/CoreAnalytics -0x000000018dc1c000 /System/Library/Frameworks/CoreGraphics.framework/Versions/A/CoreGraphics -0x000000019b2fe000 /System/Library/Frameworks/GSS.framework/Versions/A/GSS -0x00000001996e6000 /System/Library/PrivateFrameworks/InternationalSupport.framework/Versions/A/InternationalSupport -0x000000018f0f0000 /System/Library/PrivateFrameworks/RunningBoardServices.framework/Versions/A/RunningBoardServices -0x00000001a412b000 /System/Library/PrivateFrameworks/StreamingZip.framework/Versions/A/StreamingZip -0x000000018d5a7000 /usr/lib/libenergytrace.dylib -0x000000018f1eb000 /System/Library/Frameworks/Network.framework/Versions/A/Network -0x0000000195f21000 /usr/lib/libbsm.0.dylib -0x0000000196c6a000 /usr/lib/system/libkxld.dylib -0x000000023b5c5000 /System/Library/PrivateFrameworks/AppleKeyStore.framework/Versions/A/AppleKeyStore -0x000000028a993000 /usr/lib/libCoreEntitlements.dylib -0x0000000260705000 /System/Library/PrivateFrameworks/MessageSecurity.framework/Versions/A/MessageSecurity -0x000000018ed5c000 /System/Library/PrivateFrameworks/ProtocolBuffer.framework/Versions/A/ProtocolBuffer -0x00000001a05d8000 /System/Library/PrivateFrameworks/SymptomDiagnosticReporter.framework/Versions/A/SymptomDiagnosticReporter -0x0000000198469000 /System/Library/PrivateFrameworks/CrashReporterSupport.framework/Versions/A/CrashReporterSupport -0x000000018d5a9000 /usr/lib/libMobileGestalt.dylib -0x000000019667f000 /System/Library/PrivateFrameworks/AppleFSCompression.framework/Versions/A/AppleFSCompression -0x0000000195f09000 /usr/lib/libcoretls.dylib -0x000000019829a000 /usr/lib/libcoretls_cfhelpers.dylib -0x0000000196f73000 /usr/lib/libpam.2.dylib -0x0000000198310000 /usr/lib/libxar.1.dylib -0x000000019829c000 /System/Library/PrivateFrameworks/APFS.framework/Versions/A/APFS -0x0000000278713000 /System/Library/PrivateFrameworks/SwiftASN1Internal.framework/Versions/A/SwiftASN1Internal -0x000000019831f000 /usr/lib/libutil.dylib -0x00000001948a7000 /System/Library/PrivateFrameworks/AppleSystemInfo.framework/Versions/A/AppleSystemInfo -0x0000000195bd0000 /System/Library/PrivateFrameworks/IOMobileFramebuffer.framework/Versions/A/IOMobileFramebuffer -0x00000001934c0000 /System/Library/Frameworks/IOSurface.framework/Versions/A/IOSurface -0x00000001a2f37000 /System/Library/PrivateFrameworks/CoreWiFi.framework/Versions/A/CoreWiFi -0x00000001b474c000 /System/Library/PrivateFrameworks/LoggingSupport.framework/Versions/A/LoggingSupport -0x000000019b361000 /System/Library/PrivateFrameworks/MobileAsset.framework/Versions/A/MobileAsset -0x00000001a05e8000 /System/Library/PrivateFrameworks/PowerLog.framework/Versions/A/PowerLog -0x00000001a1aaa000 /System/Library/PrivateFrameworks/Rapport.framework/Versions/A/Rapport -0x000000023416e000 /System/Library/Frameworks/SwiftData.framework/Versions/A/SwiftData -0x000000018cc0a000 /System/Library/Frameworks/UniformTypeIdentifiers.framework/Versions/A/UniformTypeIdentifiers -0x00000001918f5000 /System/Library/PrivateFrameworks/UserManagement.framework/Versions/A/UserManagement -0x000000018d0fd000 /usr/lib/libboringssl.dylib -0x000000018f1d0000 /usr/lib/libdns_services.dylib -0x00000001b3772000 /usr/lib/libquic.dylib -0x000000019a554000 /usr/lib/libusrtcp.dylib -0x000000023c47f000 /System/Library/PrivateFrameworks/AtomicsInternal.framework/Versions/A/AtomicsInternal -0x00000001dab32000 /System/Library/PrivateFrameworks/InternalSwiftProtobuf.framework/Versions/A/InternalSwiftProtobuf -0x000000028c3d7000 /usr/lib/swift/libswiftDistributed.dylib -0x000000028c400000 /usr/lib/swift/libswiftObservation.dylib -0x000000028c53c000 /usr/lib/swift/libswiftSynchronization.dylib -0x00000001948a5000 /System/Library/PrivateFrameworks/AggregateDictionary.framework/Versions/A/AggregateDictionary -0x000000023ccaf000 /System/Library/PrivateFrameworks/BiomeLibrary.framework/Versions/A/BiomeLibrary -0x00000001c38d5000 /System/Library/PrivateFrameworks/BiomeStreams.framework/Versions/A/BiomeStreams -0x00000001bf924000 /System/Library/PrivateFrameworks/BiomeFoundation.framework/Versions/A/BiomeFoundation -0x00000001c9b82000 /System/Library/PrivateFrameworks/BiomePubSub.framework/Versions/A/BiomePubSub -0x000000018e96e000 /System/Library/Frameworks/CoreData.framework/Versions/A/CoreData -0x00000001a510e000 /System/Library/PrivateFrameworks/ProactiveSupport.framework/Versions/A/ProactiveSupport -0x00000002361cc000 /System/Library/Frameworks/_LocationEssentials.framework/Versions/A/_LocationEssentials -0x000000019827b000 /usr/lib/liblzma.5.dylib -0x000000019f6d1000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Accelerate -0x0000000195e02000 /System/Library/PrivateFrameworks/MobileKeyBag.framework/Versions/A/MobileKeyBag -0x00000001a3b2d000 /System/Library/PrivateFrameworks/InternationalTextSearch.framework/Versions/A/InternationalTextSearch -0x00000001bbdf7000 /System/Library/PrivateFrameworks/SoftwareUpdateCoreSupport.framework/Versions/A/SoftwareUpdateCoreSupport -0x00000001c4374000 /System/Library/PrivateFrameworks/SoftwareUpdateCoreConnect.framework/Versions/A/SoftwareUpdateCoreConnect -0x00000001a36d1000 /System/Library/PrivateFrameworks/RemoteServiceDiscovery.framework/Versions/A/RemoteServiceDiscovery -0x00000001bb9ce000 /System/Library/PrivateFrameworks/MSUDataAccessor.framework/Versions/A/MSUDataAccessor -0x00000001b691f000 /usr/lib/libbootpolicy.dylib -0x00000001a36e8000 /System/Library/PrivateFrameworks/RemoteXPC.framework/Versions/A/RemoteXPC -0x00000001c37a9000 /usr/lib/libFDR.dylib -0x00000001c9784000 /usr/lib/libamsupport.dylib -0x000000028ac89000 /usr/lib/libReverseProxyDevice.dylib -0x000000023ae33000 /System/Library/PrivateFrameworks/AppleDeviceQuerySupport.framework/Versions/A/AppleDeviceQuerySupport -0x00000001cc94e000 /usr/lib/libpartition2_dynamic.dylib -0x0000000196e8a000 /System/Library/PrivateFrameworks/AppleSauce.framework/Versions/A/AppleSauce -0x000000028a83e000 /usr/lib/libAppleArchive.dylib -0x000000019668b000 /usr/lib/libbz2.1.0.dylib -0x0000000190b3e000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vImage.framework/Versions/A/vImage -0x000000019f42d000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/vecLib -0x0000000198356000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvMisc.dylib -0x0000000187916000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib -0x00000001a3b2c000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices -0x0000000191833000 /System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo -0x000000018e372000 /System/Library/Frameworks/ColorSync.framework/Versions/A/ColorSync -0x0000000189d9a000 /System/Library/Frameworks/CoreText.framework/Versions/A/CoreText -0x0000000193fb3000 /System/Library/Frameworks/ImageIO.framework/Versions/A/ImageIO -0x000000019af0e000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATS.framework/Versions/A/ATS -0x000000018e51a000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/HIServices -0x00000001995dc000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Versions/A/PrintCore -0x000000019b2c7000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/QD.framework/Versions/A/QD -0x000000019b2c2000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ColorSyncLegacy.framework/Versions/A/ColorSyncLegacy -0x000000019aee0000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/SpeechSynthesis.framework/Versions/A/SpeechSynthesis -0x000000018d665000 /System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight -0x000000019398e000 /System/Library/PrivateFrameworks/FontServices.framework/libFontParser.dylib -0x000000018effe000 /System/Library/PrivateFrameworks/BaseBoard.framework/Versions/A/BaseBoard -0x00000001a152a000 /System/Library/PrivateFrameworks/BoardServices.framework/Versions/A/BoardServices -0x00000001a33f9000 /System/Library/PrivateFrameworks/BackBoardServices.framework/Versions/A/BackBoardServices -0x000000023cbb4000 /System/Library/PrivateFrameworks/BackBoardHIDEventFoundation.framework/Versions/A/BackBoardHIDEventFoundation -0x00000001898b6000 /System/Library/Frameworks/CoreDisplay.framework/Versions/A/CoreDisplay -0x0000000198f35000 /System/Library/Frameworks/VideoToolbox.framework/Versions/A/VideoToolbox -0x0000000196f71000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/MetalPerformanceShaders -0x000000026af43000 /System/Library/PrivateFrameworks/ProDisplayLibrary.framework/Versions/A/ProDisplayLibrary -0x00000001a722e000 /System/Library/PrivateFrameworks/IOSurfaceAccelerator.framework/Versions/A/IOSurfaceAccelerator -0x00000001934e8000 /System/Library/Frameworks/Metal.framework/Versions/A/Metal -0x00000001934dd000 /System/Library/PrivateFrameworks/IOAccelerator.framework/Versions/A/IOAccelerator -0x00000001937ec000 /System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia -0x000000018d641000 /System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC -0x0000000198eed000 /System/Library/PrivateFrameworks/WatchdogClient.framework/Versions/A/WatchdogClient -0x0000000190f63000 /System/Library/Frameworks/QuartzCore.framework/Versions/A/QuartzCore -0x0000000198eef000 /System/Library/PrivateFrameworks/MultitouchSupport.framework/Versions/A/MultitouchSupport -0x00000001cc730000 /usr/lib/swift/libswiftAccelerate.dylib -0x00000001b486c000 /usr/lib/swift/libswiftCoreAudio.dylib -0x00000001d08c1000 /usr/lib/swift/libswiftCoreMedia.dylib -0x00000001c2862000 /usr/lib/swift/libswiftMetal.dylib -0x00000001d2074000 /usr/lib/swift/libswiftOSLog.dylib -0x00000001c7c88000 /usr/lib/swift/libswiftQuartzCore.dylib -0x00000001cc720000 /usr/lib/swift/libswiftUniformTypeIdentifiers.dylib -0x000000028c56a000 /usr/lib/swift/libswiftVideoToolbox.dylib -0x00000001b83e6000 /usr/lib/swift/libswiftsimd.dylib -0x00000001c9be3000 /System/Library/PrivateFrameworks/BiomeStorage.framework/Versions/A/BiomeStorage -0x00000002593f4000 /System/Library/PrivateFrameworks/IntelligencePlatformLibrary.framework/Versions/A/IntelligencePlatformLibrary -0x0000000269d07000 /System/Library/PrivateFrameworks/PoirotSchematizer.framework/Versions/A/PoirotSchematizer -0x000000023d56a000 /System/Library/PrivateFrameworks/BiomeSync.framework/Versions/A/BiomeSync -0x000000023cc95000 /System/Library/PrivateFrameworks/BiomeDSL.framework/Versions/A/BiomeDSL -0x00000001e2d3d000 /System/Library/PrivateFrameworks/FeatureFlags.framework/Versions/A/FeatureFlags -0x0000000269d75000 /System/Library/PrivateFrameworks/PoirotUDFs.framework/Versions/A/PoirotUDFs -0x000000028c612000 /usr/lib/swift/libswift_DarwinFoundation2.dylib -0x000000028c613000 /usr/lib/swift/libswift_DarwinFoundation3.dylib -0x00000001a1a9f000 /System/Library/PrivateFrameworks/CoreTime.framework/Versions/A/CoreTime -0x0000000196d08000 /usr/lib/libiconv.2.dylib -0x0000000196c65000 /usr/lib/libcharset.1.dylib -0x0000000269cca000 /System/Library/PrivateFrameworks/PoirotSQLite.framework/Versions/A/PoirotSQLite -0x000000028c614000 /usr/lib/swift/libswift_RegexParser.dylib -0x000000023eba0000 /System/Library/PrivateFrameworks/CascadeSets.framework/Versions/A/CascadeSets -0x000000019b4d8000 /System/Library/PrivateFrameworks/CorePhoneNumbers.framework/Versions/A/CorePhoneNumbers -0x0000000198ce7000 /System/Library/PrivateFrameworks/AppleJPEG.framework/Versions/A/AppleJPEG -0x00000001986c0000 /usr/lib/libexpat.1.dylib -0x00000001994b2000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libPng.dylib -0x00000001994dd000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libTIFF.dylib -0x00000001995c5000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libGIF.dylib -0x0000000198d2c000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJP2.dylib -0x00000001983d0000 /usr/lib/libate.dylib -0x000000019956c000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJPEG.dylib -0x0000000199563000 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libRadiance.dylib -0x000000024f044000 /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/libllvm-flatbuffers.dylib -0x0000000249aa3000 /System/Library/PrivateFrameworks/FramePacing.framework/Versions/A/FramePacing -0x000000022cbb3000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCoreFSCache.dylib -0x000000024abd1000 /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/libGPUCompilerUtils.dylib -0x00000001a15f7000 /System/Library/PrivateFrameworks/GraphicsServices.framework/Versions/A/GraphicsServices -0x000000022cbc1000 /System/Library/Frameworks/OpenGL.framework/Versions/A/OpenGL -0x000000022cc12000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGLU.dylib -0x000000022cbd5000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGFXShared.dylib -0x000000022cda2000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib -0x000000022cbde000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGLImage.dylib -0x000000022cbd2000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCVMSPluginSupport.dylib -0x000000022cbbb000 /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libCoreVMClient.dylib -0x000000019955e000 /System/Library/PrivateFrameworks/GPUWrangler.framework/Versions/A/GPUWrangler -0x000000019953e000 /System/Library/PrivateFrameworks/IOPresentment.framework/Versions/A/IOPresentment -0x0000000199566000 /System/Library/PrivateFrameworks/DSExternalDisplay.framework/Versions/A/DSExternalDisplay -0x00000002805c9000 /System/Library/PrivateFrameworks/VideoToolboxParavirtualizationSupport.framework/Versions/A/VideoToolboxParavirtualizationSupport -0x0000000198677000 /System/Library/PrivateFrameworks/AppleVA.framework/Versions/A/AppleVA -0x000000022ec3e000 /System/Library/Frameworks/ExtensionFoundation.framework/Versions/A/ExtensionFoundation -0x00000001995cb000 /System/Library/PrivateFrameworks/CMCaptureCore.framework/Versions/A/CMCaptureCore -0x0000000198951000 /usr/lib/libspindump.dylib -0x0000000189fc4000 /System/Library/Frameworks/CoreAudio.framework/Versions/A/CoreAudio -0x0000000198944000 /System/Library/PrivateFrameworks/AppServerSupport.framework/Versions/A/AppServerSupport -0x000000019b2d0000 /System/Library/PrivateFrameworks/perfdata.framework/Versions/A/perfdata -0x00000001899d7000 /System/Library/PrivateFrameworks/AudioToolboxCore.framework/Versions/A/AudioToolboxCore -0x00000001937c2000 /System/Library/PrivateFrameworks/caulk.framework/Versions/A/caulk -0x000000019aec6000 /usr/lib/libAudioStatistics.dylib -0x00000001b3867000 /System/Library/PrivateFrameworks/SystemPolicy.framework/Versions/A/SystemPolicy -0x000000019b174000 /usr/lib/libSMC.dylib -0x00000001bb1dd000 /usr/lib/swift/libswiftCoreMIDI.dylib -0x00000001a651d000 /System/Library/Frameworks/CoreMIDI.framework/Versions/A/CoreMIDI -0x000000019948c000 /usr/lib/libAudioToolboxUtility.dylib -0x000000019b2de000 /usr/lib/libperfcheck.dylib -0x000000023c54e000 /System/Library/PrivateFrameworks/AudioAnalytics.framework/Versions/A/AudioAnalytics -0x00000001da81e000 /System/Library/Frameworks/OSLog.framework/Versions/A/OSLog -0x0000000265777000 /System/Library/PrivateFrameworks/OSEligibility.framework/Versions/A/OSEligibility -0x0000000198746000 /System/Library/PrivateFrameworks/IconServices.framework/Versions/A/IconServices -0x0000000230025000 /System/Library/Frameworks/LightweightCodeRequirements.framework/Versions/A/LightweightCodeRequirements -0x00000001985c0000 /System/Library/PrivateFrameworks/PlugInKit.framework/Versions/A/PlugInKit -0x0000000195e1a000 /System/Library/PrivateFrameworks/AssertionServices.framework/Versions/A/AssertionServices -0x00000001986e5000 /System/Library/PrivateFrameworks/IconFoundation.framework/Versions/A/IconFoundation -0x0000000255f80000 /System/Library/PrivateFrameworks/IconRendering.framework/Versions/A/IconRendering -0x00000001913ca000 /System/Library/PrivateFrameworks/CoreUI.framework/Versions/A/CoreUI -0x00000001942f4000 /System/Library/Frameworks/CoreImage.framework/Versions/A/CoreImage -0x000000026d172000 /System/Library/PrivateFrameworks/SFSymbols.framework/Versions/A/SFSymbols -0x000000022eaf4000 /System/Library/Frameworks/DeveloperToolsSupport.framework/Versions/A/DeveloperToolsSupport -0x00000001ab7ca000 /System/Library/PrivateFrameworks/RenderBox.framework/Versions/A/RenderBox -0x0000000193f75000 /System/Library/PrivateFrameworks/CoreSVG.framework/Versions/A/CoreSVG -0x000000019964f000 /System/Library/PrivateFrameworks/TextureIO.framework/Versions/A/TextureIO -0x00000001b48ec000 /usr/lib/swift/libswiftCoreImage.dylib -0x00000001988f4000 /System/Library/PrivateFrameworks/GraphVisualizer.framework/Versions/A/GraphVisualizer -0x00000002499ae000 /System/Library/PrivateFrameworks/FontServices.framework/Versions/A/FontServices -0x0000000198904000 /System/Library/PrivateFrameworks/OTSVG.framework/Versions/A/OTSVG -0x0000000191379000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATS.framework/Versions/A/Resources/libFontRegistry.dylib -0x000000028b763000 /usr/lib/libhvf.dylib -0x0000000266404000 /System/Library/PrivateFrameworks/ParsingInternal.framework/Versions/A/ParsingInternal -0x00000002499b2000 /System/Library/PrivateFrameworks/FontServices.framework/libXTFontStaticRegistryData.dylib -0x00000001947df000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSCore.framework/Versions/A/MPSCore -0x00000001965ea000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSImage.framework/Versions/A/MPSImage -0x0000000195fa9000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSNeuralNetwork.framework/Versions/A/MPSNeuralNetwork -0x00000001963e8000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSMatrix.framework/Versions/A/MPSMatrix -0x0000000196200000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSRayIntersector.framework/Versions/A/MPSRayIntersector -0x000000019641a000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSNDArray.framework/Versions/A/MPSNDArray -0x0000000230eaa000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSFunctions.framework/Versions/A/MPSFunctions -0x0000000230e8b000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSBenchmarkLoop.framework/Versions/A/MPSBenchmarkLoop -0x0000000230ebe000 /System/Library/Frameworks/MetalPerformanceShaders.framework/Versions/A/Frameworks/MPSHost.framework/Versions/A/MPSHost -0x000000018772d000 /System/Library/PrivateFrameworks/MetalTools.framework/Versions/A/MetalTools -0x00000001b9b0d000 /System/Library/PrivateFrameworks/IOAccelMemoryInfo.framework/Versions/A/IOAccelMemoryInfo -0x00000001c807b000 /System/Library/PrivateFrameworks/kperf.framework/Versions/A/kperf -0x00000001b4868000 /System/Library/PrivateFrameworks/GPURawCounter.framework/Versions/A/GPURawCounter -0x00000001a5299000 /System/Library/PrivateFrameworks/ASEProcessing.framework/Versions/A/ASEProcessing -0x00000001d618a000 /System/Library/PrivateFrameworks/Symbolication.framework/Versions/A/Symbolication -0x00000002698b5000 /System/Library/PrivateFrameworks/PhotosensitivityProcessing.framework/Versions/A/PhotosensitivityProcessing -0x000000026d1f8000 /System/Library/PrivateFrameworks/SILManager.framework/Versions/A/SILManager -0x00000001a1943000 /System/Library/PrivateFrameworks/CoreSymbolication.framework/Versions/A/CoreSymbolication -0x00000001b47db000 /System/Library/PrivateFrameworks/MallocStackLogging.framework/Versions/A/MallocStackLogging -0x00000001a1921000 /System/Library/PrivateFrameworks/DebugSymbols.framework/Versions/A/DebugSymbols -0x00000001c67cc000 /System/Library/PrivateFrameworks/OSAnalytics.framework/Versions/A/OSAnalytics -0x0000000247709000 /System/Library/PrivateFrameworks/DeviceRecovery.framework/Versions/A/DeviceRecovery -0x000000027be51000 /System/Library/PrivateFrameworks/Tightbeam.framework/Versions/A/Tightbeam -0x00000001c2870000 /usr/lib/swift/libswiftCompression.dylib -0x00000001ccebd000 /System/Library/PrivateFrameworks/AFKUser.framework/Versions/A/AFKUser -0x0000000199597000 /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATSUI.framework/Versions/A/ATSUI -0x000000019ac6f000 /System/Library/Frameworks/AudioToolbox.framework/Versions/A/AudioToolbox -0x0000000196a5c000 /System/Library/Frameworks/UserNotifications.framework/Versions/A/UserNotifications -0x00000001ba2a4000 /System/Library/PrivateFrameworks/SiriInstrumentation.framework/Versions/A/SiriInstrumentation -0x000000026f688000 /System/Library/PrivateFrameworks/SiriAnalytics.framework/Versions/A/SiriAnalytics -0x00000001b7a03000 /System/Library/PrivateFrameworks/FeedbackLogger.framework/Versions/A/FeedbackLogger -0x00000001d230d000 /usr/lib/swift/libswiftAVFoundation.dylib -0x000000027e67a000 /System/Library/PrivateFrameworks/UnifiedAssetFramework.framework/Versions/A/UnifiedAssetFramework -0x000000019ae45000 /System/Library/PrivateFrameworks/AudioSession.framework/Versions/A/AudioSession -0x0000000198805000 /System/Library/PrivateFrameworks/MediaExperience.framework/Versions/A/MediaExperience -0x000000019ac19000 /System/Library/PrivateFrameworks/AudioSession.framework/libSessionUtility.dylib -0x00000001a04cd000 /System/Library/Frameworks/CoreBluetooth.framework/Versions/A/CoreBluetooth -0x0000000195c8d000 /System/Library/PrivateFrameworks/CoreUtils.framework/Versions/A/CoreUtils -0x00000001ac4fe000 /System/Library/PrivateFrameworks/HID.framework/Versions/A/HID -0x00000002465fa000 /System/Library/PrivateFrameworks/CoreUtilsExtras.framework/Versions/A/CoreUtilsExtras -0x0000000255ed5000 /System/Library/PrivateFrameworks/IO80211.framework/Versions/A/IO80211 -0x000000019ce10000 /System/Library/Frameworks/IOBluetooth.framework/Versions/A/IOBluetooth -0x000000028c41f000 /usr/lib/swift/libswiftRegexBuilder.dylib -0x0000000198460000 /usr/lib/libIOReport.dylib -0x00000001e2dc2000 /System/Library/PrivateFrameworks/WiFiPeerToPeer.framework/Versions/A/WiFiPeerToPeer -0x0000000195e29000 /System/Library/Frameworks/SecurityFoundation.framework/Versions/A/SecurityFoundation -0x000000023ec54000 /System/Library/PrivateFrameworks/Centauri.framework/Versions/A/Centauri -0x0000000188111000 /System/Library/PrivateFrameworks/Lexicon.framework/Versions/A/Lexicon -0x000000028bbb3000 /usr/lib/libmrc.dylib -0x0000000255f40000 /System/Library/PrivateFrameworks/IPConfiguration.framework/Versions/A/IPConfiguration -0x00000001d6961000 /System/Library/PrivateFrameworks/Netrb.framework/Versions/A/Netrb -0x00000001a1450000 /System/Library/PrivateFrameworks/FrontBoardServices.framework/Versions/A/FrontBoardServices -0x0000000195f92000 /usr/lib/libgermantok.dylib -0x00000001949ce000 /System/Library/PrivateFrameworks/LinguisticData.framework/Versions/A/LinguisticData -0x00000001a06d6000 /System/Library/PrivateFrameworks/MediaKit.framework/Versions/A/MediaKit -0x00000001a0624000 /System/Library/Frameworks/DiscRecording.framework/Versions/A/DiscRecording -0x00000001986db000 /usr/lib/libheimdal-asn1.dylib -0x00000001a4101000 /System/Library/Frameworks/AudioUnit.framework/Versions/A/AudioUnit -0x0000000191690000 /System/Library/Frameworks/OpenDirectory.framework/Versions/A/OpenDirectory -0x000000019169e000 /System/Library/Frameworks/OpenDirectory.framework/Versions/A/Frameworks/CFOpenDirectory.framework/Versions/A/CFOpenDirectory -0x000000019d1b8000 /System/Library/PrivateFrameworks/GeoServices.framework/Versions/A/GeoServices -0x000000019abdf000 /System/Library/PrivateFrameworks/LocationSupport.framework/Versions/A/LocationSupport -0x0000000252bfe000 /System/Library/PrivateFrameworks/GeoServicesCore.framework/Versions/A/GeoServicesCore -0x00000001ad402000 /System/Library/PrivateFrameworks/PhoneNumbers.framework/Versions/A/PhoneNumbers -0x000000025b386000 /System/Library/PrivateFrameworks/LocationLogEncryption.framework/Versions/A/LocationLogEncryption -0x000000022d12e000 /System/Library/Frameworks/AVFAudio.framework/Versions/A/AVFAudio -0x000000022d272000 /System/Library/Frameworks/AVRouting.framework/Versions/A/AVRouting -0x00000001ad51a000 /usr/lib/libAccessibility.dylib -0x0000000259d70000 /System/Library/PrivateFrameworks/IsolatedCoreAudioClient.framework/Versions/A/IsolatedCoreAudioClient -0x00000002423ee000 /System/Library/PrivateFrameworks/CoreAudioOrchestration.framework/Versions/A/CoreAudioOrchestration -0x0000000199ab3000 /System/Library/Frameworks/MediaToolbox.framework/Versions/A/MediaToolbox -0x00000001a07fc000 /System/Library/PrivateFrameworks/CoreAVCHD.framework/Versions/A/CoreAVCHD -0x000000019f720000 /System/Library/Frameworks/MediaAccessibility.framework/Versions/A/MediaAccessibility -0x00000001a07f8000 /System/Library/PrivateFrameworks/Mangrove.framework/Versions/A/Mangrove -0x000000023e290000 /System/Library/PrivateFrameworks/CMPhoto.framework/Versions/A/CMPhoto -0x00000001a0fc5000 /System/Library/Frameworks/CoreTelephony.framework/Versions/A/CoreTelephony -0x00000001a07eb000 /System/Library/PrivateFrameworks/CoreAUC.framework/Versions/A/CoreAUC -0x000000023b3f6000 /System/Library/PrivateFrameworks/AppleJPEGXL.framework/Versions/A/AppleJPEGXL -0x000000019b4e8000 /usr/lib/libTelephonyUtilDynamic.dylib -0x00000001dd93f000 /System/Library/Frameworks/CryptoKit.framework/Versions/A/CryptoKit -0x00000001a40fc000 /System/Library/PrivateFrameworks/CryptoKitCBridging.framework/Versions/A/CryptoKitCBridging -0x00000001a1609000 /System/Library/Frameworks/CryptoTokenKit.framework/Versions/A/CryptoTokenKit -0x0000000247179000 /System/Library/PrivateFrameworks/Dendrite.framework/Versions/A/Dendrite -0x00000001b440c000 /System/Library/Frameworks/NaturalLanguage.framework/Versions/A/NaturalLanguage -0x0000000252901000 /System/Library/PrivateFrameworks/GenerativeModels.framework/Versions/A/GenerativeModels -0x00000001e2d49000 /usr/lib/swift/libswiftNaturalLanguage.dylib -0x000000023bf6d000 /System/Library/PrivateFrameworks/AppleMobileFileIntegrity.framework/Versions/A/AppleMobileFileIntegrity -0x000000028ad01000 /usr/lib/libTLE.dylib -0x00000001b480d000 /usr/lib/libmis.dylib -0x00000001ec491000 /System/Library/PrivateFrameworks/ConfigProfileHelper.framework/Versions/A/ConfigProfileHelper -0x00000001a428b000 /System/Library/PrivateFrameworks/Espresso.framework/Versions/A/Espresso -0x0000000191e20000 /System/Library/Frameworks/CoreML.framework/Versions/A/CoreML -0x00000001e0bf5000 /usr/lib/libedit.3.dylib -0x0000000229f3c000 /System/Library/PrivateFrameworks/ANECompiler.framework/Versions/A/ANECompiler -0x00000001a6361000 /System/Library/PrivateFrameworks/AppleNeuralEngine.framework/Versions/A/AppleNeuralEngine -0x000000025b4a4000 /System/Library/PrivateFrameworks/MIL.framework/Versions/A/MIL -0x0000000230ec4000 /System/Library/Frameworks/MetalPerformanceShadersGraph.framework/Versions/A/MetalPerformanceShadersGraph -0x000000025bc13000 /System/Library/PrivateFrameworks/MLCompilerServices.framework/Versions/A/MLCompilerServices -0x00000001a50dd000 /System/Library/PrivateFrameworks/ANEServices.framework/Versions/A/ANEServices -0x00000001b9ad0000 /usr/lib/libncurses.5.4.dylib -0x000000018b2ce000 /usr/lib/libsandbox.1.dylib -0x0000000198601000 /usr/lib/libMatch.1.dylib -0x00000002654f9000 /System/Library/PrivateFrameworks/ODIE.framework/Versions/A/ODIE -0x000000025e8a0000 /System/Library/PrivateFrameworks/MLModelAsset.framework/Versions/A/MLModelAsset -0x000000025bbb9000 /System/Library/PrivateFrameworks/MLCompilerRuntime.framework/Versions/A/MLCompilerRuntime -0x0000000196255000 /System/Library/Frameworks/MLCompute.framework/Versions/A/MLCompute -0x000000025bb3b000 /System/Library/PrivateFrameworks/MLAssetIO.framework/Versions/A/MLAssetIO -0x000000028c3f1000 /usr/lib/swift/libswiftMLCompute.dylib -0x00000001a11e0000 /System/Library/PrivateFrameworks/AVFCore.framework/Versions/A/AVFCore -0x00000001aae1b000 /System/Library/PrivateFrameworks/AVFCapture.framework/Versions/A/AVFCapture -0x000000023e087000 /System/Library/PrivateFrameworks/CMImaging.framework/Versions/A/CMImaging -0x00000001ab05d000 /System/Library/PrivateFrameworks/Quagga.framework/Versions/A/Quagga -0x00000001ab18e000 /System/Library/PrivateFrameworks/CMCapture.framework/Versions/A/CMCapture -0x000000019b071000 /System/Library/Frameworks/CoreMediaIO.framework/Versions/A/CoreMediaIO -0x000000023dfc2000 /System/Library/PrivateFrameworks/CMCaptureDevice.framework/Versions/A/CMCaptureDevice -0x0000000198a3d000 /System/Library/PrivateFrameworks/CoreBrightness.framework/Versions/A/CoreBrightness -0x000000023f14b000 /System/Library/PrivateFrameworks/CinematicFraming.framework/Versions/A/CinematicFraming -0x00000002619dd000 /System/Library/PrivateFrameworks/ModelManagerServices.framework/Versions/A/ModelManagerServices -0x00000001cf375000 /System/Library/PrivateFrameworks/CPMS.framework/Versions/A/CPMS -0x0000000279581000 /System/Library/PrivateFrameworks/SystemStatus.framework/Versions/A/SystemStatus -0x00000001b323c000 /System/Library/Frameworks/CoreMotion.framework/Versions/A/CoreMotion -0x00000001c3854000 /System/Library/PrivateFrameworks/TimeSync.framework/Versions/A/TimeSync -0x0000000247b41000 /System/Library/PrivateFrameworks/DistributedSensing.framework/Versions/A/DistributedSensing -0x00000001bec33000 /System/Library/PrivateFrameworks/MobileBluetooth.framework/Versions/A/MobileBluetooth -0x00000001c5518000 /System/Library/PrivateFrameworks/IOKitten.framework/Versions/A/IOKitten -0x000000023b270000 /System/Library/PrivateFrameworks/AppleIntelligenceReporting.framework/Versions/A/AppleIntelligenceReporting -0x0000000195b9c000 /System/Library/PrivateFrameworks/CoreEmoji.framework/Versions/A/CoreEmoji -0x0000000188425000 /usr/lib/libCRFSuite.dylib -0x0000000189706000 /System/Library/PrivateFrameworks/LanguageModeling.framework/Versions/A/LanguageModeling -0x00000001948ae000 /System/Library/PrivateFrameworks/CoreNLP.framework/Versions/A/CoreNLP -0x000000018e683000 /System/Library/PrivateFrameworks/Montreal.framework/Versions/A/Montreal -0x0000000196d10000 /usr/lib/libcmph.dylib -0x0000000195f33000 /usr/lib/libmecab.dylib -0x0000000196e81000 /usr/lib/libThaiTokenizer.dylib -0x00000002529e3000 /System/Library/PrivateFrameworks/GenerativeModelsFoundation.framework/Versions/A/GenerativeModelsFoundation -0x000000027c356000 /System/Library/PrivateFrameworks/TokenGeneration.framework/Versions/A/TokenGeneration -0x00000002527b7000 /System/Library/PrivateFrameworks/GenerativeFunctions.framework/Versions/A/GenerativeFunctions -0x0000000252805000 /System/Library/PrivateFrameworks/GenerativeFunctionsFoundation.framework/Versions/A/GenerativeFunctionsFoundation -0x000000026169e000 /System/Library/PrivateFrameworks/ModelCatalog.framework/Versions/A/ModelCatalog -0x000000026e8a2000 /System/Library/PrivateFrameworks/SensitiveContentAnalysisML.framework/Versions/A/SensitiveContentAnalysisML -0x000000025289b000 /System/Library/PrivateFrameworks/GenerativeFunctionsInstrumentation.framework/Versions/A/GenerativeFunctionsInstrumentation -0x000000026b6cb000 /System/Library/PrivateFrameworks/PromptKit.framework/Versions/A/PromptKit -0x000000026b0e1000 /System/Library/PrivateFrameworks/ProactiveDaemonSupport.framework/Versions/A/ProactiveDaemonSupport -0x000000027c58e000 /System/Library/PrivateFrameworks/TokenGenerationCore.framework/Versions/A/TokenGenerationCore -0x00000001b52db000 /System/Library/PrivateFrameworks/Trial.framework/Versions/A/Trial -0x00000001b525c000 /System/Library/PrivateFrameworks/TrialProto.framework/Versions/A/TrialProto -0x000000023ae9a000 /System/Library/PrivateFrameworks/AppleFlatBuffers.framework/Versions/A/AppleFlatBuffers -0x000000026eb72000 /System/Library/PrivateFrameworks/SentencePieceInternal.framework/Versions/A/SentencePieceInternal -0x000000019f77a000 /System/Library/Frameworks/Vision.framework/Versions/A/Vision -0x0000000246298000 /System/Library/PrivateFrameworks/CoreSceneUnderstanding.framework/Versions/A/CoreSceneUnderstanding -0x00000002811df000 /System/Library/PrivateFrameworks/VisionCore.framework/Versions/A/VisionCore -0x00000001999f0000 /System/Library/PrivateFrameworks/DataDetectorsCore.framework/Versions/A/DataDetectorsCore -0x00000001bdbc1000 /System/Library/Frameworks/Vision.framework/libfaceCore.dylib -0x00000001be6db000 /System/Library/PrivateFrameworks/Futhark.framework/Versions/A/Futhark -0x00000001c2629000 /System/Library/PrivateFrameworks/InertiaCam.framework/Versions/A/InertiaCam -0x00000001be468000 /System/Library/PrivateFrameworks/TextRecognition.framework/Versions/A/TextRecognition -0x000000022eadd000 /System/Library/Frameworks/DataDetection.framework/Versions/A/DataDetection -0x00000001b8674000 /System/Library/PrivateFrameworks/TextInput.framework/Versions/A/TextInput -0x000000019849e000 /System/Library/PrivateFrameworks/CVNLP.framework/Versions/A/CVNLP -0x00000001dad09000 /System/Library/PrivateFrameworks/HIDDisplay.framework/Versions/A/HIDDisplay -0x000000019b255000 /usr/lib/libcups.2.dylib -0x000000019b2ec000 /System/Library/Frameworks/Kerberos.framework/Versions/A/Kerberos -0x000000019af5c000 /usr/lib/libresolv.9.dylib -0x0000000198958000 /System/Library/PrivateFrameworks/Heimdal.framework/Versions/A/Heimdal -0x00000001a4100000 /System/Library/Frameworks/Kerberos.framework/Versions/A/Libraries/libHeimdalProxy.dylib -0x000000019b350000 /System/Library/PrivateFrameworks/CommonAuth.framework/Versions/A/CommonAuth -0x00000001ad40e000 /System/Library/PrivateFrameworks/AXCoreUtilities.framework/Versions/A/AXCoreUtilities -0x00000001bda0a000 /System/Library/PrivateFrameworks/AttributeGraph.framework/Versions/A/AttributeGraph -0x000000028a801000 /usr/lib/libAXSafeCategoryBundle.dylib -0x0000000235252000 /System/Library/Frameworks/TabularData.framework/Versions/A/TabularData -0x000000023c11d000 /System/Library/PrivateFrameworks/ArgumentParserInternal.framework/Versions/A/ArgumentParserInternal -0x0000000195a5e000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib -0x0000000197053000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libLAPACK.dylib -0x0000000195f95000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libLinearAlgebra.dylib -0x0000000196ec7000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libSparseBLAS.dylib -0x000000019704e000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libQuadrature.dylib -0x00000001949d5000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBNNS.dylib -0x0000000188221000 /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libSparse.dylib -0x000000022e5c2000 /System/Library/Frameworks/CoreTransferable.framework/Versions/A/CoreTransferable -0x000000019b2b4000 /System/Library/PrivateFrameworks/NetAuth.framework/Versions/A/NetAuth -0x00000001918b4000 /System/Library/PrivateFrameworks/login.framework/Versions/A/Frameworks/loginsupport.framework/Versions/A/loginsupport -0x000000018ca51000 /System/Library/PrivateFrameworks/UIFoundation.framework/Versions/A/UIFoundation -0x0000000195efd000 /usr/lib/libCheckFix.dylib -0x0000000190a3b000 /System/Library/PrivateFrameworks/MetadataUtilities.framework/Versions/A/MetadataUtilities -0x00000002569c7000 /System/Library/PrivateFrameworks/InstalledContentLibrary.framework/Versions/A/InstalledContentLibrary -0x000000018b192000 /System/Library/PrivateFrameworks/CoreServicesStore.framework/Versions/A/CoreServicesStore -0x00000001916ff000 /usr/lib/libapp_launch_measurement.dylib -0x00000001c8192000 /System/Library/PrivateFrameworks/MobileSystemServices.framework/Versions/A/MobileSystemServices -0x0000000198323000 /usr/lib/libxslt.1.dylib -0x0000000195ebc000 /System/Library/PrivateFrameworks/BackgroundTaskManagement.framework/Versions/A/BackgroundTaskManagement -0x00000001a3792000 /usr/lib/libcurl.4.dylib -0x000000028b517000 /usr/lib/libcrypto.46.dylib -0x000000028c09a000 /usr/lib/libssl.48.dylib -0x00000001a346c000 /System/Library/Frameworks/LDAP.framework/Versions/A/LDAP -0x00000001a34a8000 /System/Library/PrivateFrameworks/TrustEvaluationAgent.framework/Versions/A/TrustEvaluationAgent -0x000000019af79000 /usr/lib/libsasl2.2.dylib -0x00000001a6710000 /System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa -0x000000018b32d000 /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit -0x000000023ffce000 /System/Library/PrivateFrameworks/CollectionViewCore.framework/Versions/A/CollectionViewCore -0x0000000193f6f000 /System/Library/PrivateFrameworks/XCTTargetBootstrap.framework/Versions/A/XCTTargetBootstrap -0x0000000199a42000 /System/Library/PrivateFrameworks/UserActivity.framework/Versions/A/UserActivity -0x0000000249ab0000 /System/Library/PrivateFrameworks/FrontBoard.framework/Versions/A/FrontBoard -0x000000027df9f000 /System/Library/PrivateFrameworks/UIIntelligenceSupport.framework/Versions/A/UIIntelligenceSupport -0x00000002342db000 /System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore -0x0000000284b4b000 /System/Library/PrivateFrameworks/WritingTools.framework/Versions/A/WritingTools -0x0000000283942000 /System/Library/PrivateFrameworks/WindowManagement.framework/Versions/A/WindowManagement -0x00000002497e0000 /System/Library/PrivateFrameworks/FocusEngine.framework/Versions/A/FocusEngine -0x00000002471e9000 /System/Library/PrivateFrameworks/DesignLibrary.framework/Versions/A/DesignLibrary -0x0000000193f5a000 /System/Library/PrivateFrameworks/DFRFoundation.framework/Versions/A/DFRFoundation -0x000000027eeec000 /System/Library/PrivateFrameworks/UpdateCycle.framework/Versions/A/UpdateCycle -0x0000000193c5e000 /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox -0x000000019f040000 /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/SpeechRecognition.framework/Versions/A/SpeechRecognition -0x0000000191686000 /System/Library/PrivateFrameworks/PerformanceAnalysis.framework/Versions/A/PerformanceAnalysis -0x000000019f3d0000 /System/Library/Frameworks/Accessibility.framework/Versions/A/Accessibility -0x0000000235238000 /System/Library/Frameworks/Symbols.framework/Versions/A/Symbols -0x0000000252dac000 /System/Library/PrivateFrameworks/Gestures.framework/Versions/A/Gestures -0x000000028c4c2000 /usr/lib/swift/libswiftSpatial.dylib -0x00000001b164e000 /usr/lib/swift/libswiftCoreGraphics.dylib -0x000000019fe14000 /usr/lib/swift/libswiftFoundation.dylib -0x00000001ebe32000 /usr/lib/swift/libswiftSwiftOnoneSupport.dylib -0x000000028c748000 /usr/lib/swift/libswiftsys_time.dylib -0x00000001d699f000 /System/Library/PrivateFrameworks/CoreMaterial.framework/Versions/A/CoreMaterial -0x000000028acfe000 /usr/lib/libSpatial.dylib -0x000000028a71e000 /System/Library/SubFrameworks/UIUtilities.framework/Versions/A/UIUtilities -0x00000001057c0000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/server/libjvm.dylib -0x00000001046ac000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjimage.dylib -0x00000001047a8000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjdwp.dylib -0x00000001046f4000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libjava.dylib -0x0000000104900000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libinstrument.dylib -0x00000001046d8000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libzip.dylib -0x000000028a7f3000 /usr/lib/i18n/libiconv_std.dylib -0x000000028a7e9000 /usr/lib/i18n/libUTF8.dylib -0x000000028a7f8000 /usr/lib/i18n/libmapper_none.dylib -0x00000001049d0000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libdt_socket.dylib -0x0000000104f6c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnio.dylib -0x0000000104fb0000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libnet.dylib -0x00000001049e4000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement.dylib -0x0000000104f4c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libmanagement_ext.dylib -0x0000000104f8c000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libextnet.dylib -0x0000000105300000 /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/lib/libverify.dylib - - -VM Arguments: -jvm_args: -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:49167,suspend=y,server=n -javaagent:/Users/liangxin/Library/Caches/JetBrains/IntelliJIdea2026.1/captureAgent/debugger-agent.jar=file:///var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/capture18007844485071508468.props -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dkotlinx.coroutines.debug.enable.creation.stack.trace=false -Ddebugger.agent.enable.coroutines=true -Dkotlinx.coroutines.debug.enable.flows.stack.trace=true -Dkotlinx.coroutines.debug.enable.mutable.state.flows.stack.trace=true -Ddebugger.async.stack.trace.for.all.threads=true -Dfile.encoding=UTF-8 -java_command: org.springblade.resource.ResourceApplication -java_class_path (initial): /Users/liangxin/Project/JAVA/tms-erp-api/blade-ops/blade-resource/target/classes:/Users/liangxin/Project/JAVA/tms-erp-api/blade-common/target/classes:/Users/liangxin/.m2/repository/org/springblade/blade-core-launch/4.10.0.BASE-SNAPSHOT/blade-core-launch-4.10.0.BASE-SNAPSHOT.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-starter-web/3.5.16/spring-boot-starter-web-3.5.16.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-starter-json/3.5.16/spring-boot-starter-json-3.5.16.jar:/Users/liangxin/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.21.4/jackson-datatype-jdk8-2.21.4.jar:/Users/liangxin/.m2/repository/com/fasterxml/jackson/module/jackson-module-parameter-names/2.18.0/jackson-module-parameter-names-2.18.0.jar:/Users/liangxin/.m2/repository/org/springframework/spring-webmvc/6.2.19/spring-webmvc-6.2.19.jar:/Users/liangxin/.m2/repository/org/springframework/spring-context/6.2.19/spring-context-6.2.19.jar:/Users/liangxin/.m2/repository/org/springframework/spring-expression/6.2.19/spring-expression-6.2.19.jar:/Users/liangxin/.m2/repository/org/springframework/boot/spring-boot-starter-undertow/3.5.16/spring-boot-starter-undertow-3.5.16.jar:/Users/liangxin/.m2/repository/io/undertow/undertow-core/2.3.24.Final/undertow-core-2.3.24.Final.jar:/Users/liangxin/.m2/repository/org/jboss/xnio/xnio-api/3.8.16.Final/xnio-api-3.8.16.Final.jar:/Users/liangxin/.m2/repository/org/wildfly/common/wildfly-common/1.5.4.Final/wildfly-common-1.5.4.Final.jar:/Users/liangxin/.m2/repository/org/wildfly/client/wildfly-client-config/1.0.1.Final/wildfly-client-config-1.0.1.Final.jar:/Users/liangxin/.m2/repository/org/jboss/xnio/xnio-nio/3.8.16.Final/xnio-nio-3.8.16.Final.jar:/Users/liangxin/.m2/repository/org/jboss/threads/jboss-threads/3.7.0.Final/jboss-threads-3.7.0.Final.jar:/Users/liangxin/.m2/repository/io/smallrye/common/smallrye-common-annotation/2.6.0/smallrye-common-annotation-2.6.0.jar:/User -Launcher Type: SUN_STANDARD - -[Global flags] - intx CICompilerCount = 4 {product} {ergonomic} - uint ConcGCThreads = 3 {product} {ergonomic} - uint G1ConcRefinementThreads = 10 {product} {ergonomic} - size_t G1HeapRegionSize = 8388608 {product} {ergonomic} - uintx GCDrainStackTargetSize = 64 {product} {ergonomic} - size_t InitialHeapSize = 603979776 {product} {ergonomic} - bool ManagementServer = true {product} {command line} - size_t MarkStackSize = 4194304 {product} {ergonomic} - size_t MaxHeapSize = 9663676416 {product} {ergonomic} - size_t MaxNewSize = 5796528128 {product} {ergonomic} - size_t MinHeapDeltaBytes = 8388608 {product} {ergonomic} - size_t MinHeapSize = 8388608 {product} {ergonomic} - uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} - bool ProfileInterpreter = false {pd product} {command line} - uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} - size_t SoftMaxHeapSize = 9663676416 {manageable} {ergonomic} - intx TieredStopAtLevel = 1 {product} {command line} - bool UseCompressedClassPointers = true {product lp64_product} {ergonomic} - bool UseCompressedOops = true {product lp64_product} {ergonomic} - bool UseG1GC = true {product} {ergonomic} - bool UseNUMA = false {product} {ergonomic} - bool UseNUMAInterleaving = false {product} {ergonomic} - -Logging: -Log output configuration: - #0: stdout all=warning uptime,level,tags - #1: stderr all=off uptime,level,tags - -Environment Variables: -JAVA_HOME=/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home -PATH=/Users/liangxin/ai-infra/.venv/bin:/Users/liangxin/.nacos/bin:/Applications/Docker.app/Contents/Resources/bin:/Users/liangxin/Library/pnpm:/opt/homebrew/opt/ruby@3.2/bin:/opt/homebrew/opt/openssl@3/bin:/opt/miniconda3/bin:/opt/miniconda3/condabin:/usr/local/sbin:/usr/local/bin:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Users/liangxin/fvm/default/bin:/Applications/MAMP/bin/php/php8.1.13/bin:/opt/homebrew/opt/ruby@3.2/bin:/Users/liangxin/.nvm/versions/node/v20.18.3/bin:/Applications/apache-tomcat-9.0.78:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Users/liangxin/fvm/default/bin:/opt/homebrew/opt/libpng/bin:/Applications/pngquant:/Users/liangxin/AndroidSDK/platform-tools:/Users/liangxin/Library/Android/sdk/platform-tools:/Users/liangxin/Library/Andriod/sdk/cmdline-tools/latest/bin:/Users/liangxin/Library/Andriod/sdk:/Applications/apache-maven-3.8.1/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/local/bin:/Users/liangxin/JDK/zulu17.48.15-ca-jdk17.0.10-macosx_aarch64/zulu-17.jdk/Contents/Home/bin:/Library/Frameworks/Python.framework/Versions/3.9/bin:/Users/liangxin/.local/bin:/Users/liangxin/fvm/default/bin:/Applications/MAMP/bin/php/php8.1.13/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/Library/Apple/usr/bin:/usr/local/share/dotnet:~/.dotnet/tools:/Library/Frameworks/Mono.framework/Versions/Current/Commands:/Users/liangxin/.cargo/bin:true:/Applications/极空间.app/Contents/Resources/app.asar.unpacked/bin/platform-tools -SHELL=/bin/zsh -LANG=C.UTF-8 -TMPDIR=/var/folders/pk/drq93rk10q733kk02fgp89xh0000gn/T/ - -Active Locale: -LC_ALL=C.UTF-8 -LC_COLLATE=C.UTF-8 -LC_CTYPE=C.UTF-8 -LC_MESSAGES=C.UTF-8 -LC_MONETARY=C.UTF-8 -LC_NUMERIC=C.UTF-8 -LC_TIME=C.UTF-8 - -Signal Handlers: - SIGSEGV: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGBUS: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGFPE: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGPIPE: javaSignalHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGXFSZ: javaSignalHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGILL: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - SIGUSR2: SR_handler in libjvm.dylib, mask=00000000000000000000000000000000, flags=SA_RESTART|SA_SIGINFO, blocked - SIGHUP: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGINT: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGTERM: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGQUIT: UserHandler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, blocked - SIGTRAP: crash_handler in libjvm.dylib, mask=11100110000111110111111111111111, flags=SA_RESTART|SA_SIGINFO, unblocked - - ---------------- S Y S T E M --------------- - -OS: -uname: Darwin 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:16:36 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6030 arm64 -OS uptime: 2 days 5:15 hours -rlimit (soft/hard): STACK 8176k/65520k , CORE 0k/infinity , NPROC 6000/9000 , NOFILE 10240/infinity , AS infinity/infinity , CPU infinity/infinity , DATA infinity/infinity , FSIZE infinity/infinity , MEMLOCK infinity/infinity , RSS infinity/infinity -load average: 16.89 38.96 54.23 - -CPU: total 12 (initial active 12) 0x61:0x0:0x5f4dea93:0, fp, simd, crc, lse -machdep.cpu.brand_string:Apple M3 Pro -hw.cachelinesize:128 -hw.l1icachesize:131072 -hw.l1dcachesize:65536 -hw.l2cachesize:4194304 - -Memory: 16k page, physical 37748736k(215216k free), swap 18874368k(1062912k free) - -vm_info: OpenJDK 64-Bit Server VM (17.0.8+7-LTS) for bsd-aarch64 JRE (17.0.8+7-LTS) (Zulu17.44+15-CA), built on Jul 5 2023 00:50:04 by "zulu_re" with clang Apple LLVM 12.0.0 (clang-1200.0.32.28) - -END.