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 96eb833..3ad7419 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 @@ -1,7 +1,9 @@ package com.piuda.callcare.domain.medication.controller; import com.piuda.callcare.domain.medication.dto.request.MedicationCreateRequest; +import com.piuda.callcare.domain.medication.dto.request.MedicationUpdateRequest; import com.piuda.callcare.domain.medication.dto.response.MedicationGroupItemResponse; +import com.piuda.callcare.domain.medication.dto.response.MedicationNoteGroupResponse; import com.piuda.callcare.domain.medication.dto.response.MedicationResponse; import com.piuda.callcare.domain.medication.service.command.MedicationCommandService; import com.piuda.callcare.domain.medication.service.query.MedicationQueryService; @@ -47,4 +49,45 @@ public ResponseEntity>> getGroup( ) { return ResponseUtils.ok(medicationQueryService.getGroup(userId, seniorId, hospitalName, prescriptionDate)); } + + @Operation(summary = "약물노트 목록 조회", description = "복용 시작일 + 병원 기준으로 그룹화된 약물 목록을 반환합니다.") + @GetMapping("/notes") + public ResponseEntity>> getNoteList( + @AuthenticationPrincipal Long userId, + @RequestParam Long seniorId + ) { + return ResponseUtils.ok(medicationQueryService.getNoteList(userId, seniorId)); + } + + @Operation(summary = "약물노트 검색", description = "약 이름/별명/병원명으로 검색합니다. period: 1w·1m·3m·1y (기본 1y)") + @GetMapping("/notes/search") + public ResponseEntity>> searchNotes( + @AuthenticationPrincipal Long userId, + @RequestParam Long seniorId, + @RequestParam String keyword, + @RequestParam(required = false, defaultValue = "1y") String period + ) { + return ResponseUtils.ok(medicationQueryService.searchNotes(userId, seniorId, keyword, period)); + } + + @Operation(summary = "약 수정", description = "null 필드는 변경하지 않습니다. timesPerDay 변경 시 스케줄을 재생성합니다.") + @PatchMapping("/{medicationId}") + public ResponseEntity> update( + @AuthenticationPrincipal Long userId, + @PathVariable Long medicationId, + @RequestBody @Valid MedicationUpdateRequest request + ) { + medicationCommandService.update(userId, medicationId, request); + return ResponseUtils.ok(); + } + + @Operation(summary = "약 삭제", description = "약과 복용 스케줄을 완전 삭제합니다. 복구 불가능합니다.") + @DeleteMapping("/{medicationId}") + public ResponseEntity> delete( + @AuthenticationPrincipal Long userId, + @PathVariable Long medicationId + ) { + medicationCommandService.delete(userId, medicationId); + return ResponseUtils.noContent(); + } } diff --git a/src/main/java/com/piuda/callcare/domain/medication/converter/MedicationConverter.java b/src/main/java/com/piuda/callcare/domain/medication/converter/MedicationConverter.java index 9bfa8d5..83768f3 100644 --- a/src/main/java/com/piuda/callcare/domain/medication/converter/MedicationConverter.java +++ b/src/main/java/com/piuda/callcare/domain/medication/converter/MedicationConverter.java @@ -1,6 +1,7 @@ package com.piuda.callcare.domain.medication.converter; import com.piuda.callcare.domain.medication.dto.response.MedicationGroupItemResponse; +import com.piuda.callcare.domain.medication.dto.response.MedicationNoteItemResponse; import com.piuda.callcare.domain.medication.dto.response.MedicationResponse; import com.piuda.callcare.domain.medication.entity.Medication; import com.piuda.callcare.domain.medication.entity.MedicationSchedule; @@ -30,6 +31,22 @@ public MedicationGroupItemResponse toGroupItemResponse(Medication medication) { ); } + // Medication → MedicationNoteItemResponse (약물노트 리스트 카드 내 약 단건) + public MedicationNoteItemResponse toNoteItemResponse(Medication medication) { + return new MedicationNoteItemResponse( + medication.getId(), + medication.getDrugName(), + medication.getDrugType(), + medication.getImageUrl(), + medication.getTimesPerDay(), + medication.getDosagePerTime(), + medication.getStartDate(), + medication.getEndDate(), + medication.getTotalDays(), + medication.getIsActive() + ); + } + // Medication + 스케줄 목록 → MedicationResponse public MedicationResponse toResponse(Medication medication, List schedules) { List mealTimes = schedules.stream() diff --git a/src/main/java/com/piuda/callcare/domain/medication/dto/request/MedicationUpdateRequest.java b/src/main/java/com/piuda/callcare/domain/medication/dto/request/MedicationUpdateRequest.java new file mode 100644 index 0000000..c60c8c9 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/medication/dto/request/MedicationUpdateRequest.java @@ -0,0 +1,31 @@ +package com.piuda.callcare.domain.medication.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; + +import java.time.LocalDate; + +@Schema(description = "약 수정 요청 — null 필드는 변경하지 않음") +public record MedicationUpdateRequest( + + @Schema(description = "약 이름 (null이면 변경 안 함)") + String drugName, + + @Schema(description = "1회 복용량 (null이면 변경 안 함)") + String dosagePerTime, + + @Schema(description = "1일 복용 횟수 (null이면 변경 안 함, 변경 시 스케줄 재생성)") + @Min(1) Integer timesPerDay, + + @Schema(description = "총 복용 일수 (null이면 변경 안 함)") + @Min(1) Integer totalDays, + + @Schema(description = "복용 시작일 (null이면 변경 안 함)") + LocalDate startDate, + + @Schema(description = "병원명 (null이면 변경 안 함)") + String hospitalName, + + @Schema(description = "메모 (null이면 변경 안 함)") + String memo +) {} diff --git a/src/main/java/com/piuda/callcare/domain/medication/dto/response/MedicationNoteGroupResponse.java b/src/main/java/com/piuda/callcare/domain/medication/dto/response/MedicationNoteGroupResponse.java new file mode 100644 index 0000000..37fb4b5 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/medication/dto/response/MedicationNoteGroupResponse.java @@ -0,0 +1,19 @@ +package com.piuda.callcare.domain.medication.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "약물노트 그룹 카드 — 복용 시작일 + 병원 기준 묶음") +public record MedicationNoteGroupResponse( + + @Schema(description = "복용 시작일 (그룹 키)") + LocalDate startDate, + + @Schema(description = "병원명 (없으면 null)") + String hospitalName, + + @Schema(description = "그룹 내 약 리스트") + List medications +) {} diff --git a/src/main/java/com/piuda/callcare/domain/medication/dto/response/MedicationNoteItemResponse.java b/src/main/java/com/piuda/callcare/domain/medication/dto/response/MedicationNoteItemResponse.java new file mode 100644 index 0000000..2d88067 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/medication/dto/response/MedicationNoteItemResponse.java @@ -0,0 +1,39 @@ +package com.piuda.callcare.domain.medication.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "약물노트 그룹 카드 내 약 단건") +public record MedicationNoteItemResponse( + + @Schema(description = "약 ID") + Long medicationId, + + @Schema(description = "약 이름") + String drugName, + + @Schema(description = "약 종류 (없으면 null)") + String drugType, + + @Schema(description = "약 이미지 URL (없으면 null)") + String imageUrl, + + @Schema(description = "1일 복용 횟수") + Integer timesPerDay, + + @Schema(description = "1회 복용량 (예: 1정, 5ml)") + String dosagePerTime, + + @Schema(description = "복용 시작일") + LocalDate startDate, + + @Schema(description = "복용 종료일 (없으면 null)") + LocalDate endDate, + + @Schema(description = "총 복용 일수 (없으면 null)") + Integer totalDays, + + @Schema(description = "복용 중 여부 — false면 종료된 약, 프론트에서 재등록 버튼 노출에 사용") + Boolean isActive +) {} 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 342c992..1439338 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 @@ -102,6 +102,19 @@ public Medication(Senior senior, String hospitalName, DrugInfo drugInfo, this.ocrResultId = ocrResultId; } + public void update(String drugName, String dosagePerTime, Integer timesPerDay, + Integer totalDays, LocalDate startDate, LocalDate endDate, + String hospitalName, String memo) { + if (drugName != null) this.drugName = drugName; + if (dosagePerTime != null) this.dosagePerTime = dosagePerTime; + if (timesPerDay != null) this.timesPerDay = timesPerDay; + if (totalDays != null) this.totalDays = totalDays; + if (startDate != null) this.startDate = startDate; + if (endDate != null) this.endDate = endDate; + if (hospitalName != null) this.hospitalName = hospitalName; + if (memo != null) this.memo = memo; + } + public void deactivate() { this.isActive = false; } 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 0ccf481..43959b9 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 @@ -29,6 +29,28 @@ public interface MedicationRepository extends JpaRepository { """) List findActiveWithDrugInfoBySeniorId(@Param("seniorId") Long seniorId); + // 약물노트 전체 리스트: 활성/비활성 관계없이 전체 조회 (startDate DESC → hospitalName ASC 정렬) + @Query(""" + SELECT m FROM Medication m + WHERE m.senior.id = :seniorId + ORDER BY m.startDate DESC, m.hospitalName ASC NULLS LAST + """) + List findAllBySeniorId(@Param("seniorId") Long seniorId); + + // 약물노트 검색: 약 이름/별명/병원명 키워드 + 날짜 범위 필터 (활성/비활성 모두 포함) + @Query(""" + SELECT m FROM Medication m + WHERE m.senior.id = :seniorId + AND m.startDate >= :fromDate + AND (LOWER(m.drugName) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(m.drugNickname) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(m.hospitalName) LIKE LOWER(CONCAT('%', :keyword, '%'))) + ORDER BY m.startDate DESC, m.hospitalName ASC NULLS LAST + """) + List searchByKeyword(@Param("seniorId") Long seniorId, + @Param("keyword") String keyword, + @Param("fromDate") LocalDate fromDate); + // 약물노트 그룹 상세 조회: 병원명 + 처방일 조합이 그룹 키 (둘 다 null 가능) @Query(""" SELECT m FROM Medication m 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 75a90a0..db552ad 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 @@ -11,6 +11,8 @@ public interface MedicationScheduleRepository extends JpaRepository { + void deleteAllByMedication_Id(Long medicationId); + // 토글 검증용: 해당 약에 그 시간대의 오늘 활성(복용 기간 내) 스케줄이 실제 존재하는지 // (홈카드 완료 계산의 findActiveSchedulesForHomeCards와 동일한 active/기간 조건으로 게이트를 맞춘다) @Query(""" 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 2fbe12e..39d2dde 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 @@ -4,6 +4,7 @@ import com.piuda.callcare.domain.druginfo.repository.DrugInfoRepository; import com.piuda.callcare.domain.medication.converter.MedicationConverter; import com.piuda.callcare.domain.medication.dto.request.MedicationCreateRequest; +import com.piuda.callcare.domain.medication.dto.request.MedicationUpdateRequest; import com.piuda.callcare.domain.medication.dto.response.MedicationResponse; import com.piuda.callcare.domain.medication.entity.Medication; import com.piuda.callcare.domain.medication.entity.MedicationSchedule; @@ -33,6 +34,7 @@ public class MedicationCommandService { private final DrugInfoRepository drugInfoRepository; private final MedicationConverter medicationConverter; + // 약 1건 이상 일괄 등록 — 스케줄 자동 생성, 하나라도 실패 시 전체 롤백 @Transactional public List registerBatch(Long userId, List requests) { return requests.stream() @@ -107,6 +109,46 @@ private DrugInfo resolveDrugInfo(Long drugInfoId) { .orElseThrow(() -> new CallCareException(ErrorCode.DRUG_NOT_FOUND)); } + // 약 정보 수정 — timesPerDay 변경 시 스케줄 삭제 후 재생성 + @Transactional + public void update(Long userId, Long medicationId, MedicationUpdateRequest request) { + Medication medication = medicationRepository.findById(medicationId) + .orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND)); + seniorRepository.findByIdAndUser_Id(medication.getSenior().getId(), userId) + .orElseThrow(() -> new CallCareException(ErrorCode.FORBIDDEN)); + + // startDate 또는 totalDays 가 바뀌면 endDate 재계산 + LocalDate newEndDate = null; + if (request.startDate() != null || request.totalDays() != null) { + LocalDate effectiveStart = request.startDate() != null ? request.startDate() : medication.getStartDate(); + if (effectiveStart != null) { + Integer effectiveDays = request.totalDays() != null ? request.totalDays() : medication.getTotalDays(); + newEndDate = effectiveDays != null ? effectiveStart.plusDays(effectiveDays - 1) : null; + } + } + + medication.update(request.drugName(), request.dosagePerTime(), request.timesPerDay(), + request.totalDays(), request.startDate(), newEndDate, request.hospitalName(), request.memo()); + + // 복용 횟수 변경 시 스케줄 삭제 후 재생성( 3회→2회로 바꾸면 점심 스케줄 삭제, 2회→3회로 바꾸면 점심 스케줄 새로 생성) + if (request.timesPerDay() != null) { + medicationScheduleRepository.deleteAllByMedication_Id(medicationId); + List newSchedules = createSchedules(medication, request.timesPerDay()); + medicationScheduleRepository.saveAll(newSchedules); + } + } + + // 약 하드 삭제 — 스케줄 먼저 제거 후 Medication 완전 삭제 + @Transactional + public void delete(Long userId, Long medicationId) { + Medication medication = medicationRepository.findById(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); + } + private String buildUsageStorageInfo(DrugInfo drugInfo) { if (drugInfo == null) return null; String usage = drugInfo.getUseMethodQesitm(); 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 7bebaf0..4a96a78 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 @@ -2,6 +2,8 @@ import com.piuda.callcare.domain.medication.converter.MedicationConverter; import com.piuda.callcare.domain.medication.dto.response.MedicationGroupItemResponse; +import com.piuda.callcare.domain.medication.dto.response.MedicationNoteGroupResponse; +import com.piuda.callcare.domain.medication.dto.response.MedicationNoteItemResponse; import com.piuda.callcare.domain.medication.entity.Medication; import com.piuda.callcare.domain.medication.repository.MedicationRepository; import com.piuda.callcare.domain.senior.repository.SeniorRepository; @@ -12,7 +14,10 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Service @Transactional(readOnly = true) @@ -23,6 +28,7 @@ public class MedicationQueryService { private final SeniorRepository seniorRepository; private final MedicationConverter medicationConverter; + // 병원+처방일 그룹의 약 상세 목록 조회 public List getGroup(Long userId, Long seniorId, String hospitalName, LocalDate prescriptionDate) { if (userId == null) { throw new CallCareException(ErrorCode.FORBIDDEN); @@ -34,4 +40,50 @@ public List getGroup(Long userId, Long seniorId, St .map(medicationConverter::toGroupItemResponse) .toList(); } + + // 시니어의 약 전체 목록 (활성/비활성 모두) — startDate+병원 기준 그룹화 + public List getNoteList(Long userId, Long seniorId) { + if (userId == null) throw new CallCareException(ErrorCode.FORBIDDEN); + seniorRepository.findByIdAndUser_Id(seniorId, userId) + .orElseThrow(() -> new CallCareException(ErrorCode.SENIOR_NOT_FOUND)); + List medications = medicationRepository.findAllBySeniorId(seniorId); + return toNoteGroupResponses(medications); + } + + // 약 이름/별명/병원명 키워드 검색 + 기간 필터 (1w·1m·3m·1y) + public List searchNotes(Long userId, Long seniorId, String keyword, String period) { + if (userId == null) throw new CallCareException(ErrorCode.FORBIDDEN); + seniorRepository.findByIdAndUser_Id(seniorId, userId) + .orElseThrow(() -> new CallCareException(ErrorCode.SENIOR_NOT_FOUND)); + LocalDate fromDate = resolveFromDate(period); + List medications = medicationRepository.searchByKeyword(seniorId, keyword, fromDate); + return toNoteGroupResponses(medications); + } + + private List toNoteGroupResponses(List medications) { + Map> grouped = new LinkedHashMap<>(); + for (Medication m : medications) { + String key = m.getStartDate() + "|" + m.getHospitalName(); + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(m); + } + return grouped.values().stream() + .map(group -> { + Medication first = group.get(0); + List items = group.stream() + .map(medicationConverter::toNoteItemResponse) + .toList(); + return new MedicationNoteGroupResponse(first.getStartDate(), first.getHospitalName(), items); + }) + .toList(); + } + + private LocalDate resolveFromDate(String period) { + LocalDate today = LocalDate.now(); + return switch (period == null ? "1y" : period.toLowerCase()) { + case "1w" -> today.minusWeeks(1); + case "1m" -> today.minusMonths(1); + case "3m" -> today.minusMonths(3); + default -> today.minusYears(1); + }; + } }