Найти и исправить проблемы в расчёте счетов за приёмы

39. Найти и исправить проблемы в расчёте счетов за приёмы

Условие задачи:
Дан код формирования счетов за приёмы пациентов.

В текущей реализации есть несколько проблем:

  1. некоторые пациенты пропадают из результата, особенно если в данных отсутствует скидка или она приходит в неожиданном формате;
  2. в счетах могут появляться отрицательные суммы;
  3. изменение цены приёма через updatePrice() фактически ничего не меняет;
  4. денежные суммы выводятся с большим количеством знаков после запятой;
  5. отменённые приёмы попадают в счёт.

Static-блок используется только для эмуляции данных, которые в реальном приложении могут приходить из БД или внешней системы.

Код:

public class AppointmentBilling {

    static List<Map<String, Object>> appointments = new ArrayList<>();

    static {
        Map<String, Object> a1 = new HashMap<>();
        a1.put("id", 1);
        a1.put("doctor", "Иванов");
        a1.put("patient", "Волков");
        a1.put("base_price", 2500.0);
        a1.put("discount_pct", 0.10);
        a1.put("status", "completed");
        appointments.add(a1);

        Map<String, Object> a2 = new HashMap<>();
        a2.put("id", 2);
        a2.put("doctor", "Петрова");
        a2.put("patient", "Зайцева");
        a2.put("base_price", -1500.0);
        a2.put("discount_pct", 0.05);
        a2.put("status", "completed");
        appointments.add(a2);

        Map<String, Object> a3 = new HashMap<>();
        a3.put("id", 3);
        a3.put("doctor", "Сидоров");
        a3.put("patient", "Лисицын");
        a3.put("base_price", 3000.0);
        a3.put("discount_pct", "none");
        a3.put("status", "completed");
        appointments.add(a3);

        Map<String, Object> a4 = new HashMap<>();
        a4.put("id", 4);
        a4.put("doctor", "Кузнецова");
        a4.put("patient", "Медведева");
        a4.put("base_price", 4500.50);
        a4.put("discount_pct", 0.15);
        a4.put("status", "canceled");
        appointments.add(a4);
    }

    static double calcFinalPrice(Map<String, Object> apt) {
        double base =
                ((Number) apt.get("base_price")).doubleValue();

        double discount =
                ((Number) apt.get("discount_pct")).doubleValue();

        return base - (base * discount);
    }

    static List<Map<String, Object>> generateInvoice() {
        List<Map<String, Object>> invoice = new ArrayList<>();

        for (Map<String, Object> apt : appointments) {
            try {
                double price = calcFinalPrice(apt);

                Map<String, Object> entry = new HashMap<>();
                entry.put("patient", apt.get("patient"));
                entry.put("doctor", apt.get("doctor"));
                entry.put("price", price);

                invoice.add(entry);
            } catch (Exception e) {
                continue;
            }
        }

        return invoice;
    }

    static void updatePrice(int id, double newPrice) {
        for (Map<String, Object> apt : appointments) {
            if ((int) apt.get("id") == id) {
                apt.get("base_price");
                break;
            }
        }
    }
}

Спойлеры к решению

Подсказки
💡 catch (Exception) вместе с continue полностью скрывает ошибку и приводит к исчезновению записи из результата.
💡 Значение discount_pct не всегда является Number: в примере встречается строка "none".
💡 Для денежных расчётов лучше использовать BigDecimal, а не double.
💡 Отрицательная базовая стоимость должна валидироваться до формирования счёта.
💡 Для денег нужно явно задать округление до двух знаков через setScale().
💡 Перед формированием счёта необходимо проверить status.
💡 В updatePrice() нужно изменить значение через put(), а не просто получить его через get().

Решение

Проблемы:

  • discount_pct = "none" приводит к ClassCastException;
  • catch (Exception) скрывает эту ошибку, поэтому пациент просто исчезает из результата;
  • отсутствующая скидка у новой записи также может привести к ошибке;
  • отрицательный base_price приводит к отрицательному счёту;
  • для денег используется double;
  • отсутствует явное округление до двух знаков;
  • отменённые приёмы никак не фильтруются;
  • updatePrice() вызывает get(), но не изменяет значение;
  • использование Map<String, Object> позволяет получить данные неожиданного типа.

Исправленный вариант:

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class AppointmentBilling {

    static List<Map<String, Object>> appointments = new ArrayList<>();

    static {
        Map<String, Object> a1 = new HashMap<>();
        a1.put("id", 1);
        a1.put("doctor", "Иванов");
        a1.put("patient", "Волков");
        a1.put("base_price", 2500.0);
        a1.put("discount_pct", 0.10);
        a1.put("status", "completed");
        appointments.add(a1);

        Map<String, Object> a2 = new HashMap<>();
        a2.put("id", 2);
        a2.put("doctor", "Петрова");
        a2.put("patient", "Зайцева");
        a2.put("base_price", -1500.0);
        a2.put("discount_pct", 0.05);
        a2.put("status", "completed");
        appointments.add(a2);

        Map<String, Object> a3 = new HashMap<>();
        a3.put("id", 3);
        a3.put("doctor", "Сидоров");
        a3.put("patient", "Лисицын");
        a3.put("base_price", 3000.0);
        a3.put("discount_pct", "none");
        a3.put("status", "completed");
        appointments.add(a3);

        Map<String, Object> a4 = new HashMap<>();
        a4.put("id", 4);
        a4.put("doctor", "Кузнецова");
        a4.put("patient", "Медведева");
        a4.put("base_price", 4500.50);
        a4.put("discount_pct", 0.15);
        a4.put("status", "canceled");
        appointments.add(a4);
    }

    static BigDecimal calcFinalPrice(
            Map<String, Object> apt
    ) {
        BigDecimal base = toDecimal(
                apt.get("base_price"),
                "base_price"
        );

        if (base.signum() < 0) {
            throw new IllegalArgumentException(
                    "base_price must not be negative"
            );
        }

        BigDecimal discount = parseDiscount(
                apt.get("discount_pct")
        );

        if (discount.compareTo(BigDecimal.ZERO) < 0
                || discount.compareTo(BigDecimal.ONE) > 0) {
            throw new IllegalArgumentException(
                    "discount_pct must be between 0 and 1"
            );
        }

        return base
                .multiply(BigDecimal.ONE.subtract(discount))
                .setScale(2, RoundingMode.HALF_UP);
    }

    static BigDecimal parseDiscount(Object value) {
        if (value == null) {
            return BigDecimal.ZERO;
        }

        if (value instanceof String string) {
            if (string.isBlank()
                    || string.equalsIgnoreCase("none")) {
                return BigDecimal.ZERO;
            }
        }

        return toDecimal(value, "discount_pct");
    }

    static BigDecimal toDecimal(
            Object value,
            String field
    ) {
        if (value == null) {
            throw new IllegalArgumentException(
                    field + " must not be null"
            );
        }

        if (value instanceof BigDecimal decimal) {
            return decimal;
        }

        if (value instanceof Number number) {
            return new BigDecimal(number.toString());
        }

        if (value instanceof String string) {
            try {
                return new BigDecimal(string);
            } catch (NumberFormatException e) {
                throw new IllegalArgumentException(
                        "Invalid " + field,
                        e
                );
            }
        }

        throw new IllegalArgumentException(
                "Invalid type for " + field
        );
    }

    static List<Map<String, Object>> generateInvoice() {
        List<Map<String, Object>> invoice =
                new ArrayList<>();

        for (Map<String, Object> apt : appointments) {

            if (!"completed".equals(apt.get("status"))) {
                continue;
            }

            Map<String, Object> entry =
                    new HashMap<>();

            entry.put("patient", apt.get("patient"));
            entry.put("doctor", apt.get("doctor"));

            try {
                BigDecimal price =
                        calcFinalPrice(apt);

                entry.put("price", price);
            } catch (RuntimeException e) {
                entry.put("error", e.getMessage());
            }

            invoice.add(entry);
        }

        return invoice;
    }

    static void updatePrice(
            int id,
            BigDecimal newPrice
    ) {
        if (newPrice == null
                || newPrice.signum() < 0) {
            throw new IllegalArgumentException(
                    "Price must not be negative"
            );
        }

        for (Map<String, Object> apt : appointments) {
            if (((Number) apt.get("id")).intValue() == id) {
                apt.put("base_price", newPrice);
                return;
            }
        }

        throw new IllegalArgumentException(
                "Appointment not found: " + id
        );
    }

    public static void main(String[] args) {
        System.out.println("Счета за приёмы:");

        generateInvoice().forEach(entry -> {
            if (entry.containsKey("error")) {
                System.out.println(
                        entry.get("patient")
                                + " -> "
                                + entry.get("doctor")
                                + ": ошибка — "
                                + entry.get("error")
                );
            } else {
                System.out.println(
                        entry.get("patient")
                                + " -> "
                                + entry.get("doctor")
                                + ": "
                                + entry.get("price")
                                + " руб."
                );
            }
        });
    }
}

1. Почему пропадали пациенты

Для пациента Лисицына приходит:

discount_pct = "none"

а исходный код выполняет:

((Number) apt.get("discount_pct")).doubleValue();

Строку нельзя привести к Number, поэтому возникает ClassCastException.

После этого:

catch (Exception e) {
    continue;
}

полностью скрывает ошибку и переходит к следующему пациенту.

В исправленном варианте значение:

none

интерпретируется как отсутствие скидки:

return BigDecimal.ZERO;

То же самое происходит, если скидка отсутствует:

discount_pct = null

2. Отрицательные суммы

В исходных данных:

base_price = -1500.0

Такое значение не должно автоматически превращаться в отрицательный счёт.

Поэтому выполняется явная проверка:

if (base.signum() < 0) {
    throw new IllegalArgumentException(
            "base_price must not be negative"
    );
}

При этом пациент не исчезает полностью: в результате сохраняется запись с информацией об ошибке.

3. Изменение цены

Исходный код:

apt.get("base_price");

только получает значение и ничего не изменяет.

Необходимо использовать:

apt.put("base_price", newPrice);

После этого следующий вызов generateInvoice() рассчитает счёт уже с новой стоимостью.

4. Округление

Для денежных значений используется BigDecimal:

BigDecimal

а окончательный результат округляется:

.setScale(2, RoundingMode.HALF_UP);

Поэтому вместо:

2234.56789

получим:

2234.57

5. Отменённые приёмы

До расчёта стоимости проверяется статус:

if (!"completed".equals(apt.get("status"))) {
    continue;
}

Поэтому:

canceled
scheduled

не попадут в счёт.

Для исходных данных результат будет примерно таким:

Счета за приёмы:
Волков -> Иванов: 2250.00 руб.
Зайцева -> Петрова: ошибка — base_price must not be negative
Лисицын -> Сидоров: 3000.00 руб.

Приём Медведевой не выводится, поскольку его статус:

canceled

Главные исправления — не скрывать ошибки через catch (Exception), корректно обрабатывать отсутствующую скидку, валидировать отрицательные цены, использовать BigDecimal, округлять денежные значения до двух знаков, фильтровать статус и действительно изменять цену через put().