Skip to content

Commit 38c7973

Browse files
committed
feat: add validation for duplicate active tickets and shift assignments in ShiftManagement and ShiftTicketCenter components
1 parent 9e6cff9 commit 38c7973

File tree

4 files changed

+171
-0
lines changed

4 files changed

+171
-0
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,10 @@ public interface TicketRepository extends JpaRepository<Ticket, Long> {
2121

2222
List<Ticket> findByTicketCode(String ticketCode);
2323

24+
List<Ticket> findByTicketTypeAndCreatedByIdAndStatusIn(
25+
TicketType ticketType,
26+
Integer createdByUserId,
27+
List<TicketStatus> statuses);
28+
2429
long countByTicketType(TicketType ticketType);
2530
}

backend/src/main/java/com/smalltrend/service/CRM/TicketService.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@
2626
import lombok.RequiredArgsConstructor;
2727

2828
import java.time.LocalDate;
29+
import java.util.Set;
2930

3031
@Service
3132
@RequiredArgsConstructor
3233
public class TicketService {
3334

35+
private static final Set<TicketStatus> ACTIVE_TICKET_STATUSES = Set.of(TicketStatus.OPEN, TicketStatus.IN_PROGRESS);
36+
3437
private final TicketRepository ticketRepository;
3538
private final UserRepository userRepository;
3639
private final InventoryStockRepository inventoryStockRepository;
@@ -103,6 +106,10 @@ public TicketResponse createTicket(CreateTicketRequest request) {
103106
}
104107
}
105108

109+
if (ticketType == TicketType.SHIFT_CHANGE) {
110+
validateNoDuplicateActiveShiftTicket(request, requesterAssignment);
111+
}
112+
106113
Ticket ticket = Ticket.builder()
107114
.ticketCode(generateTicketCode(ticketType))
108115
.ticketType(ticketType)
@@ -143,6 +150,85 @@ public TicketResponse createTicket(CreateTicketRequest request) {
143150
return mapToResponse(saved);
144151
}
145152

153+
private void validateNoDuplicateActiveShiftTicket(CreateTicketRequest request, WorkShiftAssignment requesterAssignment) {
154+
Integer requesterId = request.getRequesterUserId() != null ? request.getRequesterUserId() : request.getCreatedById();
155+
if (requesterId == null) {
156+
return;
157+
}
158+
159+
List<Ticket> activeTickets = ticketRepository.findByTicketTypeAndCreatedByIdAndStatusIn(
160+
TicketType.SHIFT_CHANGE,
161+
requesterId,
162+
List.copyOf(ACTIVE_TICKET_STATUSES));
163+
164+
String incomingAction = resolveTicketAction(request.getTitle(), request.getRelatedEntityType());
165+
Long incomingRelatedEntityId = resolveRelatedEntityId(request, requesterAssignment);
166+
Integer incomingRequesterAssignmentId = request.getSwapRequesterAssignmentId() != null
167+
? request.getSwapRequesterAssignmentId()
168+
: requesterAssignment != null ? requesterAssignment.getId() : null;
169+
Integer incomingTargetUserId = request.getSwapTargetUserId() != null
170+
? request.getSwapTargetUserId()
171+
: request.getAssignedToUserId();
172+
173+
for (Ticket active : activeTickets) {
174+
String existingAction = resolveTicketAction(active.getTitle(), active.getRelatedEntityType());
175+
if (!incomingAction.equals(existingAction)) {
176+
continue;
177+
}
178+
179+
if ("SHIFT_SWAP".equals(incomingAction)) {
180+
Integer existingRequesterAssignmentId = parseMetaInt(active.getDescription(), "SWAP_REQUESTER_ASSIGNMENT_ID");
181+
Integer existingTargetUserId = parseMetaInt(active.getDescription(), "SWAP_TARGET_USER_ID");
182+
183+
if (incomingRequesterAssignmentId != null
184+
&& incomingRequesterAssignmentId.equals(existingRequesterAssignmentId)
185+
&& incomingTargetUserId != null
186+
&& incomingTargetUserId.equals(existingTargetUserId)) {
187+
throw new RuntimeException("Đã có ticket đổi ca đang chờ xử lý cho ca này");
188+
}
189+
continue;
190+
}
191+
192+
if (incomingRelatedEntityId != null && incomingRelatedEntityId.equals(active.getRelatedEntityId())) {
193+
throw new RuntimeException("Đã có ticket cùng loại đang chờ xử lý cho ca đã chọn");
194+
}
195+
}
196+
}
197+
198+
private String resolveTicketAction(String title, String relatedEntityType) {
199+
String normalizedTitle = title == null ? "" : title.trim().toLowerCase();
200+
String normalizedEntityType = relatedEntityType == null ? "" : relatedEntityType.trim().toUpperCase();
201+
202+
if ("SHIFT_SWAP".equals(normalizedEntityType)
203+
|| normalizedTitle.contains("đổi ca")
204+
|| normalizedTitle.contains("nhờ nhận ca")) {
205+
return "SHIFT_SWAP";
206+
}
207+
if (normalizedTitle.contains("nghỉ ca")) {
208+
return "SHIFT_CANCEL";
209+
}
210+
if (normalizedTitle.contains("cập nhật ca")) {
211+
return "SHIFT_UPDATE";
212+
}
213+
return normalizedEntityType;
214+
}
215+
216+
private Integer parseMetaInt(String description, String key) {
217+
if (description == null || description.isBlank()) {
218+
return null;
219+
}
220+
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\[" + key + "=(\\d+)\\]");
221+
java.util.regex.Matcher matcher = pattern.matcher(description);
222+
if (!matcher.find()) {
223+
return null;
224+
}
225+
try {
226+
return Integer.parseInt(matcher.group(1));
227+
} catch (NumberFormatException ex) {
228+
return null;
229+
}
230+
}
231+
146232
private WorkShiftAssignment validateShiftSwapTicketRequest(CreateTicketRequest request) {
147233
if (request.getRequesterUserId() == null) {
148234
throw new RuntimeException("Thiếu requesterUserId cho ticket đổi ca");

frontend/src/pages/HR/ShiftManagement.jsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,22 @@ const ShiftManagement = () => {
281281
return;
282282
}
283283
try {
284+
const shiftCode = String(shiftForm.shiftCode || '').trim();
285+
if (shiftCode) {
286+
const matchedShifts = await shiftService.getShifts({ query: shiftCode, includeExpired: true });
287+
const duplicateShift = (Array.isArray(matchedShifts) ? matchedShifts : []).find(
288+
(item) =>
289+
String(item.shiftCode || '').trim().toLowerCase() === shiftCode.toLowerCase()
290+
&& String(item.id) !== String(editingShift?.id || ''),
291+
);
292+
293+
if (duplicateShift) {
294+
setShiftFormErrors({ shiftCode: 'Mã ca đã tồn tại.' });
295+
setError('Mã ca đã tồn tại. Vui lòng chọn mã khác.');
296+
return;
297+
}
298+
}
299+
284300
const payload = buildShiftPayload(shiftForm);
285301
if (editingShift) {
286302
await shiftService.updateShift(editingShift.id, payload);
@@ -314,6 +330,31 @@ const ShiftManagement = () => {
314330
return;
315331
}
316332
try {
333+
const userId = Number(assignmentForm.userId);
334+
const workShiftId = Number(assignmentForm.workShiftId);
335+
const shiftDate = assignmentForm.shiftDate;
336+
337+
const rowsOnDate = await shiftService.getAssignments({
338+
userId,
339+
startDate: shiftDate,
340+
endDate: shiftDate,
341+
});
342+
343+
const duplicateAssignment = (Array.isArray(rowsOnDate) ? rowsOnDate : []).find(
344+
(item) =>
345+
Number(item?.user?.id) === userId
346+
&& Number(item?.shift?.id) === workShiftId
347+
&& String(item?.shiftDate || '') === String(shiftDate)
348+
&& String(item?.id) !== String(editingAssignment?.id || ''),
349+
);
350+
351+
if (duplicateAssignment) {
352+
const duplicateErrors = { workShiftId: 'Phân công này đã tồn tại cho nhân viên trong ngày đã chọn.' };
353+
setAssignmentFormErrors(duplicateErrors);
354+
setError(duplicateErrors.workShiftId);
355+
return;
356+
}
357+
317358
const payload = buildAssignmentPayload(assignmentForm);
318359
if (editingAssignment) {
319360
await shiftService.updateAssignment(editingAssignment.id, payload);

frontend/src/pages/HR/ShiftTicketCenter.jsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,45 @@ const ShiftTicketCenter = () => {
414414
return;
415415
}
416416

417+
const activeStatuses = new Set(['OPEN', 'IN_PROGRESS']);
418+
const incomingMode = String(createForm.ticketMode || '').toUpperCase();
419+
const incomingAssignmentId = Number(createForm.assignmentId);
420+
const incomingTargetUserId = Number(createForm.targetUserId || 0);
421+
422+
const duplicateActiveTicket = tickets.find((ticket) => {
423+
if (!activeStatuses.has(String(ticket?.status || '').toUpperCase())) {
424+
return false;
425+
}
426+
427+
if (Number(ticket?.createdByUserId || 0) !== Number(currentUserId || 0)) {
428+
return false;
429+
}
430+
431+
const title = String(ticket?.title || '').toLowerCase();
432+
const relatedEntityType = String(ticket?.relatedEntityType || '').toUpperCase();
433+
const relatedEntityId = Number(ticket?.relatedEntityId || 0);
434+
435+
if (incomingMode === 'SWAP') {
436+
const existingRequesterAssignmentId = extractSwapId(ticket?.description, 'SWAP_REQUESTER_ASSIGNMENT_ID');
437+
const existingTargetUserId = extractSwapId(ticket?.description, 'SWAP_TARGET_USER_ID')
438+
|| Number(ticket?.assignedToUserId || 0);
439+
return relatedEntityType === 'SHIFT_SWAP'
440+
&& Number(existingRequesterAssignmentId || relatedEntityId) === incomingAssignmentId
441+
&& Number(existingTargetUserId || 0) === incomingTargetUserId;
442+
}
443+
444+
if (incomingMode === 'CANCEL') {
445+
return title.includes('nghỉ ca') && relatedEntityId === incomingAssignmentId;
446+
}
447+
448+
return title.includes('cập nhật ca') && relatedEntityId === incomingAssignmentId;
449+
});
450+
451+
if (duplicateActiveTicket) {
452+
setError('Đã có ticket cùng loại đang chờ xử lý cho ca này. Vui lòng chờ duyệt hoặc cập nhật ticket cũ.');
453+
return;
454+
}
455+
417456
try {
418457
setSubmitting(true);
419458
if (createForm.ticketMode === 'SWAP') {

0 commit comments

Comments
 (0)