package cn.iocoder.yudao.module.mes.service.cal.schedule;
|
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.module.mes.controller.admin.cal.schedule.vo.MesCalScheduleDetailCheckReqVO;
|
import cn.iocoder.yudao.module.mes.controller.admin.cal.schedule.vo.MesCalScheduleDetailCheckRespVO;
|
import cn.iocoder.yudao.module.mes.controller.admin.cal.schedule.vo.MesCalScheduleDetailPageReqVO;
|
import cn.iocoder.yudao.module.mes.controller.admin.cal.schedule.vo.MesCalScheduleDetailSaveReqVO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.schedule.MesCalScheduleDetailDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamMemberDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO;
|
import cn.iocoder.yudao.module.mes.dal.mysql.cal.plan.MesCalPlanShiftMapper;
|
import cn.iocoder.yudao.module.mes.dal.mysql.cal.schedule.MesCalScheduleDetailMapper;
|
import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamMemberMapper;
|
import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper;
|
import cn.iocoder.yudao.module.mes.enums.cal.MesCalScheduleDetailSourceEnum;
|
import jakarta.annotation.Resource;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.validation.annotation.Validated;
|
|
import java.util.*;
|
import java.util.stream.Collectors;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_SCHEDULE_DETAIL_GENERATE_TEAM_MEMBER_EMPTY;
|
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_SCHEDULE_DETAIL_NOT_EXISTS;
|
|
/**
|
* MES 员工级排班明细 Service 实现类
|
*
|
* @author 超级管理员
|
*/
|
@Service
|
@Validated
|
@Slf4j
|
public class MesCalScheduleDetailServiceImpl implements MesCalScheduleDetailService {
|
|
/**
|
* 合规校验阈值:单班疑似连续工时上限(小时)
|
*/
|
private static final long MAX_CONTINUOUS_WORK_HOURS = 12;
|
/**
|
* 合规校验阈值:相邻班次最少休息间隔(小时)
|
*/
|
private static final long MIN_REST_HOURS = 8;
|
private static final int MINUTES_PER_DAY = 24 * 60;
|
|
@Resource
|
private MesCalScheduleDetailMapper scheduleDetailMapper;
|
@Resource
|
private MesCalTeamShiftMapper teamShiftMapper;
|
@Resource
|
private MesCalTeamMemberMapper teamMemberMapper;
|
@Resource
|
private MesCalPlanShiftMapper planShiftMapper;
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void generateDetails(Long planId) {
|
// 1. 查询该计划下的班组排班(班组 × 日期 × 班次)
|
List<MesCalTeamShiftDO> teamShifts = teamShiftMapper.selectListByPlanId(planId);
|
if (teamShifts.isEmpty()) {
|
scheduleDetailMapper.deleteByPlanId(planId);
|
throw exception(CAL_SCHEDULE_DETAIL_GENERATE_TEAM_MEMBER_EMPTY);
|
}
|
Set<Long> teamIds = teamShifts.stream().map(MesCalTeamShiftDO::getTeamId).collect(Collectors.toSet());
|
// 2. 加载班次定义(班次名称/起止时间)与班组成员
|
Map<Long, MesCalPlanShiftDO> shiftMap = planShiftMapper.selectListByPlanId(planId).stream()
|
.collect(Collectors.toMap(MesCalPlanShiftDO::getId, shift -> shift, (a, b) -> a));
|
List<MesCalTeamMemberDO> members = teamMemberMapper.selectListByTeamIds(teamIds);
|
if (members.isEmpty()) {
|
scheduleDetailMapper.deleteByPlanId(planId);
|
throw exception(CAL_SCHEDULE_DETAIL_GENERATE_TEAM_MEMBER_EMPTY);
|
}
|
Map<Long, List<MesCalTeamMemberDO>> memberMap = members.stream()
|
.collect(Collectors.groupingBy(MesCalTeamMemberDO::getTeamId));
|
// 3. 展开为员工级明细(员工 × 日期 × 班次),先清理旧数据保证幂等
|
scheduleDetailMapper.deleteByPlanId(planId);
|
List<MesCalScheduleDetailDO> details = new ArrayList<>();
|
for (MesCalTeamShiftDO teamShift : teamShifts) {
|
MesCalPlanShiftDO shift = shiftMap.get(teamShift.getShiftId());
|
if (shift == null) {
|
continue;
|
}
|
for (MesCalTeamMemberDO member : memberMap.getOrDefault(teamShift.getTeamId(), Collections.emptyList())) {
|
details.add(MesCalScheduleDetailDO.builder()
|
.planId(planId)
|
.teamId(teamShift.getTeamId())
|
.userId(member.getUserId())
|
.day(teamShift.getDay())
|
.shiftId(teamShift.getShiftId())
|
.shiftName(shift.getName())
|
.startTime(shift.getStartTime())
|
.endTime(shift.getEndTime())
|
.source(MesCalScheduleDetailSourceEnum.AUTO.getSource())
|
.build());
|
}
|
}
|
if (!details.isEmpty()) {
|
scheduleDetailMapper.insertBatch(details);
|
}
|
}
|
|
@Override
|
public PageResult<MesCalScheduleDetailDO> getDetailPage(MesCalScheduleDetailPageReqVO reqVO) {
|
return scheduleDetailMapper.selectPage(reqVO);
|
}
|
|
@Override
|
public List<MesCalScheduleDetailDO> getDetailList(MesCalScheduleDetailPageReqVO reqVO) {
|
return scheduleDetailMapper.selectList(reqVO);
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void updateDetail(MesCalScheduleDetailSaveReqVO updateReqVO) {
|
MesCalScheduleDetailDO detail = scheduleDetailMapper.selectById(updateReqVO.getId());
|
if (detail == null) {
|
throw exception(CAL_SCHEDULE_DETAIL_NOT_EXISTS);
|
}
|
MesCalScheduleDetailDO updateObj = MesCalScheduleDetailDO.builder()
|
.id(updateReqVO.getId())
|
.shiftId(updateReqVO.getShiftId())
|
.shiftName(updateReqVO.getShiftName())
|
.startTime(updateReqVO.getStartTime())
|
.endTime(updateReqVO.getEndTime())
|
.remark(updateReqVO.getRemark())
|
.source(MesCalScheduleDetailSourceEnum.MANUAL.getSource())
|
.build();
|
scheduleDetailMapper.updateById(updateObj);
|
}
|
|
@Override
|
public List<MesCalScheduleDetailCheckRespVO> checkCompliance(MesCalScheduleDetailCheckReqVO reqVO) {
|
List<MesCalScheduleDetailDO> details =
|
scheduleDetailMapper.selectListByUserIdBetween(reqVO.getUserId(), reqVO.getStartDay(), reqVO.getEndDay());
|
List<MesCalScheduleDetailCheckRespVO> result = new ArrayList<>();
|
for (int i = 0; i < details.size(); i++) {
|
MesCalScheduleDetailDO detail = details.get(i);
|
MesCalScheduleDetailCheckRespVO respVO = new MesCalScheduleDetailCheckRespVO();
|
respVO.setDay(detail.getDay());
|
respVO.setShiftName(detail.getShiftName());
|
respVO.setStartTime(detail.getStartTime());
|
respVO.setEndTime(detail.getEndTime());
|
// 校验 1:单班疑似连续工时超长
|
Long duration = shiftDurationMinutes(detail.getStartTime(), detail.getEndTime());
|
if (duration != null && duration > MAX_CONTINUOUS_WORK_HOURS * 60) {
|
respVO.setIssueType(1);
|
respVO.setIssue("单班疑似连续工时超长(" + formatMinutes(duration) + "),可能违反连续工时上限");
|
}
|
// 校验 2:与前一班次间隔休息不足(仅在同一天或相邻天才比较)
|
if (i > 0) {
|
MesCalScheduleDetailDO prev = details.get(i - 1);
|
Long rest = restMinutesBetween(prev, detail);
|
if (rest != null && rest < MIN_REST_HOURS * 60) {
|
respVO.setIssueType(2);
|
respVO.setIssue("与前一个班次间隔仅 " + formatMinutes(rest) + ",休息时间不足");
|
}
|
}
|
result.add(respVO);
|
}
|
return result;
|
}
|
|
/**
|
* 计算单班工时(分钟),跨午夜班次自动+24h;时间格式非法时返回 null
|
*/
|
private Long shiftDurationMinutes(String startTime, String endTime) {
|
Integer start = toMinutes(startTime);
|
Integer end = toMinutes(endTime);
|
if (start == null || end == null) {
|
return null;
|
}
|
int duration = end - start;
|
if (duration < 0) {
|
duration += MINUTES_PER_DAY;
|
}
|
return (long) duration;
|
}
|
|
/**
|
* 计算相邻两天班次之间的休息间隔(分钟);
|
* 班次若跨午夜,结束分钟数按超过 MINUTES_PER_DAY 计(即 day + 班次时长),
|
* 下一班次开始分钟按新的一天(+MINUTES_PER_DAY)计。
|
*/
|
private Long restMinutesBetween(MesCalScheduleDetailDO prev, MesCalScheduleDetailDO next) {
|
Integer prevStart = toMinutes(prev.getStartTime());
|
Integer prevEnd = toMinutes(prev.getEndTime());
|
Integer nextStart = toMinutes(next.getStartTime());
|
if (prevStart == null || prevEnd == null || nextStart == null) {
|
return null;
|
}
|
int prevDuration = prevEnd - prevStart;
|
if (prevDuration < 0) {
|
prevDuration += MINUTES_PER_DAY;
|
}
|
int prevEndMinutes = prevStart + prevDuration; // 相对 prev.day 零点,可能超过一天
|
int nextStartMinutes = MINUTES_PER_DAY + nextStart; // 下一班次在新的一天
|
return (long) (nextStartMinutes - prevEndMinutes);
|
}
|
|
private Integer toMinutes(String time) {
|
if (time == null) {
|
return null;
|
}
|
String[] parts = time.split(":");
|
if (parts.length != 2) {
|
return null;
|
}
|
try {
|
return Integer.parseInt(parts[0].trim()) * 60 + Integer.parseInt(parts[1].trim());
|
} catch (NumberFormatException e) {
|
return null;
|
}
|
}
|
|
private String formatMinutes(long minutes) {
|
long hours = minutes / 60;
|
long mins = minutes % 60;
|
return mins == 0 ? hours + " 小时" : hours + " 小时 " + mins + " 分钟";
|
}
|
|
}
|