Task Livecoding Java Payroll System Review and Fix

93. Code review системы расчёта зарплат + бизнес-вопросы #

Условие задачи:
📌 Есть «наследованный» класс PayrollSystem, который считает зарплаты сотрудников.
Данные приходят из внешней системы в виде List<Map<String, Object>> (мы на них повлиять не можем).

Жалобы бухгалтерии:

  1. В отчётах иногда пропадают сотрудники (особенно новые).

  2. В ведомости появляются отрицательные суммы зарплат.

  3. При изменении оклада система не всегда обновляет данные.

  4. В цифрах много дробных значений (5234.56789 вместо 5234.57).

  5. При ошибке в данных (текст вместо цифр) система «зависает» без объяснения.

Нужно:

  • провести code review,

  • предложить исправления,

  • сформулировать, какие вопросы задать бизнесу, чтобы правильно решить проблему.

Текущий код (фрагмент):

public class PayrollSystem {  
static List<Map<String, Object>> employees = new ArrayList<>();  
  
static {  
Map<String, Object> emp1 = new HashMap<>();  
emp1.put("id", 1);  
emp1.put("name", "Иванов");  
emp1.put("base_salary", 40000);  
emp1.put("bonus", 10000);  
emp1.put("tax_rate", 0.13);  
employees.add(emp1);  
  
Map<String, Object> emp2 = new HashMap<>();  
emp2.put("id", 2);  
emp2.put("name", "Петрова");  
emp2.put("base_salary", 50000.54);  
emp2.put("bonus", 6000);  
emp2.put("tax_rate", 0.13);  
employees.add(emp2);  
  
Map<String, Object> emp3 = new HashMap<>();  
emp3.put("id", 3);  
emp3.put("name", "Сидоров");  
emp3.put("base_salary", 30000.25);  
emp3.put("bonus", null);  
emp3.put("tax_rate", 0.13);  
employees.add(emp3);  
  
Map<String, Object> emp4 = new HashMap<>();  
emp4.put("id", 4);  
emp4.put("name", "Кузнецов");  
emp4.put("base_salary", 70000);  
emp4.put("bonus", 20000.30);  
emp4.put("tax_rate", 0.15);  
employees.add(emp4);  
}  
  
static double calculateSalary(Map<String, Object> employee) {  
double baseSalary = ((Number) employee.get("base_salary")).doubleValue();  
double bonus = ((Number) employee.get("bonus")).doubleValue();  
double total = baseSalary + bonus;  
double tax = ((Number) employee.get("tax_rate")).doubleValue();  
total = total - total * tax;  
return total;  
}  
  
static List<Map<String, Object>> generatePayroll() {  
List<Map<String, Object>> payroll = new ArrayList<>();  
for (Map<String, Object> emp : employees) {  
try {  
double netSalary = calculateSalary(emp);  
Map<String, Object> entry = new HashMap<>();  
entry.put("name", emp.get("name"));  
entry.put("salary", netSalary);  
payroll.add(entry);  
} catch (Exception e) {  
continue;  
}  
}  
return payroll;  
}  
  
static void printPayroll() {  
List<Map<String, Object>> payroll = generatePayroll();  
for (Map<String, Object> p : payroll) {  
System.out.println(p.get("name") + ": " + p.get("salary") + " руб.");  
}  
}  
  
static void updateBaseSalary(int employeeId, double newSalary) {  
for (Map<String, Object> emp : employees) {  
if ((int) emp.get("id") == employeeId) {  
emp.put("base_salary", newSalary);  
break;  
}  
}  
}  
  
public static void main(String[] args) {  
System.out.println("Расчет зарплат:");  
printPayroll();  
}  
}

Спойлеры к решению
Подсказки
💡 Пропадающие сотрудники = где-то ловим Exception и тихо делаем continue. Любая ошибка → сотрудник исчез.
💡 bonus может быть null((Number) null) даёт NullPointerException.
💡 Object-поля позволяют влететь в ClassCastException (текст вместо числа). Сейчас это просто глушится.
💡 Для денег лучше BigDecimal с setScale(2, RoundingMode.HALF_UP).
💡 Отрицательная зарплата должна быть ошибкой данных, а не тихо проходить.
💡 updateBaseSalary меняет только in-memory список; при новой загрузке из внешней системы всё откатится.

Решение

1. Какие проблемы в текущем коде

  1. Пропадают сотрудники

    static List<Map<String, Object>> generatePayroll() {
        ...
        try {
            double netSalary = calculateSalary(emp);
            ...
            payroll.add(entry);
        } catch (Exception e) {
            continue;
        }
    }
    
    • Любая ошибка (NPE, ClassCastException, NumberFormat…) → сотрудник просто пропускается.

    • Именно поэтому «особенно новые» могут исчезать: у них чаще всего кривые/новые поля.

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

    • Нет никакой валидации входных данных.

    • Если из внешней системы придёт отрицательный bonus или завышенный tax_rate (например, 1.5), то итог может стать отрицательным — и мы это спокойно выводим в ведомость.

  3. Изменение оклада не всегда обновляется

    static void updateBaseSalary(int employeeId, double newSalary) {
        for (Map<String, Object> emp : employees) {
            if ((int) emp.get("id") == employeeId) {
                emp.put("base_salary", newSalary);
                break;
            }
        }
    }
    

    Возможные причины:

    • employeesstatic in-memory. Если каждый расчёт запускается заново или данные заново подгружаются из внешней системы, изменения не живут дольше одного запуска.

    • Если у сотрудника некорректные данные после обновления (например, bonus = null / tax_rate = "0.13%" строкой), он попадает в catch и исчезает из отчёта → бухгалтер говорит «не обновилось».

  4. Слишком много дробных значений

    • Работает сырая арифметика с double: тип не денежный, ошибки округления гарантированы.

    • Нет принудительного setScale(2) → формат плавает.

  5. Система зависает при ошибке в данных

    • Реально она не «зависает», а тихо глотает ошибки:

      } catch (Exception e) {
          continue;
      }
      
    • В логах ничего нет, в отчёте сотрудник пропал → для бизнеса это выглядит как «зависло/сломалось, непонятно что».


2. Улучшенная реализация расчёта (идея и пример кода)

Не трогаем формат данных от внешней системы (Map<String, Object>), но:

  • Вводим типизированный DTO для внутренней работы.

  • Используем BigDecimal для денег.

  • Явно валидируем данные и логируем ошибки.

Условно:

import java.math.BigDecimal;
import java.math.RoundingMode;

record EmployeePayData(
        Integer id,
        String name,
        BigDecimal baseSalary,
        BigDecimal bonus,
        BigDecimal taxRate
) {}

record PayrollEntry(Integer id, String name, BigDecimal netSalary) {}

public class PayrollSystem {

    // employees как дано во входном коде — не трогаем

    static EmployeePayData mapEmployee(Map<String, Object> raw) {
        Integer id = (Integer) raw.get("id");
        String name = (String) raw.get("name");

        BigDecimal base = toMoney(raw.get("base_salary"), "base_salary", name, true);
        BigDecimal bonus = toMoney(raw.get("bonus"), "bonus", name, false); // может быть null → 0
        BigDecimal taxRate = toRate(raw.get("tax_rate"), "tax_rate", name);

        return new EmployeePayData(
                id,
                name,
                base,
                bonus != null ? bonus : BigDecimal.ZERO,
                taxRate
        );
    }

    private static BigDecimal toMoney(Object value, String field, String empName, boolean required) {
        if (value == null) {
            if (required) {
                throw new IllegalArgumentException("Поле " + field + " обязательно для сотрудника " + empName);
            } else {
                return null;
            }
        }
        if (value instanceof Integer i) {
            return BigDecimal.valueOf(i);
        }
        if (value instanceof Long l) {
            return BigDecimal.valueOf(l);
        }
        if (value instanceof Double d) {
            return BigDecimal.valueOf(d);
        }
        if (value instanceof String s) {
            return new BigDecimal(s);
        }
        throw new IllegalArgumentException("Некорректный тип поля " + field + " для " + empName + ": " + value.getClass());
    }

    private static BigDecimal toRate(Object value, String field, String empName) {
        if (value == null) {
            throw new IllegalArgumentException("Ставка налога обязательна для " + empName);
        }
        BigDecimal rate;
        if (value instanceof Number n) {
            rate = BigDecimal.valueOf(n.doubleValue());
        } else if (value instanceof String s) {
            rate = new BigDecimal(s);
        } else {
            throw new IllegalArgumentException("Некорректный тип " + field + " для " + empName);
        }
        if (rate.compareTo(BigDecimal.ZERO) < 0 || rate.compareTo(BigDecimal.ONE) > 0) {
            throw new IllegalArgumentException("Некорректная ставка налога " + rate + " для " + empName);
        }
        return rate;
    }

    static BigDecimal calculateSalary(EmployeePayData emp) {
        BigDecimal total = emp.baseSalary().add(emp.bonus());
        BigDecimal tax = total.multiply(emp.taxRate());
        BigDecimal net = total.subtract(tax);

        if (net.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Отрицательная ЗП для " + emp.name() + ": " + net);
        }

        return net.setScale(2, RoundingMode.HALF_UP);
    }

    static List<PayrollEntry> generatePayroll() {
        List<PayrollEntry> payroll = new ArrayList<>();

        for (Map<String, Object> raw : employees) {
            String name = (String) raw.get("name");
            try {
                EmployeePayData emp = mapEmployee(raw);
                BigDecimal net = calculateSalary(emp);
                payroll.add(new PayrollEntry(emp.id(), emp.name(), net));
            } catch (Exception e) {
                // тут в реальной системе логируем
                System.err.println("Ошибка при расчете ЗП для " + name + ": " + e.getMessage());
                // возможно, всё равно добавляем запись с статусом "ошибка"
                // payroll.add(new PayrollEntry(empId, name, null));
            }
        }

        return payroll;
    }

    static void printPayroll() {
        List<PayrollEntry> payroll = generatePayroll();
        for (PayrollEntry p : payroll) {
            System.out.println(p.name() + ": " + p.netSalary() + " руб.");
        }
    }
}

Что это даёт по пунктам жалоб:

  1. Пропадающие сотрудники

    • Теперь сотрудник либо:

      • появляется с посчитанной ЗП,

      • либо попадает в лог с понятной ошибкой (и можно решить на стороне данных).

    • Мы НЕ молча их игнорируем.

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

    • В calculateSalary отрицательная ЗП → исключение и явная ошибка, а не тихий бред в отчёте.
  3. Обновление оклада

    • Нужно договориться с бизнесом: кто «мастер» данных — внешняя система или наша.

    • Если мы всё-таки обновляем в нашей системе, то:

      • либо изменения должны сохраняться в БД и синхронизироваться с внешней системой,

      • либо updateBaseSalary — только временная корректировка на один расчёт (и это должно быть прямо описано бизнесу).

  4. Дробные значения

    • BigDecimal.setScale(2, RoundingMode.HALF_UP) → всегда две цифры после запятой.
  5. «Зависание» при ошибке

    • Мы не глушим исключения, а логируем: понятно, чей сотрудник, какое поле, что именно не так.

    • При желании можно возвращать статус «ошибка данных» вплоть до UI.


3. Вопросы, которые я бы задал бизнесу / бухгалтерии

Я не буду задавать их тебе «вживую» (по условиям системы), но вот список, которые я как разработчик обязательно бы уточнил у бизнеса:

  1. Что делать с сотрудником, у которого некорректные данные?

    • Пропускать сотрудника?

    • Показывать его в отчёте со статусом «Ошибка данных» и пустой ЗП?

    • Ставить ЗП = 0, но подсвечивать проблему?

  2. Кто владелец (мастер) данных по окладу?

    • Наша система или внешняя?

    • Если оклад изменили в нашей системе, должно ли это изменение улетать обратно во внешнюю систему?

  3. Как бизнес относится к отрицательной зарплате?

    • Это всегда ошибка данных?

    • Или поддерживаются случаи удержаний/штрафов, когда один расчёт может быть отрицательным? Если да — как это показывать в отчёте?

  4. Требования к округлению денег:

    • Всегда до двух знаков после запятой?

    • Какой режим округления: HALF_UP, HALF_DOWN, HALF_EVEN?

    • Нужно ли отдельно показывать «грязную» и «чистую» сумму?

  5. Что считать «успешным расчётом» отчёта:

    • Если у части сотрудников ошибки в данных — считать отчёт построенным или «отчёт с ошибками» и блокировать выгрузку?

    • Нужен ли агрегированный список всех проблем по сотрудникам (для бухгалтера или HR)?

  6. Частота обновления данных / режим работы:

    • Отчёт строится за один запуск или система живёт постоянно и хранит состояние?

    • Можно ли нам отказаться от static-структур и перейти к БД/кэшу?


Таким ответом ты покрываешь и техническую часть (code review / правки), и бизнесовую (какие решения нельзя принимать без требований).