From 3c5d66f5092cd96558c2fcfbe974d52fe6568372 Mon Sep 17 00:00:00 2001 From: JO HYUNGJOON Date: Mon, 10 Aug 2026 18:18:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=95=BD=20=EC=86=8C=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=EB=8F=84=EC=9E=85=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=BD=EB=AC=BC=20=EC=B6=A9=EB=8F=8C=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/DrugConflictConverter.java | 41 ++++++++--- .../dto/response/ConflictDrug.java | 19 +++++ .../response/DrugConflictDetailResponse.java | 11 ++- .../dto/response/DrugConflictResponse.java | 9 ++- .../drugconflict/entity/DrugConflict.java | 12 ++++ .../repository/DrugConflictRepository.java | 33 +++++++-- .../command/DrugConflictCommandService.java | 57 +++++++++++++-- .../MedicationToggleCommandService.java | 6 +- .../service/query/HomeCardQueryService.java | 5 +- .../controller/MedicationController.java | 2 +- .../domain/medication/entity/Medication.java | 16 +++++ .../repository/MedicationRepository.java | 11 +++ .../MedicationScheduleRepository.java | 9 ++- .../command/MedicationCommandService.java | 10 +-- .../service/query/MedicationQueryService.java | 2 +- .../DrugConflictCommandServiceTest.java | 70 +++++++++++++++++-- .../MedicationToggleCommandServiceTest.java | 12 ++-- .../query/HomeCardQueryServiceTest.java | 30 +++----- 18 files changed, 275 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java b/src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java index 03bf0cc..cb7c7fa 100644 --- a/src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java @@ -1,22 +1,25 @@ package com.piuda.callcare.domain.drugconflict.converter; +import java.time.LocalDate; + import org.springframework.stereotype.Component; +import com.piuda.callcare.domain.drugconflict.dto.response.ConflictDrug; import com.piuda.callcare.domain.drugconflict.dto.response.DrugConflictDetailResponse; import com.piuda.callcare.domain.drugconflict.dto.response.DrugConflictResponse; import com.piuda.callcare.domain.drugconflict.entity.DrugConflict; +import com.piuda.callcare.domain.medication.entity.Medication; @Component public class DrugConflictConverter { // DrugConflict → DrugConflictResponse (목록 카드용) + // 카드 표시값(제품종류·제품명·처방기관·처방날짜)은 저장하지 않고 Medication에서 조합한다. public DrugConflictResponse toResponse(DrugConflict conflict) { return new DrugConflictResponse( conflict.getId(), - conflict.getMedication1().getId(), - conflict.getMedication1().getDrugName(), - conflict.getMedication2().getId(), - conflict.getMedication2().getDrugName(), + toConflictDrug(conflict.getMedication1()), + toConflictDrug(conflict.getMedication2()), conflict.getSeverity(), conflict.getSeverity().getLabel(), conflict.getIsResolved() @@ -24,15 +27,12 @@ public DrugConflictResponse toResponse(DrugConflict conflict) { } // DrugConflict → DrugConflictDetailResponse (상세 조회용) + // 약 정보는 목록과 동일한 ConflictDrug로 채워 두 응답의 형태를 맞춘다. public DrugConflictDetailResponse toDetail(DrugConflict conflict) { return new DrugConflictDetailResponse( conflict.getId(), - conflict.getMedication1().getId(), - conflict.getMedication1().getDrugName(), - conflict.getMedication1().getDrugNickname(), - conflict.getMedication2().getId(), - conflict.getMedication2().getDrugName(), - conflict.getMedication2().getDrugNickname(), + toConflictDrug(conflict.getMedication1()), + toConflictDrug(conflict.getMedication2()), conflict.getSeverity(), conflict.getSeverity().getLabel(), conflict.getConflictDescription(), @@ -40,4 +40,23 @@ public DrugConflictDetailResponse toDetail(DrugConflict conflict) { conflict.getCreatedAt() ); } -} \ No newline at end of file + + // Medication → ConflictDrug (목록·상세에 표시할 약 정보 합성) + private ConflictDrug toConflictDrug(Medication medication) { + return new ConflictDrug( + medication.getId(), + medication.getDrugType(), + medication.getDrugName(), + medication.getDrugNickname(), + medication.getHospitalName(), + resolvePrescriptionDate(medication) + ); + } + + // 처방 날짜 폴백: prescription_date(OCR) → 없으면 start_date(사용자 입력) + private LocalDate resolvePrescriptionDate(Medication medication) { + return medication.getPrescriptionDate() != null + ? medication.getPrescriptionDate() + : medication.getStartDate(); + } +} diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java b/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java new file mode 100644 index 0000000..1dd9ba6 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java @@ -0,0 +1,19 @@ +package com.piuda.callcare.domain.drugconflict.dto.response; + +import java.time.LocalDate; + +import io.swagger.v3.oas.annotations.media.Schema; + +// 충돌에 관련된 약 한 알의 표시 정보. 목록 카드와 상세가 같은 구조를 공유한다. +// 저장하지 않고 조회 시 Medication에서 조합한다(파생값 비저장 원칙). +@Schema(description = "충돌에 관련된 약 정보") +public record ConflictDrug( + + @Schema(description = "약(medication) ID") Long medicationId, + @Schema(description = "제품 종류 (drug_type)") String drugType, + @Schema(description = "제품명 (drug_name)") String drugName, + @Schema(description = "약 별명 (drug_nickname)") String drugNickname, + @Schema(description = "처방 기관 (hospital_name)") String hospitalName, + @Schema(description = "처방 날짜 (prescription_date, 없으면 start_date로 폴백)") LocalDate prescriptionDate +) { +} diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java b/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java index eedf586..2377dd8 100644 --- a/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java @@ -7,20 +7,17 @@ import io.swagger.v3.oas.annotations.media.Schema; // 약물 충돌 상세(카드 클릭 시). 충돌 설명 원문 포함. +// 약별 표시 정보는 목록 카드와 동일한 ConflictDrug 구조를 재사용한다. @Schema(description = "약물 충돌 상세") public record DrugConflictDetailResponse( @Schema(description = "충돌 ID") Long conflictId, - @Schema(description = "약1 ID") Long medicationId1, - @Schema(description = "약1 이름") String drugName1, - @Schema(description = "약1 별명") String drugNickname1, - @Schema(description = "약2 ID") Long medicationId2, - @Schema(description = "약2 이름") String drugName2, - @Schema(description = "약2 별명") String drugNickname2, + @Schema(description = "약1 정보") ConflictDrug drug1, + @Schema(description = "약2 정보") ConflictDrug drug2, @Schema(description = "심각도 (CONTRAINDICATED=금기, CAUTION=주의)") ConflictSeverity severity, @Schema(description = "심각도 한글 라벨") String severityLabel, @Schema(description = "충돌 설명 (상호작용 텍스트에서 추출한 문장)") String conflictDescription, @Schema(description = "확인(해결) 여부") boolean isResolved, @Schema(description = "분석 저장 시각") LocalDateTime createdAt ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java b/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java index acbd80f..7fce360 100644 --- a/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java @@ -5,16 +5,15 @@ import io.swagger.v3.oas.annotations.media.Schema; // 약물 충돌 카드 1칸(목록용). 상세 설명은 상세 조회에서 제공. +// 약별 표시 정보는 ConflictDrug로 묶어 상세 응답과 같은 형태를 유지한다. @Schema(description = "약물 충돌 카드") public record DrugConflictResponse( @Schema(description = "충돌 ID") Long conflictId, - @Schema(description = "약1 ID") Long medicationId1, - @Schema(description = "약1 이름") String drugName1, - @Schema(description = "약2 ID") Long medicationId2, - @Schema(description = "약2 이름") String drugName2, + @Schema(description = "약1 정보") ConflictDrug drug1, + @Schema(description = "약2 정보") ConflictDrug drug2, @Schema(description = "심각도 (CONTRAINDICATED=금기, CAUTION=주의)") ConflictSeverity severity, @Schema(description = "심각도 한글 라벨") String severityLabel, @Schema(description = "확인(해결) 여부") boolean isResolved ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/entity/DrugConflict.java b/src/main/java/com/piuda/callcare/domain/drugconflict/entity/DrugConflict.java index 69015b6..ede7538 100644 --- a/src/main/java/com/piuda/callcare/domain/drugconflict/entity/DrugConflict.java +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/entity/DrugConflict.java @@ -11,6 +11,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.Objects; @Entity @Table(name = "drug_conflict", uniqueConstraints = @UniqueConstraint( @@ -66,4 +67,15 @@ public DrugConflict(Senior senior, Medication medication1, Medication medication public void resolve() { this.isResolved = true; } + + // 재분석 upsert용: severity/description이 실제로 바뀐 경우에만 갱신한다. + // 값이 동일하면 dirty checking으로도 UPDATE가 안 나가지만, 의도를 코드로 못박아 불필요한 갱신을 막는다. + public void updateAnalysis(ConflictSeverity severity, String conflictDescription) { + boolean changed = this.severity != severity + || !Objects.equals(this.conflictDescription, conflictDescription); + if (changed) { + this.severity = severity; + this.conflictDescription = conflictDescription; + } + } } diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/repository/DrugConflictRepository.java b/src/main/java/com/piuda/callcare/domain/drugconflict/repository/DrugConflictRepository.java index 63ac425..5a1281d 100644 --- a/src/main/java/com/piuda/callcare/domain/drugconflict/repository/DrugConflictRepository.java +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/repository/DrugConflictRepository.java @@ -11,25 +11,46 @@ public interface DrugConflictRepository extends JpaRepository { - // 중복 저장 방지용: (medication_id_1 < medication_id_2)로 정규화해 저장하므로 순서 무관 조합을 한 번에 검사 - boolean existsBySenior_IdAndMedication1_IdAndMedication2_Id(Long seniorId, Long medication1Id, Long medication2Id); + // 재분석 upsert용: 정규화된 쌍(medication_id_1 < medication_id_2)으로 기존 행을 가져와 + // 최신 분석 결과로 갱신(없으면 신규 저장). 순서 무관 중복은 정규화 + UNIQUE 제약이 함께 보장. + Optional findBySenior_IdAndMedication1_IdAndMedication2_Id(Long seniorId, Long medication1Id, Long medication2Id); - // 목록 조회: 두 약을 함께 로딩(LazyInitialization 방지). 정렬은 서비스에서 등급 우선순위로 처리. + // 재분석 정리용: 어르신의 모든 충돌 행(노출 필터 없음)을 약과 함께 로딩. + // 이번 분석에서 다시 매칭되지 않은 stale 행을 걸러내야 하므로 목록 쿼리의 활성·삭제 조건을 걸지 않는다. @Query(""" SELECT dc FROM DrugConflict dc JOIN FETCH dc.medication1 JOIN FETCH dc.medication2 WHERE dc.senior.id = :seniorId + """) + List findAllWithMedicationsForReanalysis(@Param("seniorId") Long seniorId); + + // 목록 조회: 두 약을 함께 로딩(LazyInitialization 방지). 정렬은 서비스에서 등급 우선순위로 처리. + // 두 약이 모두 활성(is_active=true)이고 삭제되지 않은 충돌만 노출 — 복용 종료·삭제된 약의 stale 충돌 방지. + @Query(""" + SELECT dc FROM DrugConflict dc + JOIN FETCH dc.medication1 m1 + JOIN FETCH dc.medication2 m2 + WHERE dc.senior.id = :seniorId + AND m1.isActive = true + AND m2.isActive = true + AND m1.deletedAt IS NULL + AND m2.deletedAt IS NULL ORDER BY dc.id ASC """) List findAllWithMedicationsBySeniorId(@Param("seniorId") Long seniorId); - // 상세 조회: 두 약을 함께 로딩 + // 상세 조회: 두 약을 함께 로딩. 노출 기준은 목록과 동일하게 맞춘다 — + // 목록에서 사라진 충돌(복용 종료·삭제된 약)은 상세도 열리지 않아야 유효하지 않은 경고를 현재 위험으로 오인하지 않는다. @Query(""" SELECT dc FROM DrugConflict dc - JOIN FETCH dc.medication1 - JOIN FETCH dc.medication2 + JOIN FETCH dc.medication1 m1 + JOIN FETCH dc.medication2 m2 WHERE dc.id = :conflictId + AND m1.isActive = true + AND m2.isActive = true + AND m1.deletedAt IS NULL + AND m2.deletedAt IS NULL """) Optional findWithMedicationsById(@Param("conflictId") Long conflictId); } \ No newline at end of file diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java b/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java index 2ca6745..9387200 100644 --- a/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java @@ -1,6 +1,10 @@ package com.piuda.callcare.domain.drugconflict.service.command; +import java.util.HashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -38,25 +42,53 @@ public void analyze(Long seniorId) { // TODO: 인증 도입 후 seniorId 소유권 검증 추가 List medications = medicationRepository.findActiveWithDrugInfoBySeniorId(seniorId); + Set matchedPairs = new HashSet<>(); for (int i = 0; i < medications.size(); i++) { for (int j = i + 1; j < medications.size(); j++) { Medication a = medications.get(i); Medication b = medications.get(j); drugConflictMatcher.match(a.getDrugInfo(), b.getDrugInfo()) - .ifPresent(match -> saveIfAbsent(senior, a, b, match)); + .ifPresent(match -> { + saveOrUpdate(senior, a, b, match); + matchedPairs.add(MedicationPair.of(a, b)); + }); } } + + deleteStaleConflicts(seniorId, medications, matchedPairs); + } + + // 이번 분석에서 다시 매칭되지 않은 기존 충돌을 제거 — 약 정보가 바뀌어 더 이상 충돌이 아닌 쌍이 + // 옛 등급으로 목록에 남는 것을 막는다(upsert만으로는 사라진 충돌을 정리할 수 없다). + // 삭제 범위는 이번 분석 대상(활성·미삭제) 약들로만 이뤄진 쌍에 한정한다 — + // 비활성·삭제된 약이 낀 행은 애초에 매칭 대상이 아니었을 뿐이므로 지우면 분석 이력이 사라진다. + private void deleteStaleConflicts(Long seniorId, List analyzed, Set matchedPairs) { + Set analyzedIds = analyzed.stream().map(Medication::getId).collect(Collectors.toSet()); + + List stale = drugConflictRepository.findAllWithMedicationsForReanalysis(seniorId).stream() + .filter(conflict -> analyzedIds.contains(conflict.getMedication1().getId()) + && analyzedIds.contains(conflict.getMedication2().getId())) + .filter(conflict -> !matchedPairs.contains(MedicationPair.of( + conflict.getMedication1(), conflict.getMedication2()))) + .toList(); + + if (!stale.isEmpty()) { + drugConflictRepository.deleteAll(stale); + } } - // (medication_id_1 < medication_id_2)로 정규화해 순서 무관 중복 저장을 방지(find→분기, 2단계 upsert 패턴). - private void saveIfAbsent(Senior senior, Medication x, Medication y, ConflictMatch match) { + // (medication_id_1 < medication_id_2)로 정규화해 순서 무관 중복을 방지하고, + // 기존 쌍이 있으면 최신 분석 결과로 upsert(값이 실제로 바뀐 경우만 UPDATE). 없으면 신규 저장. + private void saveOrUpdate(Senior senior, Medication x, Medication y, ConflictMatch match) { Medication first = x.getId() < y.getId() ? x : y; Medication second = x.getId() < y.getId() ? y : x; - boolean exists = drugConflictRepository.existsBySenior_IdAndMedication1_IdAndMedication2_Id( + Optional existing = drugConflictRepository.findBySenior_IdAndMedication1_IdAndMedication2_Id( senior.getId(), first.getId(), second.getId()); - if (exists) { + if (existing.isPresent()) { + // 이미 커밋된 쌍 → 최신 결과로 갱신(dirty checking). 값이 같으면 updateAnalysis가 UPDATE를 생략한다. + existing.get().updateAnalysis(match.severity(), match.description()); return; } @@ -69,7 +101,18 @@ private void saveIfAbsent(Senior senior, Medication x, Medication y, ConflictMat .conflictDescription(match.description()) .build()); } catch (DataIntegrityViolationException e) { - // 동시 요청으로 같은 조합이 먼저 저장된 경우(uk_drug_conflict_senior_med1_med2). 정합성은 제약이 보장하므로 무시. + // 동시 요청으로 같은 조합이 먼저 INSERT된 레이스(uk_drug_conflict_senior_med1_med2). + // 정합성은 UNIQUE 제약이 보장하므로 이번 요청은 저장을 건너뛴다(삼중 방어 유지). + } + } + + // 순서 무관 비교용 약 쌍 키. 저장 정규화 규칙과 동일하게 (작은 id, 큰 id)로 맞춘다. + private record MedicationPair(Long first, Long second) { + + static MedicationPair of(Medication x, Medication y) { + return x.getId() < y.getId() + ? new MedicationPair(x.getId(), y.getId()) + : new MedicationPair(y.getId(), x.getId()); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandService.java b/src/main/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandService.java index f347b5a..e670065 100644 --- a/src/main/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandService.java +++ b/src/main/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandService.java @@ -45,8 +45,8 @@ public MedicationLogToggleResponse toggle(Long medicationId, MealTime mealTime, throw new CallCareException(ErrorCode.MEDICATION_LOG_TOGGLE_NOT_TODAY); } - // 2) 약 존재 검증 - Medication medication = medicationRepository.findById(medicationId) + // 2) 약 존재 검증 (삭제된 약은 없는 것으로 취급) + Medication medication = medicationRepository.findByIdAndDeletedAtIsNull(medicationId) .orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND)); // 3) 그 약에 해당 시간대의 오늘 활성 스케줄이 실제 존재하는지 검증 (완료 재계산과 동일 기준) @@ -76,7 +76,7 @@ public MedicationLogToggleResponse toggle(Long medicationId, MealTime mealTime, // 홈카드와 동일한 완료 규칙으로 해당 시간대 완료 여부를 산출 → CompletedStatus로 변환 private CompletedStatus recalculateMealTimeStatus(Long seniorId, LocalDate date, MealTime mealTime) { List slotSchedules = medicationScheduleRepository - .findActiveSchedulesForHomeCards(seniorId, date).stream() + .findActiveSchedulesForHomeCards(seniorId, date, date.plusDays(1).atStartOfDay()).stream() .filter(schedule -> schedule.getMealTime() == mealTime) .toList(); diff --git a/src/main/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryService.java b/src/main/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryService.java index 35bcad6..d683d33 100644 --- a/src/main/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryService.java +++ b/src/main/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryService.java @@ -51,8 +51,9 @@ public HomeCardResponse getHomeCards(Long seniorId, LocalDate date) { } HomeCardMode mode = HomeCardMode.from(targetDate); - List schedules = - medicationScheduleRepository.findActiveSchedulesForHomeCards(seniorId, targetDate); + // 조회 날짜의 다음날 0시 — 이 시각 이후에 삭제된 약은 그 날엔 아직 복용 중이었으므로 카드에 남긴다 + List schedules = medicationScheduleRepository.findActiveSchedulesForHomeCards( + seniorId, targetDate, targetDate.plusDays(1).atStartOfDay()); // 미래 모드는 완료 개념이 없어 로그 조회 자체를 생략 Set takenKeys = mode.tracksCompletion() diff --git a/src/main/java/com/piuda/callcare/domain/medication/controller/MedicationController.java b/src/main/java/com/piuda/callcare/domain/medication/controller/MedicationController.java index 34012a7..1159149 100644 --- a/src/main/java/com/piuda/callcare/domain/medication/controller/MedicationController.java +++ b/src/main/java/com/piuda/callcare/domain/medication/controller/MedicationController.java @@ -115,7 +115,7 @@ public ResponseEntity>> getRepor return ResponseUtils.ok(medicationQueryService.getReport(userId, seniorId)); } - @Operation(summary = "약 삭제", description = "약과 복용 스케줄을 완전 삭제합니다. 복구 불가능합니다.") + @Operation(summary = "약 삭제", description = "약을 삭제 처리합니다. 삭제일 당일부터 약물노트·상세 조회·오늘/미래 홈카드·충돌 목록에서 제외되며, 삭제 이전 날짜의 홈카드와 복약 기록 리포트에는 그대로 남습니다.") @DeleteMapping("/{medicationId}") public ResponseEntity> delete( @AuthenticationPrincipal Long userId, diff --git a/src/main/java/com/piuda/callcare/domain/medication/entity/Medication.java b/src/main/java/com/piuda/callcare/domain/medication/entity/Medication.java index 1439338..01f33c3 100644 --- a/src/main/java/com/piuda/callcare/domain/medication/entity/Medication.java +++ b/src/main/java/com/piuda/callcare/domain/medication/entity/Medication.java @@ -11,7 +11,13 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; +import java.time.LocalDateTime; +// 소프트 삭제: medication_log·medication_schedule·drug_conflict가 이 행을 FK(nullable=false)로 참조하므로 +// 물리 삭제는 FK 제약 위반을 낸다. DELETE 요청을 deleted_at 갱신으로 대체하고 참조 행은 그대로 보존한다. +// 노출 규칙은 "삭제일 당일부터 앞으로만 숨김" — 지난 복약 이력에는 그대로 남는다. +// 전역 필터(@SQLRestriction)를 쓰지 않는 이유: 과거 조회까지 무조건 걸러버려 이력이 사라진다. +// 대신 조회 쿼리마다 deleted_at 조건을 명시한다(현재 시점 조회는 IS NULL, 날짜 기준 조회는 그 날짜와 비교). @Entity @Table(name = "medication") @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -76,6 +82,9 @@ public class Medication extends BaseEntity { @Column(name = "ocr_result_id") private Long ocrResultId; // OCR 경로로 등록된 경우 연결 ID (직접 등록이면 null) + @Column(name = "deleted_at") + private LocalDateTime deletedAt; // 소프트 삭제 시각. null이면 유효한 행 (builder로 받지 않고 softDelete()로만 채운다) + @Builder public Medication(Senior senior, String hospitalName, DrugInfo drugInfo, String drugName, String drugNickname, String drugType, @@ -118,4 +127,11 @@ public void update(String drugName, String dosagePerTime, Integer timesPerDay, public void deactivate() { this.isActive = false; } + + // 소프트 삭제 — 물리 삭제 대신 시각만 남긴다. 이미 삭제된 약은 시각을 덮어쓰지 않는다. + public void softDelete() { + if (this.deletedAt == null) { + this.deletedAt = LocalDateTime.now(); + } + } } \ No newline at end of file diff --git a/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationRepository.java b/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationRepository.java index e244640..b88f9d2 100644 --- a/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationRepository.java +++ b/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationRepository.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; import com.piuda.callcare.domain.medication.entity.Medication; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,11 +11,16 @@ public interface MedicationRepository extends JpaRepository { + // 단건 조회(상세·수정·삭제·토글 공용): 삭제된 약은 없는 것으로 취급해 MEDICATION_NOT_FOUND로 떨어지게 한다. + // findById는 삭제 여부를 못 거르므로 현재 시점 단건 조회는 반드시 이 메서드를 쓴다. + Optional findByIdAndDeletedAtIsNull(Long medicationId); + // 소진 예측용: 특정 어르신의 활성(복용 중) 약만 조회. 남은 일수는 endDate로 계산하므로 endDate가 있는 것만 대상으로 한다. @Query(""" SELECT m FROM Medication m WHERE m.senior.id = :seniorId AND m.isActive = true + AND m.deletedAt IS NULL AND m.endDate IS NOT NULL """) List findActiveMedicationsForDepletion(@Param("seniorId") Long seniorId); @@ -26,6 +32,7 @@ public interface MedicationRepository extends JpaRepository { JOIN FETCH m.drugInfo WHERE m.senior.id = :seniorId AND m.isActive = true + AND m.deletedAt IS NULL """) List findActiveWithDrugInfoBySeniorId(@Param("seniorId") Long seniorId); @@ -33,6 +40,7 @@ public interface MedicationRepository extends JpaRepository { @Query(""" SELECT m FROM Medication m WHERE m.senior.id = :seniorId + AND m.deletedAt IS NULL AND (:isActive IS NULL OR m.isActive = :isActive) ORDER BY m.startDate DESC, m.hospitalName ASC NULLS LAST """) @@ -44,6 +52,7 @@ List findAllBySeniorId(@Param("seniorId") Long seniorId, SELECT m FROM Medication m WHERE m.senior.id = :seniorId AND m.startDate >= :fromDate + AND m.deletedAt IS NULL AND (:isActive IS NULL OR m.isActive = :isActive) AND (LOWER(m.drugName) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(m.drugNickname) LIKE LOWER(CONCAT('%', :keyword, '%')) @@ -56,6 +65,7 @@ List searchByKeyword(@Param("seniorId") Long seniorId, @Param("isActive") Boolean isActive); // 복약 기록 리포트: 최근 90일 이내 약 전체 — 병원·약 이름·시작일 기준 정렬 (연속 합산 로직은 서비스에서 처리) + // deleted_at 조건을 일부러 넣지 않는다 — 리포트는 지난 복약 이력이므로 이후에 삭제한 약도 그대로 집계에 남긴다. @Query(""" SELECT m FROM Medication m WHERE m.senior.id = :seniorId @@ -73,6 +83,7 @@ List findForReport(@Param("seniorId") Long seniorId, AND ((:hospitalName IS NULL AND m.hospitalName IS NULL) OR m.hospitalName = :hospitalName) AND ((:prescriptionDate IS NULL AND m.prescriptionDate IS NULL) OR m.prescriptionDate = :prescriptionDate) AND m.isActive = true + AND m.deletedAt IS NULL ORDER BY m.createdAt ASC """) List findByGroup(@Param("seniorId") Long seniorId, diff --git a/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationScheduleRepository.java b/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationScheduleRepository.java index db552ad..0be30ab 100644 --- a/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationScheduleRepository.java +++ b/src/main/java/com/piuda/callcare/domain/medication/repository/MedicationScheduleRepository.java @@ -1,6 +1,7 @@ package com.piuda.callcare.domain.medication.repository; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import com.piuda.callcare.domain.medication.entity.MedicationSchedule; @@ -15,12 +16,14 @@ public interface MedicationScheduleRepository extends JpaRepository 0) FROM MedicationSchedule ms JOIN ms.medication m WHERE m.id = :medicationId AND ms.mealTime = :mealTime AND m.isActive = true + AND m.deletedAt IS NULL AND m.startDate <= :date AND m.endDate >= :date """) @@ -31,17 +34,21 @@ boolean existsActiveScheduleForToggle( ); // 홈카드용: 해당 날짜에 복용 중(active + 기간 내)인 약의 스케줄을 약과 함께 한 번에 조회 (N+1 방지) + // 삭제된 약은 삭제일 당일부터만 숨긴다 — deletedAt이 조회 날짜의 다음날 0시 이후면 그 날엔 아직 살아있던 약. + // 그래서 어제 카드에는 남고 오늘·미래 카드에서만 빠진다(지난 복약 이력 보존). @Query(""" SELECT ms FROM MedicationSchedule ms JOIN FETCH ms.medication m WHERE m.senior.id = :seniorId AND m.isActive = true + AND (m.deletedAt IS NULL OR m.deletedAt >= :hiddenFrom) AND m.startDate <= :date AND m.endDate >= :date ORDER BY ms.mealTime, m.hospitalName NULLS LAST, m.id """) List findActiveSchedulesForHomeCards( @Param("seniorId") Long seniorId, - @Param("date") LocalDate date + @Param("date") LocalDate date, + @Param("hiddenFrom") LocalDateTime hiddenFrom ); } diff --git a/src/main/java/com/piuda/callcare/domain/medication/service/command/MedicationCommandService.java b/src/main/java/com/piuda/callcare/domain/medication/service/command/MedicationCommandService.java index 39d2dde..6629556 100644 --- a/src/main/java/com/piuda/callcare/domain/medication/service/command/MedicationCommandService.java +++ b/src/main/java/com/piuda/callcare/domain/medication/service/command/MedicationCommandService.java @@ -112,7 +112,7 @@ private DrugInfo resolveDrugInfo(Long drugInfoId) { // 약 정보 수정 — timesPerDay 변경 시 스케줄 삭제 후 재생성 @Transactional public void update(Long userId, Long medicationId, MedicationUpdateRequest request) { - Medication medication = medicationRepository.findById(medicationId) + Medication medication = medicationRepository.findByIdAndDeletedAtIsNull(medicationId) .orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND)); seniorRepository.findByIdAndUser_Id(medication.getSenior().getId(), userId) .orElseThrow(() -> new CallCareException(ErrorCode.FORBIDDEN)); @@ -138,15 +138,15 @@ public void update(Long userId, Long medicationId, MedicationUpdateRequest reque } } - // 약 하드 삭제 — 스케줄 먼저 제거 후 Medication 완전 삭제 + // 약 소프트 삭제 — deleted_at만 채우고 스케줄·복약 로그·충돌 행은 그대로 남긴다(이력 보존 + FK 위반 회피). + // 삭제일 당일부터 약물노트·상세·홈카드·충돌 목록에서 빠지고, 그 이전 날짜 조회와 리포트에는 계속 남는다. @Transactional public void delete(Long userId, Long medicationId) { - Medication medication = medicationRepository.findById(medicationId) + Medication medication = medicationRepository.findByIdAndDeletedAtIsNull(medicationId) .orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND)); seniorRepository.findByIdAndUser_Id(medication.getSenior().getId(), userId) .orElseThrow(() -> new CallCareException(ErrorCode.FORBIDDEN)); - medicationScheduleRepository.deleteAllByMedication_Id(medicationId); - medicationRepository.delete(medication); + medication.softDelete(); } private String buildUsageStorageInfo(DrugInfo drugInfo) { diff --git a/src/main/java/com/piuda/callcare/domain/medication/service/query/MedicationQueryService.java b/src/main/java/com/piuda/callcare/domain/medication/service/query/MedicationQueryService.java index f25da47..1b0a959 100644 --- a/src/main/java/com/piuda/callcare/domain/medication/service/query/MedicationQueryService.java +++ b/src/main/java/com/piuda/callcare/domain/medication/service/query/MedicationQueryService.java @@ -34,7 +34,7 @@ public class MedicationQueryService { // 약 단건 상세 조회 — 재등록 화면 프리필용 public MedicationDetailResponse getDetail(Long userId, Long medicationId) { if (userId == null) throw new CallCareException(ErrorCode.FORBIDDEN); - Medication medication = medicationRepository.findById(medicationId) + Medication medication = medicationRepository.findByIdAndDeletedAtIsNull(medicationId) .orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND)); seniorRepository.findByIdAndUser_Id(medication.getSenior().getId(), userId) .orElseThrow(() -> new CallCareException(ErrorCode.FORBIDDEN)); diff --git a/src/test/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandServiceTest.java b/src/test/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandServiceTest.java index da31106..a998227 100644 --- a/src/test/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandServiceTest.java +++ b/src/test/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandServiceTest.java @@ -64,8 +64,8 @@ void analyze_savesConflict_forMatchingPair() { given(seniorRepository.findById(SENIOR_ID)).willReturn(Optional.of(senior)); given(medicationRepository.findActiveWithDrugInfoBySeniorId(SENIOR_ID)).willReturn(List.of(a, b)); - given(drugConflictRepository.existsBySenior_IdAndMedication1_IdAndMedication2_Id(anyLong(), anyLong(), anyLong())) - .willReturn(false); + given(drugConflictRepository.findBySenior_IdAndMedication1_IdAndMedication2_Id(anyLong(), anyLong(), anyLong())) + .willReturn(Optional.empty()); // When drugConflictCommandService.analyze(SENIOR_ID); @@ -81,21 +81,31 @@ void analyze_savesConflict_forMatchingPair() { } @Test - @DisplayName("이미 저장된 조합이면 중복 저장하지 않는다") - void analyze_skipsAlreadySavedPair() { + @DisplayName("이미 저장된 조합이면 신규 저장 없이 최신 분석 결과로 갱신(upsert)한다") + void analyze_updatesExistingPair_insteadOfSaving() { + // Given - 기존 행은 옛 등급(주의)이지만, 재분석은 금기로 판정됨 → 기존 행이 갱신돼야 함 Senior senior = senior(); Medication a = medication(20L, drugInfo("코감기약", null, "항히스타민제를 함유하는 내복약과 함께 복용하지 마십시오.")); Medication b = medication(10L, drugInfo("알레르기약", "[01410]항히스타민제", null)); + DrugConflict existing = DrugConflict.builder() + .senior(senior).medication1(b).medication2(a) + .severity(ConflictSeverity.CAUTION) + .conflictDescription("옛 설명") + .build(); given(seniorRepository.findById(SENIOR_ID)).willReturn(Optional.of(senior)); given(medicationRepository.findActiveWithDrugInfoBySeniorId(SENIOR_ID)).willReturn(List.of(a, b)); - given(drugConflictRepository.existsBySenior_IdAndMedication1_IdAndMedication2_Id(anyLong(), anyLong(), anyLong())) - .willReturn(true); + given(drugConflictRepository.findBySenior_IdAndMedication1_IdAndMedication2_Id(SENIOR_ID, 10L, 20L)) + .willReturn(Optional.of(existing)); + // When drugConflictCommandService.analyze(SENIOR_ID); + // Then - save는 호출되지 않고 기존 엔티티가 dirty checking으로 갱신됨 then(drugConflictRepository).should(never()).save(any()); + assertThat(existing.getSeverity()).isEqualTo(ConflictSeverity.CONTRAINDICATED); + assertThat(existing.getConflictDescription()).contains("항히스타민제"); } @Test @@ -113,6 +123,54 @@ void analyze_savesNothing_whenNoConflict() { then(drugConflictRepository).should(never()).save(any()); } + @Test + @DisplayName("재분석에서 더 이상 매칭되지 않는 기존 충돌은 삭제한다") + void analyze_deletesStaleConflict_whenPairNoLongerMatches() { + // Given - 두 약 모두 분석 대상이지만 이번엔 충돌 판정이 안 남 → 기존 행은 stale + Senior senior = senior(); + Medication a = medication(10L, drugInfo("비타민C정", "[03160]혼합비타민제", "상호작용 정보 없음.")); + Medication b = medication(20L, drugInfo("소화제", "[02330]효소제제", "상호작용 정보 없음.")); + DrugConflict stale = DrugConflict.builder() + .senior(senior).medication1(a).medication2(b) + .severity(ConflictSeverity.CAUTION) + .conflictDescription("옛 설명") + .build(); + + given(seniorRepository.findById(SENIOR_ID)).willReturn(Optional.of(senior)); + given(medicationRepository.findActiveWithDrugInfoBySeniorId(SENIOR_ID)).willReturn(List.of(a, b)); + given(drugConflictRepository.findAllWithMedicationsForReanalysis(SENIOR_ID)).willReturn(List.of(stale)); + + // When + drugConflictCommandService.analyze(SENIOR_ID); + + // Then + then(drugConflictRepository).should(times(1)).deleteAll(List.of(stale)); + } + + @Test + @DisplayName("분석 대상이 아닌 약(비활성·삭제)이 낀 충돌은 매칭되지 않아도 삭제하지 않는다") + void analyze_keepsConflict_whenMedicationNotInAnalysisScope() { + // Given - 활성 약은 a 하나뿐, 기존 행은 이미 비활성이 된 약(99L)과 엮여 있음 + Senior senior = senior(); + Medication a = medication(10L, drugInfo("비타민C정", "[03160]혼합비타민제", "상호작용 정보 없음.")); + Medication inactive = medication(99L, drugInfo("옛날약", "[01410]항히스타민제", null)); + DrugConflict old = DrugConflict.builder() + .senior(senior).medication1(a).medication2(inactive) + .severity(ConflictSeverity.CONTRAINDICATED) + .conflictDescription("옛 설명") + .build(); + + given(seniorRepository.findById(SENIOR_ID)).willReturn(Optional.of(senior)); + given(medicationRepository.findActiveWithDrugInfoBySeniorId(SENIOR_ID)).willReturn(List.of(a)); + given(drugConflictRepository.findAllWithMedicationsForReanalysis(SENIOR_ID)).willReturn(List.of(old)); + + // When + drugConflictCommandService.analyze(SENIOR_ID); + + // Then + then(drugConflictRepository).should(never()).deleteAll(any()); + } + @Test @DisplayName("예외: 어르신이 없으면 SENIOR_NOT_FOUND, 약 조회도 하지 않는다") void analyze_throws_whenSeniorNotFound() { diff --git a/src/test/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandServiceTest.java b/src/test/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandServiceTest.java index 2ec4520..6a080e5 100644 --- a/src/test/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandServiceTest.java +++ b/src/test/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandServiceTest.java @@ -65,13 +65,13 @@ class MedicationToggleCommandServiceTest { void toggle_firstCheck_insertsTrueAndCompleted() { // Given - 약 1개뿐인 시간대, 토글 후 그 약이 복용 완료 Medication med = medication(MED_ID); - given(medicationRepository.findById(MED_ID)).willReturn(Optional.of(med)); + given(medicationRepository.findByIdAndDeletedAtIsNull(MED_ID)).willReturn(Optional.of(med)); given(medicationScheduleRepository.existsActiveScheduleForToggle(MED_ID, MealTime.BREAKFAST, today)) .willReturn(true); given(medicationLogRepository.findByMedication_IdAndTakenDateAndMealTime(MED_ID, today, MealTime.BREAKFAST)) .willReturn(Optional.empty()); // 재계산: 그 시간대 스케줄 = 약1개, 로그 = 복용완료 - given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today)) + given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today, today.plusDays(1).atStartOfDay())) .willReturn(List.of(schedule(med, MealTime.BREAKFAST))); given(medicationLogRepository.findBySenior_IdAndTakenDate(SENIOR_ID, today)) .willReturn(List.of(log(med, MealTime.BREAKFAST, true))); @@ -91,13 +91,13 @@ void toggle_firstCheck_insertsTrueAndCompleted() { void toggle_uncheck_writesFalseAndIncomplete() { // Given Medication med = medication(MED_ID); - given(medicationRepository.findById(MED_ID)).willReturn(Optional.of(med)); + given(medicationRepository.findByIdAndDeletedAtIsNull(MED_ID)).willReturn(Optional.of(med)); given(medicationScheduleRepository.existsActiveScheduleForToggle(MED_ID, MealTime.DINNER, today)) .willReturn(true); given(medicationLogRepository.findByMedication_IdAndTakenDateAndMealTime(MED_ID, today, MealTime.DINNER)) .willReturn(Optional.of(log(med, MealTime.DINNER, true))); // 재계산: 해제 후 로그는 미복용 - given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today)) + given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today, today.plusDays(1).atStartOfDay())) .willReturn(List.of(schedule(med, MealTime.DINNER))); given(medicationLogRepository.findBySenior_IdAndTakenDate(SENIOR_ID, today)) .willReturn(List.of(log(med, MealTime.DINNER, false))); @@ -127,7 +127,7 @@ void toggle_throws_whenNotToday() { @Test @DisplayName("예외: 약이 없으면 MEDICATION_NOT_FOUND") void toggle_throws_whenMedicationNotFound() { - given(medicationRepository.findById(MED_ID)).willReturn(Optional.empty()); + given(medicationRepository.findByIdAndDeletedAtIsNull(MED_ID)).willReturn(Optional.empty()); assertThatThrownBy(() -> medicationToggleCommandService.toggle(MED_ID, MealTime.BREAKFAST, today)) .isInstanceOf(CallCareException.class) @@ -138,7 +138,7 @@ void toggle_throws_whenMedicationNotFound() { @DisplayName("예외: 그 시간대 스케줄이 없으면 MEDICATION_SCHEDULE_NOT_FOUND") void toggle_throws_whenScheduleNotFound() { Medication med = medication(MED_ID); - given(medicationRepository.findById(MED_ID)).willReturn(Optional.of(med)); + given(medicationRepository.findByIdAndDeletedAtIsNull(MED_ID)).willReturn(Optional.of(med)); given(medicationScheduleRepository.existsActiveScheduleForToggle(MED_ID, MealTime.LUNCH, today)) .willReturn(false); diff --git a/src/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java b/src/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java index 7ec4158..196481d 100644 --- a/src/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java +++ b/src/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java @@ -25,7 +25,6 @@ import com.piuda.callcare.domain.home.enums.CompletedStatus; import com.piuda.callcare.domain.home.enums.HomeCardMode; import com.piuda.callcare.domain.home.service.MealTimeCompletionCalculator; -import com.piuda.callcare.domain.hospital.entity.Hospital; import com.piuda.callcare.domain.medication.entity.Medication; import com.piuda.callcare.domain.medication.entity.MedicationSchedule; import com.piuda.callcare.domain.medication.enums.MealTime; @@ -64,8 +63,7 @@ class HomeCardQueryServiceTest { @DisplayName("오늘: 식사시간/병원으로 그룹화하고 로그로 isTaken·mealTimeCompleted를 합성한다") void todayMode_groupsAndSynthesizesCompletion() { // Given - 아침: 서울내과(약10) + 병원없음(약11), 점심: 서울내과(약10) - Hospital seoul = hospital(100L, "서울내과"); - Medication med10 = medication(10L, seoul); + Medication med10 = medication(10L, "서울내과"); Medication med11 = medication(11L, null); List schedules = List.of( @@ -74,7 +72,7 @@ void todayMode_groupsAndSynthesizesCompletion() { schedule(med10, MealTime.LUNCH) ); given(seniorRepository.existsById(SENIOR_ID)).willReturn(true); - given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today)).willReturn(schedules); + given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today, today.plusDays(1).atStartOfDay())).willReturn(schedules); // 아침 약10 복용 완료(약11 미완료), 점심 약10 복용 완료 given(medicationLogRepository.findBySenior_IdAndTakenDate(SENIOR_ID, today)) .willReturn(List.of( @@ -111,9 +109,9 @@ void todayMode_groupsAndSynthesizesCompletion() { void futureMode_noLogQuery_allFalse() { // Given LocalDate tomorrow = today.plusDays(1); - Medication med10 = medication(10L, hospital(100L, "서울내과")); + Medication med10 = medication(10L, "서울내과"); given(seniorRepository.existsById(SENIOR_ID)).willReturn(true); - given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, tomorrow)) + given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, tomorrow, tomorrow.plusDays(1).atStartOfDay())) .willReturn(List.of(schedule(med10, MealTime.BREAKFAST))); // When @@ -131,10 +129,10 @@ void futureMode_noLogQuery_allFalse() { void pastMode_setsCompletedStatus() { // Given LocalDate yesterday = today.minusDays(1); - Medication med10 = medication(10L, hospital(100L, "서울내과")); - Medication med11 = medication(11L, hospital(100L, "서울내과")); + Medication med10 = medication(10L, "서울내과"); + Medication med11 = medication(11L, "서울내과"); given(seniorRepository.existsById(SENIOR_ID)).willReturn(true); - given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, yesterday)) + given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, yesterday, yesterday.plusDays(1).atStartOfDay())) .willReturn(List.of(schedule(med10, MealTime.DINNER), schedule(med11, MealTime.DINNER))); given(medicationLogRepository.findBySenior_IdAndTakenDate(SENIOR_ID, yesterday)) .willReturn(List.of(log(med10, MealTime.DINNER, true, yesterday))); @@ -158,7 +156,7 @@ void bedtimeSchedule_sortedLast() { // Given Medication med10 = medication(10L, null); given(seniorRepository.existsById(SENIOR_ID)).willReturn(true); - given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today)) + given(medicationScheduleRepository.findActiveSchedulesForHomeCards(SENIOR_ID, today, today.plusDays(1).atStartOfDay())) .willReturn(List.of(schedule(med10, MealTime.BEDTIME), schedule(med10, MealTime.BREAKFAST))); given(medicationLogRepository.findBySenior_IdAndTakenDate(SENIOR_ID, today)).willReturn(List.of()); @@ -180,20 +178,14 @@ void seniorNotFound_throws() { assertThatThrownBy(() -> homeCardQueryService.getHomeCards(SENIOR_ID, today)) .isInstanceOf(CallCareException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SENIOR_NOT_FOUND); - then(medicationScheduleRepository).should(never()).findActiveSchedulesForHomeCards(anyLong(), any()); + then(medicationScheduleRepository).should(never()).findActiveSchedulesForHomeCards(anyLong(), any(), any()); } // ---- fixtures ---- - private Hospital hospital(Long id, String name) { - Hospital h = Hospital.builder().name(name).build(); - ReflectionTestUtils.setField(h, "id", id); - return h; - } - - private Medication medication(Long id, Hospital hospital) { + private Medication medication(Long id, String hospitalName) { Medication m = Medication.builder() - .hospital(hospital) + .hospitalName(hospitalName) .drugName("약" + id) .drugNickname("별명") .drugType("타입") From f34b358b798f5ff60a05c2eb747f5c0cc28c1a80 Mon Sep 17 00:00:00 2001 From: JO HYUNGJOON Date: Tue, 11 Aug 2026 01:42:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:catch=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/DrugConflictCommandService.java | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java b/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java index 9387200..e63b612 100644 --- a/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java +++ b/src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java @@ -6,7 +6,6 @@ import java.util.Set; import java.util.stream.Collectors; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -92,18 +91,16 @@ private void saveOrUpdate(Senior senior, Medication x, Medication y, ConflictMat return; } - try { - drugConflictRepository.save(DrugConflict.builder() - .senior(senior) - .medication1(first) - .medication2(second) - .severity(match.severity()) - .conflictDescription(match.description()) - .build()); - } catch (DataIntegrityViolationException e) { - // 동시 요청으로 같은 조합이 먼저 INSERT된 레이스(uk_drug_conflict_senior_med1_med2). - // 정합성은 UNIQUE 제약이 보장하므로 이번 요청은 저장을 건너뛴다(삼중 방어 유지). - } + // 동시 요청 레이스는 UNIQUE 제약(uk_drug_conflict_senior_med1_med2)이 막는다. + // 제약 위반 시 예외를 잡지 않고 전파한다 — 잡아도 세션이 rollback-only라 요청은 어차피 실패하고, + // analyze()는 멱등하므로 재요청하면 위 existing 경로로 정상 처리된다. + drugConflictRepository.save(DrugConflict.builder() + .senior(senior) + .medication1(first) + .medication2(second) + .severity(match.severity()) + .conflictDescription(match.description()) + .build()); } // 순서 무관 비교용 약 쌍 키. 저장 정규화 규칙과 동일하게 (작은 id, 큰 id)로 맞춘다.