# SvaDepCategoryHoursStatisticsServiceImpl.java

    package cloud.wconnect.workperformance.service.sva.scheduleactual.impl;

    import cloud.wconnect.commonlib.cons.BaseConstant;
    import cloud.wconnect.commonlib.pojo.dto.PropertyDTO;
    import cloud.wconnect.commonlib.util.DateTimeUtils;
    import cloud.wconnect.commonlib.util.InternationalizationUtils;
    import cloud.wconnect.commonlib.util.JsonUtils;
    import cloud.wconnect.workperformance.cons.sva.SvaSettingCalculateTypeEnum;
    import cloud.wconnect.workperformance.cons.sva.SvaSettingDriverTypeEnum;
    import cloud.wconnect.workperformance.cons.sva.SvaSettingVolumeReferenceEnum;
    import cloud.wconnect.workperformance.entity.sva.CategoryRecomHourSum;
    import cloud.wconnect.workperformance.entity.sva.SvaDepartmentCategoryStatisticsComment;
    import cloud.wconnect.workperformance.entity.sva.SvaProductivityRatioSettings;
    import cloud.wconnect.workperformance.entity.sva.avhours.SvaActualHour;
    import cloud.wconnect.workperformance.entity.sva.avhours.SvaScheduleHour;
    import cloud.wconnect.workperformance.entity.sva.employees.AttendanceEmployee;
    import cloud.wconnect.workperformance.entity.sva.employees.SvaDepartmentIgnoreConfig;
    import cloud.wconnect.workperformance.entity.sva.saleforecast.SaleForecast;
    import cloud.wconnect.workperformance.entity.sva.setting.DepCategoryRecommendHour;
    import cloud.wconnect.workperformance.entity.sva.setting.DepCodeToCategory;
    import cloud.wconnect.workperformance.entity.sva.setting.DepartmentCategory;
    import cloud.wconnect.workperformance.entity.sva.setting.SpecialEvent;
    import cloud.wconnect.workperformance.pojo.dto.sva.category.DepartmentDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.hoursstatistics.*;
    import cloud.wconnect.workperformance.pojo.dto.sva.internal.departmenthour.DepHourStatisticsDailyInternalDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.internal.departmenthour.DepHourStatisticsInternalDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.internal.departmenthour.DepHourStatisticsRecommendSumInternalDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.internal.departmenthour.DepHourStatisticsValueInternalDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.recommendhours.RecommendHoursConfigurableDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.recommendhours.RecommendHoursDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.specialevent.DepDateSpecialEventTargetHourDTO;
    import cloud.wconnect.workperformance.pojo.query.sva.schedulevsactual.SvaHoursStatisticsDailyQuery;
    import cloud.wconnect.workperformance.pojo.query.sva.schedulevsactual.SvaHoursStatisticsQuery;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaActualHourService;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaPayCodeExcludeConfigService;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaScheduleHourService;
    import cloud.wconnect.workperformance.service.sva.employees.IAttendanceEmployeeService;
    import cloud.wconnect.workperformance.service.sva.employees.ISvaDepartmentIgnoreConfigService;
    import cloud.wconnect.workperformance.service.sva.saleforecast.ISaleForecastService;
    import cloud.wconnect.workperformance.service.sva.scheduleactual.ICategoryRecomHourSumService;
    import cloud.wconnect.workperformance.service.sva.scheduleactual.ISvaDepCategoryHoursStatisticsService;
    import cloud.wconnect.workperformance.service.sva.scheduleactual.ISvaDepartmentCategoryStatisticsCommentService;
    import cloud.wconnect.workperformance.service.sva.setting.*;
    import com.baomidou.dynamic.datasource.annotation.DS;
    import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
    import com.fasterxml.jackson.core.type.TypeReference;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.google.common.collect.Lists;
    import com.google.common.collect.Sets;
    import jakarta.annotation.Resource;
    import org.apache.commons.collections.CollectionUtils;
    import org.apache.commons.collections.MapUtils;
    import org.apache.commons.lang3.StringUtils;
    import org.checkerframework.checker.nullness.qual.Nullable;
    import org.springframework.context.annotation.Lazy;
    import org.springframework.stereotype.Service;

    import java.math.BigDecimal;
    import java.math.RoundingMode;
    import java.time.LocalDate;
    import java.time.ZonedDateTime;
    import java.time.temporal.ChronoUnit;
    import java.util.*;
    import java.util.function.Function;
    import java.util.stream.Collectors;
    import java.util.stream.IntStream;

    /**
     * @author zc
     * @date 2025/02/07
     */
    @Service
    @DS("work_performance")
    public class SvaDepCategoryHoursStatisticsServiceImpl implements ISvaDepCategoryHoursStatisticsService {
        @Resource
        private ISaleForecastService saleForecastService;
        @Lazy
        @Resource
        private IDepartmentCategoryService departmentCategoryService;
        @Resource
        private IAttendanceEmployeeService employeeService;
        @Resource
        private ISvaActualHourService actualHourService;
        @Resource
        private ISvaScheduleHourService scheduleHourService;
        @Resource
        private ICategoryRecomHourSumService categoryRecommendHourSumService;
        @Resource
        private IDepCategoryRecommendHourService depCategoryRecommendHourService;
        @Resource
        private ISvaPayCodeExcludeConfigService payCodeExcludeConfigService;
        @Resource
        private ISvaDepartmentCategoryStatisticsCommentService commentService;
        @Resource
        private ISpecialEventService specialEventService;
        @Resource
        private ISvaDepartmentIgnoreConfigService ignoreConfigService;
        @Resource
        private ObjectMapper objectMapper;
        @Resource
        private ISvaProductivityRatioSettingsService productivityRatioSettingsService;
        @Resource
        private IDepCodeToCategoryService depCodeToCategoryService;

        /**
         * 获取指定部门每日的工时统计数据
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @return DepHourStatisticsInternalDTO
         * @author zc
         * @date 2025/4/4
         */
        @Override
        public DepHourStatisticsInternalDTO getHoursStatisticsDaily(PropertyDTO propertyDTO, SvaHoursStatisticsQuery query) {
            // 获取需要排除统计的部门代码
            List<String> excludeDepartmentCodes = ignoreConfigService.listConfig(propertyDTO.getId()).stream()
                    .map(SvaDepartmentIgnoreConfig::getCode).toList();
            // 如果部门代码在排除列表中，则直接返回
            if (excludeDepartmentCodes.contains(query.getDepartmentCode())) {
                return new DepHourStatisticsInternalDTO();
            }

            // 获取隐藏的 Position Id
            List<String> noShowPositionIds = employeeService.listNoShowPositionIds(propertyDTO.getId());

            // 格式化时间戳日期为 yyyyMMdd
            long startDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(query.getFromDate(),
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));
            long endDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(query.getToDate(),
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));

            // 指定 Department Code 集合
            List<String> targetDepartmentCodes = Lists.newArrayList(query.getDepartmentCode());
            // 获取 Actual Hour
            List<SvaScheduleHour> scheduleHourList = scheduleHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, noShowPositionIds, excludeDepartmentCodes,
                    null, targetDepartmentCodes, null);
            // 获取 Schedule Hour
            List<SvaActualHour> actualHourList = actualHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, noShowPositionIds, excludeDepartmentCodes,
                    null, targetDepartmentCodes, null);

            // 获取启用日期在选择日期范围内或选择日期之前的最新的一条的 Department Category 推荐工时
            List<DepCategoryRecommendHour> recommendHourList = depCategoryRecommendHourService.listByDateRangeWithNewOne(propertyDTO.getId(),
                    query.getFromDate(), query.getToDate());

            // 获取指定日期范围内特殊活动设置的工时
            Map<Long, List<SpecialEvent>> theDate2EventList = specialEventService.listByDate(propertyDTO.getId(), query.getFromDate(),
                    query.getToDate()).stream().collect(Collectors.groupingBy(SpecialEvent::getTheDate));

            ZonedDateTime fromZonedDate = DateTimeUtils.toZonedDateTime(query.getFromDate(), propertyDTO.getTimezone());
            ZonedDateTime toZonedDate = DateTimeUtils.toZonedDateTime(query.getToDate(), propertyDTO.getTimezone());

            // 获取 sale forecast 数据
            Map<Long, SaleForecast> theDate2SaleForecast = saleForecastService.listByDateRange(propertyDTO.getId(),
                            fromZonedDate.minusDays(1).toEpochSecond(), toZonedDate.minusDays(1).toEpochSecond())
                    .stream().collect(Collectors.toMap(SaleForecast::getTheDate, Function.identity()));

            // 获取计数保留规则
            RoundingMode roundingMode = InternationalizationUtils.getRoundingMode(propertyDTO.getInternationalization().getNumber().getCalculationRule());

            // 获取排除 Pay Code 配置
            List<String> excludePayCodeList = payCodeExcludeConfigService.listExcludePayCode(propertyDTO.getId());

            // 获取 Actual Hour、Schedule Hour 的 DepartmentCategoryId 集合
            List<Integer> depCategoryIds = Lists.newArrayList(getActualScheduleDepCategoryIds(scheduleHourList, actualHourList));

            Map<Long, List<SvaScheduleHour>> payDate2ScheduleHourList = scheduleHourList.stream()
                    .collect(Collectors.groupingBy(SvaScheduleHour::getPayDate));
            Map<Long, List<SvaActualHour>> payDate2ActualHourList = actualHourList.stream()
                    .collect(Collectors.groupingBy(SvaActualHour::getPayDate));

            List<DepHourStatisticsDailyInternalDTO> statisticsInternalDTOList = Lists.newArrayList();
            for (ZonedDateTime indexZonedDateTime = fromZonedDate; !indexZonedDateTime.isAfter(toZonedDate); indexZonedDateTime = indexZonedDateTime.plusDays(1)) {
                DepHourStatisticsDailyInternalDTO statisticsInternalDTO = new DepHourStatisticsDailyInternalDTO();
                long date = DateTimeUtils.parseToLong(indexZonedDateTime, BaseConstant.DATE_FORMAT_YYYYMMDD);
                statisticsInternalDTO.setDate(date);

                List<BigDecimal> recommendHours = Lists.newArrayList();
                for (int categoryId : depCategoryIds) {
                    // recommend hours
                    recommendHours.add(getRecommendHoursForOneDay(categoryId, indexZonedDateTime,
                            theDate2SaleForecast, recommendHourList, roundingMode));

                    recommendHours.add(getSpecialHoursForOneDay(categoryId, indexZonedDateTime.toEpochSecond(),
                            theDate2EventList, roundingMode));
                }
                // 设置 recommend hours
                BigDecimal recommendHoursTotal = recommendHours.stream().filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                statisticsInternalDTO.setRecommend(recommendHoursTotal);

                long payDate = DateTimeUtils.parseToLong(indexZonedDateTime, BaseConstant.DATE_FORMAT_YYYYMMDD);

                List<SvaScheduleHour> scheduleHours = payDate2ScheduleHourList.getOrDefault(payDate, Collections.emptyList());
                // 设置 scheduledHours
                BigDecimal scheduledHoursTotal = scheduleHours.stream().map(SvaScheduleHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal scheduledExcludePayCodeHoursTotal = scheduleHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaScheduleHour::getHours).reduce(BigDecimal::add).orElse(null);
                statisticsInternalDTO.setSchedule(new DepHourStatisticsValueInternalDTO(scheduledHoursTotal, scheduledExcludePayCodeHoursTotal));

                List<SvaActualHour> actualHours = payDate2ActualHourList.getOrDefault(payDate, Collections.emptyList());
                // 设置 actualHours
                BigDecimal actualHoursTotal = actualHours.stream().map(SvaActualHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal actualExcludePayCodeHoursTotal = actualHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaActualHour::getHours).reduce(BigDecimal::add).orElse(null);
                statisticsInternalDTO.setActual(new DepHourStatisticsValueInternalDTO(actualHoursTotal, actualExcludePayCodeHoursTotal));

                statisticsInternalDTOList.add(statisticsInternalDTO);
            }
            DepHourStatisticsInternalDTO depHourStatisticsInternalDTO = new DepHourStatisticsInternalDTO();
            depHourStatisticsInternalDTO.setDailyList(statisticsInternalDTOList);
            // 获取手动设置的推荐工时
            List<DepHourStatisticsRecommendSumInternalDTO> recommendSumInternalDTOList = listRecommendHourSum(propertyDTO, query, depCategoryIds);
            depHourStatisticsInternalDTO.setRecommendSumList(recommendSumInternalDTOList);

            return depHourStatisticsInternalDTO;
        }

        /**
         * 获取指定日期范围内手动设置的推荐工时
         *
         * @param propertyDTO    资产
         * @param query          查询条件
         * @param depCategoryIds 部门分类id集合
         * @return 指定日期范围内手动设置的推荐工时集合
         * @author zc
         * @date 2025/4/10
         */
        private List<DepHourStatisticsRecommendSumInternalDTO> listRecommendHourSum(PropertyDTO propertyDTO, SvaHoursStatisticsQuery query, List<Integer> depCategoryIds) {
            List<CategoryRecomHourSum> categoryRecomHourSumList = categoryRecommendHourSumService.listByDateRange(propertyDTO.getId(),
                    query.getFromDate(), query.getToDate(), depCategoryIds);
            Map<String, List<CategoryRecomHourSum>> key2SumList = categoryRecomHourSumList.stream()
                    .collect(Collectors.groupingBy(item -> item.getFromDate() + "," + item.getToDate()));
            return key2SumList.entrySet().stream().map(item -> {
                String[] dateRange = item.getKey().split(",");
                DepHourStatisticsRecommendSumInternalDTO recommendSumInternalDTO = new DepHourStatisticsRecommendSumInternalDTO();
                long fromDate = DateTimeUtils.parseToLong(DateTimeUtils.toZonedDateTime(Long.parseLong(dateRange[0]),
                        propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD);
                recommendSumInternalDTO.setFromDate(fromDate);
                long toDate = DateTimeUtils.parseToLong(DateTimeUtils.toZonedDateTime(Long.parseLong(dateRange[1]),
                        propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD);
                recommendSumInternalDTO.setToDate(toDate);
                BigDecimal recommendHourSum = item.getValue().stream().map(CategoryRecomHourSum::getRecomHourSum)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                recommendSumInternalDTO.setRecommendHourSum(recommendHourSum);
                return recommendSumInternalDTO;
            }).toList();
        }

        /**
         * 获取 Actual Hour、Schedule Hour 的 PositionId 集合
         *
         * @param scheduleHourList Schedule Hour 数据
         * @param actualHourList   Actual Hour 数据
         * @return PositionId 集合
         * @author zc
         * @date 2025/4/23
         */
        private Set<String> getActualSchedulePositionIds(List<SvaScheduleHour> scheduleHourList, List<SvaActualHour> actualHourList) {
            Set<String> positionIds = Sets.newHashSet();
            positionIds.addAll(scheduleHourList.stream().map(SvaScheduleHour::getPositionId).toList());
            positionIds.addAll(actualHourList.stream().map(SvaActualHour::getPositionId).toList());
            return positionIds;
        }

        /**
         * 获取 Actual Hour、Schedule Hour 的 DepartmentCategoryId 集合
         *
         * @param scheduleHourList Schedule Hour 数据
         * @param actualHourList   Actual Hour 数据
         * @return DepartmentCategoryId 集合
         * @author zc
         * @date 2025/4/23
         */
        private Set<Integer> getActualScheduleDepCategoryIds(List<SvaScheduleHour> scheduleHourList, List<SvaActualHour> actualHourList) {
            Set<Integer> depCategoryIds = Sets.newHashSet();
            depCategoryIds.addAll(scheduleHourList.stream().map(SvaScheduleHour::getDepCategoryId).toList());
            depCategoryIds.addAll(actualHourList.stream().map(SvaActualHour::getDepCategoryId).toList());
            return depCategoryIds;
        }

        /**
         * 获取部门分类指定日期范围内的时数统计数据
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @return DepCategoryStatisticDTO 集合
         * @author zc
         * @date 2025/4/23
         */
        @Override
        public List<SvaDepCategoryStatisticDTO> getHoursStatistics(PropertyDTO propertyDTO, SvaHoursStatisticsQuery query) {
            // 获取隐藏的 Position Id
            List<String> noShowPositionIds = employeeService.listNoShowPositionIds(propertyDTO.getId());

            // 获取需要排除统计的部门代码
            List<String> excludeDepartmentCodes = ignoreConfigService.listConfig(propertyDTO.getId()).stream()
                    .map(SvaDepartmentIgnoreConfig::getCode).toList();

            // 格式化时间戳日期为 yyyyMMdd
            long startDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(query.getFromDate(),
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));
            long endDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(query.getToDate(),
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));

            // 获取 Actual Hour
            List<SvaScheduleHour> scheduleHourList = scheduleHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, noShowPositionIds, excludeDepartmentCodes,
                    null, null, null);
            // 获取 Schedule Hour
            List<SvaActualHour> actualHourList = actualHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, noShowPositionIds, excludeDepartmentCodes,
                    null, null, null);

            // 获取 Actual Hour、Schedule Hour 的 PositionId 集合
            List<String> positionIds = Lists.newArrayList(getActualSchedulePositionIds(scheduleHourList, actualHourList));

            // 获取指定日期范围内 没有考勤、未隐藏、未离职或在选择日期后离职的员工数据（排除部门代码）
            List<AttendanceEmployee> noAttendanceEmployeeList = employeeService.listForNoAttendance(propertyDTO.getId(),
                    positionIds, query.getFromDate(), excludeDepartmentCodes);
            if (CollectionUtils.isEmpty(scheduleHourList) && CollectionUtils.isEmpty(actualHourList)
                    && CollectionUtils.isEmpty(noAttendanceEmployeeList)) {
                return Collections.emptyList();
            }

            // 获取 Actual Hour、Schedule Hour 的 DepartmentCategoryId 集合
            Set<Integer> departmentCategoryIdSet = getActualScheduleDepCategoryIds(scheduleHourList, actualHourList);
            departmentCategoryIdSet.addAll(noAttendanceEmployeeList.stream().map(AttendanceEmployee::getDepCategoryId).toList());
            List<Integer> departmentCategoryIdList = Lists.newArrayList(departmentCategoryIdSet);

            // 根据 categoryIds 获取分类信息
            Map<Integer, String> depId2DepName = departmentCategoryService.listByIds(departmentCategoryIdList).stream()
                    .collect(Collectors.toMap(DepartmentCategory::getId, DepartmentCategory::getName));

            // 获取启用日期在选择日期范围内或选择日期之前的最新的一条的 Department Category 推荐工时配置数据
            List<DepCategoryRecommendHour> depCategoryRecommendHours = depCategoryRecommendHourService.listByDateRangeWithNewOne(propertyDTO.getId(),
                    query.getFromDate(), query.getToDate());

            // 获取指定日期范围内手动设置的工时
            Map<Integer, CategoryRecomHourSum> categoryId2RecommendHoursSum = categoryRecommendHourSumService.listByDate(propertyDTO.getId(),
                            query.getFromDate(), query.getToDate(), departmentCategoryIdList).stream()
                    .collect(Collectors.toMap(CategoryRecomHourSum::getDepCategoryId, Function.identity()));

            // 获取指定日期范围内特殊活动设置的工时
            Map<Long, List<SpecialEvent>> theDate2EventList = specialEventService.listByDate(propertyDTO.getId(), query.getFromDate(),
                    query.getToDate()).stream().collect(Collectors.groupingBy(SpecialEvent::getTheDate));

            ZonedDateTime fromZonedDate = DateTimeUtils.toZonedDateTime(query.getFromDate(), propertyDTO.getTimezone());
            ZonedDateTime toZonedDate = DateTimeUtils.toZonedDateTime(query.getToDate(), propertyDTO.getTimezone());

            // 获取 sale forecast 数据
            Map<Long, SaleForecast> theDate2SaleForecast = saleForecastService.listByDateRange(propertyDTO.getId(),
                            fromZonedDate.minusDays(1).toEpochSecond(), toZonedDate.minusDays(1).toEpochSecond())
                    .stream().collect(Collectors.toMap(SaleForecast::getTheDate, v -> v));

            // 获取指定日期范围内的评论数据
            Map<Integer, Long> departmentCategoryId2CommentCount = commentService.listByDateRangeAndDepCategoryId(propertyDTO.getId(),
                            query.getFromDate(), query.getToDate(), departmentCategoryIdList).stream()
                    .collect(Collectors.groupingBy(SvaDepartmentCategoryStatisticsComment::getDepartmentCategoryId, Collectors.counting()));

            // 获取计数保留规则
            RoundingMode roundingMode = InternationalizationUtils.getRoundingMode(propertyDTO.getInternationalization().getNumber().getCalculationRule());

            Map<Integer, List<SvaScheduleHour>> depCategoryId2ScheduleHourList = scheduleHourList.stream()
                    .collect(Collectors.groupingBy(SvaScheduleHour::getDepCategoryId));
            Map<Integer, List<SvaActualHour>> depCategoryId2ActualHourList = actualHourList.stream()
                    .collect(Collectors.groupingBy(SvaActualHour::getDepCategoryId));

            // 获取排除 Pay Code 配置
            List<String> excludePayCodeList = payCodeExcludeConfigService.listExcludePayCode(propertyDTO.getId());

            // 生成指定日期范围内部门分类考勤时数统计数据
            List<SvaDepCategoryStatisticDTO> depCategoryStatisticList = Lists.newArrayList();
            for (int departmentCategoryId : departmentCategoryIdList) {
                SvaDepCategoryStatisticDTO categoryStatisticDTO = new SvaDepCategoryStatisticDTO();
                // 设置分类 id
                categoryStatisticDTO.setDepartmentCategoryId(departmentCategoryId);

                // 设置分类名称
                String categoryName = depId2DepName.getOrDefault(departmentCategoryId, DepartmentCategory.DEFAULT_CATEGORY_NAME);
                categoryStatisticDTO.setCategoryName(categoryName);

                // 设置 recommend hours
                List<BigDecimal> recommendHours = Lists.newArrayList();
                CategoryRecomHourSum categoryRecomHourSum = categoryId2RecommendHoursSum.get(departmentCategoryId);
                if (Objects.nonNull(categoryRecomHourSum)) {
                    recommendHours.add(categoryRecomHourSum.getRecomHourSum());
                } else {
                    for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                        recommendHours.add(getRecommendHoursForOneDay(departmentCategoryId, date,
                                theDate2SaleForecast, depCategoryRecommendHours, roundingMode));

                        recommendHours.add(getSpecialHoursForOneDay(departmentCategoryId, date.toEpochSecond(),
                                theDate2EventList, roundingMode));
                    }
                }
                BigDecimal recommendHoursTotal = recommendHours.stream().filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                categoryStatisticDTO.setRecommendHours(recommendHoursTotal);

                List<SvaScheduleHour> scheduleHours = depCategoryId2ScheduleHourList.getOrDefault(departmentCategoryId, Collections.emptyList());
                // 设置 scheduledHours
                BigDecimal scheduledHoursTotal = scheduleHours.stream().map(SvaScheduleHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal scheduledExcludePayCodeHoursTotal = scheduleHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaScheduleHour::getHours).reduce(BigDecimal::add).orElse(null);
                categoryStatisticDTO.setSchedule(new SvaDepCategoryStatisticValueDTO(scheduledHoursTotal, scheduledExcludePayCodeHoursTotal));

                List<SvaActualHour> actualHours = depCategoryId2ActualHourList.getOrDefault(departmentCategoryId, Collections.emptyList());
                // 设置 actualHours
                BigDecimal actualHoursTotal = actualHours.stream().map(SvaActualHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal actualExcludePayCodeHoursTotal = actualHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaActualHour::getHours).reduce(BigDecimal::add).orElse(null);
                categoryStatisticDTO.setActual(new SvaDepCategoryStatisticValueDTO(actualHoursTotal, actualExcludePayCodeHoursTotal));

                // 设置 saVariances
                BigDecimal saVariances = (Objects.nonNull(scheduledHoursTotal) && Objects.nonNull(actualHoursTotal)) ?
                        scheduledHoursTotal.subtract(actualHoursTotal) : null;
                BigDecimal saExcludePayCodeVariances = (Objects.nonNull(scheduledExcludePayCodeHoursTotal) && Objects.nonNull(actualExcludePayCodeHoursTotal)) ?
                        scheduledExcludePayCodeHoursTotal.subtract(actualExcludePayCodeHoursTotal) : null;
                categoryStatisticDTO.setSaVariances(new SvaDepCategoryStatisticVariancesDTO(saVariances, saExcludePayCodeVariances));

                // 设置 arVariances
                BigDecimal arVariances = (Objects.nonNull(actualHoursTotal) && Objects.nonNull(recommendHoursTotal)) ?
                        actualHoursTotal.subtract(recommendHoursTotal) : null;
                BigDecimal arExcludePayCodeVariances = (Objects.nonNull(actualExcludePayCodeHoursTotal) && Objects.nonNull(recommendHoursTotal)) ?
                        actualExcludePayCodeHoursTotal.subtract(recommendHoursTotal) : null;
                categoryStatisticDTO.setArVariances(new SvaDepCategoryStatisticVariancesDTO(arVariances, arExcludePayCodeVariances));

                // 设置 srVariances
                BigDecimal srVariances = (Objects.nonNull(scheduledHoursTotal) && Objects.nonNull(recommendHoursTotal)) ?
                        scheduledHoursTotal.subtract(recommendHoursTotal) : null;
                BigDecimal srExcludePayCodeVariances = (Objects.nonNull(scheduledExcludePayCodeHoursTotal) && Objects.nonNull(recommendHoursTotal)) ?
                        scheduledExcludePayCodeHoursTotal.subtract(recommendHoursTotal) : null;
                categoryStatisticDTO.setSrVariances(new SvaDepCategoryStatisticVariancesDTO(srVariances, srExcludePayCodeVariances));

                // 设置 commentCount
                categoryStatisticDTO.setCommentCount(departmentCategoryId2CommentCount.getOrDefault(departmentCategoryId, 0L));

                // 设置 category 在当前选择日期范围内对应的版本
                categoryStatisticDTO.setVersions(collectVersions(departmentCategoryId, fromZonedDate, toZonedDate, depCategoryRecommendHours));

                depCategoryStatisticList.add(categoryStatisticDTO);
            }
            return depCategoryStatisticList;
        }

        /**
         * 汇总 category 在当前选择日期范围内对应的版本
         *
         * @param categoryId                category id
         * @param fromDate                  开始日期
         * @param toDate                    结束日期
         * @param depCategoryRecommendHours 所有的推荐工时
         * @return 版本
         * @author yyt
         * @date 2025/4/1
         **/
        private Set<Long> collectVersions(Integer categoryId, ZonedDateTime fromDate, ZonedDateTime toDate, List<DepCategoryRecommendHour> depCategoryRecommendHours) {
            if (CollectionUtils.isEmpty(depCategoryRecommendHours)) {
                new HashSet<>();
            }

            Set<Long> versions = Sets.newHashSet();
            for (ZonedDateTime date = fromDate; !date.isAfter(toDate); date = date.plusDays(1)) {
                // 获取适用指定日期的 DepCategoryRecommendHour
                ZonedDateTime finalDate = date;
                Optional<DepCategoryRecommendHour> depCategoryRecommendHourOptional = depCategoryRecommendHours.stream()
                        .filter(item -> item.getStartingDate() <= finalDate.toEpochSecond())
                        .max(Comparator.comparing(DepCategoryRecommendHour::getStartingDate));
                if (depCategoryRecommendHourOptional.isEmpty()) {
                    continue;
                }

                DepCategoryRecommendHour depCategoryRecommendHour = depCategoryRecommendHourOptional.get();
                Map<String, Map<String, Object>> categoryId2RecommendHour = JsonUtils.convertStringByTypeReference(objectMapper,
                        depCategoryRecommendHour.getCategoryRecomHours(), new TypeReference<>() {
                        });

                Map<String, Object> dayOfWeek2RecommendHourObject = categoryId2RecommendHour.get(categoryId + StringUtils.EMPTY);
                if (Objects.isNull(dayOfWeek2RecommendHourObject)) {
                    continue;
                }

                versions.add(depCategoryRecommendHour.getStartingDate());
            }
            return versions;
        }

        /**
         * 获取指定分类在指定日期的推荐工时
         *
         * @param categoryId                部门分类 id
         * @param date                      指定日期
         * @param theDate2SaleForecast      theDate 2 SaleForecast 数据
         * @param depCategoryRecommendHours 推荐工时数据
         * @param roundingMode              计数保留规则
         * @return 推荐工时
         * @author zc
         * @date 2023/6/27
         */
        private BigDecimal getRecommendHoursForOneDay(int categoryId, ZonedDateTime date, Map<Long, SaleForecast> theDate2SaleForecast,
                                                      List<DepCategoryRecommendHour> depCategoryRecommendHours, RoundingMode roundingMode) {
            if (CollectionUtils.isEmpty(depCategoryRecommendHours)) {
                return null;
            }

            // 获取适用指定日期的 DepCategoryRecommendHour
            Optional<DepCategoryRecommendHour> depCategoryRecommendHourOptional = depCategoryRecommendHours.stream()
                    .filter(item -> item.getStartingDate() <= date.toEpochSecond())
                    .max(Comparator.comparing(DepCategoryRecommendHour::getStartingDate));
            if (depCategoryRecommendHourOptional.isEmpty()) {
                return null;
            }

            DepCategoryRecommendHour depCategoryRecommendHour = depCategoryRecommendHourOptional.get();
            Map<String, Map<String, Object>> categoryId2RecommendHour = JsonUtils.convertStringByTypeReference(objectMapper,
                    depCategoryRecommendHour.getCategoryRecomHours(), new TypeReference<>() {
                    });

            Map<String, Object> dayOfWeek2RecommendHourObject = categoryId2RecommendHour.get(categoryId + StringUtils.EMPTY);
            if (Objects.isNull(dayOfWeek2RecommendHourObject)) {
                return null;
            }

            Object recommendHourObject = dayOfWeek2RecommendHourObject.get(date.getDayOfWeek().getValue() + StringUtils.EMPTY);
            BigDecimal hours;
            if (Objects.isNull(recommendHourObject)) {
                hours = null;
            } else if (recommendHourObject instanceof String) {
                hours = null;
            } else if (recommendHourObject instanceof Integer) {
                hours = new BigDecimal(recommendHourObject.toString());
            } else if (recommendHourObject instanceof Double) {
                hours = new BigDecimal(recommendHourObject.toString());
            } else {
                // 获取可配置类型 RecommendHours 的考勤时数
                String recommendHourObjectStr = JsonUtils.convertObjToString(objectMapper, recommendHourObject);
                RecommendHoursConfigurableDTO recommendHoursConfigurableDTO = JsonUtils.convertStringToObject(objectMapper,
                        recommendHourObjectStr, RecommendHoursConfigurableDTO.class);
                hours = getRecommendHoursConfigurableHour(date.minusDays(1).toEpochSecond(), theDate2SaleForecast, recommendHoursConfigurableDTO);
            }

            if (Objects.nonNull(hours)) {
                hours = hours.setScale(BaseConstant.DATA_PRECISION, roundingMode);
            }
            return hours;
        }

        /**
         * 获取指定分类在指定日期特殊活动的工时
         *
         * @param categoryId        部门分类 id
         * @param date              指定日期
         * @param theDate2EventList 特殊活动工时
         * @param roundingMode      计数保留规则
         * @return java.math.BigDecimal
         * @author zc
         * @date 2023/6/28
         */
        private BigDecimal getSpecialHoursForOneDay(int categoryId, long date, Map<Long, List<SpecialEvent>> theDate2EventList, RoundingMode roundingMode) {
            List<SpecialEvent> specialEvents = theDate2EventList.get(date);
            if (CollectionUtils.isEmpty(specialEvents)) {
                return null;
            }

            BigDecimal totalHours = null;
            for (SpecialEvent specialEvent : specialEvents) {
                Map<String, BigDecimal> categoryId2Hours = JsonUtils.convertStringByTypeReference(objectMapper,
                        specialEvent.getCategoryRecomHours(), new TypeReference<>() {
                        });
                BigDecimal hours = categoryId2Hours.get(categoryId + StringUtils.EMPTY);
                if (Objects.nonNull(hours)) {
                    if (Objects.isNull(totalHours)) {
                        totalHours = hours;
                    } else {
                        totalHours = totalHours.add(hours);
                    }
                }
            }

            if (Objects.nonNull(totalHours)) {
                totalHours = totalHours.setScale(BaseConstant.DATA_PRECISION, roundingMode);
            }
            return totalHours;
        }

        /**
         * 获取可配置类型 RecommendHours 的考勤时数
         *
         * @param date                          指定日期
         * @param theDate2SaleForecast          theDate 2 SaleForecast 数据
         * @param recommendHoursConfigurableDTO 可配置类型 RecommendHours 数据
         * @return 推荐工时
         * @author zc
         * @date 2023/6/27
         */
        private BigDecimal getRecommendHoursConfigurableHour(long date, Map<Long, SaleForecast> theDate2SaleForecast,
                                                             RecommendHoursConfigurableDTO recommendHoursConfigurableDTO) {
            SaleForecast saleForecast = theDate2SaleForecast.get(date);
            if (Objects.isNull(saleForecast)) {
                return null;
            }
            Integer estiRoomSales = saleForecast.getEstiRoomSales();
            if (Objects.isNull(estiRoomSales)) {
                return null;
            }
            BigDecimal hour = recommendHoursConfigurableDTO.getHours();
            if (Objects.isNull(hour)) {
                return null;
            }

            // 前一天的预计房间售量 * work time(小时)
            return BigDecimal.valueOf(estiRoomSales).multiply(recommendHoursConfigurableDTO.getHours());
        }

        /**
         * 根据部门分类获取指定日期范围内每日的时数统计数据
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @return DepCategoryDailyDTO
         * @author zc
         * @date 2023/6/28
         */
        @Override
        public SvaDepCategoryDailyDTO getHoursStatisticsDaily(PropertyDTO propertyDTO, SvaHoursStatisticsDailyQuery query) {
            // 获取隐藏的 Position Id
            List<String> noShowPositionIds = employeeService.listNoShowPositionIds(propertyDTO.getId());

            // 格式化时间戳日期为 yyyyMMdd
            long startDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(query.getFromDate(),
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));
            long endDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(query.getToDate(),
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));

            List<String> targetDepCodes = Lists.newArrayList(query.getDepartmentCode());
            // 获取 Actual Hour
            List<SvaScheduleHour> scheduleHourList = scheduleHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, noShowPositionIds, null,
                    null, targetDepCodes, null);
            Map<Long, List<SvaScheduleHour>> payDate2ScheduleHourList = scheduleHourList.stream()
                    .collect(Collectors.groupingBy(SvaScheduleHour::getPayDate));

            // 获取 Schedule Hour
            List<SvaActualHour> actualHourList = actualHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, noShowPositionIds, null,
                    null, targetDepCodes, null);
            Map<Long, List<SvaActualHour>> payDate2ActualHourList = actualHourList.stream()
                    .collect(Collectors.groupingBy(SvaActualHour::getPayDate));

            ZonedDateTime fromZonedDate = DateTimeUtils.toZonedDateTime(query.getFromDate(), propertyDTO.getTimezone());
            ZonedDateTime toZonedDate = DateTimeUtils.toZonedDateTime(query.getToDate(), propertyDTO.getTimezone());
            // 获取资产国际化配置数据
            RoundingMode roundingMode = InternationalizationUtils.getRoundingMode(propertyDTO.getInternationalization().getNumber().getCalculationRule());

            // 获取排除 Pay Code 配置
            List<String> excludePayCodeList = payCodeExcludeConfigService.listExcludePayCode(propertyDTO.getId());

            List<SvaDepCategoryStatisticValueDTO> scheduleHoursList = Lists.newArrayList();
            List<SvaDepCategoryStatisticValueDTO> actualHoursList = Lists.newArrayList();
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                Long payDate = DateTimeUtils.parseToLong(date, BaseConstant.DATE_FORMAT_YYYYMMDD);
                List<SvaScheduleHour> scheduleHours = payDate2ScheduleHourList.getOrDefault(payDate, Collections.emptyList());
                // 设置 Schedule Hours 数据
                BigDecimal scheduledHoursTotal = scheduleHours.stream().map(SvaScheduleHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                BigDecimal scheduledExcludePayCodeHoursTotal = scheduleHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaScheduleHour::getHours).reduce(BigDecimal::add).orElse(null);
                scheduleHoursList.add(new SvaDepCategoryStatisticValueDTO(scheduledHoursTotal, scheduledExcludePayCodeHoursTotal));

                List<SvaActualHour> actualHours = payDate2ActualHourList.getOrDefault(payDate, Collections.emptyList());
                // 设置 Actual Hours 数据
                BigDecimal actualHoursTotal = actualHours.stream().map(SvaActualHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                BigDecimal actualExcludePayCodeHoursTotal = actualHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaActualHour::getHours).reduce(BigDecimal::add).orElse(null);
                actualHoursList.add(new SvaDepCategoryStatisticValueDTO(actualHoursTotal, actualExcludePayCodeHoursTotal));
            }
            return SvaDepCategoryDailyDTO.builder()
                    .scheduleHoursList(scheduleHoursList)
                    .actualHoursList(actualHoursList)
                    .build();
        }

        /**
         * 获取部门一周的时数统计数据
         *
         * @param propertyDTO 资产
         * @param fromDate    开始日期，格式yyyyMMdd
         * @param toDate      结束日期，格式yyyyMMdd
         * @return SvaDepWeeklyStatisticDTO 集合
         * @author lichen
         * @date 2026/3/5
         */
        @Override
        public SvaWeeklyStatisticDTO weeklyStatistics(PropertyDTO propertyDTO, String fromDate, String toDate) {
            // 获取最新的Recommend Hour版本
            RecommendHoursDTO latest = depCategoryRecommendHourService.getLatest(propertyDTO.getId());
            List<DepCodeToCategory> depCodeToCategorys = Collections.emptyList();
            if (Objects.nonNull(latest)) {
                // 获取版本
                int version = Integer.parseInt(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(latest.getStartingDate(), propertyDTO.getTimezone()),
                        BaseConstant.DATE_FORMAT_YYYYMMDD));
                // 查询指定版本的ADP部门
                depCodeToCategorys = depCodeToCategoryService.listByVersion(propertyDTO.getId(), version);
            }

            LocalDate fromLocalDate = DateTimeUtils.strToLocalDate(fromDate, BaseConstant.DATE_FORMAT_YYYYMMDD);
            LocalDate toLocalDate = DateTimeUtils.strToLocalDate(toDate, BaseConstant.DATE_FORMAT_YYYYMMDD);
            int fromYear = fromLocalDate.getYear();
            int toYear = toLocalDate.getYear();

            // 查询年份 productivity ratio settings
            List<SvaProductivityRatioSettings> svaProductivityRatioSettings = productivityRatioSettingsService.listByYears(propertyDTO.getId(), Sets.newHashSet(fromYear, toYear));
            Map<Integer, List<SvaProductivityRatioSettings>> year2ProductivityRatioSettings = svaProductivityRatioSettings.stream()
                    .collect(Collectors.groupingBy(SvaProductivityRatioSettings::getYear));

            // 去重合并部门
            List<DepartmentDTO> departmentDTOS = depCodeToCategoryService.distinctMergeDepartment(depCodeToCategorys, svaProductivityRatioSettings);
            if (CollectionUtils.isEmpty(departmentDTOS)) return new SvaWeeklyStatisticDTO(Collections.emptyList(), true);
            List<String> departmentCodes = departmentDTOS.stream().map(DepartmentDTO::getDepartmentCode).collect(Collectors.toList());

            // 获取隐藏的 Position Id
            List<String> noShowPositionIds = employeeService.listNoShowPositionIds(propertyDTO.getId());
            // 获取 Actual Hour
            List<SvaScheduleHour> scheduleHourList = scheduleHourService.listEffective(propertyDTO.getId(),
                    Long.parseLong(fromDate), Long.parseLong(toDate), noShowPositionIds, null,
                    null, departmentCodes, null);
            Map<String, List<SvaScheduleHour>> depCode2ScheduleHourList = scheduleHourList.stream()
                    .collect(Collectors.groupingBy(SvaScheduleHour::getDepartmentCode));
            // 获取 Schedule Hour
            List<SvaActualHour> actualHourList = actualHourService.listEffective(propertyDTO.getId(),
                    Long.parseLong(fromDate), Long.parseLong(toDate), noShowPositionIds, null,
                    null, departmentCodes, null);
            Map<String, List<SvaActualHour>> depCode2ActualHourList = actualHourList.stream()
                    .collect(Collectors.groupingBy(SvaActualHour::getDepartmentCode));
            // 获取 sale forecast 数据
            LocalDate minusDaysByFrom = fromLocalDate.minusDays(1);
            List<SaleForecast> saleForecasts = saleForecastService.listByDateRange(propertyDTO.getId(),
                    DateTimeUtils.localDateToLong(minusDaysByFrom, propertyDTO.getTimezone()),
                    DateTimeUtils.localDateToLong(toLocalDate, propertyDTO.getTimezone()));
            // 获取排除 Pay Code 配置
            List<String> excludePayCodeList = payCodeExcludeConfigService.listExcludePayCode(propertyDTO.getId());

            // 获取指定日期范围内特殊活动设置的工时
            Map<String, List<DepDateSpecialEventTargetHourDTO>> depCode2DateSpecialEventTargetHourDTOS = specialEventService.listDepDateRange(propertyDTO.getId(),
                            DateTimeUtils.localDateToLong(fromLocalDate, propertyDTO.getTimezone()),
                            DateTimeUtils.localDateToLong(toLocalDate, propertyDTO.getTimezone()))
                    .stream().collect(Collectors.groupingBy(DepDateSpecialEventTargetHourDTO::getDepartmentCode));

            // 生成一周内部门考勤时数统计数据
            List<SvaDepWeeklyStatisticDTO> weeklyStatisticDTOS = Lists.newArrayList();
            for (DepartmentDTO departmentDTO : departmentDTOS) {
                SvaDepWeeklyStatisticDTO weeklyStatisticDTO = new SvaDepWeeklyStatisticDTO();
                String departmentCode = departmentDTO.getDepartmentCode();
                weeklyStatisticDTO.setDepartmentCode(departmentCode);
                weeklyStatisticDTO.setDepartmentDesc(departmentDTO.getDepartmentDesc());
                weeklyStatisticDTO.setNote(departmentDTO.getNote());

                // 计算weeklyTargetHour
                TargetHoursDTO targetHoursDTO = calculateWeeklyTargetHours(propertyDTO, fromLocalDate, toLocalDate,
                        departmentCode, saleForecasts, year2ProductivityRatioSettings);
                BigDecimal weeklyTargetHours = targetHoursDTO.getTargetHours();
                weeklyStatisticDTO.setTargetHours(weeklyTargetHours);
                weeklyStatisticDTO.setFailureReason(targetHoursDTO.getFailureReason());

                List<SvaScheduleHour> scheduleHours = depCode2ScheduleHourList.getOrDefault(departmentCode, Collections.emptyList());
                // 设置 scheduledHours
                BigDecimal scheduledHoursTotal = scheduleHours.stream().map(SvaScheduleHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal scheduledExcludePayCodeHoursTotal = scheduleHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaScheduleHour::getHours).reduce(BigDecimal::add).orElse(null);
                weeklyStatisticDTO.setSchedule(new SvaDepCategoryStatisticValueDTO(scheduledHoursTotal, scheduledExcludePayCodeHoursTotal));

                List<SvaActualHour> actualHours = depCode2ActualHourList.getOrDefault(departmentCode, Collections.emptyList());
                // 设置 actualHours
                BigDecimal actualHoursTotal = actualHours.stream().map(SvaActualHour::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal actualExcludePayCodeHoursTotal = actualHours.stream().filter(item ->
                                excludePayCodeList.stream().noneMatch(s -> s.equalsIgnoreCase(item.getPayCode())) && Objects.nonNull(item.getHours()))
                        .map(SvaActualHour::getHours).reduce(BigDecimal::add).orElse(null);
                weeklyStatisticDTO.setActual(new SvaDepCategoryStatisticValueDTO(actualHoursTotal, actualExcludePayCodeHoursTotal));

                // 设置 saVariances
                BigDecimal saVariances = (Objects.nonNull(scheduledHoursTotal) && Objects.nonNull(actualHoursTotal)) ?
                        scheduledHoursTotal.subtract(actualHoursTotal) : null;
                BigDecimal saExcludePayCodeVariances = (Objects.nonNull(scheduledExcludePayCodeHoursTotal) && Objects.nonNull(actualExcludePayCodeHoursTotal)) ?
                        scheduledExcludePayCodeHoursTotal.subtract(actualExcludePayCodeHoursTotal) : null;
                weeklyStatisticDTO.setSaVariances(new SvaDepCategoryStatisticVariancesDTO(saVariances, saExcludePayCodeVariances));

                // 设置 arVariances
                BigDecimal arVariances = (Objects.nonNull(actualHoursTotal) && Objects.nonNull(weeklyTargetHours)) ?
                        actualHoursTotal.subtract(weeklyTargetHours) : null;
                BigDecimal arExcludePayCodeVariances = (Objects.nonNull(actualExcludePayCodeHoursTotal) && Objects.nonNull(weeklyTargetHours)) ?
                        actualExcludePayCodeHoursTotal.subtract(weeklyTargetHours) : null;
                weeklyStatisticDTO.setArVariances(new SvaDepCategoryStatisticVariancesDTO(arVariances, arExcludePayCodeVariances));

                // 设置 srVariances
                BigDecimal srVariances = (Objects.nonNull(scheduledHoursTotal) && Objects.nonNull(weeklyTargetHours)) ?
                        scheduledHoursTotal.subtract(weeklyTargetHours) : null;
                BigDecimal srExcludePayCodeVariances = (Objects.nonNull(scheduledExcludePayCodeHoursTotal) && Objects.nonNull(weeklyTargetHours)) ?
                        scheduledExcludePayCodeHoursTotal.subtract(weeklyTargetHours) : null;
                weeklyStatisticDTO.setSrVariances(new SvaDepCategoryStatisticVariancesDTO(srVariances, srExcludePayCodeVariances));

                // 设置特殊事件
                weeklyStatisticDTO.setSpecialEvents(depCode2DateSpecialEventTargetHourDTOS.getOrDefault(departmentCode, Collections.emptyList()));
                weeklyStatisticDTOS.add(weeklyStatisticDTO);
            }

            // 判断SalesForecast数据是否完整
            List<Integer> failureReasonList = weeklyStatisticDTOS.stream()
                    .map(SvaDepWeeklyStatisticDTO::getFailureReason)
                    .filter(Objects::nonNull)
                    .toList();
            boolean salesForecastIsComplete = !failureReasonList.contains(TargetHoursDTO.FAILURE_REASON_SALES_FORECAST_INCOMPLETE);

            // 汇总Total
            SvaDepWeeklyStatisticDTO totalWeeklyStatisticDTO = summaryWeeklyTotal(weeklyStatisticDTOS);
            weeklyStatisticDTOS.add(totalWeeklyStatisticDTO);

            return new SvaWeeklyStatisticDTO(weeklyStatisticDTOS, salesForecastIsComplete);
        }

        /**
         * 汇总周内部门考勤时数统计数据
         *
         * @param weeklyStatisticDTOS 周内部门考勤时数统计数据
         * @return cloud.wconnect.workperformance.pojo.dto.sva.hoursstatistics.SvaDepWeeklyStatisticDTO
         * @author lichen
         * @date 2026/3/6
         */
        private SvaDepWeeklyStatisticDTO summaryWeeklyTotal(List<SvaDepWeeklyStatisticDTO> weeklyStatisticDTOS) {
            BigDecimal totalScheduleHours = null;
            BigDecimal totalActualHours = null;
            BigDecimal totalScheduleExcludePayCodeHours = null;
            BigDecimal totalActualExcludePayCodeHours = null;
            BigDecimal totalTargetHours = null;
            for (SvaDepWeeklyStatisticDTO weeklyStatisticDTO : weeklyStatisticDTOS) {
                // 计算targetHour
                BigDecimal targetHours = weeklyStatisticDTO.getTargetHours();
                if (Objects.nonNull(targetHours)) {
                    totalTargetHours = Objects.isNull(totalTargetHours) ? targetHours : totalTargetHours.add(targetHours);
                }

                SvaDepCategoryStatisticValueDTO schedule = weeklyStatisticDTO.getSchedule();
                // 计算scheduleHours
                BigDecimal scheduleHours = schedule.getHours();
                if (Objects.nonNull(scheduleHours)) {
                    totalScheduleHours = Objects.isNull(totalScheduleHours) ? scheduleHours : totalScheduleHours.add(scheduleHours);
                }
                // 计算scheduleExcludePayCodeHours
                BigDecimal scheduleExcludePayCodeHours = schedule.getExcludePayCodeHours();
                if (Objects.nonNull(scheduleExcludePayCodeHours)) {
                    totalScheduleExcludePayCodeHours = Objects.isNull(totalScheduleExcludePayCodeHours) ?
                            scheduleExcludePayCodeHours :
                            totalScheduleExcludePayCodeHours.add(scheduleExcludePayCodeHours);
                }

                SvaDepCategoryStatisticValueDTO actual = weeklyStatisticDTO.getActual();
                // 计算actualHours
                BigDecimal actualHours = actual.getHours();
                if (Objects.nonNull(actualHours)) {
                    totalActualHours = Objects.isNull(totalActualHours) ? actualHours : totalActualHours.add(actualHours);
                }
                // 计算actualExcludePayCodeHours
                BigDecimal actualExcludePayCodeHours = actual.getExcludePayCodeHours();
                if (Objects.nonNull(actualExcludePayCodeHours)) {
                    totalActualExcludePayCodeHours = Objects.isNull(totalActualExcludePayCodeHours) ?
                            actualExcludePayCodeHours :
                            totalActualExcludePayCodeHours.add(actualExcludePayCodeHours);
                }
            }

            SvaDepWeeklyStatisticDTO weeklyStatisticDTO = new SvaDepWeeklyStatisticDTO();
            weeklyStatisticDTO.setDepartmentCode("Total");
            weeklyStatisticDTO.setDepartmentDesc("Total");
            weeklyStatisticDTO.setNote("");
            weeklyStatisticDTO.setSchedule(new SvaDepCategoryStatisticValueDTO(totalScheduleHours, totalScheduleExcludePayCodeHours));
            weeklyStatisticDTO.setActual(new SvaDepCategoryStatisticValueDTO(totalActualHours, totalActualExcludePayCodeHours));
            weeklyStatisticDTO.setTargetHours(totalTargetHours);

            // 设置 saVariances
            BigDecimal saVariances = (Objects.isNull(totalScheduleHours) || Objects.isNull(totalActualHours)) ?
                    null : totalScheduleHours.subtract(totalActualHours);
            BigDecimal saExcludePayCodeVariances = (Objects.isNull(totalScheduleExcludePayCodeHours) || Objects.isNull(totalActualExcludePayCodeHours)) ?
                    null : totalScheduleExcludePayCodeHours.subtract(totalActualExcludePayCodeHours);
            weeklyStatisticDTO.setSaVariances(new SvaDepCategoryStatisticVariancesDTO(saVariances, saExcludePayCodeVariances));

            // 设置 arVariances
            BigDecimal arVariances = (Objects.isNull(totalActualHours) || Objects.isNull(totalTargetHours))
                    ? null : totalActualHours.subtract(totalTargetHours);
            BigDecimal arExcludePayCodeVariances = (Objects.isNull(totalActualExcludePayCodeHours) || Objects.isNull(totalTargetHours))
                    ? null : totalActualExcludePayCodeHours.subtract(totalTargetHours);
            weeklyStatisticDTO.setArVariances(new SvaDepCategoryStatisticVariancesDTO(arVariances, arExcludePayCodeVariances));

            // 设置 srVariances
            BigDecimal srVariances = (Objects.isNull(totalScheduleHours) || Objects.isNull(totalTargetHours))
                    ? null : totalScheduleHours.subtract(totalTargetHours);
            BigDecimal srExcludePayCodeVariances = (Objects.isNull(totalScheduleExcludePayCodeHours) || Objects.isNull(totalTargetHours))
                    ? null : totalScheduleExcludePayCodeHours.subtract(totalTargetHours);
            weeklyStatisticDTO.setSrVariances(new SvaDepCategoryStatisticVariancesDTO(srVariances, srExcludePayCodeVariances));

            return weeklyStatisticDTO;
        }

        /**
         * 计算 Weekly Target Hour
         *
         * @param propertyDTO                    资产 DTO
         * @param fromLocalDate                  开始日期
         * @param toLocalDate                    结束日期
         * @param departmentCode                 部门编码
         * @param saleForecasts                  Sales Forecast 数据集合
         * @param year2ProductivityRatioSettings 年份到 Productivity Ratio Settings 的映射
         * @return cloud.wconnect.workperformance.pojo.dto.sva.hoursstatistics.TargetHoursDTO
         * @author lichen
         * @date 2026/3/6
         */
        private TargetHoursDTO calculateWeeklyTargetHours(PropertyDTO propertyDTO, LocalDate fromLocalDate, LocalDate toLocalDate,
                                                          String departmentCode, List<SaleForecast> saleForecasts,
                                                          Map<Integer, List<SvaProductivityRatioSettings>> year2ProductivityRatioSettings) {
            if (year2ProductivityRatioSettings.isEmpty()) {
                return new TargetHoursDTO(null, TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
            }
            if (CollectionUtils.isEmpty(saleForecasts)) {
                return new TargetHoursDTO(null, TargetHoursDTO.FAILURE_REASON_SALES_FORECAST_INCOMPLETE);
            }

            // 获取计数保留规则
            RoundingMode roundingMode = InternationalizationUtils.getRoundingMode(propertyDTO.getInternationalization().getNumber().getCalculationRule());

            // 将 SaleForecast 转换为 Map<LocalDate, SaleForecast> 便于查找
            Map<LocalDate, SaleForecast> date2SaleForecast = saleForecasts.stream()
                    .collect(Collectors.toMap(
                            sf -> DateTimeUtils.longToLocalDate(sf.getTheDate(), propertyDTO.getTimezone()),
                            Function.identity()
                    ));

            // 获取日期范围内涉及的所有年份
            Set<Integer> involvedYears = new HashSet<>();
            for (LocalDate date = fromLocalDate; !date.isAfter(toLocalDate); date = date.plusDays(1)) {
                involvedYears.add(date.getYear());
            }

            ArrayList<Integer> failureReasons = Lists.newArrayListWithExpectedSize(involvedYears.size());
            // 按年份分组计算 Weekly Target Hour
            ArrayList<BigDecimal> weeklyTargetHoursList = Lists.newArrayListWithCapacity(involvedYears.size());
            int totalDays = (int) (toLocalDate.toEpochDay() - fromLocalDate.toEpochDay() + 1);
            for (Integer year : involvedYears) {
                List<SvaProductivityRatioSettings> settingsList = year2ProductivityRatioSettings.get(year);
                if (settingsList == null || settingsList.isEmpty()) {
                    failureReasons.add(TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
                    continue;
                }

                // 找到该部门在该年份的 Productivity Ratio Settings
                SvaProductivityRatioSettings targetSetting = settingsList.stream()
                        .filter(setting -> setting.getDepartmentCode().equals(departmentCode))
                        .findFirst()
                        .orElse(null);
                if (targetSetting == null) {
                    failureReasons.add(TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
                    continue;
                }

                // 检查是否设置了Level Occ
                if (Objects.isNull(targetSetting.getLevelAOcc()) || Objects.isNull(targetSetting.getLevelBOcc())
                        || Objects.isNull(targetSetting.getLevelCOcc()) || Objects.isNull(targetSetting.getLevelDOcc())) {
                    failureReasons.add(TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
                    continue;
                }
                // 检查是否设置了固定工时
                if (SvaSettingCalculateTypeEnum.FIXED.getValue().equals(targetSetting.getCalculationType())) {
                    if (Objects.isNull(targetSetting.getLevelAFixedHours()) || Objects.isNull(targetSetting.getLevelBFixedHours())
                            || Objects.isNull(targetSetting.getLevelCFixedHours()) || Objects.isNull(targetSetting.getLevelDFixedHours())) {
                        failureReasons.add(TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
                        continue;
                    }
                } else {
                    // 检查是否设置了比例
                    if (Objects.isNull(targetSetting.getLevelARatio()) || Objects.isNull(targetSetting.getLevelBRatio())
                            || Objects.isNull(targetSetting.getLevelCRatio()) || Objects.isNull(targetSetting.getLevelDRatio())) {
                        failureReasons.add(TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
                        continue;
                    }
                }

                // 筛选出属于该年份的日期
                List<LocalDate> datesInYear = new ArrayList<>();
                for (LocalDate date = fromLocalDate; !date.isAfter(toLocalDate); date = date.plusDays(1)) {
                    if (date.getYear() == year) {
                        datesInYear.add(date);
                    }
                }

                // 计算该年份的权重（天数 / 总天数）
                BigDecimal yearWeight = BigDecimal.valueOf(datesInYear.size())
                        .divide(BigDecimal.valueOf(totalDays), BaseConstant.DATA_PRECISION, roundingMode);

                // 计算该年份日期的 Sales Forecast Sum
                BigDecimal salesForecastSum = calculateSalesForecastSum(datesInYear, date2SaleForecast,
                        targetSetting.getDriverType(), targetSetting.getVolumeReference());
                if (salesForecastSum == null) {
                    failureReasons.add(TargetHoursDTO.FAILURE_REASON_SALES_FORECAST_INCOMPLETE);
                    continue;
                }

                // 根据 Sales Forecast Sum 确定 Level
                String level = determineLevel(salesForecastSum, targetSetting, yearWeight, roundingMode);
                if (StringUtils.isEmpty(level)) {
                    failureReasons.add(TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
                    continue;
                }

                // 根据计算类型计算 Target Hours
                BigDecimal yearTargetHours = calculateTargetHoursByType(targetSetting, level, salesForecastSum, yearWeight, roundingMode);
                weeklyTargetHoursList.add(yearTargetHours);
            }

            if (weeklyTargetHoursList.size() == involvedYears.size()) {
                BigDecimal weeklyTargetHour = weeklyTargetHoursList.stream()
                        .reduce(BigDecimal.ZERO, BigDecimal::add)
                        .setScale(BaseConstant.DATA_PRECISION, roundingMode);
                return new TargetHoursDTO(weeklyTargetHour, null);
            } else {
                if (failureReasons.contains(TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE)) {
                    return new TargetHoursDTO(null, TargetHoursDTO.FAILURE_REASON_SETTING_INCOMPLETE);
                } else {
                    return new TargetHoursDTO(null, TargetHoursDTO.FAILURE_REASON_SALES_FORECAST_INCOMPLETE);
                }
            }
        }

        /**
         * 计算 Sales Forecast Sum
         *
         * @param dates             日期列表
         * @param date2SaleForecast 日期到 SaleForecast 的映射
         * @param driverType        驱动类型
         * @param volumeReference   数据日期范围（CURRENT 或 PREVIOUS_DAY）
         * @return java.math.BigDecimal Sales Forecast Sum
         * @author lichen
         * @date 2026/3/6
         */
        private BigDecimal calculateSalesForecastSum(List<LocalDate> dates, Map<LocalDate, SaleForecast> date2SaleForecast,
                                                     String driverType, String volumeReference) {
            // 获取 Driver Type 对应的取值函数
            SvaSettingDriverTypeEnum driverEnum = Arrays.stream(SvaSettingDriverTypeEnum.values())
                    .filter(e -> e.getValue().equals(driverType)).findFirst().orElse(null);
            if (driverEnum == null) {
                return null;
            }

            BigDecimal sum = BigDecimal.ZERO;
            int emptySize = 0;
            for (LocalDate targetDate : dates) {
                // 根据 VOLUME_REFERENCE 确定使用哪一天的数据
                LocalDate dataDate = targetDate;
                if (SvaSettingVolumeReferenceEnum.PREVIOUS_DAY.getValue().equals(volumeReference)) {
                    dataDate = targetDate.minusDays(1);
                }

                SaleForecast saleForecast = date2SaleForecast.get(dataDate);
                if (saleForecast == null) {
                    emptySize++;
                    continue;
                }

                // 根据 DRIVER_TYPE 获取对应的字段值
                Integer value = driverEnum.getColumn().apply(saleForecast);
                if (value == null) {
                    emptySize++;
                    continue;
                }
                sum = sum.add(BigDecimal.valueOf(value));
            }

            // 如果所有数据为空，则返回 null
            if (emptySize > 0) {
                return null;
            }

            return sum;
        }

        /**
         * 根据 Sales Forecast Sum 确定 Level
         *
         * @param salesForecastSum Sales Forecast Sum
         * @param setting          Productivity Ratio Settings
         * @param yearWeight       权重
         * @param roundingMode     舍入方式
         * @return java.lang.String Level (A/B/C/D)
         * @author lichen
         * @date 2026/3/6
         */
        private String determineLevel(BigDecimal salesForecastSum, SvaProductivityRatioSettings setting,
                                      BigDecimal yearWeight, RoundingMode roundingMode) {
            // 构建 Level 和 Occ 值的映射（按 Occ 降序排列）
            List<Map.Entry<String, BigDecimal>> levelOccList = new ArrayList<>();
            levelOccList.add(Map.entry(SvaProductivityRatioSettings.LEVEL_A,
                    BigDecimal.valueOf(setting.getLevelAOcc()).multiply(yearWeight).setScale(BaseConstant.DATA_PRECISION, roundingMode)));
            levelOccList.add(Map.entry(SvaProductivityRatioSettings.LEVEL_B,
                    BigDecimal.valueOf(setting.getLevelBOcc()).multiply(yearWeight).setScale(BaseConstant.DATA_PRECISION, roundingMode)));
            levelOccList.add(Map.entry(SvaProductivityRatioSettings.LEVEL_C,
                    BigDecimal.valueOf(setting.getLevelCOcc()).multiply(yearWeight).setScale(BaseConstant.DATA_PRECISION, roundingMode)));
            levelOccList.add(Map.entry(SvaProductivityRatioSettings.LEVEL_D,
                    BigDecimal.valueOf(setting.getLevelDOcc()).multiply(yearWeight).setScale(BaseConstant.DATA_PRECISION, roundingMode)));

            // 按 Occ 降序排序
            levelOccList.sort(Map.Entry.<String, BigDecimal>comparingByValue().reversed());

            // 高于最高等级 → 使用 Level A
            if (salesForecastSum.compareTo(levelOccList.get(0).getValue()) >= 0) {
                return levelOccList.get(0).getKey();
            }

            // 低于最低等级 → 使用 Level D
            if (salesForecastSum.compareTo(levelOccList.get(levelOccList.size() - 1).getValue()) < 0) {
                return levelOccList.get(levelOccList.size() - 1).getKey();
            }

            // 介于两个等级之间 → 使用较低等级
            for (int i = 0; i < levelOccList.size() - 1; i++) {
                BigDecimal currentOcc = levelOccList.get(i).getValue();
                BigDecimal nextOcc = levelOccList.get(i + 1).getValue();

                if (salesForecastSum.compareTo(currentOcc) < 0 &&
                        salesForecastSum.compareTo(nextOcc) >= 0) {
                    return levelOccList.get(i + 1).getKey();
                }
            }

            return "";
        }

        /**
         * 根据计算类型计算 Target Hour
         *
         * @param setting          Productivity Ratio Settings
         * @param level            等级
         * @param salesForecastSum Sales Forecast Sum
         * @param yearWeight       权重
         * @param roundingMode     舍入方式
         * @return java.math.BigDecimal Target Hour
         * @author lichen
         * @date 2026/3/6
         */
        private BigDecimal calculateTargetHoursByType(SvaProductivityRatioSettings setting, String level,
                                                      BigDecimal salesForecastSum, BigDecimal yearWeight,
                                                      RoundingMode roundingMode) {
            if (SvaSettingCalculateTypeEnum.FIXED.getValue().equals(setting.getCalculationType())) {
                // 固定工时：直接返回 Level 对应的固定工时
                Integer fixedHours = SvaProductivityRatioSettings.getFixedHours(setting, level);
                // 按权重计算
                return BigDecimal.valueOf(fixedHours).multiply(yearWeight).setScale(BaseConstant.DATA_PRECISION, roundingMode);
            } else {
                // 产量驱动：Sales Forecast Sum × Productivity Ratio
                BigDecimal ratio = SvaProductivityRatioSettings.getRatio(setting, level);
                return salesForecastSum.multiply(ratio);
            }
        }

    }