qiangdanxitong/backend/src/main/java/com/designhub/service/TaskProgressSnapshotService.java

588 lines
27 KiB
Java

package com.designhub.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.designhub.entity.Department;
import com.designhub.entity.Task;
import com.designhub.entity.TaskProgressSnapshot;
import com.designhub.entity.User;
import com.designhub.mapper.DepartmentMapper;
import com.designhub.mapper.TaskMapper;
import com.designhub.mapper.TaskProgressSnapshotMapper;
import com.designhub.mapper.UserMapper;
import com.designhub.vo.TaskProgressOverviewVO;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
@Service
@Slf4j
@RequiredArgsConstructor
public class TaskProgressSnapshotService {
private final TaskMapper taskMapper;
private final UserMapper userMapper;
private final DepartmentMapper departmentMapper;
private final TaskProgressSnapshotMapper snapshotMapper;
private final ObjectMapper objectMapper;
@Value("${analytics.task-progress.snapshot.enabled:true}")
private boolean snapshotEnabled;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Scheduled(cron = "0 0/10 * * * ?")
public void scheduledSnapshot() {
if (!snapshotEnabled) {
return;
}
try {
buildAndPersistSnapshot();
} catch (Exception ex) {
log.error("构建任务进度快照失败", ex);
}
}
public TaskProgressOverviewVO getOverview(String dateRange, String month, Long deptId) {
TaskProgressOverviewVO overview = loadLatestSnapshot()
.orElseGet(this::buildAndPersistSnapshot);
return applyFilters(overview, dateRange, month, deptId);
}
private synchronized TaskProgressOverviewVO buildAndPersistSnapshot() {
List<Task> tasks = loadTasks();
Map<Long, User> userMap = loadUsers(tasks);
Map<Long, Department> deptMap = loadDepartments(tasks);
TaskProgressOverviewVO overview = buildOverview(tasks, userMap, deptMap);
persistSnapshot(overview);
return overview;
}
private List<Task> loadTasks() {
LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
.select(
Task::getId,
Task::getTaskNo,
Task::getTitle,
Task::getStatus,
Task::getProgress,
Task::getPriority,
Task::getScore,
Task::getPublisherId,
Task::getPublisherDeptId,
Task::getReceiverId,
Task::getReceiverDeptId,
Task::getCreateTime,
Task::getActualStartTime,
Task::getStartTime,
Task::getSubmitTime,
Task::getActualEndTime,
Task::getCompleteTime,
Task::getDeadline,
Task::getAuditTime,
Task::getEstimatedHours,
Task::getDeleted
)
.eq(Task::getDeleted, 0)
.ne(Task::getStatus, "draft");
return taskMapper.selectList(wrapper);
}
private Map<Long, User> loadUsers(List<Task> tasks) {
Set<Long> userIds = tasks.stream()
.flatMap(task -> java.util.stream.Stream.of(task.getPublisherId(), task.getReceiverId()))
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (CollectionUtils.isEmpty(userIds)) {
return new HashMap<>();
}
return userMapper.selectBatchIds(userIds).stream()
.collect(Collectors.toMap(User::getId, user -> user, (a, b) -> a));
}
private Map<Long, Department> loadDepartments(List<Task> tasks) {
Set<Long> deptIds = tasks.stream()
.flatMap(task -> java.util.stream.Stream.of(task.getReceiverDeptId(), task.getPublisherDeptId()))
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (CollectionUtils.isEmpty(deptIds)) {
return new HashMap<>();
}
return departmentMapper.selectBatchIds(deptIds).stream()
.collect(Collectors.toMap(Department::getId, dept -> dept, (a, b) -> a));
}
private TaskProgressOverviewVO buildOverview(List<Task> tasks, Map<Long, User> userMap, Map<Long, Department> deptMap) {
LocalDateTime now = LocalDateTime.now();
Map<String, Long> statusDistribution = tasks.stream()
.collect(Collectors.groupingBy(task -> Optional.ofNullable(task.getStatus()).orElse("unknown"), Collectors.counting()));
long total = tasks.size();
long pending = tasks.stream().filter(task -> TaskStatusGroups.PENDING.contains(task.getStatus())).count();
long inProgress = tasks.stream().filter(task -> TaskStatusGroups.IN_PROGRESS.contains(task.getStatus())).count();
long review = tasks.stream().filter(task -> TaskStatusGroups.REVIEW.contains(task.getStatus())).count();
long completed = tasks.stream().filter(task -> TaskStatusGroups.COMPLETED.contains(task.getStatus())).count();
long overdue = tasks.stream().filter(task -> isOverdue(task, now)).count();
long onTimeCompleted = tasks.stream().filter(task -> isCompletedOnTime(task)).count();
double completionRate = total > 0 ? completed * 100.0 / total : 0d;
double onTimeRate = completed > 0 ? onTimeCompleted * 100.0 / completed : 0d;
double avgCycle = tasks.stream()
.filter(task -> task.getCompleteTime() != null)
.mapToDouble(task -> Duration.between(task.getCreateTime(), task.getCompleteTime()).toMinutes() / 60.0)
.average()
.orElse(0d);
TaskProgressOverviewVO.KpiMetrics metrics = TaskProgressOverviewVO.KpiMetrics.builder()
.totalTasks(total)
.pendingTasks(pending)
.inProgressTasks(inProgress)
.reviewTasks(review)
.completedTasks(completed)
.overdueTasks(overdue)
.completionRate(round(completionRate))
.onTimeRate(round(onTimeRate))
.avgCycleHours(round(avgCycle))
.build();
List<TaskProgressOverviewVO.TrendPoint> trend = buildTrend(tasks);
List<TaskProgressOverviewVO.StageSegment> stageSegments = buildStageSegments(tasks);
List<TaskProgressOverviewVO.DepartmentLoad> departmentLoads = buildDepartmentLoads(tasks, deptMap);
List<TaskProgressOverviewVO.TaskPoint> taskPoints = buildTaskPoints(tasks, userMap, deptMap, now);
List<TaskProgressOverviewVO.RiskTask> riskTasks = buildRiskTasks(taskPoints, now);
metrics.setRiskTaskCount(riskTasks.size());
return TaskProgressOverviewVO.builder()
.snapshotTime(now)
.metrics(metrics)
.statusDistribution(statusDistribution.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new)))
.trend(trend)
.stageSegments(stageSegments)
.departmentLoads(departmentLoads)
.riskTasks(riskTasks)
.taskPoints(taskPoints)
.build();
}
private List<TaskProgressOverviewVO.TrendPoint> buildTrend(List<Task> tasks) {
LocalDate today = LocalDate.now();
LocalDate start = today.minusDays(29);
Map<String, TaskProgressOverviewVO.TrendPoint> map = new LinkedHashMap<>();
for (int i = 0; i < 30; i++) {
LocalDate date = start.plusDays(i);
map.put(date.format(DATE_FORMATTER), TaskProgressOverviewVO.TrendPoint.builder()
.date(date.format(DATE_FORMATTER))
.createdCount(0)
.completedCount(0)
.build());
}
for (Task task : tasks) {
if (task.getCreateTime() != null) {
LocalDate date = task.getCreateTime().toLocalDate();
if (!date.isBefore(start)) {
map.computeIfPresent(date.format(DATE_FORMATTER), (k, point) -> {
point.setCreatedCount(point.getCreatedCount() + 1);
return point;
});
}
}
if (task.getCompleteTime() != null) {
LocalDate date = task.getCompleteTime().toLocalDate();
if (!date.isBefore(start)) {
map.computeIfPresent(date.format(DATE_FORMATTER), (k, point) -> {
point.setCompletedCount(point.getCompletedCount() + 1);
return point;
});
}
}
}
return new ArrayList<>(map.values());
}
private List<TaskProgressOverviewVO.StageSegment> buildStageSegments(List<Task> tasks) {
AtomicLong approvalMinutes = new AtomicLong();
AtomicLong executionMinutes = new AtomicLong();
AtomicLong reviewMinutes = new AtomicLong();
AtomicLong approvalCount = new AtomicLong();
AtomicLong executionCount = new AtomicLong();
AtomicLong reviewCount = new AtomicLong();
tasks.forEach(task -> {
if (task.getAuditTime() != null) {
approvalMinutes.addAndGet(Duration.between(task.getCreateTime(), task.getAuditTime()).toMinutes());
approvalCount.incrementAndGet();
}
LocalDateTime start = task.getActualStartTime() != null ? task.getActualStartTime() : task.getStartTime();
LocalDateTime end = task.getActualEndTime() != null ? task.getActualEndTime() : task.getCompleteTime();
if (start != null && end != null) {
executionMinutes.addAndGet(Duration.between(start, end).toMinutes());
executionCount.incrementAndGet();
}
if (task.getSubmitTime() != null && task.getCompleteTime() != null) {
reviewMinutes.addAndGet(Duration.between(task.getSubmitTime(), task.getCompleteTime()).toMinutes());
reviewCount.incrementAndGet();
}
});
double approvalAvg = approvalCount.get() == 0 ? 0d : approvalMinutes.get() / 60.0 / approvalCount.get();
double executionAvg = executionCount.get() == 0 ? 0d : executionMinutes.get() / 60.0 / executionCount.get();
double reviewAvg = reviewCount.get() == 0 ? 0d : reviewMinutes.get() / 60.0 / reviewCount.get();
double total = approvalAvg + executionAvg + reviewAvg;
List<TaskProgressOverviewVO.StageSegment> segments = new ArrayList<>();
segments.add(buildStage("审批环节", approvalAvg, total));
segments.add(buildStage("制作环节", executionAvg, total));
segments.add(buildStage("评审环节", reviewAvg, total));
return segments;
}
private TaskProgressOverviewVO.StageSegment buildStage(String stage, double avg, double total) {
double percentage = total > 0 ? avg / total * 100 : 0;
return TaskProgressOverviewVO.StageSegment.builder()
.stage(stage)
.avgHours(round(avg))
.percentage(round(percentage))
.build();
}
private List<TaskProgressOverviewVO.DepartmentLoad> buildDepartmentLoads(List<Task> tasks, Map<Long, Department> deptMap) {
Map<Long, TaskProgressOverviewVO.DepartmentLoad> loads = new HashMap<>();
for (Task task : tasks) {
Long deptId = task.getReceiverDeptId() != null ? task.getReceiverDeptId() : task.getPublisherDeptId();
if (deptId == null) {
deptId = -1L;
}
TaskProgressOverviewVO.DepartmentLoad load = loads.computeIfAbsent(deptId, id -> {
String name = id == -1L ? "未分配" : deptMap.getOrDefault(id, new Department()).getDeptName();
return TaskProgressOverviewVO.DepartmentLoad.builder()
.deptId(id)
.deptName(name != null ? name : "未知部门")
.build();
});
String status = Optional.ofNullable(task.getStatus()).orElse("unknown");
if (TaskStatusGroups.PENDING.contains(status)) {
load.setPendingCount(load.getPendingCount() + 1);
} else if (TaskStatusGroups.IN_PROGRESS.contains(status)) {
load.setInProgressCount(load.getInProgressCount() + 1);
} else if (TaskStatusGroups.REVIEW.contains(status)) {
load.setReviewCount(load.getReviewCount() + 1);
} else if (TaskStatusGroups.COMPLETED.contains(status)) {
load.setCompletedCount(load.getCompletedCount() + 1);
}
if (isOverdue(task, LocalDateTime.now())) {
load.setOverdueCount(load.getOverdueCount() + 1);
}
}
loads.values().forEach(load -> {
double workloadIndex = load.getPendingCount() * 0.5
+ load.getInProgressCount()
+ load.getReviewCount() * 0.8
+ load.getOverdueCount() * 1.2;
load.setWorkloadIndex(round(workloadIndex));
});
return loads.values().stream()
.sorted(Comparator.comparingDouble(TaskProgressOverviewVO.DepartmentLoad::getWorkloadIndex).reversed())
.collect(Collectors.toList());
}
private List<TaskProgressOverviewVO.TaskPoint> buildTaskPoints(List<Task> tasks, Map<Long, User> userMap, Map<Long, Department> deptMap, LocalDateTime now) {
return tasks.stream()
.sorted(Comparator.comparing(Task::getCreateTime).reversed())
.limit(200)
.map(task -> {
User publisher = task.getPublisherId() != null ? userMap.get(task.getPublisherId()) : null;
User receiver = task.getReceiverId() != null ? userMap.get(task.getReceiverId()) : null;
Long deptId = task.getReceiverDeptId() != null ? task.getReceiverDeptId() : task.getPublisherDeptId();
Department dept = deptId != null ? deptMap.get(deptId) : null;
return TaskProgressOverviewVO.TaskPoint.builder()
.taskId(task.getId())
.deptId(deptId)
.taskNo(task.getTaskNo())
.title(task.getTitle())
.status(task.getStatus())
.progress(task.getProgress() != null ? task.getProgress() : 0)
.priority(task.getPriority())
.score(task.getScore())
.publisherName(publisher != null ? publisher.getRealName() : "")
.receiverName(receiver != null ? receiver.getRealName() : "")
.deptName(dept != null ? dept.getDeptName() : "未分配")
.createTime(task.getCreateTime())
.deadline(task.getDeadline())
.overdue(isOverdue(task, now))
.nearDeadline(isNearDeadline(task, now))
.build();
})
.collect(Collectors.toList());
}
private List<TaskProgressOverviewVO.RiskTask> buildRiskTasks(List<TaskProgressOverviewVO.TaskPoint> taskPoints, LocalDateTime now) {
List<TaskProgressOverviewVO.RiskTask> risks = new LinkedList<>();
for (TaskProgressOverviewVO.TaskPoint point : taskPoints) {
if (point.isOverdue() || point.isNearDeadline()) {
TaskProgressOverviewVO.RiskTask risk = TaskProgressOverviewVO.RiskTask.builder()
.taskId(point.getTaskId())
.deptId(point.getDeptId())
.taskNo(point.getTaskNo())
.title(point.getTitle())
.status(point.getStatus())
.deptName(point.getDeptName())
.ownerName(point.getReceiverName())
.deadline(point.getDeadline())
.createTime(point.getCreateTime())
.warningType(point.isOverdue() ? "overdue" : "near_deadline")
.warningReason(point.isOverdue() ? "任务已超时" : "任务即将超时")
.severity(point.isOverdue() ? "danger" : "warning")
.build();
risks.add(risk);
}
}
return risks.stream()
.sorted(Comparator.comparing(TaskProgressOverviewVO.RiskTask::getSeverity)
.thenComparing(risk -> Optional.ofNullable(risk.getDeadline()).orElse(now)))
.limit(20)
.collect(Collectors.toList());
}
private void persistSnapshot(TaskProgressOverviewVO overview) {
try {
TaskProgressSnapshot snapshot = new TaskProgressSnapshot();
snapshot.setSnapshotTime(LocalDateTime.now());
snapshot.setMetricsJson(objectMapper.writeValueAsString(overview.getMetrics()));
snapshot.setStatusDistributionJson(objectMapper.writeValueAsString(overview.getStatusDistribution()));
snapshot.setTrendJson(objectMapper.writeValueAsString(overview.getTrend()));
snapshot.setStageSegmentsJson(objectMapper.writeValueAsString(overview.getStageSegments()));
snapshot.setDepartmentLoadJson(objectMapper.writeValueAsString(overview.getDepartmentLoads()));
snapshot.setRiskTasksJson(objectMapper.writeValueAsString(overview.getRiskTasks()));
snapshot.setTaskPointsJson(objectMapper.writeValueAsString(overview.getTaskPoints()));
snapshotMapper.insert(snapshot);
} catch (Exception ex) {
log.error("写入任务进度快照失败", ex);
}
}
private Optional<TaskProgressOverviewVO> loadLatestSnapshot() {
LambdaQueryWrapper<TaskProgressSnapshot> wrapper = new LambdaQueryWrapper<TaskProgressSnapshot>()
.orderByDesc(TaskProgressSnapshot::getSnapshotTime)
.last("LIMIT 1");
TaskProgressSnapshot snapshot = snapshotMapper.selectOne(wrapper);
if (snapshot == null) {
return Optional.empty();
}
try {
return Optional.of(TaskProgressOverviewVO.builder()
.snapshotTime(snapshot.getSnapshotTime())
.metrics(read(snapshot.getMetricsJson(), TaskProgressOverviewVO.KpiMetrics.class))
.statusDistribution(read(snapshot.getStatusDistributionJson(), new TypeReference<Map<String, Long>>() {}))
.trend(read(snapshot.getTrendJson(), new TypeReference<List<TaskProgressOverviewVO.TrendPoint>>() {}))
.stageSegments(read(snapshot.getStageSegmentsJson(), new TypeReference<List<TaskProgressOverviewVO.StageSegment>>() {}))
.departmentLoads(read(snapshot.getDepartmentLoadJson(), new TypeReference<List<TaskProgressOverviewVO.DepartmentLoad>>() {}))
.riskTasks(read(snapshot.getRiskTasksJson(), new TypeReference<List<TaskProgressOverviewVO.RiskTask>>() {}))
.taskPoints(read(snapshot.getTaskPointsJson(), new TypeReference<List<TaskProgressOverviewVO.TaskPoint>>() {}))
.build());
} catch (Exception ex) {
log.error("解析任务进度快照失败", ex);
return Optional.empty();
}
}
private <T> T read(String json, TypeReference<T> type) throws Exception {
if (json == null) {
return null;
}
return objectMapper.readValue(json, type);
}
private <T> T read(String json, Class<T> type) throws Exception {
if (json == null) {
return null;
}
return objectMapper.readValue(json, type);
}
private TaskProgressOverviewVO applyFilters(TaskProgressOverviewVO overview, String dateRange, String month, Long deptId) {
if (overview == null) {
return null;
}
LocalDateTime start = resolveStart(dateRange, month);
LocalDateTime end = resolveEnd(month);
List<TaskProgressOverviewVO.TrendPoint> filteredTrend = Optional.ofNullable(overview.getTrend())
.map(ArrayList::new)
.orElseGet(ArrayList::new);
if (start != null) {
filteredTrend = filteredTrend.stream()
.filter(point -> {
LocalDate date = LocalDate.parse(point.getDate(), DATE_FORMATTER);
return !date.atStartOfDay().isBefore(start);
})
.collect(Collectors.toList());
}
List<TaskProgressOverviewVO.TaskPoint> filteredTaskPoints = Optional.ofNullable(overview.getTaskPoints())
.map(ArrayList::new)
.orElseGet(ArrayList::new);
if (start != null) {
filteredTaskPoints = filteredTaskPoints.stream()
.filter(point -> point.getCreateTime() == null || !point.getCreateTime().isBefore(start))
.collect(Collectors.toList());
}
if (end != null) {
filteredTaskPoints = filteredTaskPoints.stream()
.filter(point -> point.getCreateTime() == null || !point.getCreateTime().isAfter(end))
.collect(Collectors.toList());
}
TaskProgressOverviewVO.KpiMetrics metrics = overview.getMetrics();
List<TaskProgressOverviewVO.DepartmentLoad> deptLoads = Optional.ofNullable(overview.getDepartmentLoads())
.map(ArrayList::new)
.orElseGet(ArrayList::new);
List<TaskProgressOverviewVO.RiskTask> filteredRisks = Optional.ofNullable(overview.getRiskTasks())
.map(ArrayList::new)
.orElseGet(ArrayList::new);
if (deptId != null) {
deptLoads = deptLoads.stream()
.filter(load -> load.getDeptId() != null && load.getDeptId().equals(deptId))
.collect(Collectors.toList());
if (!deptLoads.isEmpty()) {
TaskProgressOverviewVO.DepartmentLoad load = deptLoads.get(0);
long totalTasks = load.getPendingCount() + load.getInProgressCount() + load.getReviewCount() + load.getCompletedCount();
double completionRate = totalTasks == 0 ? 0 : round((double) load.getCompletedCount() * 100 / totalTasks);
metrics = TaskProgressOverviewVO.KpiMetrics.builder()
.totalTasks(totalTasks)
.pendingTasks(load.getPendingCount())
.inProgressTasks(load.getInProgressCount())
.reviewTasks(load.getReviewCount())
.completedTasks(load.getCompletedCount())
.overdueTasks(load.getOverdueCount())
.completionRate(completionRate)
.onTimeRate(overview.getMetrics().getOnTimeRate())
.avgCycleHours(overview.getMetrics().getAvgCycleHours())
.riskTaskCount(filteredRisks.stream()
.filter(risk -> deptId.equals(risk.getDeptId()))
.count())
.build();
}
Long targetDeptId = deptId;
filteredTaskPoints = filteredTaskPoints.stream()
.filter(point -> Objects.equals(point.getDeptId(), targetDeptId))
.collect(Collectors.toList());
filteredRisks = filteredRisks.stream()
.filter(risk -> Objects.equals(risk.getDeptId(), targetDeptId))
.collect(Collectors.toList());
}
if (metrics != null) {
metrics.setRiskTaskCount(filteredRisks != null ? filteredRisks.size() : 0);
}
return TaskProgressOverviewVO.builder()
.snapshotTime(overview.getSnapshotTime())
.metrics(metrics)
.statusDistribution(overview.getStatusDistribution())
.trend(filteredTrend)
.stageSegments(overview.getStageSegments())
.departmentLoads(deptLoads)
.riskTasks(filteredRisks)
.taskPoints(filteredTaskPoints)
.build();
}
private LocalDateTime resolveStart(String dateRange, String month) {
if (month != null && !month.isEmpty()) {
LocalDate date = LocalDate.parse(month + "-01", DateTimeFormatter.ofPattern("yyyy-MM-dd"));
return date.atStartOfDay();
}
LocalDateTime now = LocalDateTime.now();
if (dateRange == null) {
return null;
}
switch (dateRange) {
case "week":
return now.minusWeeks(1);
case "month":
return now.minusMonths(1);
case "quarter":
return now.minusMonths(3);
case "year":
return now.minusYears(1);
default:
return null;
}
}
private LocalDateTime resolveEnd(String month) {
if (month == null || month.isEmpty()) {
return null;
}
LocalDate date = LocalDate.parse(month + "-01", DateTimeFormatter.ofPattern("yyyy-MM-dd"));
return date.with(TemporalAdjusters.lastDayOfMonth()).atTime(LocalTime.MAX);
}
private boolean isOverdue(Task task, LocalDateTime now) {
return task.getDeadline() != null && task.getDeadline().isBefore(now)
&& !TaskStatusGroups.COMPLETED.contains(task.getStatus());
}
private boolean isNearDeadline(Task task, LocalDateTime now) {
return task.getDeadline() != null
&& !TaskStatusGroups.COMPLETED.contains(task.getStatus())
&& Duration.between(now, task.getDeadline()).toHours() <= 12
&& Duration.between(now, task.getDeadline()).toMinutes() >= 0;
}
private boolean isCompletedOnTime(Task task) {
if (!TaskStatusGroups.COMPLETED.contains(task.getStatus())) {
return false;
}
if (task.getDeadline() == null || task.getCompleteTime() == null) {
return true;
}
return !task.getCompleteTime().isAfter(task.getDeadline());
}
private double round(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
private static class TaskStatusGroups {
private static final Set<String> PENDING = Set.of("pending", "pending_audit", "pending_demand_approval", "pending_receiving_approval");
private static final Set<String> IN_PROGRESS = Set.of("in_progress");
private static final Set<String> REVIEW = Set.of("review", "submitted");
private static final Set<String> COMPLETED = Set.of("completed");
}
}