Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,43 +1,62 @@
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()
);
}

// 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(),
conflict.getIsResolved(),
conflict.getCreatedAt()
);
}
}

// 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();
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Comment on lines 10 to 18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Find code and tests that still reference removed flat response fields.
rg -n -C 3 \
  'DrugConflictResponse|DrugConflictDetailResponse|ConflictDrug|drug1Id|drug2Id|drug1Name|drug2Name' \
  src/main/java src/test/java

# Locate controller and API-documentation tests for the affected endpoints.
rg -n -C 5 \
  'getConflicts|getConflictDetail|/api/conflicts|MockMvc|WebTestClient' \
  src/main/java src/test/java

Repository: PIUDAProject/Backend

Length of output: 41576


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exact response DTOs and converter logic.
printf '--- DTO records ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java
printf '\n--- ConflictDrug ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java
printf '\n--- DrugConflictDetailResponse ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java
printf '\n--- Converter relevant section ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java | sed -n '1,80p'

# Read-only verifier: compare current public DTO public constructor/schema fields against removed flat fields.
python3 - <<'PY'
from pathlib import Path
import re

files = [
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java",
]
records = []
for path in files:
    text = Path(path).read_text()
    m = re.search(r'public record \w+\s*(.*?)\n\{', text, re.S)
    if not m:
        raise SystemExit(f"You need check {path}")
    body = re.sub(r'`@Schema`\s*\(.*?\)\s*', '', m.group(1), flags=re.S)
    fields = [re.sub(r'[,;\s]+.*','', f.strip()) for f in body.splitlines() if f.strip() and not f.strip().startswith('//')]
    records.append((path.split('/')[-1], fields))

for name, fields in records:
    print(name, fields)

flat_fields = ['drug1Id','drug2Id','drug1Name','drug2Name','drug1DrugType','drug2DrugType','drug1DrugNickname','drug2DrugNickname','drug1HospitalName','drug2HospitalName','drug1PrescriptionDate','drug2PrescriptionDate']
for name, fields in records:
    removed = [f for f in fields if f.startswith('drug') or 'drugId' in f or f in flat_fields]
    nested = [f for f in fields if f in ('drug1','drug2')]
    if removed or nested:
        print('root flat/nested fields:', name, removed or nested)
PY

printf '\n--- API tests using /api/conflicts or controller responses ---\n'
rg -n -C 4 '/api/conflicts|DrugConflictController|MockMvc|JsonPath|Extract.*Response|ApiResponse' src/test/java || true

Repository: PIUDAProject/Backend

Length of output: 249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exact response DTOs and converter logic.
echo '--- DTO records ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java
echo
echo '--- ConflictDrug ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java
echo
echo '--- DrugConflictDetailResponse ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java
echo
echo '--- Converter relevant section ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java | sed -n '1,80p'

# Read-only verifier: compare current public DTO public constructor/schema fields against removed flat fields.
python3 - <<'PY'
from pathlib import Path
import re

files = [
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java",
]
records = []
for path in files:
    text = Path(path).read_text()
    m = re.search(r'public record \w+\s*(.*?)\n\{', text, re.S)
    if not m:
        raise SystemExit(f"Check {path}")
    body = re.sub(r'`@Schema`\s*\([^)]*\)\s*', '', m.group(1), flags=re.S)
    fields = [re.sub(r'[,;\s]+.*','', f.strip()) for f in body.splitlines() if f.strip() and not f.strip().startswith('//')]
    records.append((path.split('/')[-1], fields))

flat_fields = ['drug1Id','drug2Id','drug1Name','drug2Name','drug1DrugType','drug2DrugType','drug1DrugNickname','drug2DrugNickname','drug1HospitalName','drug2HospitalName','drug1PrescriptionDate','drug2PrescriptionDate']
for name, fields in records:
    print(name, fields)
    removed = [f for f in fields if f.startswith('drug') or 'drugId' in f or f in flat_fields]
    nested = [f for f in fields if f in ('drug1','drug2')]
    if removed or nested:
        print('root flat/nested fields:', name, removed or nested)
PY

echo
echo '--- API tests using /api/conflicts or controller responses ---'
rg -n -C 4 '/api/conflicts|DrugConflictController|MockMvc|JsonPath|Extract.*Response|ApiResponse' src/test/java || true

Repository: PIUDAProject/Backend

Length of output: 6127


외부 API 응답 계약 변경을 배포 전에 확인하십시오.

약물 필드가 drug1/drug2 중첩 객체로 바뀌었습니다. 기존 클라이언트는 drug1Name, drug2Name 같은 평탄 필드를 더 읽지 못하므로 렌더링 오류가 발생할 수 있습니다.

  • GET /api/conflicts/api/conflicts/{conflictId} 응답을 새 중첩 JSON 구조로 매핑하는 프론트 변경을 배포와 맞추십시오.
  • API 문서와 예시 응답은 기존 필드 삭제와 drugType, drugName, prescriptionDate 같은 중첩 필드 추가를 반영하십시오.
  • 이 변경은 GET /api/conflicts와 상세 조회 모두에 적용됩니다.
📍 Affects 4 files
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java#L10-L18 (this comment)
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java#L10-L18
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java#L12-L22
  • src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java#L18-L60
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java`
around lines 10 - 18,
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java:10-18에서
DrugConflictResponse의 drug1/drug2 중첩 응답 계약을 기준으로 목록 API의 새 JSON 구조를 반영하십시오.
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java:10-18의
drugType, drugName, prescriptionDate 등 중첩 필드를 사용하고 기존 평탄 필드는 제거한 뒤,
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java:12-22와
src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java:18-60에서도
목록 및 상세 조회 모두 동일하게 매핑되도록 수정하십시오. API 문서와 예시 응답도 새 중첩 구조와 필드 삭제를 반영하고 프론트 소비자 변경을
배포에 맞추십시오.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.Objects;

@Entity
@Table(name = "drug_conflict", uniqueConstraints = @UniqueConstraint(
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,46 @@

public interface DrugConflictRepository extends JpaRepository<DrugConflict, Long> {

// 중복 저장 방지용: (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<DrugConflict> 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<DrugConflict> 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<DrugConflict> 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<DrugConflict> findWithMedicationsById(@Param("conflictId") Long conflictId);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -38,38 +41,75 @@ public void analyze(Long seniorId) {
// TODO: 인증 도입 후 seniorId 소유권 검증 추가

List<Medication> medications = medicationRepository.findActiveWithDrugInfoBySeniorId(seniorId);
Set<MedicationPair> 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);
}

// (medication_id_1 < medication_id_2)로 정규화해 순서 무관 중복 저장을 방지(find→분기, 2단계 upsert 패턴).
private void saveIfAbsent(Senior senior, Medication x, Medication y, ConflictMatch match) {
// 이번 분석에서 다시 매칭되지 않은 기존 충돌을 제거 — 약 정보가 바뀌어 더 이상 충돌이 아닌 쌍이
// 옛 등급으로 목록에 남는 것을 막는다(upsert만으로는 사라진 충돌을 정리할 수 없다).
// 삭제 범위는 이번 분석 대상(활성·미삭제) 약들로만 이뤄진 쌍에 한정한다 —
// 비활성·삭제된 약이 낀 행은 애초에 매칭 대상이 아니었을 뿐이므로 지우면 분석 이력이 사라진다.
private void deleteStaleConflicts(Long seniorId, List<Medication> analyzed, Set<MedicationPair> matchedPairs) {
Set<Long> analyzedIds = analyzed.stream().map(Medication::getId).collect(Collectors.toSet());

List<DrugConflict> 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)로 정규화해 순서 무관 중복을 방지하고,
// 기존 쌍이 있으면 최신 분석 결과로 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<DrugConflict> 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;
}

try {
drugConflictRepository.save(DrugConflict.builder()
.senior(senior)
.medication1(first)
.medication2(second)
.severity(match.severity())
.conflictDescription(match.description())
.build());
} catch (DataIntegrityViolationException e) {
// 동시 요청으로 같은 조합이 먼저 저장된 경우(uk_drug_conflict_senior_med1_med2). 정합성은 제약이 보장하므로 무시.
// 동시 요청 레이스는 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)로 맞춘다.
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());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) 그 약에 해당 시간대의 오늘 활성 스케줄이 실제 존재하는지 검증 (완료 재계산과 동일 기준)
Expand Down Expand Up @@ -76,7 +76,7 @@ public MedicationLogToggleResponse toggle(Long medicationId, MealTime mealTime,
// 홈카드와 동일한 완료 규칙으로 해당 시간대 완료 여부를 산출 → CompletedStatus로 변환
private CompletedStatus recalculateMealTimeStatus(Long seniorId, LocalDate date, MealTime mealTime) {
List<MedicationSchedule> slotSchedules = medicationScheduleRepository
.findActiveSchedulesForHomeCards(seniorId, date).stream()
.findActiveSchedulesForHomeCards(seniorId, date, date.plusDays(1).atStartOfDay()).stream()
.filter(schedule -> schedule.getMealTime() == mealTime)
.toList();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ public HomeCardResponse getHomeCards(Long seniorId, LocalDate date) {
}

HomeCardMode mode = HomeCardMode.from(targetDate);
List<MedicationSchedule> schedules =
medicationScheduleRepository.findActiveSchedulesForHomeCards(seniorId, targetDate);
// 조회 날짜의 다음날 0시 — 이 시각 이후에 삭제된 약은 그 날엔 아직 복용 중이었으므로 카드에 남긴다
List<MedicationSchedule> schedules = medicationScheduleRepository.findActiveSchedulesForHomeCards(
seniorId, targetDate, targetDate.plusDays(1).atStartOfDay());

// 미래 모드는 완료 개념이 없어 로그 조회 자체를 생략
Set<TakenKey> takenKeys = mode.tracksCompletion()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public ResponseEntity<ApiResponse<List<MedicationReportGroupResponse>>> getRepor
return ResponseUtils.ok(medicationQueryService.getReport(userId, seniorId));
}

@Operation(summary = "약 삭제", description = "약과 복용 스케줄을 완전 삭제합니다. 복구 불가능합니다.")
@Operation(summary = "약 삭제", description = "약을 삭제 처리합니다. 삭제일 당일부터 약물노트·상세 조회·오늘/미래 홈카드·충돌 목록에서 제외되며, 삭제 이전 날짜의 홈카드와 복약 기록 리포트에는 그대로 남습니다.")
@DeleteMapping("/{medicationId}")
public ResponseEntity<ApiResponse<Void>> delete(
@AuthenticationPrincipal Long userId,
Expand Down
Loading