pAqz&W$2ox!Ts(ob~5mOayzhlOEFZ#Hn%?6Ku}D_{em=TzpL;;IXS7|
z^08-ALJeiZ-mS%
zKc_z^7^?O3uJR-c_gwnU-XozkAAs3;=`$p{)9p8bjcO?FJX?_g7}3OUC05@2i?X{)
z&FpjTL^!J&K@4aj66u5vdbfiTKLY&Q?f^gQNSH;VvJi23PX08+<*`E}yf9FSlXe^b
z0;WuK^B{i&8sm;aUIt4^NPO5pl}%U55Dk!EA&@kj?bmDJV#XKXQI8c|FZXAI{p9U`A_{m{bli2
zYaIg&z5ep0tZ-NGnohTEV@pb@IEwXR9eq-_pk+;B%%H
z+9T5?3!FDH_R$%>GJJQNJ+Su!4u{`#NitvN-5aNO0;fEI%QXxIw780?CK;50<39?z
z>cI!c)$ctw(B424F
ziNkLmoJv3v!e<_yCo0mEn1q}pAXZmUNGx$x?M{pVV{?*#RHvw(_zaC(+4Faxn%q
zZ9L#vD3JAWuD0U@w^hEo)=*bCpD(!?g+^#HcpM4K*|~iT{qTOX^-ba&3mJbsiDzAU
znGitVP0au4GzQ;%o}j;rj!}8_k;_Cp@WZVV<}B_O0XFc$M;nd@Q+m>+1{&V}&18O-
z=@z{}XBFF>`@HfKTn&NSjg35PKBI|qv5Wq1lqx-noUoH_&qyXh2cSJ74b^hPz$f2A
z#e@ZhX?Fn|dHtq=-aFvC69t37#3R8&4+v9Z%azsySy65ZtsZ$J*lH*Jn=9huADMUB
zNqS@y`iCKM-xeGKrvxdwGhfxW8VtiQ_0xH+Uv7RiNuEmf-{*S>GuS?s>F+PNTM-1#
zF*MR8A!R1}??**MUH<%)hMjb$?EbXy>BE;_q5wc?*!nNw-$ya0MNemtzeOD=e}4CL
zK6x78bWHpWFidn+{}drlBb*LCzY!ju{Xgpe5sH4J{IiezjiN>OhsXRa_|LxJw_q0W
o%#KLlzj%hzIH$M5Z=6Mjf7}>Gy5wh0CQ%YEGNL0AW;_}F3ojeV*#H0l
literal 0
HcmV?d00001
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..802d02f811922acc5306e36fa721139e531aeb8e
GIT binary patch
literal 9848
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
+ when(sysUserService.listByIds(any())).thenReturn(List.of(firstGuardian, secondGuardian, thirdGuardian));
+
+ service.syncStudentGuardianPhones(List.of(FIRST_WARD_USER_ID));
+
+ verify(studentService).updateGuardianPhones(FIRST_WARD_USER_ID, "13800000001,13800000002");
+ }
+
+ /**
+ * 验证学生最后一条监护关系解除后,派生手机号集合会显式清空。
+ */
+ @Test
+ void shouldClearGuardianPhonesWhenNoGuardianRelationRemains() {
+ SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class);
+ SysUserStudentService studentService = mock(SysUserStudentService.class);
+ SysUserRelationServiceImpl service = spy(new SysUserRelationServiceImpl(
+ mock(SysUserService.class), relationTypeService, studentService));
+ when(relationTypeService.list(any())).thenReturn(List.of(createGuardianRelationType()));
+ doReturn(List.of()).when(service)
+ .list(org.mockito.ArgumentMatchers.>any());
+
+ service.syncStudentGuardianPhones(List.of(FIRST_WARD_USER_ID));
+
+ verify(studentService).updateGuardianPhones(FIRST_WARD_USER_ID, null);
+ }
+
private SysUserRelationEntity createRelation(Long relationId, Long wardUserId) {
SysUserRelationEntity relation = new SysUserRelationEntity();
relation.setRelationId(relationId);
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java
index 969426fd..34468e63 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java
@@ -6,6 +6,7 @@ import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
import lombok.Data;
+/** 默认开卡导入行,使用用户姓名和手机号精确定位已有用户。 */
@Data
public class WalletImportActiveDto {
@@ -16,28 +17,35 @@ public class WalletImportActiveDto {
@ExcelIgnore
private Long lineNum;
+ /** 用户姓名,必须与系统用户姓名完全一致。 */
@ColumnWidth(100)
@ExcelProperty("用户姓名")
private String username;
+ /** 用户手机号,与用户姓名共同作为导入定位条件。 */
@ColumnWidth(100)
@ExcelProperty("用户手机号")
private String userMobile;
+ /** 待写入开卡记录和饭卡钱包的卡号。 */
@ColumnWidth(100)
@ExcelProperty("卡号")
private String cardNumber;
- // 开卡的id
+ /** 校验阶段解析出的开卡记录 ID,仅供服务端导入流程使用。 */
+ @ExcelIgnore
private Long id;
- // 钱包id
+ /** 校验阶段解析出的饭卡钱包 ID,仅供服务端导入流程使用。 */
+ @ExcelIgnore
private Long walletId;
- // 用户id
+ /** 校验阶段解析出的用户 ID,仅供服务端导入流程使用。 */
+ @ExcelIgnore
private Long userId;
- // 用户部门id
+ /** 校验阶段解析出的用户部门 ID,仅供服务端导入流程兼容使用。 */
+ @ExcelIgnore
private Long userDeptId;
}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStudentImportActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStudentImportActiveDto.java
new file mode 100644
index 00000000..68012e02
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStudentImportActiveDto.java
@@ -0,0 +1,60 @@
+package com.bsd.pjcloud.wallet.api.dto;
+
+import com.alibaba.excel.annotation.ExcelIgnore;
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import com.alibaba.excel.annotation.write.style.ColumnWidth;
+import com.alibaba.excel.annotation.write.style.ContentRowHeight;
+import com.alibaba.excel.annotation.write.style.HeadRowHeight;
+import com.bsd.pjcloud.common.excel.annotation.ExcelDict;
+import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
+import lombok.Data;
+
+/**
+ * 学生开卡导入行。
+ * 使用学生学校和学号精确定位学生身份,再校验学生姓名,避免同名学生绑定错误。
+ */
+@Data
+@HeadRowHeight(20)
+@ContentRowHeight(10)
+@ExcelIgnoreUnannotated
+public class WalletStudentImportActiveDto {
+
+ /** Excel 原始行号,用于向前端返回可定位的逐行校验错误。 */
+ @ExcelLine
+ @ExcelIgnore
+ private Long lineNum;
+
+ /** 学生姓名,必须与学校和学号定位到的系统用户姓名完全一致。 */
+ @ColumnWidth(100)
+ @ExcelProperty("学生姓名")
+ private String studentName;
+
+ /** 学生学校,与学号共同作为学生身份的精确定位条件。 */
+ @ColumnWidth(100)
+ @ExcelProperty("学生学校")
+ @ExcelDict("user_student_school")
+ private String schoolName;
+
+ /** 学生学号,在同一学校内唯一。 */
+ @ColumnWidth(100)
+ @ExcelProperty("学号")
+ private String studentNo;
+
+ /** 待写入开卡记录和饭卡钱包的卡号。 */
+ @ColumnWidth(100)
+ @ExcelProperty("卡号")
+ private String cardNumber;
+
+ /** 校验阶段解析出的开卡记录 ID,仅供服务端导入流程使用。 */
+ @ExcelIgnore
+ private Long id;
+
+ /** 校验阶段解析出的饭卡钱包 ID,仅供服务端导入流程使用。 */
+ @ExcelIgnore
+ private Long walletId;
+
+ /** 校验阶段解析出的学生用户 ID,仅供服务端导入流程使用。 */
+ @ExcelIgnore
+ private Long userId;
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java
new file mode 100644
index 00000000..cc32db6f
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java
@@ -0,0 +1,70 @@
+package com.bsd.pjcloud.wallet.api.dto.recharge.excel;
+
+import com.alibaba.excel.annotation.ExcelIgnore;
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import com.alibaba.excel.annotation.write.style.ColumnWidth;
+import com.alibaba.excel.annotation.write.style.ContentRowHeight;
+import com.alibaba.excel.annotation.write.style.HeadRowHeight;
+import com.bsd.pjcloud.common.excel.annotation.ExcelDict;
+import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
+import lombok.Data;
+
+/**
+ * 学生充值/扣款申请 Excel 导入行。
+ * 使用学校和学号精确定位学生,再校验学生姓名,避免同名学生被错误充值或扣款。
+ */
+@Data
+@HeadRowHeight(20)
+@ContentRowHeight(10)
+@ExcelIgnoreUnannotated
+public class WalletStudentRechargeExcelDto {
+
+ /** Excel 原始行号,用于向前端返回可定位的逐行校验错误。 */
+ @ExcelLine
+ @ExcelIgnore
+ private Long lineNum;
+
+ /** 学生姓名,必须与学校和学号定位到的系统用户姓名完全一致。 */
+ @ColumnWidth(100)
+ @ExcelProperty("学生姓名")
+ private String studentName;
+
+ /** 学校名称,与学号共同作为学生身份定位条件,并受学生学校字典约束。 */
+ @ColumnWidth(100)
+ @ExcelProperty("学校")
+ @ExcelDict("user_student_school")
+ private String schoolName;
+
+ /** 学生学号,在同一学校内用于精确定位学生身份。 */
+ @ColumnWidth(100)
+ @ExcelProperty("学号")
+ private String studentNo;
+
+ /** Excel 中填写的操作金额,单位元;余额重置模板中允许为空。 */
+ @ColumnWidth(100)
+ @ExcelProperty("操作金额")
+ private String price;
+
+ /** Excel 中展示的财务标签名称,支持英文逗号分隔多个字典标签。 */
+ @ColumnWidth(100)
+ @ExcelProperty("操作标签")
+ @ExcelDict(value = "wallet_transaction_tag", multiple = true)
+ private String tag;
+
+ /** 校验阶段解析出的财务标签字典值,多个值使用英文逗号分隔。 */
+ @ExcelIgnore
+ private String tags;
+
+ /** 校验阶段解析出的操作金额,单位分;余额重置时固定为零。 */
+ @ExcelIgnore
+ private Integer priceNumber;
+
+ /** 校验阶段按学生身份解析出的系统用户 ID。 */
+ @ExcelIgnore
+ private Long userId;
+
+ /** 校验阶段解析出的学生所属部门 ID。 */
+ @ExcelIgnore
+ private Long userDeptId;
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml
index e0f64d9b..9aa1284f 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml
+++ b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml
@@ -112,6 +112,10 @@
com.bsd
pjcloud-common-idempotent
+
+ com.bsd
+ pjcloud-common-async-task
+
com.bsd
pjcloud-flow-task-api
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java
index 97f95194..da403219 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java
@@ -26,6 +26,7 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo;
import com.bsd.pjcloud.wallet.service.MchManageService;
import com.bsd.pjcloud.wallet.service.WalletActiveService;
+import com.bsd.pjcloud.wallet.service.WalletActiveImportService;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
@@ -56,6 +57,8 @@ import java.util.List;
public class WalletActiveController {
private final WalletActiveService walletActiveService;
+ /** 钱包开卡 Excel 导入服务,隔离默认和学生模板的校验及异步编排。 */
+ private final WalletActiveImportService walletActiveImportService;
private final MchManageService mchManageService;
private final RemoteDeptService remoteDeptService;
@@ -226,12 +229,31 @@ public class WalletActiveController {
}
/**
- * 导入开卡信息,会直接覆盖
+ * 导入默认开卡模板,按用户姓名和手机号定位用户并异步覆盖饭卡卡号。
+ *
+ * @param excelVOList 默认开卡 Excel 行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @return 校验失败时返回逐行错误;校验通过时返回异步任务初始进度
*/
@PostMapping("/import")
@PreAuthorize("@pms.hasPermission('wallet_walletActive_import')")
- public R importWalletActive(@RequestExcel List excelVOList, BindingResult bindingResult) {
- return walletActiveService.importWalletActive(excelVOList, bindingResult);
+ @Idempotent(expireTime = 5, key = "'/wallet-active/import:' + #excelVOList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作")
+ public R> importWalletActive(@RequestExcel List excelVOList, BindingResult bindingResult) {
+ return walletActiveImportService.importDefault(excelVOList, bindingResult);
+ }
+
+ /**
+ * 导入学生开卡模板,按学校和学号定位学生并异步覆盖饭卡卡号。
+ *
+ * @param excelVOList 学生开卡 Excel 行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @return 校验失败时返回逐行错误;校验通过时返回异步任务初始进度
+ */
+ @PostMapping("/import-student")
+ @PreAuthorize("@pms.hasPermission('wallet_student_walletActive_import')")
+ @Idempotent(expireTime = 5, key = "'/wallet-active/import-student:' + #excelVOList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作")
+ public R> importStudentWalletActive(@RequestExcel List excelVOList, BindingResult bindingResult) {
+ return walletActiveImportService.importStudent(excelVOList, bindingResult);
}
}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java
index 489c22ae..ba549057 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java
@@ -16,11 +16,13 @@ import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeSaveTopupDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto;
+import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletStudentRechargeExcelDto;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeApplyCompareAceVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.excel.WalletRechargeByApplyExcelVo;
import com.bsd.pjcloud.wallet.service.WalletRechargeApplyService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeApplyImportService;
import com.bsd.pjcloud.wallet.service.WalletRechargeApplyComposeService;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
@@ -43,6 +45,8 @@ import java.util.List;
public class WalletRechargeApplyController {
private final WalletRechargeApplyService walletRechargeApplyService;
+ /** 充值/扣款 Excel 导入服务,独立承载默认用户和学生用户模板校验及落库。 */
+ private final WalletRechargeApplyImportService walletRechargeApplyImportService;
private final WalletRechargeApplyComposeService walletTransactionComposeService;
/* ************************************* start callback start ************************************* */
@@ -160,25 +164,80 @@ public class WalletRechargeApplyController {
return walletRechargeApplyService.list(Wrappers.lambdaQuery(walletRechargeApply).in(ArrayUtil.isNotEmpty(ids), WalletRechargeApplyEntity::getId, ids));
}
+ /**
+ * 读取默认用户充值、扣款或退款 Excel,按姓名和手机号定位用户。
+ *
+ * @param excelVOList 默认用户充值 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @param bizType 充值业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
@PostMapping("/read-excel")
- public R readExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) {
- return walletRechargeApplyService.readExcel(excelVOList, bindingResult, bizType);
+ @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_def')")
+ @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作")
+ public R> readExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) {
+ return walletRechargeApplyImportService.importDefault(excelVOList, bindingResult, bizType);
}
/**
- * 读取重置钱包的excel
+ * 读取默认用户余额重置 Excel,按姓名和手机号定位用户且不校验操作金额。
+ *
+ * @param excelVOList 默认用户余额重置 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @param bizType 余额重置业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
*/
@PostMapping("/read-reset-excel")
+ @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_def')")
@Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作")
- public R readResetExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) {
- return walletRechargeApplyService.readResetExcel(excelVOList, bindingResult, bizType);
+ public R> readResetExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) {
+ return walletRechargeApplyImportService.importDefaultReset(excelVOList, bindingResult, bizType);
}
+ /**
+ * 读取学生充值、扣款或退款 Excel,按学校和学号定位学生并校验姓名。
+ *
+ * @param excelVOList 学生充值 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @param bizType 充值业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ @PostMapping("/read-student-excel")
+ @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_studen')")
+ @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作")
+ public R> readStudentExcel(@RequestExcel List excelVOList, BindingResult bindingResult,
+ @RequestParam(required = false) String bizType) {
+ return walletRechargeApplyImportService.importStudent(excelVOList, bindingResult, bizType);
+ }
+
+ /**
+ * 读取学生余额重置 Excel,按学校和学号定位学生且不校验操作金额。
+ *
+ * @param excelVOList 学生余额重置 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @param bizType 余额重置业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ @PostMapping("/read-student-reset-excel")
+ @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_studen')")
+ @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作")
+ public R> readStudentResetExcel(@RequestExcel List excelVOList, BindingResult bindingResult,
+ @RequestParam(required = false) String bizType) {
+ return walletRechargeApplyImportService.importStudentReset(excelVOList, bindingResult, bizType);
+ }
+
+ /**
+ * 读取绑定发票前的默认用户充值明细,并创建基础发票记录。
+ *
+ * @param excelVOList 默认用户充值 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
@PostMapping("/read-topup-excel")
@Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作")
- public R readTopupExcel(@RequestExcel List excelVOList, BindingResult bindingResult) {
- return walletRechargeApplyService.readTopupExcel(excelVOList, bindingResult);
+ public R> readTopupExcel(@RequestExcel List excelVOList, BindingResult bindingResult) {
+ return walletRechargeApplyImportService.importTopup(excelVOList, bindingResult);
}
/* ************************************* start excel start ************************************* */
-}
\ No newline at end of file
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveImportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveImportService.java
new file mode 100644
index 00000000..62270e14
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveImportService.java
@@ -0,0 +1,30 @@
+package com.bsd.pjcloud.wallet.service;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.wallet.api.dto.WalletImportActiveDto;
+import com.bsd.pjcloud.wallet.api.dto.WalletStudentImportActiveDto;
+import org.springframework.validation.BindingResult;
+
+import java.util.List;
+
+/** 钱包开卡 Excel 导入服务,与开卡 CRUD 和设备激活业务隔离。 */
+public interface WalletActiveImportService {
+
+ /**
+ * 导入默认开卡模板,按用户姓名和手机号定位用户。
+ *
+ * @param rows 默认开卡 Excel 行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度
+ */
+ R> importDefault(List rows, BindingResult bindingResult);
+
+ /**
+ * 导入学生开卡模板,按学校和学号定位学生并校验姓名。
+ *
+ * @param rows 学生开卡 Excel 行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度
+ */
+ R> importStudent(List rows, BindingResult bindingResult);
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java
index 56656b15..31f6781c 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java
@@ -2,20 +2,13 @@ package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
-import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.wallet.api.dto.WalletActiveBindCardDto;
import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto;
-import com.bsd.pjcloud.wallet.api.dto.WalletImportActiveDto;
import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardCountDto;
-import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto;
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo;
import jakarta.validation.constraints.NotEmpty;
-import org.springframework.validation.BindingResult;
-
-import java.util.List;
-
public interface WalletActiveService extends IService {
/**
* 一键开卡
@@ -80,9 +73,4 @@ public interface WalletActiveService extends IService {
*/
void delCardByCardNumber(String cardNumber);
- /**
- * 导入开卡信息,会直接覆盖
- */
- R importWalletActive(List excelVOList, BindingResult bindingResult);
-
-}
\ No newline at end of file
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyImportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyImportService.java
new file mode 100644
index 00000000..0d043101
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyImportService.java
@@ -0,0 +1,63 @@
+package com.bsd.pjcloud.wallet.service;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto;
+import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletStudentRechargeExcelDto;
+import org.springframework.validation.BindingResult;
+
+import java.util.List;
+
+/**
+ * 充值/扣款申请 Excel 导入服务,与申请 CRUD、审批和余额处理业务隔离。
+ */
+public interface WalletRechargeApplyImportService {
+
+ /**
+ * 导入不绑定发票的默认用户充值、扣款或退款数据。
+ *
+ * @param rows 默认用户 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 充值业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ R> importDefault(List rows, BindingResult bindingResult, String bizType);
+
+ /**
+ * 导入不绑定发票的默认用户余额重置数据,操作金额固定为零。
+ *
+ * @param rows 默认用户余额重置 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 余额重置业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ R> importDefaultReset(List rows, BindingResult bindingResult, String bizType);
+
+ /**
+ * 导入不绑定发票的学生充值、扣款或退款数据。
+ *
+ * @param rows 学生 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 充值业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ R> importStudent(List rows, BindingResult bindingResult, String bizType);
+
+ /**
+ * 导入不绑定发票的学生余额重置数据,操作金额固定为零。
+ *
+ * @param rows 学生余额重置 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 余额重置业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ R> importStudentReset(List rows, BindingResult bindingResult, String bizType);
+
+ /**
+ * 导入绑定发票前的充值明细并创建基础发票记录。
+ *
+ * @param rows 默认用户充值 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ R> importTopup(List rows, BindingResult bindingResult);
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java
index c060c655..bbd0d2f2 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java
@@ -2,17 +2,14 @@ package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
-import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeSaveTopupDto;
-import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeApplyVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.excel.WalletRechargeByApplyExcelVo;
-import org.springframework.validation.BindingResult;
import java.util.ArrayList;
import java.util.List;
@@ -47,21 +44,6 @@ public interface WalletRechargeApplyService extends IService excelVOList, BindingResult bindingResult, String bizType);
-
- /**
- * 读取重置钱包的excel
- */
- R readResetExcel(List excelVOList, BindingResult bindingResult, String bizType);
-
- /**
- * 读取充值excel内容
- */
- R readTopupExcel(List excelVOList, BindingResult bindingResult);
-
/**
* 审批回调
*/
@@ -75,4 +57,4 @@ public interface WalletRechargeApplyService extends IService exportExcel(WalletRechargeImportDto dto);
-}
\ No newline at end of file
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java
new file mode 100644
index 00000000..dc1ca524
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java
@@ -0,0 +1,464 @@
+package com.bsd.pjcloud.wallet.service.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.bsd.pjcloud.admin.api.dto.UserInfo;
+import com.bsd.pjcloud.admin.api.entity.SysUser;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
+import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
+import com.bsd.pjcloud.wallet.api.dto.WalletImportActiveDto;
+import com.bsd.pjcloud.wallet.api.dto.WalletStudentImportActiveDto;
+import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
+import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
+import com.bsd.pjcloud.wallet.mapper.WalletActiveMapper;
+import com.bsd.pjcloud.wallet.service.WalletActiveImportService;
+import com.bsd.pjcloud.wallet.service.WalletActiveService;
+import com.bsd.pjcloud.wallet.service.WalletManageService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.BindingResult;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 钱包开卡 Excel 导入服务实现。
+ * 同步阶段完成整表解析、用户定位和业务校验,校验全部通过后才提交公共异步事务任务,避免错误行造成部分卡号已覆盖。
+ */
+@Service
+@RequiredArgsConstructor
+public class WalletActiveImportServiceImpl implements WalletActiveImportService {
+
+ /** 默认开卡导入任务的 Redis 业务命名空间。 */
+ private static final String DEFAULT_IMPORT_REDIS_KEY = "wallet:active:import:default";
+
+ /** 学生开卡导入任务的 Redis 业务命名空间。 */
+ private static final String STUDENT_IMPORT_REDIS_KEY = "wallet:active:import:student";
+
+ /** 允许导入的饭卡卡号长度,与既有开卡导入规则保持一致。 */
+ private static final Set CARD_NUMBER_LENGTHS = Set.of(8, 10, 12);
+
+ /** 用户远程服务,用于按默认模板或学生身份条件精确定位系统用户。 */
+ private final RemoteUserService remoteUserService;
+
+ /** 钱包管理服务,用于查询饭卡钱包并同步覆盖钱包卡号。 */
+ private final WalletManageService walletManageService;
+
+ /** 开卡核心服务,用于为尚未绑定饭卡的用户复用现有自动分配和绑定逻辑。 */
+ private final WalletActiveService walletActiveService;
+
+ /** 开卡 Mapper,用于批量查询用户开卡记录并更新导入卡号。 */
+ private final WalletActiveMapper walletActiveMapper;
+
+ /** 公共异步任务执行器,用于后台事务写入和向前端报告逐行进度。 */
+ private final AsyncTaskExecutorService asyncTaskExecutorService;
+
+ /**
+ * 校验默认模板并提交异步开卡导入任务。
+ *
+ * @param rows 默认开卡 Excel 行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度
+ */
+ @Override
+ public R> importDefault(List rows, BindingResult bindingResult) {
+ List errors = getErrorMessageList(bindingResult);
+ List effectiveRows = rows == null ? List.of() : rows.stream()
+ .filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getCardNumber()))
+ .toList();
+ if (effectiveRows.isEmpty()) {
+ return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(errors);
+ }
+
+ effectiveRows.forEach(row -> {
+ row.setUsername(StrUtil.trim(row.getUsername()));
+ row.setUserMobile(StrUtil.trim(row.getUserMobile()));
+ row.setCardNumber(StrUtil.trim(row.getCardNumber()));
+ });
+ Map phoneCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList());
+ Map cardCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getCardNumber).toList());
+ List validatedRows = new ArrayList<>();
+ for (WalletImportActiveDto row : effectiveRows) {
+ Set rowErrors = new LinkedHashSet<>();
+ requireText(row.getUsername(), "请输入用户姓名", rowErrors);
+ requireText(row.getUserMobile(), "请输入用户手机号", rowErrors);
+ validateCardNumber(row.getCardNumber(), cardCounts, rowErrors);
+ if (StrUtil.isNotBlank(row.getUserMobile()) && phoneCounts.getOrDefault(row.getUserMobile(), 0L) > 1) {
+ rowErrors.add("用户手机号 <" + row.getUserMobile() + "> 在Excel中重复");
+ }
+ SysUser user = null;
+ if (StrUtil.isNotBlank(row.getUsername()) && StrUtil.isNotBlank(row.getUserMobile())) {
+ user = extractUser(remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile()));
+ if (user == null || !ObjectUtil.equals(user.getName(), row.getUsername())) {
+ rowErrors.add("用户不存在");
+ }
+ }
+ if (rowErrors.isEmpty() && user != null) {
+ validatedRows.add(new ValidatedImportRow(row.getLineNum(), user.getUserId(), row.getUsername(), row.getCardNumber()));
+ } else {
+ appendErrors(errors, row.getLineNum(), rowErrors);
+ }
+ }
+ return validateBindingsAndSubmit(validatedRows, errors, DEFAULT_IMPORT_REDIS_KEY, "默认开卡导入");
+ }
+
+ /**
+ * 校验学生模板并提交异步开卡导入任务。
+ *
+ * @param rows 学生开卡 Excel 行
+ * @param bindingResult Excel 解析阶段的逐行错误
+ * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度
+ */
+ @Override
+ public R> importStudent(List rows, BindingResult bindingResult) {
+ List errors = getErrorMessageList(bindingResult);
+ List effectiveRows = rows == null ? List.of() : rows.stream()
+ .filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getCardNumber()))
+ .toList();
+ if (effectiveRows.isEmpty()) {
+ return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(errors);
+ }
+
+ effectiveRows.forEach(row -> {
+ row.setStudentName(StrUtil.trim(row.getStudentName()));
+ row.setSchoolName(StrUtil.trim(row.getSchoolName()));
+ row.setStudentNo(StrUtil.trim(row.getStudentNo()));
+ row.setCardNumber(StrUtil.trim(row.getCardNumber()));
+ });
+ Map studentCounts = countValues(effectiveRows.stream()
+ .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList());
+ Map cardCounts = countValues(effectiveRows.stream().map(WalletStudentImportActiveDto::getCardNumber).toList());
+ List validatedRows = new ArrayList<>();
+ for (WalletStudentImportActiveDto row : effectiveRows) {
+ Set rowErrors = new LinkedHashSet<>();
+ requireText(row.getStudentName(), "请输入学生姓名", rowErrors);
+ requireText(row.getSchoolName(), "请输入学生学校", rowErrors);
+ requireText(row.getStudentNo(), "请输入学号", rowErrors);
+ validateCardNumber(row.getCardNumber(), cardCounts, rowErrors);
+ String studentKey = buildStudentKey(row.getSchoolName(), row.getStudentNo());
+ if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo()) && studentCounts.getOrDefault(studentKey, 0L) > 1) {
+ rowErrors.add("学生学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复");
+ }
+ SysUser user = null;
+ if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo())) {
+ user = extractUser(remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo()));
+ if (user == null) {
+ rowErrors.add("学生不存在");
+ } else if (!ObjectUtil.equals(user.getName(), row.getStudentName())) {
+ rowErrors.add("学生姓名与学校、学号不匹配");
+ }
+ }
+ if (rowErrors.isEmpty() && user != null) {
+ validatedRows.add(new ValidatedImportRow(row.getLineNum(), user.getUserId(), row.getStudentName(), row.getCardNumber()));
+ } else {
+ appendErrors(errors, row.getLineNum(), rowErrors);
+ }
+ }
+ return validateBindingsAndSubmit(validatedRows, errors, STUDENT_IMPORT_REDIS_KEY, "学生开卡导入");
+ }
+
+ /**
+ * 批量解析用户现有开卡记录或饭卡钱包,校验卡号占用后提交异步任务。
+ *
+ * @param rows 已完成用户定位和模板字段校验的行
+ * @param errors 可继续追加的逐行错误集合
+ * @param redisKey 当前导入类型的任务 Redis 命名空间
+ * @param taskName 当前导入类型的任务展示名称
+ * @return 校验失败响应或异步任务初始进度响应
+ */
+ private R> validateBindingsAndSubmit(List rows, List errors,
+ String redisKey, String taskName) {
+ List userIds = rows.stream().map(ValidatedImportRow::userId).distinct().toList();
+ List wallets = userIds.isEmpty() ? List.of() : ObjectUtil.defaultIfNull(
+ walletManageService.getWalletsByUserIds(userIds, WalletTypeEnum.EAT), List.of());
+ Map walletByUserId = wallets.stream()
+ .collect(Collectors.toMap(WalletManageEntity::getUserId, Function.identity(), (first, second) -> first));
+ List walletIds = wallets.stream().map(WalletManageEntity::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
+ List activeRecords = new ArrayList<>();
+ if (!userIds.isEmpty()) {
+ activeRecords.addAll(walletActiveMapper.selectList(
+ new LambdaQueryWrapper().in(WalletActiveEntity::getUserId, userIds)));
+ }
+ if (!walletIds.isEmpty()) {
+ // 兼容历史开卡记录只写 walletId、未写 userId 的数据,避免误判为未绑定后重复分配饭卡。
+ activeRecords.addAll(walletActiveMapper.selectList(
+ new LambdaQueryWrapper().in(WalletActiveEntity::getWalletId, walletIds)));
+ }
+ Map activeByUserId = activeRecords.stream()
+ .filter(active -> active.getUserId() != null)
+ .collect(Collectors.toMap(WalletActiveEntity::getUserId, Function.identity(), (first, second) -> first));
+ Map activeByWalletId = activeRecords.stream()
+ .filter(active -> active.getWalletId() != null)
+ .collect(Collectors.toMap(WalletActiveEntity::getWalletId, Function.identity(), (first, second) -> first));
+
+ rows.forEach(row -> {
+ WalletManageEntity wallet = walletByUserId.get(row.userId());
+ if (wallet == null) {
+ appendErrors(errors, row.lineNum(), Set.of("用户 <" + row.displayName() + "> 饭卡钱包不存在"));
+ return;
+ }
+ WalletActiveEntity active = activeByUserId.get(row.userId());
+ if (active == null) {
+ active = activeByWalletId.get(wallet.getId());
+ }
+ if (active != null) {
+ row.bind(active.getId(), wallet.getId());
+ return;
+ }
+ row.bind(null, wallet.getId());
+ });
+ validateCardOccupancy(rows, errors);
+ if (!errors.isEmpty()) {
+ return R.failed(errors);
+ }
+ AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(redisKey, taskName, rows.size(), reporter -> {
+ for (ValidatedImportRow row : rows) {
+ applyCardNumber(row);
+ reporter.advance("正在导入开卡信息:" + row.displayName());
+ }
+ });
+ return R.ok(progress, "Excel校验通过,已开始后台导入");
+ }
+
+ /**
+ * 校验目标卡号未被本次导入用户之外的其他开卡记录占用。
+ *
+ * @param rows 已解析用户开卡关系的导入行
+ * @param errors 可继续追加的逐行错误集合
+ */
+ private void validateCardOccupancy(List rows, List errors) {
+ List cardNumbers = rows.stream().map(ValidatedImportRow::cardNumber).distinct().toList();
+ if (cardNumbers.isEmpty()) {
+ return;
+ }
+ Map activeByCardNumber = walletActiveMapper.selectList(
+ new LambdaQueryWrapper().in(WalletActiveEntity::getCardNumber, cardNumbers))
+ .stream().collect(Collectors.toMap(WalletActiveEntity::getCardNumber, Function.identity(), (first, second) -> first));
+ rows.forEach(row -> {
+ WalletActiveEntity occupied = activeByCardNumber.get(row.cardNumber());
+ if (occupied != null && !ObjectUtil.equals(occupied.getId(), row.activeId())) {
+ appendErrors(errors, row.lineNum(), Set.of("卡号 <" + row.cardNumber() + "> 已被其他用户使用"));
+ }
+ });
+ }
+
+ /**
+ * 在异步事务内为单行用户准备开卡记录并同步覆盖开卡表和钱包表卡号。
+ *
+ * @param row 已通过全部校验并包含用户、钱包关联的导入行
+ */
+ private void applyCardNumber(ValidatedImportRow row) {
+ WalletManageEntity wallet = walletManageService.getWallet(row.walletId());
+ if (wallet == null) {
+ throw new IllegalStateException("用户 <" + row.displayName() + "> 饭卡钱包不存在");
+ }
+ if (row.activeId() == null) {
+ WalletActiveEntity active = findBoundActive(row.userId(), row.walletId());
+ if (active == null) {
+ if (ObjectUtil.isNotEmpty(wallet.getCardNumber())) {
+ // 钱包有历史卡号但没有任何开卡记录时属于孤立状态;先在本事务内清空,才能复用现有自动绑卡校验。
+ walletManageService.update(new LambdaUpdateWrapper()
+ .eq(WalletManageEntity::getId, wallet.getId())
+ .set(WalletManageEntity::getCardNumber, null));
+ wallet.setCardNumber(null);
+ }
+ walletActiveService.createAndActiveCard(wallet);
+ active = findBoundActive(row.userId(), row.walletId());
+ }
+ if (active == null) {
+ throw new IllegalStateException("用户 <" + row.displayName() + "> 自动绑定饭卡失败,请检查所属食堂商户和开卡配置");
+ }
+ row.bind(active.getId(), row.walletId());
+ }
+ WalletActiveEntity activeUpdate = new WalletActiveEntity();
+ activeUpdate.setId(row.activeId());
+ activeUpdate.setUserId(row.userId());
+ activeUpdate.setWalletId(row.walletId());
+ activeUpdate.setWalletTypeId(wallet.getWalletTypeId());
+ activeUpdate.setCardNumber(row.cardNumber());
+ walletActiveMapper.updateById(activeUpdate);
+
+ WalletManageEntity walletUpdate = new WalletManageEntity();
+ walletUpdate.setId(row.walletId());
+ walletUpdate.setCardNumber(row.cardNumber());
+ walletManageService.updateById(walletUpdate);
+ }
+
+ /**
+ * 按用户或饭卡钱包查询已经绑定的开卡记录,兼容历史数据缺少其中一个关联字段的情况。
+ *
+ * @param userId 当前导入用户 ID
+ * @param walletId 当前用户饭卡钱包 ID
+ * @return 已绑定的开卡记录;两个关联条件均未命中时返回 {@code null}
+ */
+ private WalletActiveEntity findBoundActive(Long userId, Long walletId) {
+ return walletActiveMapper.selectList(new LambdaQueryWrapper()
+ .and(wrapper -> wrapper.eq(WalletActiveEntity::getUserId, userId)
+ .or().eq(WalletActiveEntity::getWalletId, walletId))
+ .last("limit 1"))
+ .stream().findFirst().orElse(null);
+ }
+
+ /**
+ * 校验必填文本并追加当前行错误。
+ *
+ * @param value 待校验文本
+ * @param message 为空时的中文错误说明
+ * @param errors 当前行错误集合
+ */
+ private void requireText(String value, String message, Set errors) {
+ if (StrUtil.isBlank(value)) {
+ errors.add(message);
+ }
+ }
+
+ /**
+ * 校验卡号必填、长度和 Excel 内唯一性。
+ *
+ * @param cardNumber 卡号
+ * @param cardCounts 当前 Excel 非空卡号出现次数
+ * @param errors 当前行错误集合
+ */
+ private void validateCardNumber(String cardNumber, Map cardCounts, Set errors) {
+ if (StrUtil.isBlank(cardNumber)) {
+ errors.add("请输入卡号");
+ return;
+ }
+ if (!CARD_NUMBER_LENGTHS.contains(cardNumber.length())) {
+ errors.add("卡号长度错误,卡号长度必须为8、10、12");
+ }
+ if (cardCounts.getOrDefault(cardNumber, 0L) > 1) {
+ errors.add("卡号 <" + cardNumber + "> 在Excel中重复");
+ }
+ }
+
+ /**
+ * 从 Feign 统一响应中安全提取用户,失败响应或空数据均按未找到处理。
+ *
+ * @param response UPMS 用户查询响应
+ * @return 用户实体;响应失败或数据为空时返回 {@code null}
+ */
+ private SysUser extractUser(R response) {
+ return response == null || !response.isOk() || response.getData() == null ? null : response.getData().getSysUser();
+ }
+
+ /**
+ * 统计非空文本在 Excel 中的出现次数。
+ *
+ * @param values 待统计文本列表
+ * @return 文本到出现次数的映射
+ */
+ private Map countValues(List values) {
+ return values.stream().filter(StrUtil::isNotBlank)
+ .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
+ }
+
+ /**
+ * 生成学生学校和学号组合键,仅用于当前 Excel 内重复校验。
+ *
+ * @param schoolName 学生学校
+ * @param studentNo 学号
+ * @return 不与业务协议交互的内存组合键
+ */
+ private String buildStudentKey(String schoolName, String studentNo) {
+ return StrUtil.nullToEmpty(schoolName) + "\u0000" + StrUtil.nullToEmpty(studentNo);
+ }
+
+ /**
+ * 按 Excel 行号合并解析错误与业务错误,避免同一行拆成多条记录。
+ *
+ * @param errors 整表错误集合
+ * @param lineNum Excel 行号
+ * @param rowErrors 当前行新增错误
+ */
+ private void appendErrors(List errors, Long lineNum, Set rowErrors) {
+ if (rowErrors.isEmpty()) {
+ return;
+ }
+ ErrorMessage existing = errors.stream().filter(error -> ObjectUtil.equals(error.getLineNum(), lineNum))
+ .findFirst().orElse(null);
+ if (existing == null) {
+ errors.add(new ErrorMessage(lineNum, new LinkedHashSet<>(rowErrors)));
+ } else {
+ existing.getErrors().addAll(rowErrors);
+ }
+ }
+
+ /**
+ * 获取 Excel 解析器写入 BindingResult 目标中的逐行错误集合。
+ *
+ * @param bindingResult Excel 参数绑定结果
+ * @return 可继续追加业务错误的列表;解析器未提供列表时返回新列表
+ */
+ @SuppressWarnings("unchecked")
+ private List getErrorMessageList(BindingResult bindingResult) {
+ Object target = bindingResult == null ? null : bindingResult.getTarget();
+ return target instanceof List> targetList ? (List) targetList : new ArrayList<>();
+ }
+
+ /**
+ * 已通过用户定位的开卡导入上下文,关联 ID 在整表校验阶段补齐并由异步事务使用。
+ *
+ * @param lineNum Excel 原始行号
+ * @param userId 已定位用户 ID
+ * @param displayName 进度和错误提示使用的用户姓名
+ * @param cardNumber 待覆盖的卡号
+ */
+ private record ValidatedImportRow(Long lineNum, Long userId, String displayName, String cardNumber,
+ Binding binding) {
+
+ /**
+ * 创建尚未绑定钱包和开卡记录的校验上下文。
+ *
+ * @param lineNum Excel 原始行号
+ * @param userId 已定位用户 ID
+ * @param displayName 用户姓名
+ * @param cardNumber 待覆盖卡号
+ */
+ private ValidatedImportRow(Long lineNum, Long userId, String displayName, String cardNumber) {
+ this(lineNum, userId, displayName, cardNumber, new Binding());
+ }
+
+ /**
+ * 更新当前导入行关联的开卡记录和钱包 ID。
+ *
+ * @param activeId 开卡记录 ID;尚未自动分配饭卡时为空
+ * @param walletId 饭卡钱包 ID
+ */
+ private void bind(Long activeId, Long walletId) {
+ binding.activeId = activeId;
+ binding.walletId = walletId;
+ }
+
+ /** @return 当前开卡记录 ID;未自动分配饭卡时为空。 */
+ private Long activeId() {
+ return binding.activeId;
+ }
+
+ /** @return 当前饭卡钱包 ID。 */
+ private Long walletId() {
+ return binding.walletId;
+ }
+ }
+
+ /** 开卡导入行的可变关联标识,仅在校验完成后、异步任务提交前由当前请求线程写入。 */
+ private static final class Binding {
+
+ /** 已有或自动创建的开卡记录 ID。 */
+ private Long activeId;
+
+ /** 用户饭卡钱包 ID。 */
+ private Long walletId;
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java
index 4acd7af3..f2c39a96 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java
@@ -17,7 +17,6 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
-import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
import com.bsd.pjcloud.wallet.api.constant.WalleCardStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.*;
@@ -36,10 +35,8 @@ import com.bsd.pjcloud.wallet.service.WalletManageService;
import com.bsd.pjcloud.wallet.service.WalletTypeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.tuple.ImmutablePair;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
@@ -116,6 +113,15 @@ public class WalletActiveServiceImpl extends ServiceImpl 0;
}
+ /**
+ * 将指定空闲饭卡绑定到用户的饭卡钱包。
+ * 饭卡记录和钱包记录均通过带未绑定条件的更新完成抢占;任一记录已被并发请求绑定时抛出异常,
+ * 当前事务会回滚,避免同一卡号覆盖绑定到多个用户或同一钱包关联多张饭卡。
+ *
+ * @param dto 待绑定的用户、钱包和饭卡卡号
+ * @return 完成绑定后的饭卡记录
+ * @throws RuntimeException 饭卡或钱包不存在、饭卡已被绑定、用户已绑定饭卡时抛出
+ */
@Override
@Transactional(rollbackFor = Exception.class)
public WalletActiveEntity bindCard(WalletActiveBindCardDto dto) {
@@ -128,15 +134,29 @@ public class WalletActiveServiceImpl extends ServiceImpl()
+ .eq(WalletActiveEntity::getId, walletActive.getId())
+ .isNull(WalletActiveEntity::getUserId)
+ .isNull(WalletActiveEntity::getWalletId));
+ if (activeUpdateCount == 0) {
+ throw new RuntimeException("该卡号已经绑定用户");
+ }
+ // 钱包也需要条件更新;失败时抛出异常,由事务回滚前面已经完成的饭卡抢占。
WalletManageEntity walletObj = WalletManageEntity.builder().id(selfWallet.getId()).cardNumber(walletActive.getCardNumber()).status(WalleCardStatusEnum.ACTIVE.getCode()).activeBindTime(LocalDateTime.now()).build();
- walletManageService.updateById(walletObj);
+ boolean walletUpdateSuccess = walletManageService.update(walletObj, new LambdaUpdateWrapper()
+ .eq(WalletManageEntity::getId, selfWallet.getId())
+ .isNull(WalletManageEntity::getCardNumber));
+ if (!walletUpdateSuccess) {
+ throw new RuntimeException("用户已绑定饭卡");
+ }
return baseMapper.selectById(walletActiveObj.getId());
}
@@ -151,12 +171,20 @@ public class WalletActiveServiceImpl extends ServiceImpl学生学校可能挂在“其他食堂”商户下,因此自动绑卡同时匹配其他食堂和自营食堂;
+ * 钱包已有卡号时视为已绑定并保持原行为,不重复分配。
+ *
+ * @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作
+ */
@Override
@Transactional(rollbackFor = Exception.class)
public void createAndActiveCard(WalletManageEntity wallet) {
if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber())) return;
- // 查询用户所属于的商户
- MchManageVo mch = mchManageService.getFirstMchByDeptId(wallet.getDeptId(), List.of(MerchantTypeEnum.CANTEEN));
+ // 学校部门可能归属其他食堂,必须与用户部门商户关系同步时采用相同的食堂类型范围。
+ MchManageVo mch = mchManageService.getFirstMchByDeptId(wallet.getDeptId(),
+ List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN));
if (ObjectUtil.isEmpty(mch)) return;
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.isNull(WalletActiveEntity::getUserId)
@@ -170,7 +198,8 @@ public class WalletActiveServiceImpl extends ServiceImpl selectOneWrapper = wrapper.clone().orderByAsc(WalletActiveEntity::getCardSort).last("limit 1");
+ // 在当前事务内锁定候选卡;后续并发请求等待前一笔提交后重新判断空闲条件,避免选中同一张卡。
+ LambdaQueryWrapper selectOneWrapper = wrapper.clone().orderByAsc(WalletActiveEntity::getCardSort).last("limit 1 for update");
WalletActiveEntity walletActive = baseMapper.selectOne(selectOneWrapper);
if (ObjectUtil.isNotEmpty(walletActive)) {
// 绑定饭卡
@@ -352,148 +381,6 @@ public class WalletActiveServiceImpl extends ServiceImpl().eq(WalletActiveEntity::getCardNumber, cardNumber));
}
- /**
- * 导入开卡信息并覆盖用户当前饭卡卡号;用户尚未绑定饭卡时,先在同一事务中自动分配并绑定饭卡。
- *
- * @param excelVOList Excel 中读取的开卡数据
- * @param bindingResult Excel 行级校验错误容器
- * @return 导入结果;校验失败时返回逐行错误,成功时返回导入成功提示
- */
- @Override
- @Transactional(rollbackFor = Exception.class)
- public R importWalletActive(List excelVOList, BindingResult bindingResult) {
- ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, false);
- List errorMessages = pair.getLeft();
- List list = pair.getRight();
- if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages);
- // 更改卡号
- List updateActiveList = new ArrayList<>();
- List updateWalletManageList = new ArrayList<>();
- for (WalletImportActiveDto v : list) {
- if (ObjectUtil.isEmpty(v.getId())) {
- WalletManageEntity wallet = walletManageService.getWallet(v.getWalletId());
- // 校验阶段只确认饭卡钱包存在,实际绑定延迟到导入事务内,避免其他行校验失败后产生半成品数据。
- getSelf().createAndActiveCard(wallet);
- WalletActiveEntity targetWalletActive = baseMapper.selectList(new LambdaQueryWrapper()
- .eq(WalletActiveEntity::getUserId, v.getUserId()))
- .stream().findFirst().orElse(null);
- if (ObjectUtil.isEmpty(targetWalletActive)) {
- throw new RuntimeException("用户 <" + v.getUsername() + "> 自动绑定饭卡失败,请检查所属食堂商户和开卡配置");
- }
- v.setId(targetWalletActive.getId());
- v.setWalletId(targetWalletActive.getWalletId());
- }
- if (ObjectUtil.isNotEmpty(v.getId())) {
- WalletActiveEntity entity = new WalletActiveEntity();
- entity.setId(v.getId());
- entity.setCardNumber(v.getCardNumber());
- updateActiveList.add(entity);
- }
- if (ObjectUtil.isNotEmpty(v.getWalletId())) {
- WalletManageEntity entity = new WalletManageEntity();
- entity.setId(v.getWalletId());
- entity.setCardNumber(v.getCardNumber());
- updateWalletManageList.add(entity);
- }
- }
- // 更改钱包绑定的饭卡卡号
- baseMapper.updateById(updateActiveList);
- // 更改钱包管理绑定的饭卡卡号
- walletManageService.updateBatchById(updateWalletManageList);
- return R.ok(null, "导入成功");
- }
-
- /**
- * 校验开卡导入数据并补充后续导入需要的用户、钱包及开卡记录标识。
- *
- * @param list Excel 中读取的原始行数据,空白行会被过滤
- * @param bindingResult Excel 解析阶段产生的行级错误容器
- * @param validatePrice 预留的金额校验开关,当前开卡导入流程不使用
- * @return 左值为校验错误,右值为校验通过且已补充关联标识的导入数据
- */
- public ImmutablePair, List> validateExcelData(List list, BindingResult bindingResult, boolean validatePrice) {
- if (ObjectUtil.isEmpty(list)) {
- throw new RuntimeException("读取excel数据错误");
- }
- list = list.stream().filter(v -> !ObjectUtil.isAllEmpty(v.getUsername(), v.getUserMobile(), v.getCardNumber())).toList();
- if (ObjectUtil.isEmpty(list)) {
- throw new RuntimeException("读取excel数据错误");
- }
- List errorMessageList = (List) bindingResult.getTarget();
- List usernameList = list.stream().map(WalletImportActiveDto::getUsername).toList();
- List userPoneList = list.stream().map(WalletImportActiveDto::getUserMobile).toList();
- for (WalletImportActiveDto v : list) {
- Set errors = new LinkedHashSet<>();
- if (ObjectUtil.isEmpty(v.getUsername())) {
- errors.add("请输入用户姓名");
- }
- if (ObjectUtil.isEmpty(v.getUserMobile())) {
- errors.add("请输入用户手机号");
- }
- if (ObjectUtil.isEmpty(v.getCardNumber())) {
- errors.add("请输入卡号");
- } else {
- if (!List.of(8, 10, 12).contains(v.getCardNumber().length())) {
- errors.add("卡号长度错误,卡号长度必须为8、10、12");
- }
- }
- if (ObjectUtil.isNotEmpty(v.getUsername()) && ObjectUtil.isNotEmpty(v.getUserMobile())) {
- R user = userService.getUserByNameAndMobile(v.getUsername(), v.getUserMobile());
- Optional sysUser = Optional.of(user).map(R::getData).map(UserInfo::getSysUser);
- if (sysUser.isEmpty()) {
- errors.add("用户不存在");
- } else {
- if (!ObjectUtil.equals(sysUser.get().getName(), v.getUsername())) {
- errors.add("用户不存在");
- } else {
- v.setUserId(sysUser.get().getUserId());
- v.setUserDeptId(sysUser.get().getDeptId());
- }
- }
- }
- long userPhone = userPoneList.stream().filter(i -> ObjectUtil.equals(i, v.getUserMobile())).count();
- if (userPhone > 1) {
- errors.add("用手机号 <" + v.getUserMobile() + "> 重复");
- }
- if (ObjectUtil.isNotEmpty(errors)) {
- errorMessageList.add(new ErrorMessage(v.getLineNum(), errors));
- }
- }
- // 查询用户有没有绑定过饭卡
- List userIdList = list.stream().map(WalletImportActiveDto::getUserId).distinct().filter(ObjectUtil::isNotEmpty).toList();
- if (ObjectUtil.isNotEmpty(userIdList)) {
- List walletActiveList = baseMapper.selectList(new LambdaQueryWrapper().in(WalletActiveEntity::getUserId, userIdList));
- List walletManageList = walletManageService.getWalletsByUserIds(userIdList, WalletTypeEnum.EAT);
- for (WalletImportActiveDto v : list) {
- WalletActiveEntity targetWalletActive = walletActiveList.stream().filter(i -> ObjectUtil.equals(i.getUserId(), v.getUserId())).findFirst().orElse(null);
- if (ObjectUtil.isEmpty(targetWalletActive)) {
- WalletManageEntity targetWalletManage = walletManageList.stream().filter(i -> ObjectUtil.equals(i.getUserId(), v.getUserId())).findFirst().orElse(null);
- if (ObjectUtil.isEmpty(targetWalletManage)) {
- Set errors = new LinkedHashSet<>();
- // 饭卡钱包是自动绑卡的必要载体,缺失时保留行号并阻止整批数据写入。
- ErrorMessage errorMessage = errorMessageList.stream().filter(i -> ObjectUtil.equals(i.getLineNum(), v.getLineNum())).findFirst().orElse(null);
- if (ObjectUtil.isNotEmpty(errorMessage)) {
- errors = errorMessage.getErrors();
- }
- errors.add("用户 <" + v.getUsername() + "> 饭卡钱包不存在");
- if (ObjectUtil.isEmpty(errorMessage)) {
- errorMessageList.add(new ErrorMessage(v.getLineNum(), errors));
- }
- } else {
- v.setWalletId(targetWalletManage.getId());
- }
- } else {
- v.setId(targetWalletActive.getId());
- v.setWalletId(targetWalletActive.getWalletId());
- }
- }
- }
- if (ObjectUtil.isNotEmpty(errorMessageList)) {
- return ImmutablePair.of(errorMessageList, null);
- }
- return ImmutablePair.of(null, list);
- }
-
private boolean existWalletCarNumber(String cardNumber, Long id) {
Long count = baseMapper.selectCount(new LambdaQueryWrapper()
.eq(ObjectUtil.isNotEmpty(cardNumber), WalletActiveEntity::getCardNumber, cardNumber)
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java
new file mode 100644
index 00000000..5572585b
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java
@@ -0,0 +1,534 @@
+package com.bsd.pjcloud.wallet.service.impl.recharge;
+
+import cn.hutool.core.lang.Validator;
+import cn.hutool.core.util.ObjectUtil;
+import com.bsd.pjcloud.admin.api.dto.UserInfo;
+import com.bsd.pjcloud.admin.api.entity.SysDictItem;
+import com.bsd.pjcloud.admin.api.entity.SysUser;
+import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
+import com.bsd.pjcloud.common.security.service.PjcloudUser;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum;
+import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto;
+import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletStudentRechargeExcelDto;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
+import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper;
+import com.bsd.pjcloud.wallet.service.WalletRechargeApplyImportService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeImportService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.tuple.ImmutablePair;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.BindingResult;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 充值/扣款申请 Excel 导入服务实现。
+ * 负责默认用户模板校验、申请或发票创建及导入明细落库,不承载申请 CRUD 与审批逻辑。
+ */
+@Service
+@RequiredArgsConstructor
+public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApplyImportService {
+
+ /** 充值流水财务标签使用的字典类型,Excel 标签需转换为稳定字典值后落库。 */
+ private static final String TRANSACTION_TAG_DICT_TYPE = "wallet_transaction_tag";
+
+ /** 充值/扣款申请 Mapper,用于创建不绑定发票的申请主记录。 */
+ private final WalletRechargeApplyMapper walletRechargeApplyMapper;
+
+ /** 充值导入明细服务,用于批量保存已校验的数据行。 */
+ private final WalletRechargeImportService walletRechargeImportService;
+
+ /** 充值发票服务,用于绑定发票导入时创建基础发票记录。 */
+ private final WalletRechargeInvoiceService walletRechargeInvoiceService;
+
+ /** 用户远程服务,用于按姓名和手机号精确定位默认用户。 */
+ private final RemoteUserService remoteUserService;
+
+ /** 字典远程服务,用于将 Excel 财务标签名称转换为持久化值。 */
+ private final RemoteDictService remoteDictService;
+
+ /**
+ * 校验默认用户充值、扣款或退款模板,并创建申请及明细记录。
+ *
+ * @param rows 默认用户 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 充值业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public R> importDefault(List rows, BindingResult bindingResult, String bizType) {
+ validateBizType(bizType, false);
+ ImmutablePair, List> pair = validateExcelData(rows, bindingResult, true);
+ if (ObjectUtil.isNotEmpty(pair.getLeft())) {
+ return R.failed(pair.getLeft());
+ }
+ return createApplyAndDetails(pair.getRight(), bizType, false);
+ }
+
+ /**
+ * 校验默认用户余额重置模板,并以零金额创建申请明细。
+ *
+ * @param rows 默认用户余额重置 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 余额重置业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public R> importDefaultReset(List rows, BindingResult bindingResult, String bizType) {
+ validateBizType(bizType, true);
+ ImmutablePair, List> pair = validateExcelData(rows, bindingResult, false);
+ if (ObjectUtil.isNotEmpty(pair.getLeft())) {
+ return R.failed(pair.getLeft());
+ }
+ return createApplyAndDetails(pair.getRight(), bizType, true);
+ }
+
+ /**
+ * 校验学生充值、扣款或退款模板,并创建申请及明细记录。
+ *
+ * @param rows 学生 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 充值业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public R> importStudent(List rows, BindingResult bindingResult, String bizType) {
+ validateBizType(bizType, false);
+ ImmutablePair, List> pair = validateStudentExcelData(rows, bindingResult, true);
+ if (ObjectUtil.isNotEmpty(pair.getLeft())) {
+ return R.failed(pair.getLeft());
+ }
+ return createStudentApplyAndDetails(pair.getRight(), bizType, false);
+ }
+
+ /**
+ * 校验学生余额重置模板,并以零金额创建申请明细。
+ *
+ * @param rows 学生余额重置 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @param bizType 余额重置业务类型协议值
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public R> importStudentReset(List rows, BindingResult bindingResult, String bizType) {
+ validateBizType(bizType, true);
+ ImmutablePair, List> pair = validateStudentExcelData(rows, bindingResult, false);
+ if (ObjectUtil.isNotEmpty(pair.getLeft())) {
+ return R.failed(pair.getLeft());
+ }
+ return createStudentApplyAndDetails(pair.getRight(), bizType, true);
+ }
+
+ /**
+ * 校验绑定发票的默认用户充值模板,创建基础发票及未绑定的导入明细。
+ *
+ * @param rows 默认用户充值 Excel 数据行
+ * @param bindingResult Excel 解析阶段的逐行校验结果
+ * @return 校验失败时返回逐行错误,通过时返回导入成功结果
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public R> importTopup(List rows, BindingResult bindingResult) {
+ ImmutablePair, List> pair = validateExcelData(rows, bindingResult, true);
+ if (ObjectUtil.isNotEmpty(pair.getLeft())) {
+ return R.failed(pair.getLeft());
+ }
+ List validatedRows = pair.getRight();
+ PjcloudUser loginUser = SecurityUtils.getUser();
+ Integer totalPrice = validatedRows.stream().map(WalletRechargeExcelDto::getPriceNumber).reduce(Integer::sum).orElse(0);
+ WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice);
+ if (invoice == null) {
+ throw new IllegalStateException("导入失败");
+ }
+ List details = validatedRows.stream()
+ .map(row -> WalletRechargeImportEntity.builder()
+ .price(row.getPriceNumber())
+ .userId(row.getUserId())
+ .userDeptId(row.getUserDeptId())
+ .invoiceId(invoice.getId())
+ .tags(row.getTags())
+ .build())
+ .toList();
+ walletRechargeImportService.saveBatch(details);
+ return R.ok(null, "导入成功");
+ }
+
+ /**
+ * 创建当前登录人的申请主记录,并批量保存已校验导入明细。
+ *
+ * @param rows 已完成用户定位与字段校验的数据行
+ * @param bizType 充值业务类型协议值
+ * @param resetBalance 是否为余额重置;为真时明细金额固定为零
+ * @return 导入成功结果
+ */
+ private R> createApplyAndDetails(List rows, String bizType, boolean resetBalance) {
+ PjcloudUser loginUser = SecurityUtils.getUser();
+ WalletRechargeApplyEntity apply = new WalletRechargeApplyEntity();
+ apply.setDeptId(loginUser.getDeptId());
+ apply.setUserId(loginUser.getId());
+ apply.setBizType(bizType);
+ if (walletRechargeApplyMapper.insert(apply) <= 0) {
+ throw new IllegalStateException("导入失败");
+ }
+ List details = rows.stream()
+ .map(row -> WalletRechargeImportEntity.builder()
+ .applyId(apply.getId())
+ .price(resetBalance ? 0 : row.getPriceNumber())
+ .userId(row.getUserId())
+ .userDeptId(row.getUserDeptId())
+ .tags(row.getTags())
+ .build())
+ .toList();
+ walletRechargeImportService.saveBatch(details);
+ return R.ok(null, "导入成功");
+ }
+
+ /**
+ * 创建学生充值/扣款申请主记录,并批量保存已解析学生用户的明细。
+ *
+ * @param rows 已完成学生定位与字段校验的数据行
+ * @param bizType 充值业务类型协议值
+ * @param resetBalance 是否为余额重置;为真时明细金额固定为零
+ * @return 导入成功结果
+ */
+ private R> createStudentApplyAndDetails(List rows, String bizType, boolean resetBalance) {
+ PjcloudUser loginUser = SecurityUtils.getUser();
+ WalletRechargeApplyEntity apply = new WalletRechargeApplyEntity();
+ apply.setDeptId(loginUser.getDeptId());
+ apply.setUserId(loginUser.getId());
+ apply.setBizType(bizType);
+ if (walletRechargeApplyMapper.insert(apply) <= 0) {
+ throw new IllegalStateException("导入失败");
+ }
+ List details = rows.stream()
+ .map(row -> WalletRechargeImportEntity.builder()
+ .applyId(apply.getId())
+ .price(resetBalance ? 0 : row.getPriceNumber())
+ .userId(row.getUserId())
+ .userDeptId(row.getUserDeptId())
+ .tags(row.getTags())
+ .build())
+ .toList();
+ walletRechargeImportService.saveBatch(details);
+ return R.ok(null, "导入成功");
+ }
+
+ /**
+ * 校验接口与充值业务类型匹配,防止绕过前端把余额重置提交到金额导入入口。
+ *
+ * @param bizType 充值业务类型协议值
+ * @param resetEndpoint 当前是否为余额重置专用入口
+ */
+ private void validateBizType(String bizType, boolean resetEndpoint) {
+ if (ObjectUtil.isEmpty(bizType)) {
+ throw new IllegalArgumentException("请选择充值类型");
+ }
+ boolean resetBizType = ObjectUtil.equals(WalletBizRechargeEnum.RESET.getCode(), bizType);
+ if (resetEndpoint != resetBizType) {
+ throw new IllegalArgumentException("充值类型与导入模板不匹配");
+ }
+ boolean supported = Arrays.stream(WalletBizRechargeEnum.values())
+ .anyMatch(item -> ObjectUtil.equals(item.getCode(), bizType));
+ if (!supported) {
+ throw new IllegalArgumentException("充值类型不存在");
+ }
+ }
+
+ /**
+ * 校验默认用户模板并补齐用户、部门、分值金额及财务标签字典值。
+ *
+ * @param rows Excel 原始数据行
+ * @param bindingResult 公共 Excel 监听器生成的逐行错误
+ * @param validatePrice 是否要求并解析操作金额
+ * @return 左侧为错误集合,右侧为校验通过的数据行,二者不会同时有值
+ */
+ @SuppressWarnings("unchecked")
+ private ImmutablePair, List> validateExcelData(
+ List rows, BindingResult bindingResult, boolean validatePrice) {
+ if (ObjectUtil.isEmpty(rows)) {
+ throw new IllegalArgumentException("读取excel数据错误");
+ }
+ List effectiveRows = rows.stream()
+ .filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getPrice()))
+ .toList();
+ if (effectiveRows.isEmpty()) {
+ throw new IllegalArgumentException("读取excel数据错误");
+ }
+ List errors = getErrorMessageList(bindingResult);
+ resolveTransactionTagValues(effectiveRows, errors);
+ List userPhones = effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList();
+ for (WalletRechargeExcelDto row : effectiveRows) {
+ Set rowErrors = new LinkedHashSet<>();
+ validatePrice(row, validatePrice, rowErrors);
+ resolveDefaultUser(row, rowErrors);
+ long phoneCount = userPhones.stream().filter(phone -> ObjectUtil.equals(phone, row.getUserMobile())).count();
+ if (ObjectUtil.isNotEmpty(row.getUserMobile()) && phoneCount > 1) {
+ rowErrors.add("用户手机号 <" + row.getUserMobile() + "> 重复");
+ }
+ if (!rowErrors.isEmpty()) {
+ errors.add(new ErrorMessage(row.getLineNum(), rowErrors));
+ }
+ }
+ return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null);
+ }
+
+ /**
+ * 校验学生模板并按学校、学号补齐用户及部门信息。
+ *
+ * @param rows 学生 Excel 原始数据行
+ * @param bindingResult 公共 Excel 监听器生成的逐行错误
+ * @param validatePrice 是否要求并解析操作金额
+ * @return 左侧为错误集合,右侧为校验通过的数据行,二者不会同时有值
+ */
+ @SuppressWarnings("unchecked")
+ private ImmutablePair, List> validateStudentExcelData(
+ List rows, BindingResult bindingResult, boolean validatePrice) {
+ if (ObjectUtil.isEmpty(rows)) {
+ throw new IllegalArgumentException("读取excel数据错误");
+ }
+ List effectiveRows = rows.stream()
+ .filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getPrice()))
+ .toList();
+ if (effectiveRows.isEmpty()) {
+ throw new IllegalArgumentException("读取excel数据错误");
+ }
+ List errors = getErrorMessageList(bindingResult);
+ resolveStudentTransactionTagValues(effectiveRows, errors);
+ List studentKeys = effectiveRows.stream()
+ .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo()))
+ .toList();
+ for (WalletStudentRechargeExcelDto row : effectiveRows) {
+ Set rowErrors = new LinkedHashSet<>();
+ validateStudentPrice(row, validatePrice, rowErrors);
+ resolveStudentUser(row, rowErrors);
+ String studentKey = buildStudentKey(row.getSchoolName(), row.getStudentNo());
+ long studentCount = studentKeys.stream().filter(key -> ObjectUtil.equals(key, studentKey)).count();
+ if (ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo()) && studentCount > 1) {
+ rowErrors.add("学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复");
+ }
+ if (!rowErrors.isEmpty()) {
+ errors.add(new ErrorMessage(row.getLineNum(), rowErrors));
+ }
+ }
+ return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null);
+ }
+
+ /**
+ * 校验并将元单位操作金额转换为钱包统一使用的整数分。
+ *
+ * @param row 当前 Excel 数据行
+ * @param required 是否要求填写操作金额
+ * @param errors 当前行错误集合
+ */
+ private void validatePrice(WalletRechargeExcelDto row, boolean required, Set errors) {
+ if (!required) {
+ return;
+ }
+ if (ObjectUtil.isEmpty(row.getPrice())) {
+ errors.add("请输入操作金额");
+ return;
+ }
+ try {
+ row.setPriceNumber(new BigDecimal(row.getPrice()).multiply(BigDecimal.valueOf(100)).intValue());
+ } catch (Exception exception) {
+ errors.add("请输入正确的操作金额");
+ }
+ }
+
+ /**
+ * 校验学生模板操作金额,并把元单位金额转换为整数分。
+ *
+ * @param row 当前学生 Excel 数据行
+ * @param required 是否要求填写操作金额
+ * @param errors 当前行错误集合
+ */
+ private void validateStudentPrice(WalletStudentRechargeExcelDto row, boolean required, Set errors) {
+ if (!required) {
+ return;
+ }
+ if (ObjectUtil.isEmpty(row.getPrice())) {
+ errors.add("请输入操作金额");
+ return;
+ }
+ try {
+ row.setPriceNumber(new BigDecimal(row.getPrice()).multiply(BigDecimal.valueOf(100)).intValue());
+ } catch (Exception exception) {
+ errors.add("请输入正确的操作金额");
+ }
+ }
+
+ /**
+ * 按姓名和手机号精确定位默认用户,并把用户及部门 ID 写回导入行。
+ *
+ * @param row 当前 Excel 数据行
+ * @param errors 当前行错误集合
+ */
+ private void resolveDefaultUser(WalletRechargeExcelDto row, Set errors) {
+ if (ObjectUtil.isEmpty(row.getUsername())) {
+ errors.add("请输入用户名");
+ return;
+ }
+ if (ObjectUtil.isEmpty(row.getUserMobile())) {
+ errors.add("请输入用户手机号");
+ return;
+ }
+ if (!Validator.isMobile(row.getUserMobile())) {
+ errors.add("用户手机号格式不正确");
+ return;
+ }
+ R response = remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile());
+ Optional user = Optional.ofNullable(response).map(R::getData).map(UserInfo::getSysUser);
+ if (user.isEmpty() || !ObjectUtil.equals(user.get().getName(), row.getUsername())) {
+ errors.add("用户不存在");
+ return;
+ }
+ row.setUserId(user.get().getUserId());
+ row.setUserDeptId(user.get().getDeptId());
+ }
+
+ /**
+ * 按学校和学号精确定位学生,并校验模板姓名与系统用户一致。
+ *
+ * @param row 当前学生 Excel 数据行
+ * @param errors 当前行错误集合
+ */
+ private void resolveStudentUser(WalletStudentRechargeExcelDto row, Set errors) {
+ if (ObjectUtil.isEmpty(row.getStudentName())) {
+ errors.add("请输入学生姓名");
+ }
+ if (ObjectUtil.isEmpty(row.getSchoolName())) {
+ errors.add("请输入学校");
+ }
+ if (ObjectUtil.isEmpty(row.getStudentNo())) {
+ errors.add("请输入学号");
+ }
+ if (ObjectUtil.hasEmpty(row.getSchoolName(), row.getStudentNo())) {
+ return;
+ }
+ R response = remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo());
+ Optional user = Optional.ofNullable(response).map(R::getData).map(UserInfo::getSysUser);
+ if (user.isEmpty()) {
+ errors.add("学生不存在");
+ return;
+ }
+ if (!ObjectUtil.equals(user.get().getName(), row.getStudentName())) {
+ errors.add("学生姓名与学校、学号不匹配");
+ return;
+ }
+ row.setUserId(user.get().getUserId());
+ row.setUserDeptId(user.get().getDeptId());
+ }
+
+ /**
+ * 生成学校和学号组合键,仅用于当前学生 Excel 内重复校验。
+ *
+ * @param schoolName 学校名称
+ * @param studentNo 学号
+ * @return 保留空值语义的组合键
+ */
+ private String buildStudentKey(String schoolName, String studentNo) {
+ return Objects.toString(schoolName, "") + "\u0000" + Objects.toString(studentNo, "");
+ }
+
+ /**
+ * 获取公共 Excel 解析器写入 BindingResult 的逐行错误集合。
+ *
+ * @param bindingResult Excel 参数绑定结果
+ * @return 可继续追加业务错误的列表;解析器未提供列表时返回新列表
+ */
+ @SuppressWarnings("unchecked")
+ private List getErrorMessageList(BindingResult bindingResult) {
+ Object target = bindingResult == null ? null : bindingResult.getTarget();
+ return target instanceof List> ? (List) target : new java.util.ArrayList<>();
+ }
+
+ /**
+ * 将 Excel 财务标签名称转换为稳定字典值,支持英文逗号分隔多个标签。
+ *
+ * @param rows 已通过公共 Excel 字典标签校验的数据行
+ * @param errors 当前导入的逐行错误集合
+ */
+ private void resolveTransactionTagValues(List rows, List errors) {
+ List taggedRows = rows.stream().filter(row -> ObjectUtil.isNotEmpty(row.getTag())).toList();
+ if (taggedRows.isEmpty()) {
+ return;
+ }
+ R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE);
+ if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) {
+ taggedRows.forEach(row -> errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签字典查询失败"))));
+ return;
+ }
+ Map valueByLabel = result.getData().stream()
+ .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue()))
+ .collect(Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first));
+ for (WalletRechargeExcelDto row : taggedRows) {
+ List tagValues = Arrays.stream(row.getTag().split(","))
+ .map(String::trim)
+ .distinct()
+ .map(valueByLabel::get)
+ .filter(Objects::nonNull)
+ .toList();
+ long selectedTagCount = Arrays.stream(row.getTag().split(",")).map(String::trim).distinct().count();
+ if (tagValues.size() != selectedTagCount) {
+ errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签包含无法映射的字典项")));
+ continue;
+ }
+ row.setTags(String.join(",", tagValues));
+ }
+ }
+
+ /**
+ * 将学生 Excel 财务标签名称转换为稳定字典值。
+ *
+ * @param rows 学生 Excel 数据行
+ * @param errors 当前导入的逐行错误集合
+ */
+ private void resolveStudentTransactionTagValues(List rows, List errors) {
+ List taggedRows = rows.stream().filter(row -> ObjectUtil.isNotEmpty(row.getTag())).toList();
+ if (taggedRows.isEmpty()) {
+ return;
+ }
+ R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE);
+ if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) {
+ taggedRows.forEach(row -> errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签字典查询失败"))));
+ return;
+ }
+ Map valueByLabel = result.getData().stream()
+ .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue()))
+ .collect(Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first));
+ for (WalletStudentRechargeExcelDto row : taggedRows) {
+ List tagValues = Arrays.stream(row.getTag().split(","))
+ .map(String::trim)
+ .distinct()
+ .map(valueByLabel::get)
+ .filter(Objects::nonNull)
+ .toList();
+ long selectedTagCount = Arrays.stream(row.getTag().split(",")).map(String::trim).distinct().count();
+ if (tagValues.size() != selectedTagCount) {
+ errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签包含无法映射的字典项")));
+ continue;
+ }
+ row.setTags(String.join(",", tagValues));
+ }
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
index a755fa87..ed1ecc55 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.wallet.service.impl.recharge;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
-import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.spring.SpringUtil;
@@ -12,10 +11,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
-import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
-import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.admin.api.vo.UserVO;
@@ -25,7 +22,6 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
-import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService;
@@ -43,7 +39,6 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeSaveTopupDto;
-import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
@@ -58,7 +53,6 @@ import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper;
-import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeInvoiceMapper;
import com.bsd.pjcloud.wallet.service.*;
import com.bsd.pjcloud.wallet.utils.flow.FlowUtil;
import com.bsd.pjcloud.wallet.utils.flow.dto.WalletRechargeApplyFlowDto;
@@ -66,11 +60,8 @@ import com.bsd.pjcloud.wallet.utils.flow.dto.WalletRechargeApplyFlowDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.tuple.ImmutablePair;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
@@ -92,11 +83,6 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl excelVOList, BindingResult bindingResult, String bizType) {
- ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, true);
- List errorMessages = pair.getLeft();
- List list = pair.getRight();
- if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages);
- PjcloudUser loginUser = SecurityUtils.getUser();
- WalletRechargeApplyEntity insertObj = new WalletRechargeApplyEntity();
- insertObj.setDeptId(loginUser.getDeptId());
- insertObj.setUserId(loginUser.getId());
- insertObj.setBizType(bizType);
- int i = baseMapper.insert(insertObj);
- if (i <= 0) {
- throw new RuntimeException("导入失败");
- }
- List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList();
- batchSaveList.forEach(v -> v.setApplyId(insertObj.getId()));
- rechargeImportService.saveBatch(batchSaveList);
- return R.ok(null, "导入成功");
- }
-
- @Override
- public R readResetExcel(List excelVOList, BindingResult bindingResult, String bizType) {
- ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, false);
- List errorMessages = pair.getLeft();
- List list = pair.getRight();
- if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages);
- PjcloudUser loginUser = SecurityUtils.getUser();
- WalletRechargeApplyEntity insertObj = new WalletRechargeApplyEntity();
- insertObj.setDeptId(loginUser.getDeptId());
- insertObj.setUserId(loginUser.getId());
- insertObj.setBizType(bizType);
- int i = baseMapper.insert(insertObj);
- if (i <= 0) {
- throw new RuntimeException("导入失败");
- }
- List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(0).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList();
- batchSaveList.forEach(v -> v.setApplyId(insertObj.getId()));
- rechargeImportService.saveBatch(batchSaveList);
- return R.ok(null, "导入成功");
- }
-
- /**
- * 读取充值excel内容
- */
- @Override
- @Transactional(rollbackFor = Exception.class)
- public R readTopupExcel(List excelVOList, BindingResult bindingResult) {
- ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, true);
- List errorMessages = pair.getLeft();
- List list = pair.getRight();
- if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages);
- PjcloudUser loginUser = SecurityUtils.getUser();
- // 先创建发票
- Integer totalPrice = list.stream().map(WalletRechargeExcelDto::getPriceNumber).reduce(Integer::sum).orElse(0);
- WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice);
- if (ObjectUtil.isEmpty(invoice)) throw new RuntimeException("导入失败");
- // 导入数据
- List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).invoiceId(invoice.getId()).tags(v.getTags()).build()).toList();
- rechargeImportService.saveBatch(batchSaveList);
- return R.ok(null, "导入成功");
- }
-
- /**
- * 校验充值/扣款excel的数据
- */
- public ImmutablePair, List> validateExcelData(List list, BindingResult bindingResult, boolean validatePrice) {
- if (ObjectUtil.isEmpty(list)) {
- throw new RuntimeException("读取excel数据错误");
- }
- list = list.stream().filter(v -> !ObjectUtil.isAllEmpty(v.getUsername(), v.getUserMobile(), v.getPrice())).toList();
- if (ObjectUtil.isEmpty(list)) {
- throw new RuntimeException("读取excel数据错误");
- }
- List errorMessageList = (List) bindingResult.getTarget();
- resolveTransactionTagValues(list, errorMessageList);
- List usernameList = list.stream().map(WalletRechargeExcelDto::getUsername).toList();
- List userPoneList = list.stream().map(WalletRechargeExcelDto::getUserMobile).toList();
- for (WalletRechargeExcelDto v : list) {
- Set errors = new LinkedHashSet<>();
- if (validatePrice) {
- if (ObjectUtil.isEmpty(v.getPrice())) {
- errors.add("请输入操作金额");
- } else {
- try {
- int i = new BigDecimal(v.getPrice()).multiply(new BigDecimal(100)).intValue();
- v.setPriceNumber(i);
- } catch (Exception e) {
- errors.add("请输入正确的操作金额");
- }
- }
- }
- if (ObjectUtil.isEmpty(v.getUsername())) {
- errors.add("请输入用户名");
- } else if (ObjectUtil.isEmpty(v.getUserMobile())) {
- errors.add("请输入用户手机号");
- } else if (!Validator.isMobile(v.getUserMobile())) {
- errors.add("用户手机号格式不正确");
- } else {
- R user = userService.getUserByNameAndMobile(v.getUsername(), v.getUserMobile());
- Optional sysUser = Optional.of(user).map(R::getData).map(UserInfo::getSysUser);
- if (sysUser.isEmpty()) {
- errors.add("用户不存在");
- } else {
- if (!ObjectUtil.equals(sysUser.get().getName(), v.getUsername())) {
- errors.add("用户不存在");
- } else {
- v.setUserId(sysUser.get().getUserId());
- v.setUserDeptId(sysUser.get().getDeptId());
- }
- }
- }
- // long userNameCount = usernameList.stream().filter(i -> ObjectUtil.equals(i, v.getUsername())).count();
- // if (userNameCount > 1) {
- // errors.add("用户名 <" + v.getUsername() + "> 重复");
- // }
- long userPhone = userPoneList.stream().filter(i -> ObjectUtil.equals(i, v.getUserMobile())).count();
- if (userPhone > 1) {
- errors.add("用手机号 <" + v.getUserMobile() + "> 重复");
- }
- if (ObjectUtil.isNotEmpty(errors)) {
- errorMessageList.add(new ErrorMessage(v.getLineNum(), errors));
- }
- }
- if (ObjectUtil.isNotEmpty(errorMessageList)) {
- return ImmutablePair.of(errorMessageList, null);
- }
- return ImmutablePair.of(null, list);
- }
-
- /**
- * 将 Excel 中展示的财务标签转换为稳定的字典值,供导入记录和最终钱包流水持久化及统计查询。
- *
- * @param list 已通过公共 Excel 字典标签校验的数据行
- * @param errorMessageList 当前导入任务的行级错误集合,字典服务异常或标签映射缺失时追加错误
- */
- private void resolveTransactionTagValues(List list, List errorMessageList) {
- List taggedRows = list.stream().filter(item -> ObjectUtil.isNotEmpty(item.getTag())).toList();
- if (taggedRows.isEmpty()) {
- return;
- }
- R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE);
- if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) {
- taggedRows.forEach(item -> errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签字典查询失败"))));
- return;
- }
- Map valueByLabel = result.getData().stream()
- .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue()))
- .collect(java.util.stream.Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first));
- for (WalletRechargeExcelDto item : taggedRows) {
- List tagValues = Arrays.stream(item.getTag().split(","))
- .map(String::trim)
- .distinct()
- .map(valueByLabel::get)
- .filter(Objects::nonNull)
- .toList();
- long selectedTagCount = Arrays.stream(item.getTag().split(",")).map(String::trim).distinct().count();
- if (tagValues.size() != selectedTagCount) {
- errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签包含无法映射的字典项")));
- continue;
- }
- item.setTags(String.join(",", tagValues));
- }
- }
-
- /* ************************************* end excel end ************************************* */
-
@Override
@Transactional(rollbackFor = Exception.class)
public void applyCallback(ProcessNodeRecordAssignUserParamDto dto, Long tenantId) {