Prechádzať zdrojové kódy

修改
1、建行下单调整
BUGFIX
1、解决长者档案导出合同信息sheet内容缺失问题
2、解决先生成长护险再生成账单时无法带入到账单的问题。

liangwenxuan 1 mesiac pred
rodič
commit
928db4fe37
12 zmenil súbory, kde vykonal 402 pridanie a 78 odobranie
  1. 9 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
  2. 0 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/ElderlyInfoController.java
  3. 190 35
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/pay/CcbWxUnifiedOrderController.java
  4. 1 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/pay/vo/CcbCloseOrderRequestVO.java
  5. 2 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/TenantController.java
  6. 6 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyAskLeaveServiceImpl.java
  7. 91 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyInfoServiceImpl.java
  8. 0 4
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseSubsidyServiceImpl.java
  9. 1 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/RefundSettlementOrderServiceImpl.java
  10. 24 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/ccb/CcbBusinessService.java
  11. 77 34
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/util/pay/CcbPayUtils.java
  12. 1 0
      yudao-server/src/main/resources/application.yaml

+ 9 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java

@@ -330,6 +330,15 @@ public interface ErrorCodeConstants {
     ErrorCode VISIT_APPOINTMENT_NOT_FOUND = new ErrorCode(1_002_041_000, "探访预约记录不存在");
     ErrorCode VISIT_APPOINTMENT_CANCELED = new ErrorCode(1_002_041_001, "该预约已取消或已删除,无法重复取消");
 
+    // ========== 建行支付相关 1-008-025-000 ==========
+    ErrorCode EXPENSE_ORDER_PAY_AMOUNT_INVALID = new ErrorCode(1_008_025_000, "账单金额异常,无法发起支付,请联系院方核对账单!");
+
+    ErrorCode EXPENSE_ORDER_PAY_AMOUNT_NOT_MATCH = new ErrorCode(1_008_025_001, "账单金额已发生变化,请刷新后重新支付!");
+
+    ErrorCode CCB_MERCHANT_NOT_FOUND = new ErrorCode(1_008_025_002, "未配置该院区的建行商户信息,无法发起支付!");
+
+    ErrorCode CCB_ORDER_ALREADY_PAID = new ErrorCode(1_008_025_003, "该订单在建行已支付成功,请稍后刷新账单或联系院方核对!");
+
     // ========== 医嘱模块(长期/临时/执行) 1-008-024-000 ==========
     ErrorCode ELDERLY_DOCTOR_ORDER_NOT_EXISTS = new ErrorCode(1_008_024_000, "医嘱单不存在");
     ErrorCode ELDERLY_DOCTOR_ORDER_ENTRY_NOT_EXISTS = new ErrorCode(1_008_024_001, "医嘱条目不存在");

+ 0 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/ElderlyInfoController.java

@@ -234,7 +234,6 @@ public class ElderlyInfoController {
     @GetMapping("/exportElder")
     @Operation(summary = "导出长者信息Excel")
     public void exportElder(@Valid ElderlyInfoPageReqVO pageVO, HttpServletResponse response) throws IOException {
-        pageVO.setPageSize(PageParam.PAGE_SIZE_NONE);
         Map<String, Object> dataMap = elderlyInfoService.selectExportElderData(pageVO);
         List<ElderlyBaseInfoExportExcelVO> baseInfo = (List<ElderlyBaseInfoExportExcelVO>) dataMap.get("baseInfo");
         List<ElderlyContractInfoExportExcelVO> contractInfo = (List<ElderlyContractInfoExportExcelVO>) dataMap.get("contractInfo");

+ 190 - 35
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/pay/CcbWxUnifiedOrderController.java

@@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.system.dal.dataobject.ccb.MerchantRecordDO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.ExpenseOrderMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.ccb.MerchantRecordMapper;
 import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
+import cn.iocoder.yudao.module.system.enums.common.BooleanEnum;
 import cn.iocoder.yudao.module.system.service.ccb.CcbBusinessService;
 import cn.iocoder.yudao.module.system.util.pay.CcbPayConstant;
 import cn.iocoder.yudao.module.system.util.pay.CcbPayUtils;
@@ -29,7 +30,9 @@ import javax.annotation.security.PermitAll;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 
@@ -64,9 +67,27 @@ public class CcbWxUnifiedOrderController {
         // 商户信息
         MerchantRecordDO merchantRecordDO = merchantRecordMapper.selectOne(new LambdaQueryWrapperX<MerchantRecordDO>()
                 .eq(MerchantRecordDO::getTenantId, tenantId));
+        if (merchantRecordDO == null) {
+            throw exception(ErrorCodeConstants.CCB_MERCHANT_NOT_FOUND);
+        }
+        // 1. 已支付账单不允许重复发起支付(payStatus:0未缴费 1已缴费 2部分缴费)
+        if (expenseOrder.getPayStatus() != null && expenseOrder.getPayStatus().equals(BooleanEnum.TRUE.getValue())) {
+            throw exception(ErrorCodeConstants.EXPENSE_ORDER_ALREADY_PAY);
+        }
+        // 2. 支付金额以账单应收金额为准(回调同样按应收金额校验,避免用户付款成功但回调金额校验不通过)
+        BigDecimal amount = expenseOrder.getActualAmount();
+        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+            throw exception(ErrorCodeConstants.EXPENSE_ORDER_PAY_AMOUNT_INVALID);
+        }
+        if (req.getAmount() != null && req.getAmount().compareTo(amount) != 0) {
+            throw exception(ErrorCodeConstants.EXPENSE_ORDER_PAY_AMOUNT_NOT_MATCH);
+        }
+        // 2.1 建行 PAYMENT 为 NUMBER(16,2),统一格式化为两位小数(MAC 摘要同步使用该值,避免 8 位小数被建行拒绝)
+        BigDecimal payAmount = amount.setScale(2, RoundingMode.HALF_UP);
+
+        // 3. 查询用户的微信小程序 openId
         ResponseEntity<String> response = restTemplate.getForEntity(
                 "http://home.ynims.com/api/wechat/getOpenIdsByUserPhone?userPhone={userPhone}", String.class, req.getUserPhone());
-
         log.info("查询用户openId结果:{}", response.getBody());
 
         ObjectMapper objectMapper = new ObjectMapper();
@@ -78,40 +99,27 @@ public class CcbWxUnifiedOrderController {
             String miniAppId = dataNode.path("miniAppId").asText();
             String miniOpenId = dataNode.path("miniOpenId").asText();
 
-            //        //测试阶段金额设置为0.01
-//        amount = new BigDecimal("0.01");
-
-            // 1. 构建请求对象(赋值必传+可选字段,空值默认"")
-            CcbPayRequest request = new CcbPayRequest();
-//            request.setMerchantId(CcbPayConstant.MERCHANTID);
-            request.setMerchantId(merchantRecordDO.getMerchantNumber());
-//            request.setPosId(CcbPayConstant.POSID);
-            request.setPosId(merchantRecordDO.getCounterNumber());
-            request.setBranchId(CcbPayConstant.BRANCHID);
-            request.setOrderId(expenseOrder.getBillOrderNumber());
-            request.setPayment(req.getAmount());
-            request.setTradeType("MINIPRO");
-            request.setSubAppId(miniAppId);
-            request.setSubOpenId(miniOpenId);
-            request.setPub(CcbPayConstant.CCB_PUB);
-            request.setRemark1("JTSH:"+CcbPayConstant.MERCHANTID+CcbPayConstant.POSID);
-            // 2. 生成MAC值
-            request.setMac(CcbPayUtils.generateMac(request));
-
-            // 3. 发送请求并解析响应
-            String respStr = CcbPayUtils.sendRequest(request);
-            JSONObject jsonObject = JSON.parseObject(respStr);
-
-            // 4. 校验业务结果并发起请求微信参数
-
-            if ("true".equals(jsonObject.getString("SUCCESS"))) {
-                String payUrl = jsonObject.getString("PAYURL");
-                try {
-                    wxPayParams = CcbPayUtils.requestPayUrl(payUrl);
-                } catch (Exception e) {
-                    log.error("请求微信参数异常 {}", e.getMessage());
+            // 4. 同一订单加进程内锁,防止并发重复下单占用建行侧订单号
+            synchronized (getOrderLock(expenseOrder.getBillOrderNumber())) {
+                String baseOrderId = expenseOrder.getBillOrderNumber();
+                // 4.1 优先使用账单号下单,与回调、对账口径保持一致
+                wxPayParams = doUnifiedOrder(expenseOrder, merchantRecordDO, payAmount, miniAppId, miniOpenId, baseOrderId);
+                // 4.2 订单号被占用:先尝试关闭建行侧残留订单,成功后仍用原订单号下单(不产生额外订单号)
+                if (isOrderIdConflict(wxPayParams)) {
+                    log.warn("建行下单失败,订单号:{},错误码:{},错误信息:{},尝试关闭建行侧残留订单后重新下单",
+                            baseOrderId, wxPayParams.getErrCode(), wxPayParams.getErrMsg());
+                    JSONObject closeResp = closeCcbOrder(expenseOrder, merchantRecordDO, miniAppId, false);
+                    if (closeResp != null) {
+                        wxPayParams = doUnifiedOrder(expenseOrder, merchantRecordDO, payAmount, miniAppId, miniOpenId, baseOrderId);
+                    }
+                }
+                // 4.3 关单未成功(如建行关单接口不可用)时,换全新支付订单号重试,绕开被占用的订单号
+                for (int seq = 1; seq <= MAX_NEW_ORDER_RETRY && isOrderIdConflict(wxPayParams); seq++) {
+                    String newOrderId = baseOrderId + PAY_ORDER_SEQ_PREFIX + seq;
+                    log.warn("建行下单失败,订单号:{},错误码:{},错误信息:{},改用新支付订单号[{}]重试",
+                            baseOrderId, wxPayParams.getErrCode(), wxPayParams.getErrMsg(), newOrderId);
+                    wxPayParams = doUnifiedOrder(expenseOrder, merchantRecordDO, payAmount, miniAppId, miniOpenId, newOrderId);
                 }
-
             }
         } else {
             // 处理错误情况
@@ -168,7 +176,8 @@ public class CcbWxUnifiedOrderController {
     @PermitAll
     public CommonResult<JSONObject> closeOrder(
             @RequestParam String orderId,
-            @RequestParam(required = false) String subAppId) throws Exception {
+            @RequestParam(required = false) String subAppId,
+            @RequestParam(required = false) String txCode) throws Exception {
         // 传回来的就是智慧系统的订单编号
         ExpenseOrderDO expenseOrder = expenseOrderMapper.selectOne(new LambdaQueryWrapperX<ExpenseOrderDO>()
                 .eq(ExpenseOrderDO::getBillOrderNumber,orderId));
@@ -182,6 +191,8 @@ public class CcbWxUnifiedOrderController {
         request.setBRANCHID(CcbPayConstant.BRANCHID);
         request.setORDERID(orderId);
         request.setSUB_APPID(subAppId); // 可选字段,可为null
+        // 关单交易码,可通过参数临时覆盖(用于排查交易码是否正确,MAC校验失败可能由交易码不匹配导致)
+        request.setTXCODE(txCode == null || txCode.isEmpty() ? CcbPayConstant.CLOSE_TXCODE : txCode);
 
 
         String mac = CcbPayUtils.generateCloseOrderMac(request);
@@ -202,4 +213,148 @@ public class CcbWxUnifiedOrderController {
         }
         return CommonResult.success(response);
     }
+
+    // ==================== 下单辅助方法 ====================
+
+    /**
+     * 执行建行微信统一下单:构建请求参数、生成MAC、发送下单请求、成功后请求PAYURL获取微信支付参数。
+     * 任何异常均封装进返回值,不向上抛出,由调用方决定是否换号/关单重试。
+     *
+     * @param payOrderId 实际提交给建行的商户订单号(可能与账单号不同,见 PAY_ORDER_SEQ_PREFIX)
+     */
+    private WxPayParams doUnifiedOrder(ExpenseOrderDO expenseOrder, MerchantRecordDO merchantRecordDO,
+                                       BigDecimal amount, String miniAppId, String miniOpenId, String payOrderId) {
+        try {
+            // 1. 构建请求对象(赋值必传+可选字段,空值默认"")
+            CcbPayRequest request = new CcbPayRequest();
+            request.setMerchantId(merchantRecordDO.getMerchantNumber());
+            request.setPosId(merchantRecordDO.getCounterNumber());
+            request.setBranchId(CcbPayConstant.BRANCHID);
+            request.setOrderId(payOrderId);
+            request.setPayment(amount);
+            request.setTradeType("MINIPRO");
+            request.setSubAppId(miniAppId);
+            request.setSubOpenId(miniOpenId);
+            request.setPub(CcbPayConstant.CCB_PUB);
+            request.setRemark1("JTSH:" + CcbPayConstant.MERCHANTID + CcbPayConstant.POSID);
+
+            // 2. 生成MAC值
+            request.setMac(CcbPayUtils.generateMac(request));
+
+            // 3. 发送请求并解析响应
+            String respStr = CcbPayUtils.sendRequest(request);
+            JSONObject jsonObject = JSON.parseObject(respStr);
+            WxPayParams wxPayParams = new WxPayParams();
+
+            // 4. 校验业务结果并发起请求微信参数
+            if ("true".equals(jsonObject.getString("SUCCESS"))) {
+                String payUrl = jsonObject.getString("PAYURL");
+                wxPayParams = CcbPayUtils.requestPayUrl(payUrl);
+            } else {
+                // 建行返回业务失败(SUCCESS=false),回填错误码/错误信息
+                wxPayParams.setSuccess(jsonObject.getString("SUCCESS"));
+                wxPayParams.setErrCode(jsonObject.getString("ERRCODE"));
+                wxPayParams.setErrMsg(jsonObject.getString("ERRMSG"));
+            }
+            return wxPayParams;
+        } catch (Exception e) {
+            // 解析异常(如建行返回 HTML 错误页)、网络异常等统一封装,避免直接报错
+            log.error("建行下单异常,订单号:{}", payOrderId, e);
+            WxPayParams wxPayParams = new WxPayParams();
+            wxPayParams.setSuccess("false");
+            wxPayParams.setErrCode("EXCEPTION");
+            String message = e.getMessage();
+            // 建行异常时可能返回整页 HTML,截取前500字符便于定位
+            if (message != null && message.length() > 500) {
+                message = message.substring(0, 500);
+            }
+            wxPayParams.setErrMsg("建行下单异常:" + message);
+            return wxPayParams;
+        }
+    }
+
+    /**
+     * 判断建行统一下单是否成功(需同时满足:SUCCESS=true、错误码 ERRCODE=000000 且已返回 prepayid)
+     * 注意:建行在“商户订单号重复”等场景会返回 SUCCESS=true 但 ERRCODE 为业务错误码(如 ZBLAP101WX01),
+     * 此时并未真正拿到微信支付参数,必须视为下单失败走关单重试,否则前端会拿到空参数导致调起支付失败。
+     */
+    private boolean isUnifiedOrderSuccess(WxPayParams wxPayParams) {
+        return wxPayParams != null
+                && "true".equals(wxPayParams.getSuccess())
+                && "000000".equals(wxPayParams.getErrCode())
+                && wxPayParams.getPrepayId() != null
+                && !wxPayParams.getPrepayId().isEmpty();
+    }
+
+    /**
+     * 判断下单失败是否由建行侧订单号重复(残留订单占用)导致。
+     * 只有这类失败才适合换支付订单号重试,其他异常换号既无意义又会产生多余的支付订单。
+     */
+    private boolean isOrderIdConflict(WxPayParams wxPayParams) {
+        if (wxPayParams == null) {
+            return false;
+        }
+        String errCode = wxPayParams.getErrCode();
+        String errMsg = wxPayParams.getErrMsg();
+        return (errCode != null && errCode.startsWith("ZBLAP101"))
+                || (errMsg != null && errMsg.contains("订单号重复"));
+    }
+
+    /**
+     * 关闭建行侧订单
+     *
+     * @param throwIfFail 关闭失败时是否向上抛异常
+     * @return 关闭成功返回建行响应JSON;关闭失败且不抛异常时返回null
+     */
+    private JSONObject closeCcbOrder(ExpenseOrderDO expenseOrder, MerchantRecordDO merchantRecordDO,
+                                     String subAppId, boolean throwIfFail) throws Exception {
+        try {
+            CcbCloseOrderRequestVO request = new CcbCloseOrderRequestVO();
+            request.setMERCHANTID(merchantRecordDO.getMerchantNumber());
+            request.setPOSID(merchantRecordDO.getCounterNumber());
+            request.setBRANCHID(CcbPayConstant.BRANCHID);
+            request.setORDERID(expenseOrder.getBillOrderNumber());
+            request.setSUB_APPID(subAppId); // 可选字段,可为null
+            request.setTXCODE(CcbPayConstant.CLOSE_TXCODE);
+
+            String mac = CcbPayUtils.generateCloseOrderMac(request);
+            request.setMAC(mac);
+            log.info("关闭订单生成MAC:{},订单号:{}", mac, expenseOrder.getBillOrderNumber());
+
+            String responseStr = CcbPayUtils.sendCloseOrderRequest(request);
+            JSONObject response = JSONObject.parseObject(responseStr);
+
+            // 业务结果日志(ERRCODE=000000表示关闭成功)
+            if ("000000".equals(response.get("ERRCODE"))) {
+                log.info("订单关闭成功,订单号:{},响应信息:{}", expenseOrder.getBillOrderNumber(), response.get("ERRMSG"));
+                return response;
+            }
+            log.error("订单关闭失败,订单号:{},错误码:{},错误信息:{}",
+                    expenseOrder.getBillOrderNumber(), response.get("ERRCODE"), response.get("ERRMSG"));
+            if (throwIfFail) {
+                throw new Exception(String.format("订单关闭失败:错误码[%s],错误信息[%s]",
+                        response.get("ERRCODE"), response.get("ERRMSG")));
+            }
+            return null;
+        } catch (Exception e) {
+            log.error("关闭订单异常,订单号:{}", expenseOrder.getBillOrderNumber(), e);
+            if (throwIfFail) {
+                throw e;
+            }
+            return null;
+        }
+    }
+
+    /** 订单级锁,防止同一订单并发重复下单(进程内锁) */
+    private static final ConcurrentHashMap<String, Object> ORDER_LOCKS = new ConcurrentHashMap<>();
+
+    /** 建行侧订单号被残留订单占用时,换支付订单号重试的最大次数(订单号形如:原单号R1、R2...) */
+    private static final int MAX_NEW_ORDER_RETRY = 5;
+
+    /** 换号重试的序号前缀,回调侧会按该前缀还原真实账单号 */
+    private static final String PAY_ORDER_SEQ_PREFIX = "R";
+
+    private Object getOrderLock(String billOrderNumber) {
+        return ORDER_LOCKS.computeIfAbsent(billOrderNumber, key -> new Object());
+    }
 }

+ 1 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/pay/vo/CcbCloseOrderRequestVO.java

@@ -10,6 +10,7 @@ public class CcbCloseOrderRequestVO {
     private String POSID;      // 商户柜台代码(CHAR(9),建行分配)
     private String BRANCHID;   // 分行代码(CHAR(9),建行指定)
     private String ORDERID;    // 定单号(CHAR(30),需关闭的订单号,与下单时一致)
+    private String TXCODE;     // 交易码(关单交易码,默认取 CcbPayConstant.CLOSE_TXCODE,可临时覆盖用于排查)
 
     // 可选字段(文档要求N)
     private String SUB_APPID;  // 子商户公众账号ID(微信支付专有,非必传)

+ 2 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/TenantController.java

@@ -81,6 +81,8 @@ public class TenantController {
     @GetMapping("/get")
     @Operation(summary = "获得租户")
     @Parameter(name = "id", description = "编号", required = true, example = "1024")
+    @TenantIgnore
+    @PermitAll
     public CommonResult<TenantRespVO> getTenant(@RequestParam("id") Long id) {
         TenantDO tenant = tenantService.getTenant(id);
         return success(BeanUtils.toBean(tenant, TenantRespVO.class));

+ 6 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyAskLeaveServiceImpl.java

@@ -48,6 +48,8 @@ import java.util.*;
 import java.util.stream.Collectors;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exceptionCustomMsg;
+import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.COMMON_ERROR;
 import static cn.iocoder.yudao.module.system.enums.LogRecordConstants.*;
 
 @Service
@@ -137,6 +139,10 @@ public class ElderlyAskLeaveServiceImpl implements ElderlyAskLeaveService {
         if (createReqVO.getLeaveStatus() == null) {
             createReqVO.setLeaveStatus(ElderlyAskLeaveDO.LEAVE_STATUS_PENDING);
         }
+        ElderlyInfoDO elderlyInfoDO = elderlyInfoMapper.selectById(createReqVO.getElderId());
+        if(elderlyInfoDO.getInStatus() != 1){
+            throw exceptionCustomMsg(COMMON_ERROR, "该长者未办理入住");
+        }
         elderlyAskLeaveMapper.insert(createReqVO);
 
         Long startUserId = resolveOutboundRegistrationStartUserId(createReqVO.getTenantId());

+ 91 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyInfoServiceImpl.java

@@ -823,6 +823,7 @@ public class ElderlyInfoServiceImpl implements ElderlyInfoService {
     @Override
     public Map<String, Object> selectExportElderData(ElderlyInfoPageReqVO pageVO) {
         Page<ElderlyInfoDO> page = new Page<>(pageVO.getPageNo(), pageVO.getPageSize());
+        pageVO.setOrderByClause(buildOrderBy(pageVO.getProp(), pageVO.getOrder()));
         List<ElderlyInfoDO> list = elderlyInfoMapper.findPage(page, pageVO);
         AtomicInteger atomicInteger = new AtomicInteger(1);
         List<ElderlyBaseInfoExportExcelVO> baseInfoExportExcelList = list.stream().map(e -> {
@@ -863,9 +864,98 @@ public class ElderlyInfoServiceImpl implements ElderlyInfoService {
             return baseInfoExportExcelVO;
         }).collect(Collectors.toList());
 
+        // ========== 合同信息(含费用信息、评估信息) ==========//
+        AtomicInteger contractIndex = new AtomicInteger(1);
+        List<ElderlyContractInfoExportExcelVO> contractInfoExportExcelList = list.stream().map(e -> {
+            ElderlyContractInfoExportExcelVO contractInfoVO = new ElderlyContractInfoExportExcelVO();
+            contractInfoVO.setNum(contractIndex.getAndIncrement());
+            contractInfoVO.setFileNumber(e.getFileNumber());
+            contractInfoVO.setElderName(e.getElderName());
+            contractInfoVO.setElderSex(e.getElderSex());
+            contractInfoVO.setElderAge(e.getElderAge());
+            contractInfoVO.setBirthday(e.getBirthday());
+            contractInfoVO.setPapersType(e.getPapersType() == null ? null : String.valueOf(e.getPapersType()));
+            contractInfoVO.setIdCard(e.getIdCard());
+            contractInfoVO.setElderTags(e.getElderTags());
+            contractInfoVO.setIsPrivateRoom(e.getIsPrivateRoom());
+            contractInfoVO.setBedName(buildService.getFullBedName(e.getId()));
+
+            // 合同信息与费用信息
+            try {
+                ElderlyContractResVO contractResVO = elderlyContractService.getContractByElderId(e.getId());
+                if (contractResVO != null) {
+                    contractInfoVO.setNursingLevelName(contractResVO.getNursingLevelName());
+                    // 当前有效合同:优先进行中(1),其次未开始(2),最后已过期(0)
+                    List<ElderlyContractDO> contracts = contractResVO.getContracts();
+                    ElderlyContractDO currentContract = null;
+                    if (CollectionUtil.isNotEmpty(contracts)) {
+                        for (Integer status : Arrays.asList(1, 2, 0)) {
+                            currentContract = contracts.stream()
+                                    .filter(c -> Objects.equals(status, c.getStatus()))
+                                    .findFirst().orElse(null);
+                            if (currentContract != null) {
+                                break;
+                            }
+                        }
+                    }
+                    if (currentContract != null) {
+                        contractInfoVO.setContractNumber(currentContract.getContractNumber());
+                        contractInfoVO.setBeginTime(currentContract.getBeginTime());
+                        contractInfoVO.setExpireTime(currentContract.getExpireTime());
+                        contractInfoVO.setContractTerm(currentContract.getContractTerm());
+                    }
+                    // 费用明细:1床位费 2护理费 3膳食费
+                    if (CollectionUtil.isNotEmpty(contractResVO.getExpenseItems())) {
+                        for (ExpenseItemDO expenseItem : contractResVO.getExpenseItems()) {
+                            if (expenseItem.getType() == null) {
+                                continue;
+                            }
+                            switch (expenseItem.getType()) {
+                                case 1:
+                                    contractInfoVO.setBedItemName(expenseItem.getItemName());
+                                    contractInfoVO.setBedAmount(expenseItem.getActualAmount());
+                                    break;
+                                case 2:
+                                    contractInfoVO.setNurseAmount(expenseItem.getActualAmount());
+                                    break;
+                                case 3:
+                                    contractInfoVO.setMealItemName(expenseItem.getItemName());
+                                    contractInfoVO.setMealAmount(expenseItem.getActualAmount());
+                                    break;
+                                default:
+                                    break;
+                            }
+                        }
+                    }
+                }
+            } catch (Exception ex) {
+                log.warn("[selectExportElderData][获取长者({})合同费用信息失败]", e.getId(), ex);
+            }
+            // 护理等级兜底:未取到时按护理等级ID查询
+            if (StringUtils.isBlank(contractInfoVO.getNursingLevelName()) && e.getNurseLevelId() != null) {
+                NurseLevelDO nurseLevelDO = nurseLevelMapper.selectById(e.getNurseLevelId());
+                contractInfoVO.setNursingLevelName(nurseLevelDO == null ? null : nurseLevelDO.getNurseLevelName());
+            }
+
+            // 评估信息(优先取本院机构评估,取最新的一条)
+            List<ElderlySyntheticAbilityDO> abilityList = elderlySyntheticAbilityMapper.selectList(
+                    new LambdaQueryWrapperX<ElderlySyntheticAbilityDO>()
+                            .eq(ElderlySyntheticAbilityDO::getElderId, e.getId())
+                            .orderByDesc(ElderlySyntheticAbilityDO::getEvaluatorDate));
+            if (CollectionUtil.isNotEmpty(abilityList)) {
+                ElderlySyntheticAbilityDO ability = abilityList.stream()
+                        .filter(a -> "1".equals(a.getEvaluationAgency()))
+                        .findFirst()
+                        .orElse(abilityList.get(0));
+                contractInfoVO.setEvaluatorDate(ability.getEvaluatorDate());
+                contractInfoVO.setSyntheticAbilityResult(ability.getResult());
+            }
+            return contractInfoVO;
+        }).collect(Collectors.toList());
+
         Map<String, Object> dataMap = new HashMap<>();
         dataMap.put("baseInfo", baseInfoExportExcelList);
-        dataMap.put("contractInfo", new ArrayList<ElderlyContractInfoExportExcelVO>());
+        dataMap.put("contractInfo", contractInfoExportExcelList);
         return dataMap;
     }
 

+ 0 - 4
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseSubsidyServiceImpl.java

@@ -95,10 +95,6 @@ public class ExpenseSubsidyServiceImpl implements ExpenseSubsidyService {
             expenseSubsidyAccount.setTenantId(createReqVO.getTenantId());
             expenseSubsidyAccountMapper.insert(expenseSubsidyAccount);
         } else {
-            if (expenseSubsidyAccount.getAmount().compareTo(BigDecimal.ZERO) < 0) {
-                //小于0进行账单抵扣
-                createReqVO.setStatus(BooleanEnum.TRUE.getValue());
-            }
             expenseSubsidyAccount.setAmount(expenseSubsidyAccount.getAmount().add(createReqVO.getAmount()));
             expenseSubsidyAccountMapper.updateById(expenseSubsidyAccount);
         }

+ 1 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/RefundSettlementOrderServiceImpl.java

@@ -1675,6 +1675,7 @@ public class RefundSettlementOrderServiceImpl implements RefundSettlementOrderSe
                 return;
             }
             ElderlyConsumerVouchersDO voucher = elderlyConsumerVouchersMapper.selectById(item.getSourceExpenseItemId());
+            itemVO.setItemName("消费券抵扣");
             itemVO.setAmount(voucher.getAmount());
             itemVO.setPrice(voucher.getAmount());
             itemVO.setRemarks(voucher.getRemarks());

+ 24 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/ccb/CcbBusinessService.java

@@ -57,9 +57,7 @@ public class CcbBusinessService {
         String orderId = CcbPayUtils.getMapValue(paramMap, "ORDERID");
         String callbackAmountStr = CcbPayUtils.getMapValue(paramMap, "PAYMENT");
         BigDecimal callbackAmount = new BigDecimal(callbackAmountStr);
-        ExpenseOrderDO expenseOrderDO = expenseOrderMapper.selectOne(new LambdaQueryWrapperX<ExpenseOrderDO>()
-                .eq(ExpenseOrderDO::getBillOrderNumber, orderId)
-                .last("FOR UPDATE"));   //加悲观锁,防止并发操作导致数据不一致的问题
+        ExpenseOrderDO expenseOrderDO = findExpenseOrderByPayOrderId(orderId);
         if (expenseOrderDO == null) {
             String errorMessage = "订单不存在: " + orderId;
             saveCallbackRecord(paramMap, errorMessage);
@@ -114,6 +112,29 @@ public class CcbBusinessService {
         }
     }
 
+    /**
+     * 按支付订单号查找账单(加悲观锁)。
+     * 若下单时账单号在建行侧已被占用,实际提交给建行的是 "原账单号R{序号}" 形式的支付订单号,
+     * 回调带回的是带序号后缀的订单号,这里去掉后缀后再匹配账单。
+     */
+    private ExpenseOrderDO findExpenseOrderByPayOrderId(String orderId) {
+        ExpenseOrderDO expenseOrderDO = selectExpenseOrderForUpdate(orderId);
+        if (expenseOrderDO == null) {
+            String baseOrderId = orderId.replaceAll("R\\d{1,3}$", "");
+            if (!baseOrderId.equals(orderId)) {
+                log.info("回调订单号[{}]未匹配到账单,去掉重试序号后缀后重新匹配:{}", orderId, baseOrderId);
+                expenseOrderDO = selectExpenseOrderForUpdate(baseOrderId);
+            }
+        }
+        return expenseOrderDO;
+    }
+
+    private ExpenseOrderDO selectExpenseOrderForUpdate(String orderId) {
+        return expenseOrderMapper.selectOne(new LambdaQueryWrapperX<ExpenseOrderDO>()
+                .eq(ExpenseOrderDO::getBillOrderNumber, orderId)
+                .last("FOR UPDATE"));   //加悲观锁,防止并发操作导致数据不一致的问题
+    }
+
     private void saveCallbackRecord(Map<String, String> paramMap, String errorMessage) {
         CcbPayCallbackRecordDO record = new CcbPayCallbackRecordDO();
         record.setOrderId(CcbPayUtils.getMapValue(paramMap, "ORDERID"));

+ 77 - 34
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/util/pay/CcbPayUtils.java

@@ -19,8 +19,11 @@ import org.apache.http.util.EntityUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -176,47 +179,36 @@ public class CcbPayUtils {
         }
     }
 
+    /**
+     * 关闭订单 MAC 摘要。
+     * 按建行文档:参与摘要运算的字符串及其顺序为 MERCHANTID、POSID、BRANCHID、ORDERID
+     * (变量名必须大写,TXCODE、SUB_APPID 等字段不参与摘要)
+     */
     public static String generateCloseOrderMac(CcbCloseOrderRequestVO request) {
-        // 严格按文档字段顺序拼接(必传字段,无值传空字符串,不可省略)
-        StringBuilder macSource = new StringBuilder();
-        macSource.append("MERCHANTID=").append(request.getMERCHANTID())
-                .append("&POSID=").append(request.getPOSID())
-                .append("&BRANCHID=").append(request.getBRANCHID())
-                .append("&ORDERID=").append(request.getORDERID());
-
-        // 标准MD5加密(UTF-8编码)
-        return DigestUtils.md5Hex(macSource.toString().getBytes());
+        String source = macSource(linkedMap(
+                "MERCHANTID", request.getMERCHANTID(),
+                "POSID", request.getPOSID(),
+                "BRANCHID", request.getBRANCHID(),
+                "ORDERID", request.getORDERID()));
+        log.info("关闭订单MAC摘要原串:{}", source);
+        return DigestUtils.md5Hex(source.getBytes(StandardCharsets.UTF_8));
     }
 
+    /**
+     * 发送关单请求。
+     * 按建行文档以 GET 方式提交完整URL:
+     * ccbMain?CCB_IBSVersion=V6&MERCHANTID=xx&POSID=xx&BRANCHID=xx&ORDERID=xx&TXCODE=xx&SUB_APPID=xx&MAC=xx
+     * (SUB_APPID 为可选字段,有值才上送)
+     */
     public static String sendCloseOrderRequest(CcbCloseOrderRequestVO request) throws Exception {
-        // 1. 创建HTTP客户端
+        String url = buildCloseOrderUrl(request);
+        log.info("关闭订单请求URL:{},订单号:{}", url, request.getORDERID());
         try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
-            // 2. 构建POST请求
-            HttpPost httpPost = new HttpPost(CcbPayConstant.PAY_URL);
-
-            // 3. 封装请求参数(按文档字段传递,含必选和可选)
-            List<NameValuePair> params = new ArrayList<>();
-            params.add(new BasicNameValuePair("MERCHANTID", request.getMERCHANTID()));
-            params.add(new BasicNameValuePair("POSID", request.getPOSID()));
-            params.add(new BasicNameValuePair("BRANCHID", request.getBRANCHID()));
-            params.add(new BasicNameValuePair("ORDERID", request.getORDERID()));
-            params.add(new BasicNameValuePair("TXCODE", CcbPayConstant.CLOSE_TXCODE));
-            params.add(new BasicNameValuePair("MAC", request.getMAC()));
-            // 可选字段:SUB_APPID(非必传,有值才添加)
-            if (request.getSUB_APPID() != null && !request.getSUB_APPID().isEmpty()) {
-                params.add(new BasicNameValuePair("SUB_APPID", request.getSUB_APPID()));
-            }
-
-            // 4. 设置请求体(表单格式,符合HTTP POST标准)
-            HttpEntity entity = new UrlEncodedFormEntity(params, "UTF-8");
-            httpPost.setEntity(entity);
-
-            // 5. 发送请求并获取响应
-            try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
-                // 6. 解析响应(建行返回JSON字符串)
+            HttpGet httpGet = new HttpGet(url);
+            try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
                 HttpEntity responseEntity = response.getEntity();
                 if (responseEntity != null) {
-                    String responseStr = EntityUtils.toString(responseEntity, "UTF-8");
+                    String responseStr = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8);
                     log.info("关闭订单接口响应:{},订单号:{}", responseStr, request.getORDERID());
                     return responseStr;
                 }
@@ -228,6 +220,34 @@ public class CcbPayUtils {
         }
     }
 
+    /** 拼装关单请求URL(PAY_URL 已包含 CCB_IBSVersion=V6) */
+    private static String buildCloseOrderUrl(CcbCloseOrderRequestVO request) {
+        StringBuilder url = new StringBuilder(CcbPayConstant.PAY_URL);
+        url.append("&MERCHANTID=").append(urlEncode(request.getMERCHANTID()))
+                .append("&POSID=").append(urlEncode(request.getPOSID()))
+                .append("&BRANCHID=").append(urlEncode(request.getBRANCHID()))
+                .append("&ORDERID=").append(urlEncode(request.getORDERID()))
+                .append("&TXCODE=").append(urlEncode(
+                        request.getTXCODE() == null ? CcbPayConstant.CLOSE_TXCODE : request.getTXCODE()));
+        if (request.getSUB_APPID() != null && !request.getSUB_APPID().isEmpty()) {
+            url.append("&SUB_APPID=").append(urlEncode(request.getSUB_APPID()));
+        }
+        url.append("&RETURN_FIELD=").append("10000000000000000000");
+        url.append("&MAC=").append(urlEncode(request.getMAC()));
+        return url.toString();
+    }
+
+    private static String urlEncode(String value) {
+        if (value == null) {
+            return "";
+        }
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            return value;
+        }
+    }
+
     /**
      * 构建签名原串(按文档顺序,空值不跳过)
      */
@@ -252,4 +272,27 @@ public class CcbPayUtils {
     public static String getMapValue(Map<String, String> paramMap, String key) {
         return paramMap.getOrDefault(key, "").trim(); // 去除首尾空格,避免格式问题
     }
+
+    /** 按传入顺序组装字段,空值以空字符串参与拼接 */
+    private static LinkedHashMap<String, String> linkedMap(Object... keyValues) {
+        LinkedHashMap<String, String> map = new LinkedHashMap<>();
+        for (int i = 0; i < keyValues.length; i += 2) {
+            String key = String.valueOf(keyValues[i]);
+            String value = keyValues[i + 1] == null ? "" : String.valueOf(keyValues[i + 1]);
+            map.put(key, value);
+        }
+        return map;
+    }
+
+    /** 拼接MAC摘要源串:key=value&key=value */
+    private static String macSource(LinkedHashMap<String, String> fields) {
+        StringBuilder sb = new StringBuilder();
+        for (Map.Entry<String, String> entry : fields.entrySet()) {
+            if (sb.length() > 0) {
+                sb.append("&");
+            }
+            sb.append(entry.getKey()).append("=").append(entry.getValue());
+        }
+        return sb.toString();
+    }
 }

+ 1 - 0
yudao-server/src/main/resources/application.yaml

@@ -263,6 +263,7 @@ yudao:
       - /admin-api/elderly-build-floor-order-food/delete
       - /admin-api/elderly-build-floor-order-food/get
       - /admin-api/elderly-build-floor-order-food/page
+      - /admin-api/system/tenant/get
     ignore-tables:
       - system_tenant
       - system_groups