93. Code review системы расчёта зарплат + бизнес-вопросы #
Условие задачи:
📌 Есть «наследованный» класс PayrollSystem, который считает зарплаты сотрудников.
Данные приходят из внешней системы в виде List<Map<String, Object>> (мы на них повлиять не можем).
Жалобы бухгалтерии:
В отчётах иногда пропадают сотрудники (особенно новые).
В ведомости появляются отрицательные суммы зарплат.
При изменении оклада система не всегда обновляет данные.
В цифрах много дробных значений (5234.56789 вместо 5234.57).
При ошибке в данных (текст вместо цифр) система «зависает» без объяснения.
Нужно:
провести 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. Какие проблемы в текущем коде
Пропадают сотрудники
static List<Map<String, Object>> generatePayroll() { ... try { double netSalary = calculateSalary(emp); ... payroll.add(entry); } catch (Exception e) { continue; } }Любая ошибка (NPE, ClassCastException, NumberFormat…) → сотрудник просто пропускается.
Именно поэтому «особенно новые» могут исчезать: у них чаще всего кривые/новые поля.
Отрицательные суммы
Нет никакой валидации входных данных.
Если из внешней системы придёт отрицательный
bonusили завышенныйtax_rate(например, 1.5), то итог может стать отрицательным — и мы это спокойно выводим в ведомость.
Изменение оклада не всегда обновляется
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; } } }Возможные причины:
employees— static in-memory. Если каждый расчёт запускается заново или данные заново подгружаются из внешней системы, изменения не живут дольше одного запуска.Если у сотрудника некорректные данные после обновления (например,
bonus = null/tax_rate = "0.13%"строкой), он попадает вcatchи исчезает из отчёта → бухгалтер говорит «не обновилось».
Слишком много дробных значений
Работает сырая арифметика с
double: тип не денежный, ошибки округления гарантированы.Нет принудительного
setScale(2)→ формат плавает.
Система зависает при ошибке в данных
Реально она не «зависает», а тихо глотает ошибки:
} 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() + " руб.");
}
}
}
Что это даёт по пунктам жалоб:
Пропадающие сотрудники
Теперь сотрудник либо:
появляется с посчитанной ЗП,
либо попадает в лог с понятной ошибкой (и можно решить на стороне данных).
Мы НЕ молча их игнорируем.
Отрицательные суммы
- В
calculateSalaryотрицательная ЗП → исключение и явная ошибка, а не тихий бред в отчёте.
- В
Обновление оклада
Нужно договориться с бизнесом: кто «мастер» данных — внешняя система или наша.
Если мы всё-таки обновляем в нашей системе, то:
либо изменения должны сохраняться в БД и синхронизироваться с внешней системой,
либо
updateBaseSalary— только временная корректировка на один расчёт (и это должно быть прямо описано бизнесу).
Дробные значения
BigDecimal.setScale(2, RoundingMode.HALF_UP)→ всегда две цифры после запятой.
«Зависание» при ошибке
Мы не глушим исключения, а логируем: понятно, чей сотрудник, какое поле, что именно не так.
При желании можно возвращать статус «ошибка данных» вплоть до UI.
3. Вопросы, которые я бы задал бизнесу / бухгалтерии
Я не буду задавать их тебе «вживую» (по условиям системы), но вот список, которые я как разработчик обязательно бы уточнил у бизнеса:
Что делать с сотрудником, у которого некорректные данные?
Пропускать сотрудника?
Показывать его в отчёте со статусом «Ошибка данных» и пустой ЗП?
Ставить ЗП = 0, но подсвечивать проблему?
Кто владелец (мастер) данных по окладу?
Наша система или внешняя?
Если оклад изменили в нашей системе, должно ли это изменение улетать обратно во внешнюю систему?
Как бизнес относится к отрицательной зарплате?
Это всегда ошибка данных?
Или поддерживаются случаи удержаний/штрафов, когда один расчёт может быть отрицательным? Если да — как это показывать в отчёте?
Требования к округлению денег:
Всегда до двух знаков после запятой?
Какой режим округления:
HALF_UP,HALF_DOWN,HALF_EVEN?Нужно ли отдельно показывать «грязную» и «чистую» сумму?
Что считать «успешным расчётом» отчёта:
Если у части сотрудников ошибки в данных — считать отчёт построенным или «отчёт с ошибками» и блокировать выгрузку?
Нужен ли агрегированный список всех проблем по сотрудникам (для бухгалтера или HR)?
Частота обновления данных / режим работы:
Отчёт строится за один запуск или система живёт постоянно и хранит состояние?
Можно ли нам отказаться от
static-структур и перейти к БД/кэшу?
Таким ответом ты покрываешь и техническую часть (code review / правки), и бизнесовую (какие решения нельзя принимать без требований).