Sfoglia il codice sorgente

Merge remote-tracking branch 'origin/dev' into dev

jinwenhai 10 ore fa
parent
commit
5fb7e51a66
16 ha cambiato i file con 733 aggiunte e 41 eliminazioni
  1. 28 8
      nightFragrance-massage/src/main/java/com/ylx/massage/controller/MerchantDailyAttendanceController.java
  2. 63 18
      nightFragrance-massage/src/main/java/com/ylx/massage/domain/MerchantDailyAttendance.java
  3. 41 0
      nightFragrance-massage/src/main/java/com/ylx/massage/domain/dto/MerchantDailyAttendanceQueryDTO.java
  4. 26 0
      nightFragrance-massage/src/main/java/com/ylx/massage/service/MerchantDailyAttendanceService.java
  5. 316 1
      nightFragrance-massage/src/main/java/com/ylx/massage/service/impl/MerchantDailyAttendanceServiceImpl.java
  6. 29 0
      nightFragrance-massage/src/main/java/com/ylx/massage/task/MerchantDailyAttendanceTask.java
  7. 9 0
      nightFragrance-massage/src/main/java/com/ylx/order/controller/AfterSalesServiceController.java
  8. 3 0
      nightFragrance-massage/src/main/java/com/ylx/order/domain/dto/AfterSalesServiceDTO.java
  9. 19 0
      nightFragrance-massage/src/main/java/com/ylx/order/domain/vo/RefundCalculationVO.java
  10. 0 2
      nightFragrance-massage/src/main/java/com/ylx/order/domain/vo/RegulationConfigVO.java
  11. 3 4
      nightFragrance-massage/src/main/java/com/ylx/order/enums/OrderStatusEnum.java
  12. 3 0
      nightFragrance-massage/src/main/java/com/ylx/order/service/IAfterSalesServiceService.java
  13. 187 1
      nightFragrance-massage/src/main/java/com/ylx/order/service/impl/AfterSalesServiceServiceImpl.java
  14. 3 4
      nightFragrance-massage/src/main/java/com/ylx/project/controller/ProjectController.java
  15. 1 1
      nightFragrance-massage/src/main/java/com/ylx/project/service/ProjectService.java
  16. 2 2
      nightFragrance-massage/src/main/java/com/ylx/project/service/impl/ProjectServiceImpl.java

+ 28 - 8
nightFragrance-massage/src/main/java/com/ylx/massage/controller/MerchantDailyAttendanceController.java

@@ -1,11 +1,9 @@
 package com.ylx.massage.controller;
 
-
-
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ylx.common.core.domain.R;
 import com.ylx.massage.domain.MerchantDailyAttendance;
+import com.ylx.massage.domain.dto.MerchantDailyAttendanceQueryDTO;
 import com.ylx.massage.service.MerchantDailyAttendanceService;
 import org.springframework.web.bind.annotation.*;
 
@@ -32,12 +30,34 @@ public class MerchantDailyAttendanceController  {
      * 分页查询所有数据
      *
      * @param page 分页对象
-     * @param merchantDailyAttendance 查询实体
-     * @return 所有数据
+     * @param queryDTO 查询参数
+     * @return R 所有数据
+     */
+    @GetMapping("/queryDailyAttendance")
+    public R selectAll(Page<MerchantDailyAttendance> page, MerchantDailyAttendanceQueryDTO queryDTO) {
+        try {
+            return R.ok(this.merchantDailyAttendanceService.queryDailyAttendance(page, queryDTO));
+        } catch (Exception e) {
+            e.printStackTrace();
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * 查询商户指定日期的工作时间明细
+     *
+     * @param merchantId 商户ID
+     * @param attendanceDate 考勤日期,格式:yyyy-MM-dd
+     * @return R 工作时间明细
      */
-    @GetMapping
-    public R selectAll(Page<MerchantDailyAttendance> page, MerchantDailyAttendance merchantDailyAttendance) {
-        return R.ok(this.merchantDailyAttendanceService.page(page, new QueryWrapper<>(merchantDailyAttendance)));
+    @GetMapping("/queryDailyWorkTime")
+    public R queryDailyWorkTime(@RequestParam("merchantId") Integer merchantId, @RequestParam("attendanceDate") String attendanceDate) {
+        try {
+            return R.ok(this.merchantDailyAttendanceService.queryDailyWorkTime(merchantId, attendanceDate));
+        } catch (Exception e) {
+            e.printStackTrace();
+            throw new RuntimeException(e);
+        }
     }
 
     /**

+ 63 - 18
nightFragrance-massage/src/main/java/com/ylx/massage/domain/MerchantDailyAttendance.java

@@ -4,7 +4,6 @@ import java.time.LocalDateTime;
 import java.util.Date;
 
 import com.baomidou.mybatisplus.annotation.*;
-import com.baomidou.mybatisplus.extension.activerecord.Model;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -23,60 +22,106 @@ import java.io.Serializable;
 @Accessors(chain = true)
 @TableName(value = "merchant_daily_attendance", autoResultMap = true)
 public class MerchantDailyAttendance implements Serializable {
-//主键ID
+
     /**
-     * 主键
+     * 主键ID。
      */
     @TableId(value = "id", type = IdType.AUTO)
     private Long id;
-    //关联商户ID
+
+    /**
+     * 关联商户ID。
+     */
     @TableField("merchant_id")
     private Integer merchantId;
-    //关联商户姓名
+
+    /**
+     * 关联商户姓名。
+     */
     @TableField("merchant_name")
     private String merchantName;
-    //考勤日期 (对应UI中的"日期")
+
+    /**
+     * 考勤日期,对应页面中的日期。
+     */
     @TableField("attendance_date")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
     private Date attendanceDate;
-    //当日首次打卡/上线时间
+
+    /**
+     * 当日首次打卡或上线时间。
+     */
     @TableField("attendance_start_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private Date attendanceStartTime;
-    //当日末次打卡/下线时间
+
+    /**
+     * 当日末次打卡或下线时间。
+     */
     @TableField("attendance_end_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private Date attendanceEndTime;
-    //日累计工作时长(分钟),方便精确计算
+
+    /**
+     * 日累计工作时长,单位:分钟。
+     */
     @TableField("total_work_minutes")
     private Integer totalWorkMinutes;
-    //考勤状态: 1-正常, 2-异常
+
+    /**
+     * 考勤状态,1表示正常,2表示异常。
+     */
     @TableField("attendance_status")
     private Integer attendanceStatus;
-    //扣款金额
+
+    /**
+     * 扣款金额。
+     */
     @TableField("deduction_amount")
     private Double deductionAmount;
-    //备注
+
+    /**
+     * 备注。
+     */
     @TableField("remark")
     private String remark;
-    //创建人
+
+    /**
+     * 创建人。
+     */
     @TableField("create_by")
     private String createBy;
+
     /**
-     * 创建时间
+     * 创建时间
      */
     @TableField(value = "create_time", fill = FieldFill.INSERT)
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private LocalDateTime createTime;
 
     /**
-     * 更新时间
+     * 更新时间
      */
     @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private LocalDateTime updateTime;
-    //修改人
+
+    /**
+     * 修改人。
+     */
     @TableField("update_by")
     private String updateBy;
-    //是否删除(0否1是)
+
+    /**
+     * 逻辑删除标识,0表示否,1表示是。
+     */
     @TableField("is_delete")
+    @TableLogic
     private Integer isDelete;
-}
 
+    /**
+     * 页面展示日累计工作时长文本,不对应数据库字段。
+     */
+    @TableField(exist = false)
+    private String totalWorkDurationText;
+}

+ 41 - 0
nightFragrance-massage/src/main/java/com/ylx/massage/domain/dto/MerchantDailyAttendanceQueryDTO.java

@@ -0,0 +1,41 @@
+package com.ylx.massage.domain.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 商户每日考勤明细查询参数。
+ */
+@Data
+@ApiModel("商户每日考勤明细查询参数")
+public class MerchantDailyAttendanceQueryDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 商户姓名。
+     */
+    @ApiModelProperty("商户姓名")
+    private String merchantName;
+
+    /**
+     * 查询开始日期,格式:yyyy-MM-dd。
+     */
+    @ApiModelProperty("查询开始日期")
+    private String beginDate;
+
+    /**
+     * 查询结束日期,格式:yyyy-MM-dd。
+     */
+    @ApiModelProperty("查询结束日期")
+    private String endDate;
+
+    /**
+     * 考勤状态,1表示正常,2表示异常。
+     */
+    @ApiModelProperty("考勤状态:1-正常 2-异常")
+    private Integer attendanceStatus;
+}

+ 26 - 0
nightFragrance-massage/src/main/java/com/ylx/massage/service/MerchantDailyAttendanceService.java

@@ -1,9 +1,13 @@
 package com.ylx.massage.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ylx.massage.domain.MerchantDailyAttendance;
+import com.ylx.massage.domain.dto.MerchantDailyAttendanceQueryDTO;
 import org.springframework.stereotype.Service;
 
+import java.util.List;
+
 
 /**
  * 商户每日考勤统计表(MerchantDailyAttendance)表服务接口
@@ -14,5 +18,27 @@ import org.springframework.stereotype.Service;
 @Service
 public interface MerchantDailyAttendanceService extends IService<MerchantDailyAttendance> {
 
+    /**
+     * 分页查询考勤明细列表。
+     *
+     * @param page 分页参数
+     * @param query 查询条件
+     * @return 考勤明细分页数据
+     */
+    Page<MerchantDailyAttendance> queryDailyAttendance(Page<MerchantDailyAttendance> page, MerchantDailyAttendanceQueryDTO query);
+
+    /**
+     * 查询商户指定日期的工作时间明细。
+     *
+     * @param merchantId 商户ID
+     * @param attendanceDate 考勤日期,格式:yyyy-MM-dd
+     * @return 工作时间记录
+     */
+    List<MerchantDailyAttendance> queryDailyWorkTime(Integer merchantId, String attendanceDate);
+
+    /**
+     * 刷新昨日考勤扣款金额。
+     */
+    void refreshYesterdayDeductionAmount();
 }
 

+ 316 - 1
nightFragrance-massage/src/main/java/com/ylx/massage/service/impl/MerchantDailyAttendanceServiceImpl.java

@@ -1,10 +1,38 @@
 package com.ylx.massage.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ylx.attendanceconfig.domain.AttendanceDeductionRule;
+import com.ylx.attendanceconfig.domain.AttendanceRule;
+import com.ylx.attendanceconfig.mapper.AttendanceDeductionRuleMapper;
+import com.ylx.attendanceconfig.mapper.AttendanceRuleMapper;
+import com.ylx.common.exception.ServiceException;
+import com.ylx.common.utils.DateUtils;
+import com.ylx.common.utils.StringUtils;
 import com.ylx.massage.domain.MerchantDailyAttendance;
+import com.ylx.massage.domain.dto.MerchantDailyAttendanceQueryDTO;
 import com.ylx.massage.mapper.MerchantDailyAttendanceMapper;
 import com.ylx.massage.service.MerchantDailyAttendanceService;
+import org.springframework.beans.BeanUtils;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
 
 /**
  * 商户每日考勤统计表(MerchantDailyAttendance)表服务实现类
@@ -15,5 +43,292 @@ import org.springframework.stereotype.Service;
 @Service("merchantDailyAttendanceService")
 public class MerchantDailyAttendanceServiceImpl extends ServiceImpl<MerchantDailyAttendanceMapper, MerchantDailyAttendance> implements MerchantDailyAttendanceService {
 
-}
+    private static final int NOT_DELETE = 0;
+
+    /**
+     * 正常考勤状态
+     */
+    private static final int ATTENDANCE_NORMAL = 1;
+
+    /**
+     * 异常考勤状态
+     */
+    private static final int ATTENDANCE_ABNORMAL = 2;
+    private static final int ENABLED = 1;
+    private static final int EARLY_LEAVE_DEDUCTION = 1;
+
+    @Resource
+    private AttendanceRuleMapper attendanceRuleMapper;
+
+    @Resource
+    private AttendanceDeductionRuleMapper attendanceDeductionRuleMapper;
+
+    @Override
+    public Page<MerchantDailyAttendance> queryDailyAttendance(Page<MerchantDailyAttendance> page, MerchantDailyAttendanceQueryDTO query) {
+        MerchantDailyAttendanceQueryDTO condition = query == null ? new MerchantDailyAttendanceQueryDTO() : query;
+        List<MerchantDailyAttendance> attendanceList = list(buildBaseQuery(condition));
+        AttendanceRule enabledRule = getEnabledAttendanceRule();
+        List<AttendanceDeductionRule> deductionRules = getDeductionRules(enabledRule);
+
+        /**
+         * 构建每日考勤统计列表
+         */
+        List<MerchantDailyAttendance> summaryList = buildDailySummaryList(attendanceList, enabledRule, deductionRules);
+        if (condition.getAttendanceStatus() != null) {
+            summaryList = summaryList.stream()
+                    .filter(item -> condition.getAttendanceStatus().equals(item.getAttendanceStatus()))
+                    .collect(Collectors.toList());
+        }
+
+        long current = page.getCurrent() <= 0 ? 1 : page.getCurrent();
+        long size = page.getSize() <= 0 ? 10 : page.getSize();
+        int fromIndex = (int) Math.min((current - 1) * size, summaryList.size());
+        int toIndex = (int) Math.min(fromIndex + size, summaryList.size());
+
+        Page<MerchantDailyAttendance> resultPage = new Page<>(current, size);
+        resultPage.setTotal(summaryList.size());
+        resultPage.setRecords(summaryList.subList(fromIndex, toIndex));
+        return resultPage;
+    }
+
+    @Override
+    public List<MerchantDailyAttendance> queryDailyWorkTime(Integer merchantId, String attendanceDate) {
+        if (merchantId == null) {
+            throw new ServiceException("商户ID不能为空");
+        }
+        Date dayStart = parseDateStart(attendanceDate, "考勤日期不能为空");
+        Date nextDayStart = addDays(dayStart, 1);
+
+        List<MerchantDailyAttendance> records = list(new LambdaQueryWrapper<MerchantDailyAttendance>()
+                .eq(MerchantDailyAttendance::getMerchantId, merchantId)
+                .ge(MerchantDailyAttendance::getAttendanceDate, dayStart)
+                .lt(MerchantDailyAttendance::getAttendanceDate, nextDayStart)
+                .orderByAsc(MerchantDailyAttendance::getAttendanceStartTime));
+
+        records.forEach(item -> item.setTotalWorkDurationText(formatDuration(defaultMinutes(item.getTotalWorkMinutes()))));
+        return records;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void refreshYesterdayDeductionAmount() {
+        Date yesterday = addDays(DateUtils.getNowDate(), -1);
+        refreshDeductionAmountByDate(yesterday);
+    }
+
+    /**
+     * 构建基础查询条件。
+     * @param query 查询实体
+     * @return LambdaQueryWrapper<MerchantDailyAttendance> 基础查询条件
+     */
+    private LambdaQueryWrapper<MerchantDailyAttendance> buildBaseQuery(MerchantDailyAttendanceQueryDTO query) {
+        LambdaQueryWrapper<MerchantDailyAttendance> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(StringUtils.isNotBlank(query.getMerchantName()), MerchantDailyAttendance::getMerchantName, query.getMerchantName())
+                .orderByDesc(MerchantDailyAttendance::getAttendanceDate);
+        //考勤状态
+        /*if (query.getAttendanceStatus() != null) {
+            wrapper.eq(MerchantDailyAttendance::getAttendanceStatus, query.getAttendanceStatus());
+        }*/
+        //开始日期
+        if (StringUtils.isNotBlank(query.getBeginDate())) {
+            wrapper.ge(MerchantDailyAttendance::getAttendanceDate, parseDateStart(query.getBeginDate(), "开始日期格式不正确"));
+        }
+        //结束日期
+        if (StringUtils.isNotBlank(query.getEndDate())) {
+            wrapper.lt(MerchantDailyAttendance::getAttendanceDate, addDays(parseDateStart(query.getEndDate(), "结束日期格式不正确"), 1));
+        }
+        return wrapper;
+    }
+
+    /**
+     * 构建每日考勤统计列表
+     * @param attendanceList 考勤记录列表
+     * @param enabledRule 有效考勤规则
+     * @param deductionRules 早退扣款规则列表
+     * @return List<MerchantDailyAttendance>
+     */
+    private List<MerchantDailyAttendance> buildDailySummaryList(List<MerchantDailyAttendance> attendanceList, AttendanceRule enabledRule, List<AttendanceDeductionRule> deductionRules) {
+        Map<String, List<MerchantDailyAttendance>> groupMap = attendanceList.stream()
+                .filter(item -> item.getMerchantId() != null && item.getAttendanceDate() != null)
+                .collect(Collectors.groupingBy(this::buildDailyGroupKey, LinkedHashMap::new, Collectors.toList()));
 
+        List<MerchantDailyAttendance> summaryList = new ArrayList<>();
+        for (List<MerchantDailyAttendance> groupRecords : groupMap.values()) {
+            MerchantDailyAttendance first = groupRecords.get(0);
+            int totalMinutes = groupRecords.stream()
+                    .map(MerchantDailyAttendance::getTotalWorkMinutes)
+                    .filter(Objects::nonNull)
+                    .mapToInt(Integer::intValue)
+                    .sum();
+
+            MerchantDailyAttendance summary = new MerchantDailyAttendance();
+            BeanUtils.copyProperties(first, summary);
+            summary.setTotalWorkMinutes(totalMinutes);
+            summary.setTotalWorkDurationText(formatDuration(totalMinutes));
+            summary.setAttendanceStatus(calculateAttendanceStatus(totalMinutes, enabledRule, first.getAttendanceStatus()));
+            summary.setDeductionAmount(calculateDeductionAmount(totalMinutes, enabledRule, deductionRules).doubleValue());
+            summaryList.add(summary);
+        }
+
+        summaryList.sort(Comparator
+                .comparing(MerchantDailyAttendance::getAttendanceDate, Comparator.nullsLast(Date::compareTo)).reversed()
+                .thenComparing(MerchantDailyAttendance::getMerchantId, Comparator.nullsLast(Integer::compareTo)));
+        return summaryList;
+    }
+
+    private void refreshDeductionAmountByDate(Date attendanceDate) {
+        Date dayStart = startOfDay(attendanceDate);
+        Date nextDayStart = addDays(dayStart, 1);
+        List<MerchantDailyAttendance> records = list(new LambdaQueryWrapper<MerchantDailyAttendance>()
+                .ge(MerchantDailyAttendance::getAttendanceDate, dayStart)
+                .lt(MerchantDailyAttendance::getAttendanceDate, nextDayStart)
+                .eq(MerchantDailyAttendance::getIsDelete, NOT_DELETE));
+        if (records.isEmpty()) {
+            return;
+        }
+
+        AttendanceRule enabledRule = getEnabledAttendanceRule();
+        List<AttendanceDeductionRule> deductionRules = getDeductionRules(enabledRule);
+        List<MerchantDailyAttendance> summaryList = buildDailySummaryList(records, enabledRule, deductionRules);
+        for (MerchantDailyAttendance summary : summaryList) {
+            update(null, new LambdaUpdateWrapper<MerchantDailyAttendance>()
+                    .eq(MerchantDailyAttendance::getMerchantId, summary.getMerchantId())
+                    .ge(MerchantDailyAttendance::getAttendanceDate, startOfDay(summary.getAttendanceDate()))
+                    .lt(MerchantDailyAttendance::getAttendanceDate, addDays(startOfDay(summary.getAttendanceDate()), 1))
+                    .eq(MerchantDailyAttendance::getIsDelete, NOT_DELETE)
+                    .set(MerchantDailyAttendance::getAttendanceStatus, summary.getAttendanceStatus())
+                    .set(MerchantDailyAttendance::getDeductionAmount, summary.getDeductionAmount())
+                    .set(MerchantDailyAttendance::getUpdateTime, java.time.LocalDateTime.now()));
+        }
+    }
+
+    /**
+     * 获取当前生效的考勤规则
+     * @return AttendanceRule
+     */
+    private AttendanceRule getEnabledAttendanceRule() {
+        return attendanceRuleMapper.selectOne(new LambdaQueryWrapper<AttendanceRule>()
+                .eq(AttendanceRule::getStatus, ENABLED)
+                .eq(AttendanceRule::getWorkDurationRuleEnabled, ENABLED)
+                .orderByDesc(AttendanceRule::getCreateTime)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 获取早退扣款规则
+     * @param rule
+     * @return List<AttendanceDeductionRule>
+     */
+    private List<AttendanceDeductionRule> getDeductionRules(AttendanceRule rule) {
+        if (rule == null || rule.getId() == null) {
+            return new ArrayList<>();
+        }
+        return attendanceDeductionRuleMapper.selectList(new LambdaQueryWrapper<AttendanceDeductionRule>()
+                .eq(AttendanceDeductionRule::getRuleId, rule.getId())
+                .eq(AttendanceDeductionRule::getRuleType, EARLY_LEAVE_DEDUCTION)
+                .orderByAsc(AttendanceDeductionRule::getCreateTime));
+    }
+
+    /**
+     * 计算考勤状态
+     * @param totalMinutes 工作时长,单位:分钟
+     * @param rule 考勤规则
+     * @param fallbackStatus 回退状态
+     * @return 考勤状态
+     */
+    private Integer calculateAttendanceStatus(int totalMinutes, AttendanceRule rule, Integer fallbackStatus) {
+        if (rule == null || rule.getBasicWorkHours() == null) {
+            return fallbackStatus == null ? ATTENDANCE_NORMAL : fallbackStatus;
+        }
+        return totalMinutes >= getBasicWorkMinutes(rule) ? ATTENDANCE_NORMAL : ATTENDANCE_ABNORMAL;
+    }
+
+    /**
+     * 计算扣款金额
+     * @param totalMinutes
+     * @param rule
+     * @param deductionRules
+     * @return BigDecimal 扣款金额,单位:元
+     */
+    private BigDecimal calculateDeductionAmount(int totalMinutes, AttendanceRule rule, List<AttendanceDeductionRule> deductionRules) {
+        if (rule == null || rule.getBasicWorkHours() == null || deductionRules == null || deductionRules.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        int lackMinutes = getBasicWorkMinutes(rule) - totalMinutes;
+        if (lackMinutes <= 0) {
+            return BigDecimal.ZERO;
+        }
+        return deductionRules.stream()
+                .filter(ruleItem -> ruleItem.getStartMinutes() != null && ruleItem.getEndMinutes() != null)
+                .filter(ruleItem -> lackMinutes >= ruleItem.getStartMinutes() && lackMinutes <= ruleItem.getEndMinutes())
+                .map(AttendanceDeductionRule::getDeductAmount)
+                .filter(Objects::nonNull)
+                .findFirst()
+                .orElse(BigDecimal.ZERO);
+    }
+
+    /**
+     * 获取基本工作时长
+     *
+     * @param rule 考勤规则
+     * @return 基本工作时长,单位:分钟
+     */
+    private int getBasicWorkMinutes(AttendanceRule rule) {
+        return rule.getBasicWorkHours()
+                .multiply(BigDecimal.valueOf(60))
+                .setScale(0, RoundingMode.HALF_UP)
+                .intValue();
+    }
+
+    /**
+     * 构建每日考勤统计分组键
+     * @param attendance 考勤记录
+     * @return String 分组键
+     */
+    private String buildDailyGroupKey(MerchantDailyAttendance attendance) {
+        return attendance.getMerchantId() + "_" + DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, attendance.getAttendanceDate());
+    }
+
+    /**
+     * 格式化工作时长
+     * @param minutes 工作时长,单位:分钟
+     * @return 格式化后的字符串时长
+     */
+    private String formatDuration(Integer minutes) {
+        int totalMinutes = defaultMinutes(minutes);
+        return totalMinutes / 60 + "小时" + String.format("%02d", totalMinutes % 60) + "分钟";
+    }
+
+    private int defaultMinutes(Integer minutes) {
+        return minutes == null ? 0 : minutes;
+    }
+
+    /**
+     * 解析日期字符串为日期对象
+     * @param date
+     * @param errorMessage
+     * @return
+     */
+    private Date parseDateStart(String date, String errorMessage) {
+        if (StringUtils.isBlank(date)) {
+            throw new ServiceException(errorMessage);
+        }
+        Date parsedDate = DateUtils.parseDate(date.trim());
+        if (parsedDate == null) {
+            throw new ServiceException(errorMessage);
+        }
+        return startOfDay(parsedDate);
+    }
+
+    private Date startOfDay(Date date) {
+        LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+        return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
+    }
+
+    private Date addDays(Date date, int amount) {
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(date);
+        calendar.add(Calendar.DAY_OF_MONTH, amount);
+        return calendar.getTime();
+    }
+}

+ 29 - 0
nightFragrance-massage/src/main/java/com/ylx/massage/task/MerchantDailyAttendanceTask.java

@@ -0,0 +1,29 @@
+package com.ylx.massage.task;
+
+import com.ylx.massage.service.MerchantDailyAttendanceService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 商户考勤定时任务。
+ */
+@Slf4j
+@Component("merchantDailyAttendanceTask")
+public class MerchantDailyAttendanceTask {
+
+    @Resource
+    private MerchantDailyAttendanceService merchantDailyAttendanceService;
+
+    /**
+     * 刷新昨日考勤扣款金额。
+     * <p>
+     * Quartz配置建议:0 0 0 * * ?
+     */
+    public void refreshYesterdayDeductionAmount() {
+        log.info("开始刷新昨日商户考勤扣款金额");
+        merchantDailyAttendanceService.refreshYesterdayDeductionAmount();
+        log.info("刷新昨日商户考勤扣款金额完成");
+    }
+}

+ 9 - 0
nightFragrance-massage/src/main/java/com/ylx/order/controller/AfterSalesServiceController.java

@@ -6,6 +6,7 @@ import com.ylx.common.core.domain.R;
 import com.ylx.common.core.domain.entity.SysDictData;
 import com.ylx.common.utils.DictUtils;
 import com.ylx.order.domain.dto.AfterSalesServiceDTO;
+import com.ylx.order.domain.vo.RefundCalculationVO;
 import com.ylx.order.domain.vo.RegulationConfigVO;
 import com.ylx.order.enums.AfterSaleServiceDictTypeEnum;
 import com.ylx.order.service.IAfterSalesServiceService;
@@ -65,6 +66,14 @@ public class AfterSalesServiceController {
         return R.ok();
     }
 
+    @PreAuthorize("@customerAuth.isCustomer()")
+    @ApiOperation("客户端发起售后计算退款金额")
+    @PostMapping("/calculate/refund")
+    public R<RefundCalculationVO> calculateRefund(@Validated @RequestBody AfterSalesServiceDTO dto) {
+        RefundCalculationVO vo = this.afterSalesServiceService.calculateRefund(dto);
+        return R.ok(vo);
+    }
+
 
     @ApiOperation("根据商户履约状态获取退款描述")
     @GetMapping("/desc/list")

+ 3 - 0
nightFragrance-massage/src/main/java/com/ylx/order/domain/dto/AfterSalesServiceDTO.java

@@ -16,4 +16,7 @@ public class AfterSalesServiceDTO implements Serializable {
     @ApiModelProperty("关联的主订单ID")
     private Long orderId;
 
+    @ApiModelProperty("退款说明")
+    private String remark;
+
 }

+ 19 - 0
nightFragrance-massage/src/main/java/com/ylx/order/domain/vo/RefundCalculationVO.java

@@ -0,0 +1,19 @@
+package com.ylx.order.domain.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@ApiModel("计算退款金额VO")
+@Data
+public class RefundCalculationVO {
+
+    private BigDecimal refundAmount;
+    private String refundDesc;
+
+    public RefundCalculationVO(BigDecimal refundAmount, String refundDesc) {
+        this.refundAmount = refundAmount;
+        this.refundDesc = refundDesc;
+    }
+}

+ 0 - 2
nightFragrance-massage/src/main/java/com/ylx/order/domain/vo/RegulationConfigVO.java

@@ -1,7 +1,5 @@
 package com.ylx.order.domain.vo;
 
-import cn.hutool.core.util.ObjectUtil;
-import com.ylx.giftCard.domain.GiftCard;
 import com.ylx.order.domain.RefundRuleDetail;
 import io.swagger.annotations.ApiModel;
 import lombok.Data;

+ 3 - 4
nightFragrance-massage/src/main/java/com/ylx/order/enums/OrderStatusEnum.java

@@ -11,10 +11,9 @@ public enum OrderStatusEnum {
     PENDING_SERVICE(3, "待服务"),
     IN_SERVICE(4, "服务中"),
     COMPLETED(5, "已完成"),
-    REFUNDED(6, "已退款"),
-    CANCELLED(7, "已取消"),
-    CLOSED(8, "已关闭"),
-    REJECTED(9, "拒绝接单");
+    CANCELLED(6, "已取消"),
+    CLOSED(7, "已关闭"),
+    REJECTED(8, "拒绝接单");
 
     private final Integer code;
     private final String info;

+ 3 - 0
nightFragrance-massage/src/main/java/com/ylx/order/service/IAfterSalesServiceService.java

@@ -3,7 +3,10 @@ package com.ylx.order.service;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.ylx.order.domain.AfterSalesService;
 import com.ylx.order.domain.dto.AfterSalesServiceDTO;
+import com.ylx.order.domain.vo.RefundCalculationVO;
 
 public interface IAfterSalesServiceService extends IService<AfterSalesService> {
     void submitAfterSale(AfterSalesServiceDTO dto);
+
+    RefundCalculationVO calculateRefund(AfterSalesServiceDTO dto);
 }

+ 187 - 1
nightFragrance-massage/src/main/java/com/ylx/order/service/impl/AfterSalesServiceServiceImpl.java

@@ -1,24 +1,210 @@
 package com.ylx.order.service.impl;
 
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ylx.common.core.domain.model.WxLoginUser;
+import com.ylx.common.exception.ServiceException;
+import com.ylx.common.utils.DateUtils;
+import com.ylx.common.utils.SecurityUtils;
 import com.ylx.order.domain.AfterSalesService;
+import com.ylx.order.domain.RefundRuleDetail;
+import com.ylx.order.domain.TOrder;
 import com.ylx.order.domain.dto.AfterSalesServiceDTO;
+import com.ylx.order.domain.vo.RefundCalculationVO;
+import com.ylx.order.enums.AfterSaleServiceStatusEnum;
+import com.ylx.order.enums.OrderStatusEnum;
+import com.ylx.order.enums.RefundStageTypeEnum;
 import com.ylx.order.mapper.AfterSalesServiceMapper;
 import com.ylx.order.service.IAfterSalesServiceService;
+import com.ylx.order.service.RefundRuleDetailService;
+import com.ylx.order.service.TOrderService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+
 @Slf4j
 @Service
 public class AfterSalesServiceServiceImpl extends ServiceImpl<AfterSalesServiceMapper, AfterSalesService>
         implements IAfterSalesServiceService {
 
+    @Resource
+    private TOrderService tOrderService;
+    @Resource
+    private RefundRuleDetailService refundRuleDetailService;
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void submitAfterSale(AfterSalesServiceDTO dto) {
 
-        // 1. 获取订单信息
+        // 1. 获取当前用户
+        WxLoginUser wxLoginUser = SecurityUtils.getWxLoginUser();
+        if (ObjectUtil.isNull(wxLoginUser)) {
+            log.warn("用户未登录,无法创建订单");
+            throw new ServiceException("用户未登录");
+        }
+
+        Long currentUserId = Long.valueOf(wxLoginUser.getId());
+
+        // 2. 获取订单信息
+        TOrder order = this.tOrderService.getById(dto.getOrderId());
+        if (ObjectUtil.isNull(order) || ObjectUtil.equals(1, order.getIsDelete())) {
+            throw new ServiceException("订单不存在");
+        }
+        if (ObjectUtil.notEqual(order.getUserId(), currentUserId)) {
+            throw new ServiceException("您无权操作此订单");
+        }
+
+        validateOrderStatus(order);
+        validateNoPendingAfterSale(dto.getOrderId());
+
+        RefundCalculationVO refundResult = calculateRefund(order);
+
+        AfterSalesService afterSalesService = new AfterSalesService();
+        afterSalesService.setServiceNo(generateServiceNo(dto.getOrderId()));
+        afterSalesService.setOrderId(dto.getOrderId());
+        afterSalesService.setUserId(currentUserId);
+        afterSalesService.setStatus(AfterSaleServiceStatusEnum.PENDING_AUDIT.getCode());
+        afterSalesService.setActualRefundAmount(refundResult.getRefundAmount());
+        afterSalesService.setRefundDesc(refundResult.getRefundDesc());
+        afterSalesService.setCreateTime(DateUtils.getNowDate());
+        afterSalesService.setRemark(dto.getRemark());
+
+        if (!this.save(afterSalesService)) {
+            throw new ServiceException("创建售后服务单失败");
+        }
+
+        log.info("用户提交售后成功, orderId={}, serviceNo={}, refundAmount={}",
+                dto.getOrderId(), afterSalesService.getServiceNo(), refundResult.getRefundAmount());
+    }
+
+    @Override
+    public RefundCalculationVO calculateRefund(AfterSalesServiceDTO dto) {
+
+        TOrder order = this.tOrderService.getById(dto.getOrderId());
+        if (ObjectUtil.isNull(order) || ObjectUtil.equals(1, order.getIsDelete())) {
+            throw new ServiceException("订单不存在");
+        }
+
+        return calculateRefund(order);
+    }
+
+    private void validateOrderStatus(TOrder order) {
+        Integer status = order.getStatus();
+        if (OrderStatusEnum.IN_SERVICE.getCode().equals(status)) {
+            throw new ServiceException("操作错误,请重试");
+        }
+        if (OrderStatusEnum.CANCELLED.getCode().equals(status)) {
+            throw new ServiceException("已取消订单不支持售后");
+        }
+    }
+
+    private void validateNoPendingAfterSale(Long orderId) {
+        long count = this.count(new LambdaQueryWrapper<AfterSalesService>()
+                .eq(AfterSalesService::getOrderId, orderId)
+                .in(AfterSalesService::getStatus,
+                        AfterSaleServiceStatusEnum.PENDING_AUDIT.getCode(),
+                        AfterSaleServiceStatusEnum.APPROVED.getCode(),
+                        AfterSaleServiceStatusEnum.REFUND_SUCCESS.getCode()
+                ));
+        if (count > 0) {
+            throw new ServiceException("操作错误,请重试");
+        }
+    }
+
+    private RefundCalculationVO calculateRefund(TOrder order) {
+        Integer stageType = resolveStageType(order);
+        List<RefundRuleDetail> details = listDetailsByStage(stageType);
+        if (CollUtil.isEmpty(details)) {
+            throw new ServiceException("未配置退款规则,请联系客服");
+        }
+
+        RefundRuleDetail matchedRule;
+        if (RefundStageTypeEnum.PRE_DEPARTURE.getCode().equals(stageType)) {
+            matchedRule = matchPreDepartureRule(details, order.getAppointmentStartTime());
+        } else {
+            matchedRule = CollUtil.getFirst(details);
+        }
+
+        BigDecimal percent = Optional.ofNullable(matchedRule.getRefundPercent()).orElse(BigDecimal.ZERO);
+        BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
+        BigDecimal refundAmount = finalAmount.multiply(percent)
+                .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+
+        return new RefundCalculationVO(refundAmount, matchedRule.getRefundDesc());
+    }
+
+    private Integer resolveStageType(TOrder order) {
+        if (OrderStatusEnum.IN_SERVICE.getCode().equals(order.getStatus())) {
+            return RefundStageTypeEnum.IN_SERVICE.getCode();
+        }
+        return ObjectUtil.defaultIfNull(order.getExecStatus(), RefundStageTypeEnum.PRE_DEPARTURE.getCode());
+    }
+
+    private List<RefundRuleDetail> listDetailsByStage(Integer stageType) {
+        LambdaQueryWrapper<RefundRuleDetail> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(RefundRuleDetail::getStageType, stageType)
+                .eq(RefundRuleDetail::getIsDelete, 0)
+                .orderByAsc(RefundRuleDetail::getSortOrder)
+                .orderByAsc(RefundRuleDetail::getId);
+        return this.refundRuleDetailService.list(wrapper);
+    }
+
+    private RefundRuleDetail matchPreDepartureRule(List<RefundRuleDetail> details, LocalDateTime appointmentStartTime) {
+
+        // 预排序:确保按时间区间顺序匹配
+        details.sort(Comparator.comparing(RefundRuleDetail::getSortOrder, Comparator.nullsLast(Integer::compareTo)));
+
+        RefundRuleDetail first = CollUtil.getFirst(details);
+        if (details.size() == 1 && ObjectUtil.equals(0, first.getRefundType())) {
+            return first;
+        }
+
+        BigDecimal hoursUntilStart = calcHoursUntilStart(appointmentStartTime);
+        if (hoursUntilStart.compareTo(BigDecimal.ZERO) <= 0) {
+            throw new ServiceException("已过预约服务时间,无法申请退款");
+        }
+
+        for (RefundRuleDetail detail : details) {
+            if (matchTimeRange(detail, hoursUntilStart)) {
+                return detail;
+            }
+        }
+        throw new ServiceException("未匹配到适用的退款规则");
+    }
+
+    private boolean matchTimeRange(RefundRuleDetail detail, BigDecimal hoursUntilStart) {
+        BigDecimal start = detail.getTimeStartHours();
+        BigDecimal end = detail.getTimeEndHours();
+        if (start != null && start.compareTo(BigDecimal.ZERO) > 0) {
+            return hoursUntilStart.compareTo(end) > 0 && hoursUntilStart.compareTo(start) <= 0;
+        }
+        if (end != null) {
+            return hoursUntilStart.compareTo(end) <= 0;
+        }
+        return false;
+    }
+
+    private BigDecimal calcHoursUntilStart(LocalDateTime appointmentStartTime) {
+        if (ObjectUtil.isNull(appointmentStartTime)) {
+            throw new ServiceException("订单预约时间缺失,无法计算退款金额");
+        }
+        long minutes = Duration.between(LocalDateTime.now(), appointmentStartTime).toMinutes();
+        return BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
+    }
+
+    private String generateServiceNo(Long orderId) {
+        return "ASS" + orderId + System.currentTimeMillis() + (int)(Math.random() * 1000);
     }
 
 }

+ 3 - 4
nightFragrance-massage/src/main/java/com/ylx/project/controller/ProjectController.java

@@ -7,7 +7,6 @@ import com.ylx.common.enums.BusinessType;
 import com.ylx.common.exception.ServiceException;
 import com.ylx.project.domain.Project;
 import com.ylx.project.domain.bookMerchant.dto.BookMerchantDTO;
-import com.ylx.project.domain.bookMerchant.vo.BookMerchantVO;
 import com.ylx.project.domain.bookMerchant.vo.BookProjectDetailVO;
 import com.ylx.project.domain.dto.ProjectAddDTO;
 import com.ylx.project.domain.dto.ProjectSearchDTO;
@@ -135,9 +134,9 @@ public class ProjectController {
 
     @PreAuthorize("@customerAuth.isCustomer()")
     @ApiOperation("客户端根据服务标签获取服务项目集合数据")
-    @GetMapping("/type/{type}")
-    public R<List<ProjectBaseVo>> getProjectTabList(@PathVariable("type") Integer type) {
-        List<ProjectBaseVo> list = this.projectService.getProjectTabListByType(type);
+    @GetMapping("/categoryId/{categoryId}")
+    public R<List<ProjectBaseVo>> getProjectTabList(@PathVariable("categoryId") Integer categoryId) {
+        List<ProjectBaseVo> list = this.projectService.getProjectTabListByCategoryId(categoryId);
         return R.ok(list);
     }
 

+ 1 - 1
nightFragrance-massage/src/main/java/com/ylx/project/service/ProjectService.java

@@ -36,7 +36,7 @@ public interface ProjectService extends IService<Project> {
 
     Page<ProductServiceOptionVO> selectServiceOptionsPage(Page page, ServiceOptionDTO dto);
 
-    List<ProjectBaseVo> getProjectTabListByType(Integer type);
+    List<ProjectBaseVo> getProjectTabListByCategoryId(Integer categoryId);
 
     BookProjectDetailVO getBookingProjectDetail(BookMerchantDTO dto);
 }

+ 2 - 2
nightFragrance-massage/src/main/java/com/ylx/project/service/impl/ProjectServiceImpl.java

@@ -182,10 +182,10 @@ public class ProjectServiceImpl extends ServiceImpl<ProjectMapper, Project> impl
     }
 
     @Override
-    public List<ProjectBaseVo> getProjectTabListByType(Integer type) {
+    public List<ProjectBaseVo> getProjectTabListByCategoryId(Integer categoryId) {
 
         LambdaQueryWrapper<Project> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(Project::getType, type)
+        wrapper.eq(Project::getCategoryId, categoryId)
                 .eq(Project::getStatus, ProjectStatusEnum.ON_SHELF.getCode());
 
         List<Project> projects = this.baseMapper.selectList(wrapper);