Skip to content
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,4 +49,45 @@ public ResponseEntity<ApiResponse<List<MedicationGroupItemResponse>>> getGroup(
) {
return ResponseUtils.ok(medicationQueryService.getGroup(userId, seniorId, hospitalName, prescriptionDate));
}

@Operation(summary = "약물노트 목록 조회", description = "복용 시작일 + 병원 기준으로 그룹화된 약물 목록을 반환합니다.")
@GetMapping("/notes")
public ResponseEntity<ApiResponse<List<MedicationNoteGroupResponse>>> 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<ApiResponse<List<MedicationNoteGroupResponse>>> 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<ApiResponse<Void>> update(
@AuthenticationPrincipal Long userId,
@PathVariable Long medicationId,
@RequestBody @Valid MedicationUpdateRequest request
) {
medicationCommandService.update(userId, medicationId, request);
return ResponseUtils.ok();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Operation(summary = "약 삭제", description = "약과 복용 스케줄을 완전 삭제합니다. 복구 불가능합니다.")
@DeleteMapping("/{medicationId}")
public ResponseEntity<ApiResponse<Void>> delete(
@AuthenticationPrincipal Long userId,
@PathVariable Long medicationId
) {
medicationCommandService.delete(userId, medicationId);
return ResponseUtils.noContent();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<MedicationSchedule> schedules) {
List<MealTime> mealTimes = schedules.stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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<MedicationNoteItemResponse> medications
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ public interface MedicationRepository extends JpaRepository<Medication, Long> {
""")
List<Medication> 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<Medication> 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<Medication> searchByKeyword(@Param("seniorId") Long seniorId,
@Param("keyword") String keyword,
@Param("fromDate") LocalDate fromDate);

// 약물노트 그룹 상세 조회: 병원명 + 처방일 조합이 그룹 키 (둘 다 null 가능)
@Query("""
SELECT m FROM Medication m
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

public interface MedicationScheduleRepository extends JpaRepository<MedicationSchedule, Long> {

void deleteAllByMedication_Id(Long medicationId);

// 토글 검증용: 해당 약에 그 시간대의 오늘 활성(복용 기간 내) 스케줄이 실제 존재하는지
// (홈카드 완료 계산의 findActiveSchedulesForHomeCards와 동일한 active/기간 조건으로 게이트를 맞춘다)
@Query("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -33,6 +34,7 @@ public class MedicationCommandService {
private final DrugInfoRepository drugInfoRepository;
private final MedicationConverter medicationConverter;

// 약 1건 이상 일괄 등록 — 스케줄 자동 생성, 하나라도 실패 시 전체 롤백
@Transactional
public List<MedicationResponse> registerBatch(Long userId, List<MedicationCreateRequest> requests) {
return requests.stream()
Expand Down Expand Up @@ -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<MedicationSchedule> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -23,6 +28,7 @@ public class MedicationQueryService {
private final SeniorRepository seniorRepository;
private final MedicationConverter medicationConverter;

// 병원+처방일 그룹의 약 상세 목록 조회
public List<MedicationGroupItemResponse> getGroup(Long userId, Long seniorId, String hospitalName, LocalDate prescriptionDate) {
if (userId == null) {
throw new CallCareException(ErrorCode.FORBIDDEN);
Expand All @@ -34,4 +40,50 @@ public List<MedicationGroupItemResponse> getGroup(Long userId, Long seniorId, St
.map(medicationConverter::toGroupItemResponse)
.toList();
}

// 시니어의 약 전체 목록 (활성/비활성 모두) — startDate+병원 기준 그룹화
public List<MedicationNoteGroupResponse> 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<Medication> medications = medicationRepository.findAllBySeniorId(seniorId);
return toNoteGroupResponses(medications);
}

// 약 이름/별명/병원명 키워드 검색 + 기간 필터 (1w·1m·3m·1y)
public List<MedicationNoteGroupResponse> 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<Medication> medications = medicationRepository.searchByKeyword(seniorId, keyword, fromDate);
return toNoteGroupResponses(medications);
}

private List<MedicationNoteGroupResponse> toNoteGroupResponses(List<Medication> medications) {
Map<String, List<Medication>> 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<MedicationNoteItemResponse> 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);
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}