|
@@ -0,0 +1,407 @@
|
|
|
|
|
+package com.example.hrlab.payroll;
|
|
|
|
|
+
|
|
|
|
|
+import com.example.hrlab.audit.AuditService;
|
|
|
|
|
+import com.example.hrlab.common.AsyncTaskPublisher;
|
|
|
|
|
+import com.example.hrlab.common.BusinessException;
|
|
|
|
|
+import com.example.hrlab.common.ErrorCodes;
|
|
|
|
|
+import com.example.hrlab.common.JsonUtils;
|
|
|
|
|
+import com.example.hrlab.config.AppProperties;
|
|
|
|
|
+import com.example.hrlab.integration.IntegrationEventService;
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.math.RoundingMode;
|
|
|
|
|
+import java.time.Clock;
|
|
|
|
|
+import java.time.Duration;
|
|
|
|
|
+import java.time.LocalDate;
|
|
|
|
|
+import java.time.OffsetDateTime;
|
|
|
|
|
+import java.time.temporal.ChronoUnit;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+import org.springframework.transaction.support.TransactionTemplate;
|
|
|
|
|
+
|
|
|
|
|
+@Service
|
|
|
|
|
+public class PayrollService {
|
|
|
|
|
+ private final JdbcTemplate jdbcTemplate;
|
|
|
|
|
+ private final TransactionTemplate transactionTemplate;
|
|
|
|
|
+ private final PayrollCalculator calculator;
|
|
|
|
|
+ private final PayrollFormulaEngine formulaEngine;
|
|
|
|
|
+ private final AppProperties properties;
|
|
|
|
|
+ private final Clock clock;
|
|
|
|
|
+ private final AuditService auditService;
|
|
|
|
|
+ private final IntegrationEventService integrationEventService;
|
|
|
|
|
+ private final AsyncTaskPublisher taskPublisher;
|
|
|
|
|
+
|
|
|
|
|
+ public PayrollService(JdbcTemplate jdbcTemplate, TransactionTemplate transactionTemplate, PayrollCalculator calculator,
|
|
|
|
|
+ PayrollFormulaEngine formulaEngine,
|
|
|
|
|
+ AppProperties properties, Clock clock, AuditService auditService,
|
|
|
|
|
+ IntegrationEventService integrationEventService, AsyncTaskPublisher taskPublisher) {
|
|
|
|
|
+ this.jdbcTemplate = jdbcTemplate;
|
|
|
|
|
+ this.transactionTemplate = transactionTemplate;
|
|
|
|
|
+ this.calculator = calculator;
|
|
|
|
|
+ this.formulaEngine = formulaEngine;
|
|
|
|
|
+ this.properties = properties;
|
|
|
|
|
+ this.clock = clock;
|
|
|
|
|
+ this.auditService = auditService;
|
|
|
|
|
+ this.integrationEventService = integrationEventService;
|
|
|
|
|
+ this.taskPublisher = taskPublisher;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Long submitRun(String periodKey, PayrollRunRequest request) {
|
|
|
|
|
+ Map<String, Object> period = jdbcTemplate.queryForMap("SELECT * FROM payroll_periods WHERE period_key = ?", periodKey);
|
|
|
|
|
+ if ("LOCKED".equals(period.get("status"))) {
|
|
|
|
|
+ throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "薪酬期间已锁定");
|
|
|
|
|
+ }
|
|
|
|
|
+ Integer nextRunNo = jdbcTemplate.queryForObject("""
|
|
|
|
|
+ SELECT COALESCE(max(run_no), 0) + 1 FROM payroll_runs WHERE payroll_period_id = ?
|
|
|
|
|
+ """, Integer.class, period.get("id"));
|
|
|
|
|
+ Long runId = jdbcTemplate.queryForObject("""
|
|
|
|
|
+ INSERT INTO payroll_runs(payroll_period_id, run_no, formula_version, status, started_at)
|
|
|
|
|
+ VALUES (?, ?, ?, 'QUEUED', ?) RETURNING id
|
|
|
|
|
+ """, Long.class, period.get("id"), nextRunNo, request.formulaVersion() == null ? "v1.0.0" : request.formulaVersion(),
|
|
|
|
|
+ OffsetDateTime.now(clock));
|
|
|
|
|
+ auditService.record("PAYROLL_RUN_SUBMIT", "payroll_runs", runId, null, request);
|
|
|
|
|
+ if (properties.tasks().inline()) {
|
|
|
|
|
+ processRun(runId, request.includeDepartments());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ taskPublisher.publish("payroll", String.valueOf(runId));
|
|
|
|
|
+ }
|
|
|
|
|
+ return runId;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void processRun(Long runId, List<Long> departments) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (properties.lab().failpoint().payrollExtraDelayMs() > 0) {
|
|
|
|
|
+ Thread.sleep(properties.lab().failpoint().payrollExtraDelayMs());
|
|
|
|
|
+ }
|
|
|
|
|
+ transactionTemplate.executeWithoutResult(status -> calculateInsideTransaction(runId, departments));
|
|
|
|
|
+ jdbcTemplate.update("UPDATE payroll_runs SET status = 'CALCULATED', finished_at = ?, error_message = NULL, version = version + 1, updated_at = now() WHERE id = ?",
|
|
|
|
|
+ OffsetDateTime.now(clock), runId);
|
|
|
|
|
+ } catch (Exception ex) {
|
|
|
|
|
+ jdbcTemplate.update("UPDATE payroll_runs SET status = 'FAILED', finished_at = ?, error_message = ?, version = version + 1, updated_at = now() WHERE id = ?",
|
|
|
|
|
+ OffsetDateTime.now(clock), ex.getMessage(), runId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void calculateInsideTransaction(Long runId, List<Long> departments) {
|
|
|
|
|
+ Map<String, Object> run = jdbcTemplate.queryForMap("""
|
|
|
|
|
+ SELECT pr.*, pp.start_date, pp.end_date FROM payroll_runs pr
|
|
|
|
|
+ JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
|
|
|
|
|
+ WHERE pr.id = ?
|
|
|
|
|
+ """, runId);
|
|
|
|
|
+ LocalDate start = ((java.sql.Date) run.get("start_date")).toLocalDate();
|
|
|
|
|
+ LocalDate end = ((java.sql.Date) run.get("end_date")).toLocalDate();
|
|
|
|
|
+ jdbcTemplate.update("DELETE FROM payslips WHERE payroll_run_id = ?", runId);
|
|
|
|
|
+ List<Map<String, Object>> payrollItems = jdbcTemplate.queryForList("SELECT * FROM payroll_items ORDER BY item_code");
|
|
|
|
|
+ String deptSql = departments == null || departments.isEmpty() ? "" : " AND department_id IN (" + departments.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse("") + ")";
|
|
|
|
|
+ List<Map<String, Object>> employees = jdbcTemplate.queryForList("""
|
|
|
|
|
+ SELECT id, department_id, leave_date FROM employees
|
|
|
|
|
+ WHERE employment_status IN ('ACTIVE','LEFT') AND archived = false
|
|
|
|
|
+ AND (leave_date IS NULL OR leave_date >= ?)
|
|
|
|
|
+ """ + deptSql, start);
|
|
|
|
|
+ int employeeCount = 0;
|
|
|
|
|
+ BigDecimal totalGross = BigDecimal.ZERO;
|
|
|
|
|
+ BigDecimal totalDeduction = BigDecimal.ZERO;
|
|
|
|
|
+ BigDecimal totalNet = BigDecimal.ZERO;
|
|
|
|
|
+ for (Map<String, Object> employee : employees) {
|
|
|
|
|
+ Long employeeId = ((Number) employee.get("id")).longValue();
|
|
|
|
|
+ Long departmentId = ((Number) employee.get("department_id")).longValue();
|
|
|
|
|
+ BigDecimal ratio = prorateRatio(start, end, (java.sql.Date) employee.get("leave_date"));
|
|
|
|
|
+ long totalDays = ChronoUnit.DAYS.between(start, end.plusDays(1));
|
|
|
|
|
+ long paidDays = paidDays(start, end, (java.sql.Date) employee.get("leave_date"));
|
|
|
|
|
+ CalculationResult calculation = calculateAmountWithItems(payrollItems, employeeId, departmentId, start, end, ratio, paidDays, totalDays);
|
|
|
|
|
+ PayrollCalculator.PayslipAmount amount = calculation.amount();
|
|
|
|
|
+ List<Map<String, Object>> detail = calculateLines(calculation.lines(), amount);
|
|
|
|
|
+ jdbcTemplate.update("""
|
|
|
|
|
+ INSERT INTO payslips(payroll_run_id, employee_id, base_salary, allowance_total, attendance_deduction,
|
|
|
|
|
+ performance_bonus, manual_adjustment, deduction_total, net_amount, detail_jsonb)
|
|
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb)
|
|
|
|
|
+ """, runId, employeeId, amount.baseSalary(), amount.allowanceTotal(), amount.attendanceDeduction(),
|
|
|
|
|
+ amount.performanceBonus(), amount.manualAdjustment(), amount.deductionTotal(), amount.netAmount(),
|
|
|
|
|
+ JsonUtils.toJson(detail));
|
|
|
|
|
+ employeeCount++;
|
|
|
|
|
+ totalGross = totalGross.add(amount.baseSalary()).add(amount.allowanceTotal()).add(amount.performanceBonus()).add(amount.manualAdjustment());
|
|
|
|
|
+ totalDeduction = totalDeduction.add(amount.deductionTotal());
|
|
|
|
|
+ totalNet = totalNet.add(amount.netAmount());
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> summary = new LinkedHashMap<>();
|
|
|
|
|
+ summary.put("employeeCount", employeeCount);
|
|
|
|
|
+ summary.put("totalGross", money(totalGross));
|
|
|
|
|
+ summary.put("totalDeduction", money(totalDeduction));
|
|
|
|
|
+ summary.put("totalNet", money(totalNet));
|
|
|
|
|
+ summary.put("formulaItemCount", payrollItems.size());
|
|
|
|
|
+ summary.put("periodStart", start);
|
|
|
|
|
+ summary.put("periodEnd", end);
|
|
|
|
|
+ jdbcTemplate.update("UPDATE payroll_runs SET summary_jsonb = ?::jsonb, updated_at = now() WHERE id = ?",
|
|
|
|
|
+ JsonUtils.toJson(summary), runId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private BigDecimal prorateRatio(LocalDate start, LocalDate end, java.sql.Date leaveDateSql) {
|
|
|
|
|
+ if (leaveDateSql == null) {
|
|
|
|
|
+ return BigDecimal.ONE;
|
|
|
|
|
+ }
|
|
|
|
|
+ LocalDate leaveDate = leaveDateSql.toLocalDate();
|
|
|
|
|
+ if (leaveDate.isAfter(end) || leaveDate.isEqual(end)) {
|
|
|
|
|
+ return BigDecimal.ONE;
|
|
|
|
|
+ }
|
|
|
|
|
+ long totalDays = ChronoUnit.DAYS.between(start, end.plusDays(1));
|
|
|
|
|
+ long paidDays = Math.max(0, ChronoUnit.DAYS.between(start, leaveDate.plusDays(1)));
|
|
|
|
|
+ return BigDecimal.valueOf(paidDays).divide(BigDecimal.valueOf(totalDays), 6, RoundingMode.HALF_UP);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private long paidDays(LocalDate start, LocalDate end, java.sql.Date leaveDateSql) {
|
|
|
|
|
+ if (leaveDateSql == null) {
|
|
|
|
|
+ return ChronoUnit.DAYS.between(start, end.plusDays(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ LocalDate leaveDate = leaveDateSql.toLocalDate();
|
|
|
|
|
+ if (leaveDate.isAfter(end) || leaveDate.isEqual(end)) {
|
|
|
|
|
+ return ChronoUnit.DAYS.between(start, end.plusDays(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ return Math.max(0, ChronoUnit.DAYS.between(start, leaveDate.plusDays(1)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CalculationResult calculateAmountWithItems(List<Map<String, Object>> payrollItems,
|
|
|
|
|
+ Long employeeId,
|
|
|
|
|
+ Long departmentId,
|
|
|
|
|
+ LocalDate start,
|
|
|
|
|
+ LocalDate end,
|
|
|
|
|
+ BigDecimal ratio,
|
|
|
|
|
+ long paidDays,
|
|
|
|
|
+ long totalDays) {
|
|
|
|
|
+ calculator.verifyFormulaAvailable();
|
|
|
|
|
+ Map<String, BigDecimal> variables = variables(employeeId, departmentId, start, end, ratio, paidDays, totalDays);
|
|
|
|
|
+ List<LineAmount> lineAmounts = evaluateItems(payrollItems, variables);
|
|
|
|
|
+ BigDecimal base = amountByCode(lineAmounts, "BASE", variables.getOrDefault("proratedBaseSalary", BigDecimal.ZERO));
|
|
|
|
|
+ BigDecimal performance = amountByCode(lineAmounts, "PERF", variables.getOrDefault("performanceBonus", BigDecimal.ZERO));
|
|
|
|
|
+ BigDecimal manual = amountByCode(lineAmounts, "MANUAL", variables.getOrDefault("manualAdjustment", BigDecimal.ZERO));
|
|
|
|
|
+ BigDecimal attendanceDeduction = amountByCode(lineAmounts, "ATT_DEDUCT", variables.getOrDefault("attendanceDeduction", BigDecimal.ZERO));
|
|
|
|
|
+ BigDecimal allowance = sumByType(lineAmounts, "EARNING", List.of("BASE", "PERF", "MANUAL", "NET"));
|
|
|
|
|
+ BigDecimal deduction = lineAmounts.stream().anyMatch(line -> "DEDUCTION_TOTAL".equals(line.code()))
|
|
|
|
|
+ ? amountByCode(lineAmounts, "DEDUCTION_TOTAL", variables.getOrDefault("deductionTotal", BigDecimal.ZERO))
|
|
|
|
|
+ : sumByType(lineAmounts, "DEDUCTION", List.of("DEDUCTION_TOTAL"));
|
|
|
|
|
+ BigDecimal net = amountByCode(lineAmounts, "NET", base.add(allowance).add(performance).add(manual).subtract(deduction));
|
|
|
|
|
+ PayrollCalculator.PayslipAmount amount = new PayrollCalculator.PayslipAmount(money(base), money(allowance),
|
|
|
|
|
+ money(attendanceDeduction), money(performance), money(manual), money(deduction), money(net));
|
|
|
|
|
+ return new CalculationResult(amount, lineAmounts);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<Map<String, Object>> calculateLines(List<LineAmount> lineAmounts, PayrollCalculator.PayslipAmount amount) {
|
|
|
|
|
+ List<Map<String, Object>> lines = new ArrayList<>();
|
|
|
|
|
+ for (LineAmount line : lineAmounts) {
|
|
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
|
|
+ item.put("code", line.code());
|
|
|
|
|
+ item.put("name", line.name());
|
|
|
|
|
+ item.put("type", line.type());
|
|
|
|
|
+ item.put("formula", line.formula());
|
|
|
|
|
+ item.put("amount", money(line.amount()));
|
|
|
|
|
+ lines.add(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ addSystemLine(lines, "DEDUCTION_TOTAL", "扣减合计", "SUMMARY", amount.deductionTotal());
|
|
|
|
|
+ addSystemLine(lines, "NET", "实发金额", "SUMMARY", amount.netAmount());
|
|
|
|
|
+ return lines;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<LineAmount> evaluateItems(List<Map<String, Object>> payrollItems, Map<String, BigDecimal> variables) {
|
|
|
|
|
+ List<LineAmount> results = new ArrayList<>();
|
|
|
|
|
+ for (Map<String, Object> item : payrollItems) {
|
|
|
|
|
+ String code = String.valueOf(item.get("item_code"));
|
|
|
|
|
+ String name = String.valueOf(item.get("item_name"));
|
|
|
|
|
+ String type = String.valueOf(item.get("item_type"));
|
|
|
|
|
+ String formula = item.get("formula") == null ? defaultFormula(code) : String.valueOf(item.get("formula"));
|
|
|
|
|
+ BigDecimal amount = formulaEngine.evaluate(formula, variables);
|
|
|
|
|
+ results.add(new LineAmount(code, name, type, formula, amount));
|
|
|
|
|
+ }
|
|
|
|
|
+ return results;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String defaultFormula(String code) {
|
|
|
|
|
+ return switch (code) {
|
|
|
|
|
+ case "BASE" -> "baseSalary";
|
|
|
|
|
+ case "ALLOWANCE" -> "allowanceTotal";
|
|
|
|
|
+ case "ATT_DEDUCT" -> "attendanceDeduction";
|
|
|
|
|
+ case "PERF" -> "performanceBonus";
|
|
|
|
|
+ case "MANUAL" -> "manualAdjustment";
|
|
|
|
|
+ default -> "0";
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, BigDecimal> variables(Long employeeId, Long departmentId, LocalDate start, LocalDate end,
|
|
|
|
|
+ BigDecimal ratio, long paidDays, long totalDays) {
|
|
|
|
|
+ Map<String, BigDecimal> variables = new HashMap<>();
|
|
|
|
|
+ variables.putAll(resolveConfiguredVariables(employeeId, departmentId));
|
|
|
|
|
+ AttendanceFacts facts = attendanceFacts(employeeId, start, end, totalDays);
|
|
|
|
|
+ variables.putIfAbsent("workHours", facts.workHours());
|
|
|
|
|
+ variables.putIfAbsent("attendanceDays", BigDecimal.valueOf(facts.attendanceDays()));
|
|
|
|
|
+ variables.putIfAbsent("absenceDays", BigDecimal.valueOf(facts.absenceDays()));
|
|
|
|
|
+ variables.put("anomalyCount", BigDecimal.valueOf(facts.anomalyCount()));
|
|
|
|
|
+ variables.put("paidDays", BigDecimal.valueOf(paidDays));
|
|
|
|
|
+ variables.put("totalDays", BigDecimal.valueOf(totalDays));
|
|
|
|
|
+ variables.put("prorateRatio", ratio);
|
|
|
|
|
+ BigDecimal baseSalary = variables.getOrDefault("baseSalary", BigDecimal.ZERO);
|
|
|
|
|
+ variables.put("standardBaseSalary", baseSalary);
|
|
|
|
|
+ variables.put("proratedBaseSalary", money(baseSalary.multiply(ratio)));
|
|
|
|
|
+ variables.putIfAbsent("allowanceTotal", money(
|
|
|
|
|
+ variables.getOrDefault("highTemperatureDays", BigDecimal.ZERO).multiply(variables.getOrDefault("highTemperatureAllowanceRate", BigDecimal.ZERO))
|
|
|
|
|
+ .add(variables.getOrDefault("specialEnvironmentDays", BigDecimal.ZERO).multiply(variables.getOrDefault("specialEnvironmentAllowanceRate", BigDecimal.ZERO)))
|
|
|
|
|
+ .add(variables.getOrDefault("holidayDays", BigDecimal.ZERO).multiply(variables.getOrDefault("holidayAllowanceRate", BigDecimal.ZERO)))
|
|
|
|
|
+ .add(variables.getOrDefault("businessTripDays", BigDecimal.ZERO).multiply(variables.getOrDefault("businessTripAllowanceRate", BigDecimal.ZERO)))));
|
|
|
|
|
+ variables.putIfAbsent("attendanceDeduction", variables.get("anomalyCount").multiply(variables.getOrDefault("attendanceDeductionPerAnomaly", BigDecimal.ZERO)));
|
|
|
|
|
+ variables.putIfAbsent("performanceBonus", variables.getOrDefault("monthlyPerformance", BigDecimal.ZERO).multiply(variables.getOrDefault("performanceBonusRate", BigDecimal.ZERO)));
|
|
|
|
|
+ variables.putIfAbsent("manualAdjustment", variables.getOrDefault("extraRewardPenalty", BigDecimal.ZERO));
|
|
|
|
|
+ variables.putIfAbsent("deductionTotal", variables.getOrDefault("attendanceDeduction", BigDecimal.ZERO).max(BigDecimal.ZERO));
|
|
|
|
|
+ variables.putIfAbsent("netAmount", variables.get("proratedBaseSalary")
|
|
|
|
|
+ .add(variables.getOrDefault("allowanceTotal", BigDecimal.ZERO))
|
|
|
|
|
+ .add(variables.getOrDefault("performanceBonus", BigDecimal.ZERO))
|
|
|
|
|
+ .add(variables.getOrDefault("manualAdjustment", BigDecimal.ZERO))
|
|
|
|
|
+ .subtract(variables.getOrDefault("deductionTotal", BigDecimal.ZERO)));
|
|
|
|
|
+ return variables;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, BigDecimal> resolveConfiguredVariables(Long employeeId, Long departmentId) {
|
|
|
|
|
+ Map<String, BigDecimal> variables = new HashMap<>();
|
|
|
|
|
+ List<Map<String, Object>> departmentVariables = jdbcTemplate.queryForList("""
|
|
|
|
|
+ WITH RECURSIVE dept_tree AS (
|
|
|
|
|
+ SELECT id, parent_id, 0 depth FROM departments WHERE id = ?
|
|
|
|
|
+ UNION ALL
|
|
|
|
|
+ SELECT d.id, d.parent_id, dt.depth + 1 FROM departments d
|
|
|
|
|
+ JOIN dept_tree dt ON dt.parent_id = d.id
|
|
|
|
|
+ )
|
|
|
|
|
+ SELECT pv.variable_key, pv.variable_value
|
|
|
|
|
+ FROM dept_tree dt
|
|
|
|
|
+ JOIN payroll_variables pv ON pv.scope_type = 'DEPARTMENT' AND pv.department_id = dt.id
|
|
|
|
|
+ ORDER BY dt.depth DESC, pv.variable_key
|
|
|
|
|
+ """, departmentId);
|
|
|
|
|
+ for (Map<String, Object> variable : departmentVariables) {
|
|
|
|
|
+ variables.put(String.valueOf(variable.get("variable_key")), (BigDecimal) variable.get("variable_value"));
|
|
|
|
|
+ }
|
|
|
|
|
+ List<Map<String, Object>> employeeVariables = jdbcTemplate.queryForList("""
|
|
|
|
|
+ SELECT variable_key, variable_value FROM payroll_variables
|
|
|
|
|
+ WHERE scope_type = 'EMPLOYEE' AND employee_id = ?
|
|
|
|
|
+ ORDER BY variable_key
|
|
|
|
|
+ """, employeeId);
|
|
|
|
|
+ for (Map<String, Object> variable : employeeVariables) {
|
|
|
|
|
+ variables.put(String.valueOf(variable.get("variable_key")), (BigDecimal) variable.get("variable_value"));
|
|
|
|
|
+ }
|
|
|
|
|
+ return variables;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private AttendanceFacts attendanceFacts(Long employeeId, LocalDate start, LocalDate end, long totalDays) {
|
|
|
|
|
+ List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
|
|
|
+ SELECT clock_in_at, clock_out_at, anomaly_status FROM attendance_records
|
|
|
|
|
+ WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
|
|
|
|
+ """, employeeId, start, end);
|
|
|
|
|
+ BigDecimal workHours = BigDecimal.ZERO;
|
|
|
|
|
+ int attendanceDays = 0;
|
|
|
|
|
+ int anomalies = 0;
|
|
|
|
|
+ for (Map<String, Object> row : rows) {
|
|
|
|
|
+ if (!"NORMAL".equals(row.get("anomaly_status"))) {
|
|
|
|
|
+ anomalies++;
|
|
|
|
|
+ }
|
|
|
|
|
+ OffsetDateTime in = toOffsetDateTime(row.get("clock_in_at"));
|
|
|
|
|
+ OffsetDateTime out = toOffsetDateTime(row.get("clock_out_at"));
|
|
|
|
|
+ if (in != null && out != null) {
|
|
|
|
|
+ attendanceDays++;
|
|
|
|
|
+ workHours = workHours.add(BigDecimal.valueOf(Duration.between(in, out).toMinutes())
|
|
|
|
|
+ .divide(new BigDecimal("60"), 2, RoundingMode.HALF_UP));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ long absenceDays = Math.max(0, totalDays - attendanceDays);
|
|
|
|
|
+ return new AttendanceFacts(workHours, attendanceDays, absenceDays, anomalies);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private OffsetDateTime toOffsetDateTime(Object value) {
|
|
|
|
|
+ if (value instanceof OffsetDateTime offsetDateTime) {
|
|
|
|
|
+ return offsetDateTime;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value instanceof java.sql.Timestamp timestamp) {
|
|
|
|
|
+ return timestamp.toInstant().atOffset(clock.getZone().getRules().getOffset(timestamp.toInstant()));
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private BigDecimal amountByCode(List<LineAmount> lines, String code, BigDecimal fallback) {
|
|
|
|
|
+ return lines.stream().filter(line -> code.equals(line.code())).map(LineAmount::amount).findFirst().orElse(fallback);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private BigDecimal sumByType(List<LineAmount> lines, String type, List<String> excludedCodes) {
|
|
|
|
|
+ return lines.stream()
|
|
|
|
|
+ .filter(line -> type.equals(line.type()))
|
|
|
|
|
+ .filter(line -> !excludedCodes.contains(line.code()))
|
|
|
|
|
+ .map(LineAmount::amount)
|
|
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void addSystemLine(List<Map<String, Object>> lines, String code, String name, String type, BigDecimal amount) {
|
|
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
|
|
+ item.put("code", code);
|
|
|
|
|
+ item.put("name", name);
|
|
|
|
|
+ item.put("type", type);
|
|
|
|
|
+ item.put("formula", "");
|
|
|
|
|
+ item.put("amount", money(amount));
|
|
|
|
|
+ lines.add(item);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private BigDecimal money(BigDecimal value) {
|
|
|
|
|
+ return value.setScale(2, RoundingMode.HALF_UP);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private record LineAmount(String code, String name, String type, String formula, BigDecimal amount) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private record CalculationResult(PayrollCalculator.PayslipAmount amount, List<LineAmount> lines) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private record AttendanceFacts(BigDecimal workHours, int attendanceDays, long absenceDays, int anomalyCount) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void transitionRun(Long id, String targetStatus, String action) {
|
|
|
|
|
+ Map<String, Object> before = jdbcTemplate.queryForMap("SELECT * FROM payroll_runs WHERE id = ?", id);
|
|
|
|
|
+ if ("LOCKED".equals(before.get("status"))) {
|
|
|
|
|
+ throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "已锁定批次不得修改");
|
|
|
|
|
+ }
|
|
|
|
|
+ jdbcTemplate.update("UPDATE payroll_runs SET status = ?, version = version + 1, updated_at = now() WHERE id = ?",
|
|
|
|
|
+ targetStatus, id);
|
|
|
|
|
+ auditService.record(action, "payroll_runs", id, before, Map.of("status", targetStatus));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void publishPayslip(Long id) {
|
|
|
|
|
+ Map<String, Object> before = jdbcTemplate.queryForMap("SELECT * FROM payslips WHERE id = ?", id);
|
|
|
|
|
+ Long runId = ((Number) before.get("payroll_run_id")).longValue();
|
|
|
|
|
+ String status = jdbcTemplate.queryForObject("SELECT status FROM payroll_runs WHERE id = ?", String.class, runId);
|
|
|
|
|
+ if (!"LOCKED".equals(status) && !"APPROVED".equals(status)) {
|
|
|
|
|
+ throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "工资批次未批准或锁定,不能发布工资单");
|
|
|
|
|
+ }
|
|
|
|
|
+ jdbcTemplate.update("UPDATE payslips SET published_at = ?, updated_at = now() WHERE id = ?", OffsetDateTime.now(clock), id);
|
|
|
|
|
+ integrationEventService.createEvent("PAYSLIP_PUBLISHED", "payslip:" + id, Map.of("payslipId", id));
|
|
|
|
|
+ auditService.record("PAYSLIP_PUBLISH", "payslips", id, before, Map.of("published", true));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public int publishPayslips(String periodKey, Long runId) {
|
|
|
|
|
+ List<Object> params = new ArrayList<>();
|
|
|
|
|
+ List<String> conditions = new ArrayList<>();
|
|
|
|
|
+ conditions.add("ps.published_at IS NULL");
|
|
|
|
|
+ conditions.add("pr.status IN ('APPROVED', 'LOCKED')");
|
|
|
|
|
+ if (periodKey != null && !periodKey.isBlank()) {
|
|
|
|
|
+ conditions.add("pp.period_key = ?");
|
|
|
|
|
+ params.add(periodKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (runId != null) {
|
|
|
|
|
+ conditions.add("ps.payroll_run_id = ?");
|
|
|
|
|
+ params.add(runId);
|
|
|
|
|
+ }
|
|
|
|
|
+ List<Long> ids = jdbcTemplate.queryForList("""
|
|
|
|
|
+ SELECT ps.id FROM payslips ps
|
|
|
|
|
+ JOIN payroll_runs pr ON pr.id = ps.payroll_run_id
|
|
|
|
|
+ JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
|
|
|
|
|
+ WHERE """ + String.join(" AND ", conditions) + " ORDER BY ps.id", Long.class, params.toArray());
|
|
|
|
|
+ return transactionTemplate.execute(status -> {
|
|
|
|
|
+ for (Long id : ids) {
|
|
|
|
|
+ publishPayslip(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return ids.size();
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|