Skip to content

Commit ff85618

Browse files
committed
Merge remote-tracking branch 'origin/shift' into dev
2 parents 24bd4d5 + 57acd34 commit ff85618

34 files changed

+4567
-1444
lines changed

.github/appmod/assessment/reports/report-1770857152479/report.json

Lines changed: 746 additions & 0 deletions
Large diffs are not rendered by default.

backend/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@
7272
<scope>test</scope>
7373
</dependency>
7474

75+
<dependency>
76+
<groupId>com.h2database</groupId>
77+
<artifactId>h2</artifactId>
78+
<scope>test</scope>
79+
</dependency>
80+
7581
<!-- Lombok -->
7682
<dependency>
7783
<groupId>org.projectlombok</groupId>

backend/src/main/java/com/smalltrend/controller/UserController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public ResponseEntity<?> uploadMyAvatar(Authentication authentication, @RequestP
161161
* Cập nhật thông tin user
162162
*/
163163
@PutMapping("/{id}")
164-
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
164+
@PreAuthorize("hasRole('ADMIN')")
165165
public ResponseEntity<?> updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserUpdateRequest request) {
166166
try {
167167
// Validate ID

backend/src/main/java/com/smalltrend/dto/shift/PayrollSummaryResponse.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import lombok.NoArgsConstructor;
77

88
import java.math.BigDecimal;
9+
import java.time.LocalDateTime;
910
import java.util.List;
1011

1112
@Data
@@ -42,5 +43,9 @@ public static class Row {
4243
private BigDecimal grossPay;
4344
private BigDecimal deductions;
4445
private BigDecimal netPay;
46+
private Boolean isPaid;
47+
private LocalDateTime paidAt;
48+
private Integer overdueDays;
49+
private Boolean attendanceFlag;
4550
}
4651
}

backend/src/main/java/com/smalltrend/entity/WorkShift.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,17 +125,32 @@ protected void onUpdate() {
125125

126126
private void calculateWorkingMinutes() {
127127
if (startTime != null && endTime != null) {
128-
// Tính tổng phút trong ca
129-
plannedMinutes = (int) java.time.Duration.between(startTime, endTime).toMinutes();
128+
int dayMinutes = 24 * 60;
129+
int shiftStart = startTime.toSecondOfDay() / 60;
130+
int shiftEnd = endTime.toSecondOfDay() / 60;
131+
132+
plannedMinutes = shiftEnd - shiftStart;
133+
if (plannedMinutes <= 0) {
134+
plannedMinutes += dayMinutes;
135+
}
130136

131137
// Trừ đi thời gian nghỉ
132138
if (breakStartTime != null && breakEndTime != null) {
133-
breakMinutes = (int) java.time.Duration.between(breakStartTime, breakEndTime).toMinutes();
139+
int breakStart = breakStartTime.toSecondOfDay() / 60;
140+
int breakEnd = breakEndTime.toSecondOfDay() / 60;
141+
breakMinutes = breakEnd - breakStart;
142+
if (breakMinutes <= 0) {
143+
breakMinutes += dayMinutes;
144+
}
134145
workingMinutes = plannedMinutes - breakMinutes;
135146
} else {
136147
breakMinutes = 0;
137148
workingMinutes = plannedMinutes;
138149
}
150+
151+
if (workingMinutes < 0) {
152+
workingMinutes = 0;
153+
}
139154
}
140155
}
141156
}

backend/src/main/java/com/smalltrend/repository/PayrollCalculationRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ Optional<PayrollCalculation> findByUserIdAndPayPeriodStartAndPayPeriodEnd(
2424
Integer userId,
2525
LocalDate payPeriodStart,
2626
LocalDate payPeriodEnd);
27+
28+
boolean existsByUserIdAndStatusNotIgnoreCase(Integer userId, String status);
2729
}

backend/src/main/java/com/smalltrend/service/ShiftWorkforceService.java

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ public AttendanceResponse upsertAttendance(AttendanceUpsertRequest request) {
149149

150150
Attendance saved = attendanceRepository.save(attendance);
151151

152-
if (assignment != null && shouldMarkAssignmentCompleted(saved.getStatus())) {
152+
if (assignment != null && shouldMarkAssignmentCompleted(saved)) {
153153
assignment.setStatus("COMPLETED");
154154
assignmentRepository.save(assignment);
155155
}
@@ -269,14 +269,28 @@ public PayrollSummaryResponse buildPayrollSummary(String month,
269269
BigDecimal shiftHours = resolveWorkedHours(assignment.getWorkShift(), attendance);
270270
BigDecimal regularHours = shiftHours.min(BigDecimal.valueOf(8));
271271
BigDecimal overtimeHours = shiftHours.subtract(regularHours).max(BigDecimal.ZERO);
272+
BigDecimal bonusFactor = resolveShiftBonusFactor(assignment.getWorkShift());
273+
BigDecimal overtimeFactor = resolveShiftOvertimeFactor(assignment.getWorkShift());
272274

273275
acc.workedHours = acc.workedHours.add(shiftHours);
274276
acc.overtimeHours = acc.overtimeHours.add(overtimeHours);
277+
acc.adjustedRegularHours = acc.adjustedRegularHours.add(regularHours.multiply(bonusFactor));
278+
acc.adjustedOvertimeHours = acc.adjustedOvertimeHours.add(overtimeHours.multiply(bonusFactor).multiply(overtimeFactor));
275279
}
276280
}
277281

282+
Map<String, LocalDateTime> paidAtMonthMap = paidPayrolls.stream()
283+
.filter(item -> "PAID".equalsIgnoreCase(Optional.ofNullable(item.getStatus()).orElse("")))
284+
.filter(item -> item.getUser() != null && item.getUser().getId() != null)
285+
.filter(item -> item.getPayPeriodStart() != null)
286+
.filter(item -> item.getPaidAt() != null)
287+
.collect(Collectors.toMap(
288+
item -> key(item.getUser().getId(), item.getPayPeriodStart()),
289+
PayrollCalculation::getPaidAt,
290+
(first, second) -> first));
291+
278292
List<PayrollSummaryResponse.Row> rows = accumulators.values().stream()
279-
.map(acc -> toPayrollRow(acc, hourlyRateOverride))
293+
.map(acc -> toPayrollRow(acc, hourlyRateOverride, endDate, paidMonthMap, paidAtMonthMap))
280294
.sorted(Comparator.comparing(PayrollSummaryResponse.Row::getFullName, Comparator.nullsLast(String::compareToIgnoreCase)))
281295
.collect(Collectors.toList());
282296

@@ -447,7 +461,11 @@ public Map<String, Object> buildPayrollPaymentStatus(
447461
: (isPaid ? "Đã thanh toán lương tháng" : "Chưa thanh toán lương tháng"));
448462
}
449463

450-
private PayrollSummaryResponse.Row toPayrollRow(PayrollAccumulator acc, BigDecimal hourlyRateOverride) {
464+
private PayrollSummaryResponse.Row toPayrollRow(PayrollAccumulator acc,
465+
BigDecimal hourlyRateOverride,
466+
LocalDate periodEnd,
467+
Map<String, Boolean> paidMonthMap,
468+
Map<String, LocalDateTime> paidAtMonthMap) {
451469
SalaryProfile salaryProfile = resolveSalaryProfile(acc.user.getId(), hourlyRateOverride);
452470

453471
BigDecimal hourlyRate = salaryProfile.hourlyRate;
@@ -459,11 +477,10 @@ private PayrollSummaryResponse.Row toPayrollRow(PayrollAccumulator acc, BigDecim
459477
boolean eligibleForMonthlySalary = true;
460478

461479
if (salaryProfile.salaryType == SalaryType.HOURLY) {
462-
regularPay = acc.workedHours.subtract(acc.overtimeHours).max(BigDecimal.ZERO)
480+
regularPay = acc.adjustedRegularHours.max(BigDecimal.ZERO)
481+
.multiply(hourlyRate);
482+
overtimePay = acc.adjustedOvertimeHours.max(BigDecimal.ZERO)
463483
.multiply(hourlyRate);
464-
overtimePay = acc.overtimeHours
465-
.multiply(hourlyRate)
466-
.multiply(BigDecimal.valueOf(1.5));
467484
grossPay = regularPay.add(overtimePay);
468485
deductions = BigDecimal.valueOf(acc.absentShifts)
469486
.multiply(hourlyRate)
@@ -482,6 +499,14 @@ private PayrollSummaryResponse.Row toPayrollRow(PayrollAccumulator acc, BigDecim
482499
netPay = grossPay;
483500
}
484501

502+
String paidKey = key(acc.user.getId(), periodEnd.withDayOfMonth(1));
503+
boolean isPaid = Boolean.TRUE.equals(paidMonthMap.get(paidKey));
504+
LocalDate dueDate = periodEnd.plusDays(5);
505+
int overdueDays = !isPaid && LocalDate.now().isAfter(dueDate)
506+
? (int) ChronoUnit.DAYS.between(dueDate, LocalDate.now())
507+
: 0;
508+
boolean attendanceFlag = acc.absentShifts > 0 || acc.lateShifts > 0;
509+
485510
return PayrollSummaryResponse.Row.builder()
486511
.userId(acc.user.getId())
487512
.fullName(acc.user.getFullName())
@@ -499,9 +524,40 @@ private PayrollSummaryResponse.Row toPayrollRow(PayrollAccumulator acc, BigDecim
499524
.grossPay(grossPay.setScale(2, RoundingMode.HALF_UP))
500525
.deductions(deductions.setScale(2, RoundingMode.HALF_UP))
501526
.netPay(netPay.setScale(2, RoundingMode.HALF_UP))
527+
.isPaid(isPaid)
528+
.paidAt(paidAtMonthMap.get(paidKey))
529+
.overdueDays(overdueDays)
530+
.attendanceFlag(attendanceFlag)
502531
.build();
503532
}
504533

534+
private BigDecimal resolveShiftBonusFactor(WorkShift shift) {
535+
if (shift == null) {
536+
return BigDecimal.ONE;
537+
}
538+
539+
BigDecimal totalBonusPercent = BigDecimal.ZERO;
540+
if (shift.getNightShiftBonus() != null) {
541+
totalBonusPercent = totalBonusPercent.add(shift.getNightShiftBonus());
542+
}
543+
if (shift.getWeekendBonus() != null) {
544+
totalBonusPercent = totalBonusPercent.add(shift.getWeekendBonus());
545+
}
546+
if (shift.getHolidayBonus() != null) {
547+
totalBonusPercent = totalBonusPercent.add(shift.getHolidayBonus());
548+
}
549+
550+
return BigDecimal.ONE.add(totalBonusPercent.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
551+
}
552+
553+
private BigDecimal resolveShiftOvertimeFactor(WorkShift shift) {
554+
if (shift == null || shift.getOvertimeMultiplier() == null || shift.getOvertimeMultiplier().compareTo(BigDecimal.ONE) < 0) {
555+
return BigDecimal.valueOf(1.5);
556+
}
557+
558+
return shift.getOvertimeMultiplier();
559+
}
560+
505561
private SalaryProfile resolveSalaryProfile(Integer userId, BigDecimal hourlyRateOverride) {
506562
if (hourlyRateOverride != null && hourlyRateOverride.compareTo(BigDecimal.ZERO) > 0) {
507563
return new SalaryProfile(
@@ -567,6 +623,10 @@ private BigDecimal resolveWorkedHours(WorkShift shift, Attendance attendance) {
567623
return BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
568624
}
569625

626+
if (attendance != null && attendance.getTimeIn() != null && attendance.getTimeOut() == null) {
627+
return BigDecimal.ZERO;
628+
}
629+
570630
if (attendance != null && attendance.getShiftWorkingMinutesSnapshot() != null) {
571631
return BigDecimal.valueOf(attendance.getShiftWorkingMinutesSnapshot())
572632
.divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
@@ -619,8 +679,12 @@ private String normalizeStatus(String status) {
619679
return status.trim().toUpperCase();
620680
}
621681

622-
private boolean shouldMarkAssignmentCompleted(String attendanceStatus) {
623-
String normalized = Optional.ofNullable(attendanceStatus).orElse("").trim().toUpperCase();
682+
private boolean shouldMarkAssignmentCompleted(Attendance attendance) {
683+
if (attendance == null || attendance.getTimeOut() == null) {
684+
return false;
685+
}
686+
687+
String normalized = Optional.ofNullable(attendance.getStatus()).orElse("").trim().toUpperCase();
624688
return "PRESENT".equals(normalized) || "LATE".equals(normalized);
625689
}
626690

@@ -657,20 +721,19 @@ private boolean hasShiftEndedWithoutCheckIn(LocalTime checkIn,
657721
}
658722

659723
LocalDate shiftDate = assignment.getShiftDate();
660-
if (shiftDate.isBefore(today)) {
661-
return true;
662-
}
663-
664-
if (!shiftDate.isEqual(today)) {
665-
return false;
666-
}
667-
668724
WorkShift shift = assignment.getWorkShift();
669725
if (shift == null || shift.getEndTime() == null) {
670726
return false;
671727
}
672728

673-
return !now.isBefore(shift.getEndTime());
729+
LocalDateTime shiftEndDateTime = LocalDateTime.of(shiftDate, shift.getEndTime());
730+
if (shift.getStartTime() != null && !shift.getEndTime().isAfter(shift.getStartTime())) {
731+
shiftEndDateTime = shiftEndDateTime.plusDays(1);
732+
}
733+
734+
LocalDateTime nowDateTime = LocalDateTime.of(today, now);
735+
736+
return !nowDateTime.isBefore(shiftEndDateTime);
674737
}
675738

676739
private static class PayrollAccumulator {
@@ -682,6 +745,8 @@ private static class PayrollAccumulator {
682745
private int absentShifts;
683746
private BigDecimal workedHours = BigDecimal.ZERO;
684747
private BigDecimal overtimeHours = BigDecimal.ZERO;
748+
private BigDecimal adjustedRegularHours = BigDecimal.ZERO;
749+
private BigDecimal adjustedOvertimeHours = BigDecimal.ZERO;
685750

686751
private PayrollAccumulator(User user) {
687752
this.user = user;

backend/src/main/java/com/smalltrend/service/UserService.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.smalltrend.entity.UserCredential;
1111
import com.smalltrend.entity.enums.SalaryType;
1212
import com.smalltrend.exception.UserException;
13+
import com.smalltrend.repository.PayrollCalculationRepository;
1314
import com.smalltrend.repository.RoleRepository;
1415
import com.smalltrend.repository.UserCredentialsRepository;
1516
import com.smalltrend.repository.UserRepository;
@@ -33,6 +34,7 @@
3334
import java.util.Collections;
3435
import java.util.List;
3536
import java.util.Map;
37+
import java.util.Objects;
3638
import java.util.Optional;
3739

3840
@Service
@@ -43,6 +45,7 @@ public class UserService implements UserDetailsService {
4345
private final UserRepository userRepository;
4446
private final UserCredentialsRepository userCredentialsRepository;
4547
private final RoleRepository roleRepository;
48+
private final PayrollCalculationRepository payrollCalculationRepository;
4649
private final CloudinaryService cloudinaryService;
4750
private final PasswordEncoder passwordEncoder;
4851
private final JwtUtil jwtUtil;
@@ -51,12 +54,14 @@ public UserService(
5154
UserRepository userRepository,
5255
UserCredentialsRepository userCredentialsRepository,
5356
RoleRepository roleRepository,
57+
PayrollCalculationRepository payrollCalculationRepository,
5458
CloudinaryService cloudinaryService,
5559
@Lazy PasswordEncoder passwordEncoder,
5660
JwtUtil jwtUtil) {
5761
this.userRepository = userRepository;
5862
this.userCredentialsRepository = userCredentialsRepository;
5963
this.roleRepository = roleRepository;
64+
this.payrollCalculationRepository = payrollCalculationRepository;
6065
this.cloudinaryService = cloudinaryService;
6166
this.passwordEncoder = passwordEncoder;
6267
this.jwtUtil = jwtUtil;
@@ -341,6 +346,11 @@ public User updateUser(Integer id, UserUpdateRequest request) {
341346
user.setStatus(request.getStatus().toUpperCase());
342347
}
343348

349+
if (isSalaryConfigChanged(user, request)
350+
&& payrollCalculationRepository.existsByUserIdAndStatusNotIgnoreCase(user.getId(), "PAID")) {
351+
throw new RuntimeException("Phải thanh toán hết lương cũ trước khi thay đổi cấu hình lương");
352+
}
353+
344354
applySalaryFields(user,
345355
request.getSalaryType(),
346356
request.getBaseSalary(),
@@ -352,6 +362,44 @@ public User updateUser(Integer id, UserUpdateRequest request) {
352362
return userRepository.save(user);
353363
}
354364

365+
private boolean isSalaryConfigChanged(User user, UserUpdateRequest request) {
366+
if (request == null || user == null) {
367+
return false;
368+
}
369+
370+
SalaryType incomingSalaryType = parseSalaryType(request.getSalaryType());
371+
if (incomingSalaryType != null && incomingSalaryType != user.getSalaryType()) {
372+
return true;
373+
}
374+
375+
if (request.getBaseSalary() != null
376+
&& !Objects.equals(request.getBaseSalary(), user.getBaseSalary())) {
377+
return true;
378+
}
379+
380+
if (request.getHourlyRate() != null
381+
&& !Objects.equals(request.getHourlyRate(), user.getHourlyRate())) {
382+
return true;
383+
}
384+
385+
if (request.getMinRequiredShifts() != null
386+
&& !Objects.equals(request.getMinRequiredShifts(), user.getMinRequiredShifts())) {
387+
return true;
388+
}
389+
390+
if (request.getCountLateAsPresent() != null
391+
&& !Objects.equals(request.getCountLateAsPresent(), user.getCountLateAsPresent())) {
392+
return true;
393+
}
394+
395+
if (request.getWorkingHoursPerMonth() != null
396+
&& !Objects.equals(request.getWorkingHoursPerMonth(), user.getWorkingHoursPerMonth())) {
397+
return true;
398+
}
399+
400+
return false;
401+
}
402+
355403
public void deleteUser(Integer id) {
356404
User user = getUserById(id);
357405

0 commit comments

Comments
 (0)