# SvaActualHourServiceImpl.java

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

    import cloud.wconnect.commonlib.component.BeanMapper;
    import cloud.wconnect.commonlib.cons.BaseConstant;
    import cloud.wconnect.commonlib.cons.MoudleEventSubjectEnum;
    import cloud.wconnect.commonlib.exception.ListenerException;
    import cloud.wconnect.commonlib.pojo.dto.PropertyDTO;
    import cloud.wconnect.commonlib.util.DateTimeUtils;
    import cloud.wconnect.commonlib.util.JsonUtils;
    import cloud.wconnect.commonlib.util.TransactionUtils;
    import cloud.wconnect.sns.http.processor.Sender;
    import cloud.wconnect.workperformance.entity.sva.avhours.SvaActualHour;
    import cloud.wconnect.workperformance.entity.sva.avhours.SvaActualScheduleRecord;
    import cloud.wconnect.workperformance.mapper.sva.avhours.SvaActualHourMapper;
    import cloud.wconnect.workperformance.pojo.bo.modelevent.dephourstatistics.SvaDepHourStatisticsEventBO;
    import cloud.wconnect.workperformance.pojo.bo.sva.actualschedulehour.SvaActualHoursBO;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaActualHourService;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaActualScheduleRecordService;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.actualhour.IActualHourParseService;
    import cloud.wconnect.workperformance.service.sva.employees.ISvaEmployeesImportFilterConfigService;
    import cloud.wconnect.workperformance.service.sva.setting.IDepCodeToCategoryService;
    import com.baomidou.dynamic.datasource.annotation.DS;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import jakarta.annotation.Resource;
    import org.apache.commons.collections.CollectionUtils;
    import org.apache.commons.compress.utils.Lists;
    import org.apache.ibatis.session.ExecutorType;
    import org.apache.ibatis.session.SqlSession;
    import org.apache.ibatis.session.SqlSessionFactory;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.context.ApplicationContext;
    import org.springframework.lang.Nullable;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;

    import java.io.InputStream;
    import java.time.LocalDate;
    import java.util.Collection;
    import java.util.Collections;
    import java.util.List;
    import java.util.Map;
    import java.util.concurrent.Executor;
    import java.util.function.Function;
    import java.util.stream.Collectors;

    /**
     * <p>
     * SVA Actual Work Hour 服务实现类
     * </p>
     *
     * @author lichen
     * @since 2025-04-21
     */
    @Service
    @DS("work_performance")
    public class SvaActualHourServiceImpl extends ServiceImpl<SvaActualHourMapper, SvaActualHour> implements ISvaActualHourService {
        @Resource
        private ISvaEmployeesImportFilterConfigService excludeEmployeeConfigService;
        @Resource
        private IDepCodeToCategoryService depCodeToCategoryService;
        @Resource
        private ISvaActualScheduleRecordService actualScheduleRecordService;
        @Resource
        private BeanMapper beanMapper;
        @Resource
        private ApplicationContext applicationContext;
        @Resource
        private Executor executor;
        @Resource
        private ObjectMapper objectMapper;
        @Resource
        @Qualifier("moduleEventSender")
        private Sender moduleEventSender;
        @Resource
        private SqlSessionFactory sqlSessionFactory;

        /**
         * 解析报告
         *
         * @param propertyList         资产集合
         * @param in                   报告文件流
         * @param source               数据源
         * @param propertyId2DateRange 报告数据日期范围(yyyyMMdd)
         * @return SvaActualHoursBO 集合
         * @author zc
         * @date 2025/4/21
         */
        @Override
        public List<SvaActualHoursBO> parseReport(List<PropertyDTO> propertyList, InputStream in, String source,
                                                  Map<Integer, List<Long>> propertyId2DateRange) {
            Collection<IActualHourParseService> parserList = applicationContext.getBeansOfType(IActualHourParseService.class).values();
            for (IActualHourParseService parseService : parserList) {
                if (parseService.supports(source)) {
                    return parseService.parse(propertyList, in, propertyId2DateRange);
                }
            }
            throw new ListenerException("Not found report parser");
        }

        /**
         * 处理 Actual Hours
         *
         * @param propertyDTOList      资产集合
         * @param actualHoursBOList    实际工时
         * @param operatorId           操作人 id
         * @param propertyId2DateRange 资产 id 与 日期范围集合
         * @author zc
         * @date 2025/4/21
         */
        @Override
        @Transactional(rollbackFor = Exception.class)
        public void handleActualHours(List<PropertyDTO> propertyDTOList, List<SvaActualHoursBO> actualHoursBOList, int operatorId,
                                      Map<Integer, List<Long>> propertyId2DateRange) {
            Map<Integer, List<SvaActualHour>> propertyId2ActualHourList = formatActualHours(propertyDTOList, actualHoursBOList).stream()
                    .collect(Collectors.groupingBy(SvaActualHour::getPropertyId));
            long nowTimestamp = DateTimeUtils.nowTimestamp();
            List<SvaActualHour> newActualHourList = Lists.newArrayList();
            for (PropertyDTO propertyDTO : propertyDTOList) {
                List<Long> dateRangeList = propertyId2DateRange.getOrDefault(propertyDTO.getId(), Collections.emptyList());
                // 删除指定日期范围内的数据
                deleteActualHourByDateRange(propertyDTO.getId(), dateRangeList);

                List<SvaActualHour> actualHourList = propertyId2ActualHourList.getOrDefault(propertyDTO.getId(), Collections.emptyList());
                if (CollectionUtils.isEmpty(actualHourList)) {
                    continue;
                }

                // 过滤掉排除的员工数据
                List<SvaActualHour> effectiveActualHourList = filterByExcludeEmployeeConfig(propertyDTO.getId(), actualHourList);

                // 累积需要匹配 Department Category 的 Department Code 和 Department Desc
                Map<Long, Map<String, String>> depCode2DepDescByPayDateTimestamp = effectiveActualHourList.stream()
                        .collect(Collectors.groupingBy(item -> DateTimeUtils.dateStrToEpochSecond(item.getPayDate().toString(),
                                BaseConstant.DATE_FORMAT_YYYYMMDD, propertyDTO.getTimezone()), Collectors.toMap(SvaActualHour::getDepartmentCode,
                                SvaActualHour::getDepartmentDescription, (v1, v2) -> v2)));

                // 获取指定日期、指定 Department Code 集合在指定 Property 下对应的 Department Category Id
                Map<Long, Map<String, Integer>> depCode2CategoryIdByPayDateTimestamp =
                        depCodeToCategoryService.generateCategoryId4DepCodes(propertyDTO.getId(), depCode2DepDescByPayDateTimestamp);

                // 组装 Department Category Id
                effectiveActualHourList.forEach(item -> {
                    item.setUpdatedBy(operatorId);
                    item.setUpdatedTime(nowTimestamp);
                    long payDateTimestamp = DateTimeUtils.dateStrToEpochSecond(item.getPayDate().toString(),
                            BaseConstant.DATE_FORMAT_YYYYMMDD, propertyDTO.getTimezone());
                    int departmentCategoryId = depCode2CategoryIdByPayDateTimestamp.getOrDefault(payDateTimestamp,
                            Collections.emptyMap()).getOrDefault(item.getDepartmentCode(), BaseConstant.ZERO);
                    item.setDepCategoryId(departmentCategoryId);
                });

                newActualHourList.addAll(effectiveActualHourList);
            }

            saveBatchRewrite(newActualHourList);
            // 更新记录
            actualScheduleRecordService.updateActualHourRecord(propertyDTOList, propertyId2DateRange, newActualHourList);

            // 指定日期范围内数据发生变化时，发送 SNS 事件
            TransactionUtils.afterCommitAsyncExecute(executor, () -> sendModuleEvent(propertyDTOList, propertyId2DateRange));
        }

        /**
         * 批量新增
         *
         * @param actualHourList Actual Hour List
         * @author zc
         * @date 2025/4/25
         */
        public void saveBatchRewrite(List<SvaActualHour> actualHourList) {
            if (CollectionUtils.isEmpty(actualHourList)) {
                return;
            }

            try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
                SvaActualHourMapper mapper = sqlSession.getMapper(SvaActualHourMapper.class);
                for (int i = 0; i < actualHourList.size(); i++) {
                    SvaActualHour actualHour = actualHourList.get(i);
                    mapper.insert(actualHour);

                    // 每 50 条提交一次
                    if ((i + 1) % 50 == 0) {
                        sqlSession.flushStatements();
                    }
                }
                sqlSession.flushStatements();
            }
        }

        /**
         * 根据 PositionId 和 PayDate 查询 Actual Hours
         *
         * @param propertyId 资产 id
         * @param recordList Actual Schedule Record 集合
         * @return SvaActualHour 集合
         * @author zc
         * @date 2025/4/22
         */
        @Override
        public List<SvaActualHour> listPositionIdAndPayDateInActualHour(int propertyId, List<SvaActualScheduleRecord> recordList) {
            if (CollectionUtils.isEmpty(recordList)) {
                return Collections.emptyList();
            }
            return baseMapper.listPositionIdAndPayDateInActualHour(propertyId, recordList);
        }

        /**
         * 根据日期范围查询 Actual Hours
         *
         * @param propertyId             资产 id
         * @param startDate              开始日期
         * @param endDate                结束日期
         * @param excludeDepartmentCodes 需要排除的部门编码集合（可空）
         * @return SvaActualHour 集合
         * @author zc
         * @date 2025/4/22
         */
        @Override
        public List<SvaActualHour> listByDate(int propertyId, long startDate, long endDate, @Nullable List<String> excludeDepartmentCodes) {
            return list(new LambdaQueryWrapper<SvaActualHour>()
                    .between(SvaActualHour::getPayDate, startDate, endDate)
                    .notIn(CollectionUtils.isNotEmpty(excludeDepartmentCodes),
                            SvaActualHour::getDepartmentCode, excludeDepartmentCodes)
                    .eq(SvaActualHour::getPropertyId, propertyId));
        }

        /**
         * 获取有效的 Actual Hours
         *
         * @param propertyId             资产 id
         * @param startDate              开始日期(yyyyMMdd)
         * @param endDate                结束日期(yyyyMMdd)
         * @param noShowPositionIds      隐藏的 PositionId 集合
         * @param excludeDepartmentCodes 需要排除的 DepartmentCode 集合
         * @param excludePayCodeList     需要排除的 PayCode 集合
         * @param targetDepartmentCodes  指定的 DepartmentCode 集合
         * @param targetDepCategoryIds   指定的 DepartmentCategoryIds 集合
         * @return SvaActualHour 集合
         * @author zc
         * @date 2025/4/23
         */
        @Override
        public List<SvaActualHour> listEffective(int propertyId, long startDate, long endDate, @Nullable List<String> noShowPositionIds,
                                                 @Nullable List<String> excludeDepartmentCodes, @Nullable List<String> excludePayCodeList,
                                                 @Nullable List<String> targetDepartmentCodes, @Nullable List<Integer> targetDepCategoryIds) {
            return list(new LambdaQueryWrapper<SvaActualHour>()
                    .between(SvaActualHour::getPayDate, startDate, endDate)
                    .notIn(CollectionUtils.isNotEmpty(noShowPositionIds), SvaActualHour::getPositionId, noShowPositionIds)
                    .notIn(CollectionUtils.isNotEmpty(excludeDepartmentCodes), SvaActualHour::getDepartmentCode, excludeDepartmentCodes)
                    .notIn(CollectionUtils.isNotEmpty(excludePayCodeList), SvaActualHour::getPayCode, excludePayCodeList)
                    .in(CollectionUtils.isNotEmpty(targetDepartmentCodes), SvaActualHour::getDepartmentCode, targetDepartmentCodes)
                    .in(CollectionUtils.isNotEmpty(targetDepCategoryIds), SvaActualHour::getDepCategoryId, targetDepCategoryIds)
                    .eq(SvaActualHour::getPropertyId, propertyId));
        }

        /**
         * 发送数据变更事件
         *
         * @param propertyList            资产集合
         * @param propertyId2DateRangeMap 报告数据日期范围(yyyyMMdd)
         * @author zc
         * @date 2025/4/4
         */
        private void sendModuleEvent(List<PropertyDTO> propertyList, Map<Integer, List<Long>> propertyId2DateRangeMap) {
            List<SvaDepHourStatisticsEventBO> eventBOList = propertyList.stream().map(propertyDTO -> {
                List<Long> dateRange = propertyId2DateRangeMap.get(propertyDTO.getId());
                return SvaDepHourStatisticsEventBO.builder()
                        .propertyId(propertyDTO.getId())
                        .startDate(dateRange.get(0))
                        .endDate(dateRange.get(1))
                        .build();
            }).toList();
            String messages = JsonUtils.convertObjListToString(objectMapper, eventBOList);
            moduleEventSender.publish(MoudleEventSubjectEnum.SVA_DEPARTMENT_HOUR_STATISTICS.getSubject(), messages);
        }

        /**
         * 删除日期范围内的 Actual Hours 数据
         *
         * @param propertyId    资产 id
         * @param dateRangeList 日期范围
         * @author zc
         * @date 2025/4/21
         */
        private void deleteActualHourByDateRange(int propertyId, List<Long> dateRangeList) {
            if (CollectionUtils.isEmpty(dateRangeList)) {
                return;
            }
            remove(new LambdaQueryWrapper<SvaActualHour>()
                    .between(SvaActualHour::getPayDate, dateRangeList.get(0), dateRangeList.get(1))
                    .eq(SvaActualHour::getPropertyId, propertyId));
        }

        /**
         * 过滤掉排除的员工数据
         *
         * @param propertyId     资产 id
         * @param actualHourList 实际工时集合
         * @return SvaActualHour 集合
         * @author zc
         * @date 2025/4/21
         */
        private List<SvaActualHour> filterByExcludeEmployeeConfig(int propertyId, List<SvaActualHour> actualHourList) {
            List<String> excludePositionIds = excludeEmployeeConfigService.listPositionId(propertyId);
            return actualHourList.stream().filter(item -> !excludePositionIds.contains(item.getPositionId())).toList();
        }

        /**
         * 格式化 BO 数据
         *
         * @param propertyDTOList   资产集合
         * @param actualHoursBOList 实际工时 BO 集合
         * @return SvaActualHour 集合
         * @author zc
         * @date 2025/4/21
         */
        private List<SvaActualHour> formatActualHours(List<PropertyDTO> propertyDTOList, List<SvaActualHoursBO> actualHoursBOList) {
            Map<String, PropertyDTO> abbr2Property = propertyDTOList.stream()
                    .collect(Collectors.toMap(PropertyDTO::getAbbr, Function.identity()));
            return beanMapper.mapList(actualHoursBOList, SvaActualHour.class, (bo, item) -> {
                PropertyDTO propertyDTO = abbr2Property.get(bo.getProperty());
                item.setPropertyId(propertyDTO.getId());
                item.setDepCategoryId(BaseConstant.ZERO);
                LocalDate payDateLocalDate = DateTimeUtils.strToLocalDate(bo.getPayDateStr(), BaseConstant.DATE_FORMAT_YYYY_MM_DD);
                item.setPayDate(Long.parseLong(DateTimeUtils.localDateToStr(payDateLocalDate, BaseConstant.DATE_FORMAT_YYYYMMDD)));
                item.setTimeIn(DateTimeUtils.dateTimeStrToEpochSecond(bo.getTimeInStr(),
                        BaseConstant.DATE_TIME_FORMAT_YYYY_MM_DD_HH_MM, propertyDTO.getTimezone()));
                item.setTimeOut(DateTimeUtils.dateTimeStrToEpochSecond(bo.getTimeOutStr(),
                        BaseConstant.DATE_TIME_FORMAT_YYYY_MM_DD_HH_MM, propertyDTO.getTimezone()));
                item.setDepartmentDescription(bo.getDepartmentDesc());
            });
        }
    }