Разделить список услуг на группы в пределах 14 дней

Рейтинг: 1Ответов: 2Опубликовано: 27.03.2023

Есть список услуг, например:

List<Item>  list = new ArrayList<>();
list.add(new Item(123456789, 01.04.2023));
list.add(new Item(123456789, 14.04.2023));
list.add(new Item(123456789, 21.04.2023));
list.add(new Item(123456789, 30.04.2023));

Даты в формате LocalDate, но для наглядности указал так.

Необходимо разделить список услуг на группы так, чтобы в каждой группе были услуги в пределах 14 дней. Например для примера выше будет две группы:

Первая с датами 01.04.2023 и 14.04.2023 Вторая с датами 21.04.2023 и 30.04.2023

Я делаю так:

list.sort(Comparator.comparing(Item::getDate_in));

Map<Integer, List<Item>> groups = list.stream()
        .collect(Collectors.groupingBy(i ->
                (int) ChronoUnit.DAYS.between(list.get(0).getDate_in(), i.getDate_in()) / 14));

for (Map.Entry<Integer, List<Item>> entry1 : groups.entrySet()) {
    System.out.println(entry1.getKey());
    for (Item Item : entry1.getValue()) {
        System.out.println(Item + " \n");
    }
}

Но получается три группы ,вместо двух. Подскажите пожалуйста как правильнее разделить на группы?

Использовать Map<Boolean, List<Item>> как в вопросе https://ru.stackoverflow.com/questions/1507202/Разделить-элементы-по-условиям/1507257?noredirect=1#comment2704072_1507257 не подходит так как это решение делить список на две группы, а возможна ситуация когда будет три группы. Например такая:

List<Item>  list = new ArrayList<>();
list.add(new Item(123456789, 01.04.2023));
list.add(new Item(123456789, 14.04.2023));
list.add(new Item(123456789, 15.04.2023));
list.add(new Item(123456789, 30.04.2023));

Тут уже должно быть три группы.

Ответы

▲ 1Принят

Похоже, что вы вычисляете начало группы динамически, а не от первой даты отсчёта, то есть у вас минимальное значение даты меняется при прохождении по списку, и поэтому меняется количество груп.

В связи с этим использовать Stream API / forEach с лямбдой для построения такой мапы нецелесообразно, так как здесь необходимо запоминать некое состояние (опорную дату) за пределами стрима, то есть нарушается требование, что переменные в лямбдах должны быть эффективно финальными (effectively final).

Однако можно просто проитерироваться по входному списку, отсортированному по дате, и построить мапу при помощи computeIfAbsent. В качестве начальной ключевой даты берётся дата первого элемента списка. Затем в цикле вычисляется разница в днях между этой датой и датой текущего элемента, если такая разница не удовлетворяет заданному условию (больше 14 дней например), то ключевая дата обновляется.

Примерная реализация:

record Item(int id, LocalDate date) {}

public static Map<LocalDate, List<Item>> groupByDays(List<Item> items, int days) {
    items.sort(Comparator.comparing(Item::date));
    
    LocalDate keyDate = items.get(0).date();
    
    Map<LocalDate, List<Item>> groups = new LinkedHashMap<>();
    for (Item i : items) {
        keyDate = ChronoUnit.DAYS.between(keyDate, i.date()) < days ? keyDate : i.date();
        groups.computeIfAbsent(keyDate, k -> new ArrayList<>()).add(i);
    }
    return groups;
}

Тест:

List<Item> items = Arrays.asList(
    new Item(1, LocalDate.of(2023, 3, 1)),
    new Item(2, LocalDate.of(2023, 3, 14)),
    new Item(3, LocalDate.of(2023, 3, 15)),
    new Item(4, LocalDate.of(2023, 3, 30))
);

var groups = groupByDays(items, 14);
groups.forEach((k, v) -> System.out.println(k + ": " + v));
2023-03-01: [Item[id=1, date=2023-03-01], Item[id=2, date=2023-03-14]]
2023-03-15: [Item[id=3, date=2023-03-15]]
2023-03-30: [Item[id=4, date=2023-03-30]]

Для случая Item(3, LocalDate.of(2023, 3, 20))получится две группы:

2023-03-01: [Item[id=1, date=2023-03-01], Item[id=2, date=2023-03-14]]
2023-03-20: [Item[id=3, date=2023-03-20], Item[id=4, date=2023-03-30]]

Решение с forEach могло бы написано при помощи массива (ссылка на сам массив будет финальная), но это считается трюкачеством:

public static Map<LocalDate, List<Item>> groupByDaysLambda(List<Item> items, int days) {
    items.sort(Comparator.comparing(Item::date));

    Map<LocalDate, List<Item>> groups = new LinkedHashMap<>();

    LocalDate[] keyDate = {items.get(0).date()};
    
    items.forEach(i -> groups.computeIfAbsent(
            keyDate[0] = ChronoUnit.DAYS.between(keyDate[0], i.date()) < days ? keyDate[0] : i.date(), 
            k -> new ArrayList<>()
        ).add(i)
    );

    return groups;
}

Аналогично со стримом -- будет работать только для последовательного упорядоченного стрима, иначе результат будет непредсказуемым:

public static Map<LocalDate, List<Item>> groupByDaysStream(List<Item> items, int days) {
    items.sort(Comparator.comparing(Item::date));

    LocalDate[] keyDate = {items.get(0).date()};
    
    return items.stream()
        .collect(Collectors.groupingBy(
            i -> keyDate[0] = ChronoUnit.DAYS.between(keyDate[0], i.date()) < days ? keyDate[0] : i.date(),
            LinkedHashMap::new,
            Collectors.toList()
        ));
}
▲ 0

Если дата и окончание периода не принципиальны, то можно использовать в качестве параметра группировки следующую формулу (номер_недели+1)/2. Добавление 1 нужно чтобы первая неделя не осталась "сиротой". Тогда в одну группу попадут даты 1-2, 3-4 и так далее.

Способ работает для дат в пределах одного года. Так же следует учитывать правила перехода, когда у первого января номер недели может оказаться в последней неделе предыдущего года.

    @Test
    void groupBy2WeekTest() {
        record Item(Integer id, LocalDate published) {
        }
        var items = List.of(
            new Item(1, LocalDate.of(2023, 3, 27)),
            new Item(2, LocalDate.of(2023, 4, 1)),
            new Item(3, LocalDate.of(2023, 4, 5)),
            new Item(4, LocalDate.of(2023, 3, 6)),
            new Item(5, LocalDate.of(2023, 3, 17)),
            new Item(6, LocalDate.of(2023, 3, 23)),
            new Item(7, LocalDate.of(2023, 3, 13)),
            new Item(8, LocalDate.of(2023, 3, 26))
        );
        TemporalField woy = WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear();
        var result = items.stream().collect(Collectors.groupingBy(i -> ((i.published().get(woy))+1)/2));
        result.forEach((g,o) -> System.out.println(g+"\n=============\n"+o));
    }