From e404fb3b42a1aff764e4d13c2bbb30c28d96750b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EB=8B=A4=EC=97=B0?= <95288297+Dayon-Hong@users.noreply.github.com> Date: Fri, 9 Aug 2024 19:38:07 +0900 Subject: [PATCH] =?UTF-8?q?[Backend=20:=20feat=20]=20=EC=98=81=EC=88=98?= =?UTF-8?q?=EC=A6=9D=20=EA=B4=80=EB=A0=A8=20API=20=EA=B5=AC=ED=98=84=20=20?= =?UTF-8?q?(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : s3이미지 업로드시, 디렉토리명 체크 메서드 수정 * feat : 영수증 저장 entity 설정 * feat : 글로벌 예외처리 - 영수증 관련 추가 * fix : 일정 목록 조회시, 국가 통화 명 컬럼 추가 * feat : 영수증 저장에 필요한 DTO 추가 * feat : 영수증 저장 API 구현 * feat : 중간 커밋 * refector : 서비스 코드의 인터페이스 코드 분리 * Feat : 영수증 순서를 위한 orderNum 추가 * feat : 영수증 등록,수정,조회 등 API 구현 --- .../country/service/CountryService.java | 191 +------------- .../country/service/CountryServiceImpl.java | 201 ++++++++++++++ .../service/ExchangeRateScheduler.java | 1 + .../country/service/ExchangeRateService.java | 110 +------- .../service/ExchangeRateServiceImpl.java | 119 +++++++++ .../receipt/controller/ReceiptController.java | 80 +++++- .../request/ChangeReceiptOrderRequest.java | 18 ++ .../dto/request/ReceiptDetailRequest.java | 20 ++ .../dto/request/SaveReceiptRequest.java | 29 +++ .../dto/response/ReceiptDetailResponse.java | 20 ++ .../dto/response/ReceiptListResponse.java | 26 ++ .../receipt/dto/response/ReceiptResponse.java | 27 ++ .../dto/response/ScheduleReceiptResponse.java | 28 ++ .../domain/receipt/entity/Receipt.java | 25 +- .../domain/receipt/entity/ReceiptDetail.java | 30 +++ .../domain/receipt/entity/StoreType.java | 18 ++ .../domain/receipt/mapper/ReceiptMapper.java | 91 +++++++ .../repository/ReceiptDetailRepository.java | 14 + .../receipt/repository/ReceiptRepository.java | 20 ++ .../receipt/service/ReceiptService.java | 245 +++++++++++++++++- .../dto/response/ScheduleListResponse.java | 2 + .../schedule/mapper/ScheduleMapper.java | 3 +- .../backend/global/exception/ErrorCode.java | 4 + .../Image/ImageAlreadyExistingException.java | 13 + .../receipt/ReceiptNotFoundException.java | 12 + .../global/s3/constant/S3BucketDirectory.java | 26 +- .../global/s3/service/S3ImageService.java | 6 +- 27 files changed, 1072 insertions(+), 307 deletions(-) create mode 100644 backend/src/main/java/com/isp/backend/domain/country/service/CountryServiceImpl.java create mode 100644 backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateServiceImpl.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ChangeReceiptOrderRequest.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ReceiptDetailRequest.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/dto/request/SaveReceiptRequest.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptDetailResponse.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptListResponse.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptResponse.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ScheduleReceiptResponse.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/entity/ReceiptDetail.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/entity/StoreType.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/mapper/ReceiptMapper.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptDetailRepository.java create mode 100644 backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptRepository.java create mode 100644 backend/src/main/java/com/isp/backend/global/exception/Image/ImageAlreadyExistingException.java create mode 100644 backend/src/main/java/com/isp/backend/global/exception/receipt/ReceiptNotFoundException.java diff --git a/backend/src/main/java/com/isp/backend/domain/country/service/CountryService.java b/backend/src/main/java/com/isp/backend/domain/country/service/CountryService.java index ffe467dd..86ff3c5d 100644 --- a/backend/src/main/java/com/isp/backend/domain/country/service/CountryService.java +++ b/backend/src/main/java/com/isp/backend/domain/country/service/CountryService.java @@ -1,201 +1,26 @@ package com.isp.backend.domain.country.service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.isp.backend.domain.country.dto.response.DailyWeatherResponse; import com.isp.backend.domain.country.dto.response.LocationResponse; import com.isp.backend.domain.country.dto.response.WeatherResponse; import com.isp.backend.domain.country.entity.Country; -import com.isp.backend.domain.country.mapper.WeatherMapper; -import com.isp.backend.domain.country.repository.CountryRepository; -import com.isp.backend.global.exception.openApi.OpenWeatherSearchFailedException; -import com.isp.backend.global.exception.schedule.CountryNotFoundException; -import com.isp.backend.global.s3.constant.S3BucketDirectory; -import com.isp.backend.global.s3.service.S3ImageService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; -import java.io.IOException; import java.text.DecimalFormat; import java.time.*; import java.time.format.DateTimeFormatter; import java.time.format.TextStyle; -import java.util.*; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +public interface CountryService { + Optional findLocationByCity(String city); -@Service -public class CountryService { + WeatherResponse getCurrentWeather(String city); - @Autowired - private CountryRepository countryRepository; + List getWeeklyWeather(String city, LocalTime requestedTime); - @Value("${api-key.open-weather}") - private String weatherApiKey; - - private final RestTemplate restTemplate = new RestTemplate(); - - @Autowired - private S3ImageService s3ImageService; - - - /** 여행지 좌표 찾기 **/ - public Optional findLocationByCity(String city) { - Optional findCountry = countryRepository.findIdByCity(city); - - if (findCountry.isPresent()) { - return findCountry.map(country -> { - LocationResponse locationDTO = new LocationResponse(); - locationDTO.setLatitude(country.getLatitude()); - locationDTO.setLongitude(country.getLongitude()); - return locationDTO; - }); - } else { - throw new CountryNotFoundException(); - } - } - - - - /** 여행지의 날씨 정보 가져오기 **/ - public WeatherResponse getCurrentWeather(String city) { - Optional findCountry = findCountryByCity(city); - Country country = findCountry.get(); - double latitude = country.getLatitude(); - double longitude = country.getLongitude(); - - String url = String.format("http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%s", latitude, longitude, weatherApiKey); - String jsonResponse = restTemplate.getForObject(url, String.class); - - ObjectMapper objectMapper = new ObjectMapper(); - WeatherResponse weatherResponse = new WeatherResponse(); - - try { - JsonNode rootNode = objectMapper.readTree(jsonResponse); - - JsonNode weatherNode = rootNode.path("weather").get(0); - weatherResponse.setMain(weatherNode.path("main").asText()); - String descriptionId = weatherNode.path("id").asText(); // ID 값 가져오기 - String descriptionTranslation = WeatherMapper.getWeatherDescriptionTranslation(descriptionId); // 날씨 설명 - weatherResponse.setDescription(descriptionTranslation); - - String icon = weatherNode.path("icon").asText(); - weatherResponse.setIconUrl(s3ImageService.get(S3BucketDirectory.WEATHER.getDirectory(), icon + ".png")); - - JsonNode mainNode = rootNode.path("main"); - weatherResponse.setTemp(convertToCelsius(mainNode.path("temp").asDouble())); - weatherResponse.setTempMin(convertToCelsius(mainNode.path("temp_min").asDouble())); - weatherResponse.setTempMax(convertToCelsius(mainNode.path("temp_max").asDouble())); - - int timezoneOffset = rootNode.path("timezone").asInt(); - weatherResponse.setLocalTime(getLocalTime(timezoneOffset)); - - } catch (IOException e) { - e.printStackTrace(); - throw new OpenWeatherSearchFailedException(); - } - - return weatherResponse; - } - - - - /** 한 주의 날씨 정보 조회 **/ - public List getWeeklyWeather(String city, LocalTime requestedTime) { - Optional findCountry = findCountryByCity(city); - Country country = findCountry.get(); - double latitude = country.getLatitude(); - double longitude = country.getLongitude(); - - List weeklyWeather = new ArrayList<>(); - String url = String.format("http://api.openweathermap.org/data/2.5/forecast?lat=%f&lon=%f&appid=%s", latitude, longitude, weatherApiKey); - String jsonResponse = restTemplate.getForObject(url, String.class); - - try { - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode rootNode = objectMapper.readTree(jsonResponse); - JsonNode forecastList = rootNode.path("list"); - - Map dailyWeatherMap = new HashMap<>(); // 각 날짜의 날씨 정보를 저장할 맵 - - for (JsonNode forecast : forecastList) { - String dateTime = forecast.path("dt_txt").asText(); // 예측 시간 정보 - LocalDateTime localDateTime = LocalDateTime.parse(dateTime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - - // 요청된 시간대의 온도만 고려 - if (localDateTime.toLocalTime().equals(requestedTime)) { - String dateString = localDateTime.toLocalDate().toString(); - double temperature = forecast.path("main").path("temp").asDouble(); // 온도 정보 - - // 해당 날짜의 온도 정보를 저장 (동일한 시간대에 여러 데이터가 있을 경우, 가장 첫 번째 데이터를 사용) - if (!dailyWeatherMap.containsKey(dateString)) { - // 아이콘 URL 가져오기 - String iconCode = forecast.path("weather").get(0).path("icon").asText(); - String iconUrl = String.format(s3ImageService.get(S3BucketDirectory.WEATHER.getDirectory(), iconCode + ".png")); - - DailyWeatherResponse dailyWeather = new DailyWeatherResponse(); - dailyWeather.setDate(parseDayOfWeek(dateString)); - dailyWeather.setTemp(convertToCelsius(temperature)); - dailyWeather.setIconUrl(iconUrl); - - dailyWeatherMap.put(dateString, dailyWeather); - } - } - } - - weeklyWeather.addAll(dailyWeatherMap.values()); - weeklyWeather.sort(Comparator.comparing(DailyWeatherResponse::getDate)); - - } catch (IOException e) { - e.printStackTrace(); - throw new OpenWeatherSearchFailedException(); - } - - return weeklyWeather; - } - - - - /** 현지 시간으로 변환 **/ - private String getLocalTime(int timezoneOffset) { - Instant now = Instant.now(); - ZoneOffset offset = ZoneOffset.ofTotalSeconds(timezoneOffset); - LocalDateTime localDateTime = LocalDateTime.ofInstant(now, offset); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"); - return localDateTime.format(formatter); - } - - - - /** 온도를 섭씨로 변환 **/ - private String convertToCelsius(double kelvin) { - double celsius = kelvin - 273.15; - DecimalFormat df = new DecimalFormat("#.##"); - return df.format(celsius); - } - - - - /** 날짜 문자열에서 요일을 파싱 **/ - private String parseDayOfWeek(String dateString) { - LocalDate localDate = LocalDate.parse(dateString); - DayOfWeek dayOfWeek = localDate.getDayOfWeek(); - String weekDay = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN); - return (dateString + "," + weekDay); - } - - - - /** 도시이름으로 나라 찾기 **/ - public Optional findCountryByCity(String city) { - Optional findCountry = countryRepository.findIdByCity(city); - if (!findCountry.isPresent()) { - throw new CountryNotFoundException(); - } - return findCountry; - } + Optional findCountryByCity(String city); } - diff --git a/backend/src/main/java/com/isp/backend/domain/country/service/CountryServiceImpl.java b/backend/src/main/java/com/isp/backend/domain/country/service/CountryServiceImpl.java new file mode 100644 index 00000000..1a113aea --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/country/service/CountryServiceImpl.java @@ -0,0 +1,201 @@ +package com.isp.backend.domain.country.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.isp.backend.domain.country.dto.response.DailyWeatherResponse; +import com.isp.backend.domain.country.dto.response.LocationResponse; +import com.isp.backend.domain.country.dto.response.WeatherResponse; +import com.isp.backend.domain.country.entity.Country; +import com.isp.backend.domain.country.mapper.WeatherMapper; +import com.isp.backend.domain.country.repository.CountryRepository; +import com.isp.backend.global.exception.openApi.OpenWeatherSearchFailedException; +import com.isp.backend.global.exception.schedule.CountryNotFoundException; +import com.isp.backend.global.s3.constant.S3BucketDirectory; +import com.isp.backend.global.s3.service.S3ImageService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.text.DecimalFormat; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.TextStyle; +import java.util.*; + + +@Service +public class CountryServiceImpl implements CountryService { + + @Autowired + private CountryRepository countryRepository; + + @Value("${api-key.open-weather}") + private String weatherApiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Autowired + private S3ImageService s3ImageService; + + + /** 여행지 좌표 찾기 **/ + @Override + public Optional findLocationByCity(String city) { + Optional findCountry = countryRepository.findIdByCity(city); + + if (findCountry.isPresent()) { + return findCountry.map(country -> { + LocationResponse locationDTO = new LocationResponse(); + locationDTO.setLatitude(country.getLatitude()); + locationDTO.setLongitude(country.getLongitude()); + return locationDTO; + }); + } else { + throw new CountryNotFoundException(); + } + } + + + + /** 여행지의 날씨 정보 가져오기 **/ + @Override + public WeatherResponse getCurrentWeather(String city) { + Optional findCountry = findCountryByCity(city); + Country country = findCountry.get(); + double latitude = country.getLatitude(); + double longitude = country.getLongitude(); + + String url = String.format("http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%s", latitude, longitude, weatherApiKey); + String jsonResponse = restTemplate.getForObject(url, String.class); + + ObjectMapper objectMapper = new ObjectMapper(); + WeatherResponse weatherResponse = new WeatherResponse(); + + try { + JsonNode rootNode = objectMapper.readTree(jsonResponse); + + JsonNode weatherNode = rootNode.path("weather").get(0); + weatherResponse.setMain(weatherNode.path("main").asText()); + String descriptionId = weatherNode.path("id").asText(); // ID 값 가져오기 + String descriptionTranslation = WeatherMapper.getWeatherDescriptionTranslation(descriptionId); // 날씨 설명 + weatherResponse.setDescription(descriptionTranslation); + + String icon = weatherNode.path("icon").asText(); + weatherResponse.setIconUrl(s3ImageService.get(S3BucketDirectory.WEATHER.getDirectory(), icon + ".png")); + + JsonNode mainNode = rootNode.path("main"); + weatherResponse.setTemp(convertToCelsius(mainNode.path("temp").asDouble())); + weatherResponse.setTempMin(convertToCelsius(mainNode.path("temp_min").asDouble())); + weatherResponse.setTempMax(convertToCelsius(mainNode.path("temp_max").asDouble())); + + int timezoneOffset = rootNode.path("timezone").asInt(); + weatherResponse.setLocalTime(getLocalTime(timezoneOffset)); + + } catch (IOException e) { + e.printStackTrace(); + throw new OpenWeatherSearchFailedException(); + } + + return weatherResponse; + } + + + + /** 한 주의 날씨 정보 조회 **/ + @Override + public List getWeeklyWeather(String city, LocalTime requestedTime) { + Optional findCountry = findCountryByCity(city); + Country country = findCountry.get(); + double latitude = country.getLatitude(); + double longitude = country.getLongitude(); + + List weeklyWeather = new ArrayList<>(); + String url = String.format("http://api.openweathermap.org/data/2.5/forecast?lat=%f&lon=%f&appid=%s", latitude, longitude, weatherApiKey); + String jsonResponse = restTemplate.getForObject(url, String.class); + + try { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode rootNode = objectMapper.readTree(jsonResponse); + JsonNode forecastList = rootNode.path("list"); + + Map dailyWeatherMap = new HashMap<>(); // 각 날짜의 날씨 정보를 저장할 맵 + + for (JsonNode forecast : forecastList) { + String dateTime = forecast.path("dt_txt").asText(); // 예측 시간 정보 + LocalDateTime localDateTime = LocalDateTime.parse(dateTime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + // 요청된 시간대의 온도만 고려 + if (localDateTime.toLocalTime().equals(requestedTime)) { + String dateString = localDateTime.toLocalDate().toString(); + double temperature = forecast.path("main").path("temp").asDouble(); // 온도 정보 + + // 해당 날짜의 온도 정보를 저장 (동일한 시간대에 여러 데이터가 있을 경우, 가장 첫 번째 데이터를 사용) + if (!dailyWeatherMap.containsKey(dateString)) { + // 아이콘 URL 가져오기 + String iconCode = forecast.path("weather").get(0).path("icon").asText(); + String iconUrl = String.format(s3ImageService.get(S3BucketDirectory.WEATHER.getDirectory(), iconCode + ".png")); + + DailyWeatherResponse dailyWeather = new DailyWeatherResponse(); + dailyWeather.setDate(parseDayOfWeek(dateString)); + dailyWeather.setTemp(convertToCelsius(temperature)); + dailyWeather.setIconUrl(iconUrl); + + dailyWeatherMap.put(dateString, dailyWeather); + } + } + } + + weeklyWeather.addAll(dailyWeatherMap.values()); + weeklyWeather.sort(Comparator.comparing(DailyWeatherResponse::getDate)); + + } catch (IOException e) { + e.printStackTrace(); + throw new OpenWeatherSearchFailedException(); + } + + return weeklyWeather; + } + + + /** 도시이름으로 나라 찾기 **/ + @Override + public Optional findCountryByCity(String city) { + Optional findCountry = countryRepository.findIdByCity(city); + if (!findCountry.isPresent()) { + throw new CountryNotFoundException(); + } + return findCountry; + } + + + /** 현지 시간으로 변환 **/ + private String getLocalTime(int timezoneOffset) { + Instant now = Instant.now(); + ZoneOffset offset = ZoneOffset.ofTotalSeconds(timezoneOffset); + LocalDateTime localDateTime = LocalDateTime.ofInstant(now, offset); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"); + return localDateTime.format(formatter); + } + + + /** 온도를 섭씨로 변환 **/ + private String convertToCelsius(double kelvin) { + double celsius = kelvin - 273.15; + DecimalFormat df = new DecimalFormat("#.##"); + return df.format(celsius); + } + + + /** 날짜 문자열에서 요일을 파싱 **/ + private String parseDayOfWeek(String dateString) { + LocalDate localDate = LocalDate.parse(dateString); + DayOfWeek dayOfWeek = localDate.getDayOfWeek(); + String weekDay = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN); + return (dateString + "," + weekDay); + } + + +} + diff --git a/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateScheduler.java b/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateScheduler.java index 1e0201f7..0b7740ba 100644 --- a/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateScheduler.java +++ b/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateScheduler.java @@ -19,4 +19,5 @@ public void scheduleExchangeRateUpdate() { System.out.println("Failed to update exchange rates: " + e.getMessage()); } } + } \ No newline at end of file diff --git a/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateService.java b/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateService.java index 5bc0cfbb..14a6469a 100644 --- a/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateService.java +++ b/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateService.java @@ -1,117 +1,13 @@ package com.isp.backend.domain.country.service; -import com.google.gson.Gson; -import com.google.gson.JsonObject; import com.isp.backend.domain.country.dto.response.ExchangeRateResponse; -import com.isp.backend.domain.country.entity.ExchangeRate; -import com.isp.backend.domain.country.mapper.ExchangeRateMapper; -import com.isp.backend.domain.country.repository.ExchangeRateRepository; -import com.isp.backend.global.exception.openApi.ExchangeRateIsFailedException; -import com.isp.backend.global.exception.openApi.ExchangeRateSearchFailedException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -@Service -public class ExchangeRateService { - - private final ExchangeRateRepository exchangeRateRepository; - private final ExchangeRateMapper exchangeRateMapper ; - - @Value("${api-key.exchange-rate}") - private String exchangeRateApiKey; - - @Autowired - public ExchangeRateService(ExchangeRateRepository exchangeRateRepository, ExchangeRateMapper exchangeRateMapper) { - this.exchangeRateRepository = exchangeRateRepository; - this.exchangeRateMapper = exchangeRateMapper; - } - - /** 환율 데이터 가져와서 업데이트 하는 API 메서드 **/ +public interface ExchangeRateService { @Transactional - public void updateExchangeRates() { - // 한국과 미국 통화 환율 비율 저장 - updateRatesForBaseCurrency("KRW"); - updateRatesForBaseCurrency("USD"); - } - - - /** 환율 데이터 가져와서 업데이트 하는 API 메서드 **/ - public List getAllExchangeRates() { - try { - // DB에서 모든 환율 데이터를 가져옴 - List exchangeRates = exchangeRateRepository.findAll(); - - // baseCurrency가 "KRW" 또는 "USD"인 데이터만 필터링하고 DTO로 변환 - return exchangeRates.stream() - .filter(rate -> ("KRW".equals(rate.getBaseCurrency()) || "USD".equals(rate.getBaseCurrency())) - && !rate.getBaseCurrency().equals(rate.getTargetCurrency())) - .map(exchangeRateMapper::convertToDto) - .collect(Collectors.toList()); - } catch (Exception e) { - throw new ExchangeRateIsFailedException() ; - } - } - - - /** 측정 baseCurrency 통화에 대해 업데이트 하는 메서드**/ - private void updateRatesForBaseCurrency(String baseCurrency) { - String exchangeRateAPI_URL = "https://v6.exchangerate-api.com/v6/" + exchangeRateApiKey + "/latest/" + baseCurrency; - - try { - URL url = new URL(exchangeRateAPI_URL); - HttpURLConnection request = (HttpURLConnection) url.openConnection(); - request.connect(); - - // API 응답 데이터를 JsonObject로 변환 - Gson gson = new Gson(); - JsonObject jsonobj = gson.fromJson(new InputStreamReader(request.getInputStream()), JsonObject.class); - - // API 응답 결과 - String req_result = jsonobj.get("result").getAsString(); - - if ("success".equals(req_result)) { - JsonObject conversionRates = jsonobj.getAsJsonObject("conversion_rates"); - for (String targetCurrency : conversionRates.keySet()) { - // 필요한 통화만 가져온다 - if (isSupportedCurrency(targetCurrency)) { - double rate = conversionRates.get(targetCurrency).getAsDouble(); - - // DB에서 환율 데이터 존재 여부 확인 - ExchangeRate existingRate = exchangeRateRepository.findByBaseCurrencyAndTargetCurrency(baseCurrency, targetCurrency); - - if (existingRate == null) { - ExchangeRate newExchangeRate = new ExchangeRate(baseCurrency, targetCurrency, rate); - exchangeRateRepository.save(newExchangeRate); - } else { - // 기록이 이미 존재하면, rate만 수정한다. - existingRate.setRate(rate); - exchangeRateRepository.save(existingRate); - } - } - } - } else { - throw new ExchangeRateSearchFailedException(); - } - } catch (Exception e) { - throw new ExchangeRateIsFailedException() ; - } - } - - /** 지원하는 통화만 가져오는 메서드 **/ - private boolean isSupportedCurrency(String currencyCode) { - return Set.of("JPY", "GBP", "EUR", "CHF", "CZK", "USD", "SGD", "TWD", "LAK", "MYR", "VND", "THB", "IDR", "PHP", "KRW").contains(currencyCode); - } - - - + void updateExchangeRates(); + List getAllExchangeRates(); } diff --git a/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateServiceImpl.java b/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateServiceImpl.java new file mode 100644 index 00000000..3411defe --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/country/service/ExchangeRateServiceImpl.java @@ -0,0 +1,119 @@ +package com.isp.backend.domain.country.service; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.isp.backend.domain.country.dto.response.ExchangeRateResponse; +import com.isp.backend.domain.country.entity.ExchangeRate; +import com.isp.backend.domain.country.mapper.ExchangeRateMapper; +import com.isp.backend.domain.country.repository.ExchangeRateRepository; +import com.isp.backend.global.exception.openApi.ExchangeRateIsFailedException; +import com.isp.backend.global.exception.openApi.ExchangeRateSearchFailedException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ExchangeRateServiceImpl implements ExchangeRateService { + + private final ExchangeRateRepository exchangeRateRepository; + private final ExchangeRateMapper exchangeRateMapper ; + + @Value("${api-key.exchange-rate}") + private String exchangeRateApiKey; + + @Autowired + public ExchangeRateServiceImpl(ExchangeRateRepository exchangeRateRepository, ExchangeRateMapper exchangeRateMapper) { + this.exchangeRateRepository = exchangeRateRepository; + this.exchangeRateMapper = exchangeRateMapper; + } + + /** 환율 데이터 가져와서 업데이트 하는 API 메서드 **/ + @Override + @Transactional + public void updateExchangeRates() { + // 한국과 미국 통화 환율 비율 저장 + updateRatesForBaseCurrency("KRW"); + updateRatesForBaseCurrency("USD"); + } + + + /** 환율 데이터 가져와서 업데이트 하는 API 메서드 **/ + @Override + public List getAllExchangeRates() { + try { + // DB에서 모든 환율 데이터를 가져옴 + List exchangeRates = exchangeRateRepository.findAll(); + + // baseCurrency가 "KRW" 또는 "USD"인 데이터만 필터링하고 DTO로 변환 + return exchangeRates.stream() + .filter(rate -> ("KRW".equals(rate.getBaseCurrency()) || "USD".equals(rate.getBaseCurrency())) + && !rate.getBaseCurrency().equals(rate.getTargetCurrency())) + .map(exchangeRateMapper::convertToDto) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new ExchangeRateIsFailedException() ; + } + } + + + /** 측정 baseCurrency 통화에 대해 업데이트 하는 메서드**/ + private void updateRatesForBaseCurrency(String baseCurrency) { + String exchangeRateAPI_URL = "https://v6.exchangerate-api.com/v6/" + exchangeRateApiKey + "/latest/" + baseCurrency; + + try { + URL url = new URL(exchangeRateAPI_URL); + HttpURLConnection request = (HttpURLConnection) url.openConnection(); + request.connect(); + + // API 응답 데이터를 JsonObject로 변환 + Gson gson = new Gson(); + JsonObject jsonobj = gson.fromJson(new InputStreamReader(request.getInputStream()), JsonObject.class); + + // API 응답 결과 + String req_result = jsonobj.get("result").getAsString(); + + if ("success".equals(req_result)) { + JsonObject conversionRates = jsonobj.getAsJsonObject("conversion_rates"); + for (String targetCurrency : conversionRates.keySet()) { + // 필요한 통화만 가져온다 + if (isSupportedCurrency(targetCurrency)) { + double rate = conversionRates.get(targetCurrency).getAsDouble(); + + // DB에서 환율 데이터 존재 여부 확인 + ExchangeRate existingRate = exchangeRateRepository.findByBaseCurrencyAndTargetCurrency(baseCurrency, targetCurrency); + + if (existingRate == null) { + ExchangeRate newExchangeRate = new ExchangeRate(baseCurrency, targetCurrency, rate); + exchangeRateRepository.save(newExchangeRate); + } else { + // 기록이 이미 존재하면, rate만 수정한다. + existingRate.setRate(rate); + exchangeRateRepository.save(existingRate); + } + } + } + } else { + throw new ExchangeRateSearchFailedException(); + } + } catch (Exception e) { + throw new ExchangeRateIsFailedException() ; + } + } + + /** 지원하는 통화만 가져오는 메서드 **/ + private boolean isSupportedCurrency(String currencyCode) { + return Set.of("JPY", "GBP", "EUR", "CHF", "CZK", "USD", "SGD", "TWD", "LAK", "MYR", "VND", "THB", "IDR", "PHP", "KRW").contains(currencyCode); + } + + + + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/controller/ReceiptController.java b/backend/src/main/java/com/isp/backend/domain/receipt/controller/ReceiptController.java index afed05a8..91828fac 100644 --- a/backend/src/main/java/com/isp/backend/domain/receipt/controller/ReceiptController.java +++ b/backend/src/main/java/com/isp/backend/domain/receipt/controller/ReceiptController.java @@ -1,12 +1,88 @@ package com.isp.backend.domain.receipt.controller; +import com.isp.backend.domain.receipt.dto.request.ChangeReceiptOrderRequest; +import com.isp.backend.domain.receipt.dto.request.SaveReceiptRequest; +import com.isp.backend.domain.receipt.dto.response.ReceiptResponse; +import com.isp.backend.domain.receipt.dto.response.ScheduleReceiptResponse; +import com.isp.backend.domain.receipt.entity.Receipt; +import com.isp.backend.domain.receipt.service.ReceiptService; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @RestController @RequestMapping("/receipts") @RequiredArgsConstructor public class ReceiptController { + private final ReceiptService receiptService; + + + /** 영수증 저장 API **/ + @PostMapping + public ResponseEntity saveReceipt( @RequestPart("request") SaveReceiptRequest request, + @RequestPart(value = "receiptImg", required = false) MultipartFile receiptImg) { + Long receiptId = receiptService.saveReceipt(request, receiptImg); + return ResponseEntity.status(HttpStatus.CREATED).body(receiptId); + } + + + /** 영수증 삭제 API **/ + @DeleteMapping("/{receiptId}") + public ResponseEntity deleteReceipt(@PathVariable Long receiptId) { + receiptService.deleteReceipt(receiptId); + return ResponseEntity.ok().build(); + } + + + /** 영수증 수정 API **/ + @PutMapping("/{receiptId}") + public ResponseEntity updateReceipt(@PathVariable Long receiptId, + @RequestPart("request") SaveReceiptRequest request, + @RequestPart(value = "receiptImg", required = false) MultipartFile receiptImg) { + Long newReceiptId = receiptService.updateReceipt(receiptId, request, receiptImg); + return ResponseEntity.status(HttpStatus.OK).body(newReceiptId); + } + + + /** 여행 별 영수증 리스트 전체 조회 API **/ + @GetMapping("/{scheduleId}/list") + public ResponseEntity getReceiptList(@PathVariable Long scheduleId) { + ScheduleReceiptResponse response = receiptService.getReceiptList(scheduleId); + return ResponseEntity.ok(response); + } + + + /** 영수증 별 상세 내역 조회 API **/ + @GetMapping("/detail/{receiptId}/list") + public ResponseEntity getReceiptDetail(@PathVariable Long receiptId) { + ReceiptResponse receiptResponse = receiptService.getReceiptDetail(receiptId); + return ResponseEntity.ok(receiptResponse); + } + + + + /** 영수증 순서 변경 API **/ + @PutMapping("/order/{scheduleId}") + public ResponseEntity changeOrderReceipt(@PathVariable Long scheduleId, + @RequestBody List changeRequests) { + receiptService.changeOrderReceipt(scheduleId, changeRequests); + return ResponseEntity.ok().build(); + } + + + + /** test - 영수증 사진 저장 API **/ + @PostMapping("/{receiptId}/image") + public ResponseEntity saveReceiptImg(@PathVariable Long receiptId, + @RequestParam("receiptImg") MultipartFile receiptImg) { + receiptService.saveReceiptImg(receiptId, receiptImg); + return ResponseEntity.ok().build(); + } + + } diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ChangeReceiptOrderRequest.java b/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ChangeReceiptOrderRequest.java new file mode 100644 index 00000000..5fbc1424 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ChangeReceiptOrderRequest.java @@ -0,0 +1,18 @@ +package com.isp.backend.domain.receipt.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class ChangeReceiptOrderRequest { + + private Long receiptId ; + private String purchaseDate ; + private int orderNum ; + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ReceiptDetailRequest.java b/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ReceiptDetailRequest.java new file mode 100644 index 00000000..6b7983ba --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/ReceiptDetailRequest.java @@ -0,0 +1,20 @@ +package com.isp.backend.domain.receipt.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class ReceiptDetailRequest { + + private String item; + + private int count; + + private double itemPrice; + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/SaveReceiptRequest.java b/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/SaveReceiptRequest.java new file mode 100644 index 00000000..e398b42b --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/dto/request/SaveReceiptRequest.java @@ -0,0 +1,29 @@ +package com.isp.backend.domain.receipt.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class SaveReceiptRequest { + + private Long scheduleId ; + + private String storeName; + + private String storeType ; + + private double totalPrice ; + + private String purchaseDate ; + + private List receiptDetails; + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptDetailResponse.java b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptDetailResponse.java new file mode 100644 index 00000000..9b1e3118 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptDetailResponse.java @@ -0,0 +1,20 @@ +package com.isp.backend.domain.receipt.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class ReceiptDetailResponse { + + private String item; + + private int count; + + private double itemPrice; + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptListResponse.java b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptListResponse.java new file mode 100644 index 00000000..9614d368 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptListResponse.java @@ -0,0 +1,26 @@ +package com.isp.backend.domain.receipt.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class ReceiptListResponse { + + private Long receiptId; + + private String storeName; + + private String storeType ; + + private double price ; // receipt 테이블의 totalPrice + + private int orderNum ; + + private String purchaseDate ; + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptResponse.java b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptResponse.java new file mode 100644 index 00000000..f11c3662 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ReceiptResponse.java @@ -0,0 +1,27 @@ +package com.isp.backend.domain.receipt.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class ReceiptResponse { + + private Long receiptId ; + + private String purchaseDate ; + + private int orderNum ; + + private String receiptImg ; + + private double totalPrice ; + + private List receiptDetailList; +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ScheduleReceiptResponse.java b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ScheduleReceiptResponse.java new file mode 100644 index 00000000..4cbc6041 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/dto/response/ScheduleReceiptResponse.java @@ -0,0 +1,28 @@ +package com.isp.backend.domain.receipt.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class ScheduleReceiptResponse { + + private String scheduleName; + + private String startDate; + + private String endDate; + + private String currencyName; // schedule table -> country table의 currency_name 컬럼 + + private double totalReceiptsPrice ; + + private List receiptList; + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/entity/Receipt.java b/backend/src/main/java/com/isp/backend/domain/receipt/entity/Receipt.java index 467ccfea..9a30367a 100644 --- a/backend/src/main/java/com/isp/backend/domain/receipt/entity/Receipt.java +++ b/backend/src/main/java/com/isp/backend/domain/receipt/entity/Receipt.java @@ -1,13 +1,18 @@ package com.isp.backend.domain.receipt.entity; +import com.isp.backend.domain.schedule.entity.Schedule; import com.isp.backend.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; +import java.util.ArrayList; +import java.util.List; + @Getter @AllArgsConstructor @Entity @Builder +@Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "receipt") public class Receipt extends BaseEntity { @@ -17,16 +22,20 @@ public class Receipt extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; -// @ManyToOne(fetch = FetchType.LAZY) -// @JoinColumn(name = "schedules_id", nullable = false) -// private Schedules schedules; + @ManyToOne(fetch = FetchType.LAZY) + private Schedule schedule; + + private String storeName; - @Column(name = "title") - private String title; + @Enumerated(EnumType.STRING) + private StoreType storeType; - @Column(name = "price") - private double price; + private double totalPrice; - @Column(name = "purchase_date") private String purchaseDate; + + private String receiptImg; + + private int orderNum ; + } diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/entity/ReceiptDetail.java b/backend/src/main/java/com/isp/backend/domain/receipt/entity/ReceiptDetail.java new file mode 100644 index 00000000..c08b890a --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/entity/ReceiptDetail.java @@ -0,0 +1,30 @@ +package com.isp.backend.domain.receipt.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Getter +@AllArgsConstructor +@Entity +@Builder +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "receipt_detail") +public class ReceiptDetail { + + @Id + @Column(name = "id", unique = true, nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receipt_id", nullable = false) + private Receipt receipt; + + private String item; + + private int count; + + private double itemPrice; + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/entity/StoreType.java b/backend/src/main/java/com/isp/backend/domain/receipt/entity/StoreType.java new file mode 100644 index 00000000..28b3e2d0 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/entity/StoreType.java @@ -0,0 +1,18 @@ +package com.isp.backend.domain.receipt.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum StoreType { + + AIRPLANE, + HOTEL, + PLACE, + TRANSFER + +}; + + + diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/mapper/ReceiptMapper.java b/backend/src/main/java/com/isp/backend/domain/receipt/mapper/ReceiptMapper.java new file mode 100644 index 00000000..33baac10 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/mapper/ReceiptMapper.java @@ -0,0 +1,91 @@ +package com.isp.backend.domain.receipt.mapper; + +import com.isp.backend.domain.receipt.dto.request.ReceiptDetailRequest; +import com.isp.backend.domain.receipt.dto.request.SaveReceiptRequest; +import com.isp.backend.domain.receipt.dto.response.ReceiptDetailResponse; +import com.isp.backend.domain.receipt.dto.response.ReceiptListResponse; +import com.isp.backend.domain.receipt.dto.response.ReceiptResponse; +import com.isp.backend.domain.receipt.entity.Receipt; +import com.isp.backend.domain.receipt.entity.ReceiptDetail; +import com.isp.backend.domain.receipt.entity.StoreType; +import com.isp.backend.domain.schedule.entity.Schedule; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ReceiptMapper { + + // 영수증 저장 + public Receipt toEntity(SaveReceiptRequest request, Schedule schedule, int orderNum) { + StoreType storeType = getStoreType(request.getStoreType()); + + return Receipt.builder() + .schedule(schedule) + .storeName(request.getStoreName()) + .storeType(storeType) + .totalPrice(request.getTotalPrice()) + .purchaseDate(request.getPurchaseDate()) + .orderNum(orderNum) + .build(); + } + + public ReceiptDetail toEntity(ReceiptDetailRequest request, Receipt receipt) { + return ReceiptDetail.builder() + .receipt(receipt) + .item(request.getItem()) + .count(request.getCount()) + .itemPrice(request.getItemPrice()) + .build(); + } + + + // 올바른 영수증 타입을 갖는지 확인 + public StoreType getStoreType(String storeType) { + try { + return StoreType.valueOf(storeType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid store type: " + storeType); + } + } + + + // 영수증 목록 조회 + public ReceiptListResponse toReceiptListResponse(Receipt receipt) { + return new ReceiptListResponse( + receipt.getId(), + receipt.getStoreName(), + receipt.getStoreType().name(), + receipt.getTotalPrice(), + receipt.getOrderNum(), + receipt.getPurchaseDate() + ); + } + + + // 영수증 상세 조회 + public ReceiptResponse toReceiptResponse(Receipt receipt, List receiptDetailResponses) { + return new ReceiptResponse( + receipt.getId(), + receipt.getPurchaseDate(), + receipt.getOrderNum(), + receipt.getReceiptImg(), + receipt.getTotalPrice(), + receiptDetailResponses + ); + } + + public ReceiptDetailResponse toReceiptDetailResponse(ReceiptDetail receiptDetail) { + return new ReceiptDetailResponse( + receiptDetail.getItem(), + receiptDetail.getCount(), + receiptDetail.getItemPrice() + ); + } + + + +} + diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptDetailRepository.java b/backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptDetailRepository.java new file mode 100644 index 00000000..8de4135a --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptDetailRepository.java @@ -0,0 +1,14 @@ +package com.isp.backend.domain.receipt.repository; + +import com.isp.backend.domain.receipt.entity.Receipt; +import com.isp.backend.domain.receipt.entity.ReceiptDetail; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReceiptDetailRepository extends JpaRepository { + void deleteAllByReceipt(Receipt receipt); + + List findByReceiptId(Long receiptId); + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptRepository.java b/backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptRepository.java new file mode 100644 index 00000000..a2d4fe82 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/domain/receipt/repository/ReceiptRepository.java @@ -0,0 +1,20 @@ +package com.isp.backend.domain.receipt.repository; + +import com.isp.backend.domain.receipt.entity.Receipt; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ReceiptRepository extends JpaRepository { + + @Query("SELECT COALESCE(MAX(r.orderNum), 0) FROM Receipt r WHERE r.schedule.id = :scheduleId AND r.purchaseDate = :purchaseDate") + int findMaxOrderNumByScheduleIdAndPurchaseDate(@Param("scheduleId") Long scheduleId, @Param("purchaseDate") String purchaseDate); + + // 스케줄에 해당하는 영수증을 purchaseDate 오름차순, orderNum 오름차순으로 정렬하여 반환 + List findByScheduleIdOrderByPurchaseDateAscOrderNumAsc(Long scheduleId); + + List findByScheduleId(Long scheduleId); + +} diff --git a/backend/src/main/java/com/isp/backend/domain/receipt/service/ReceiptService.java b/backend/src/main/java/com/isp/backend/domain/receipt/service/ReceiptService.java index 4a8d2ad4..e42e01de 100644 --- a/backend/src/main/java/com/isp/backend/domain/receipt/service/ReceiptService.java +++ b/backend/src/main/java/com/isp/backend/domain/receipt/service/ReceiptService.java @@ -1,9 +1,252 @@ package com.isp.backend.domain.receipt.service; +import com.isp.backend.domain.receipt.dto.request.ChangeReceiptOrderRequest; +import com.isp.backend.domain.receipt.dto.request.ReceiptDetailRequest; +import com.isp.backend.domain.receipt.dto.request.SaveReceiptRequest; +import com.isp.backend.domain.receipt.dto.response.ReceiptDetailResponse; +import com.isp.backend.domain.receipt.dto.response.ReceiptListResponse; +import com.isp.backend.domain.receipt.dto.response.ReceiptResponse; +import com.isp.backend.domain.receipt.dto.response.ScheduleReceiptResponse; +import com.isp.backend.domain.receipt.entity.Receipt; +import com.isp.backend.domain.receipt.entity.ReceiptDetail; +import com.isp.backend.domain.receipt.mapper.ReceiptMapper; +import com.isp.backend.domain.receipt.repository.ReceiptDetailRepository; +import com.isp.backend.domain.receipt.repository.ReceiptRepository; +import com.isp.backend.domain.schedule.entity.Schedule; +import com.isp.backend.domain.schedule.repository.ScheduleRepository; +import com.isp.backend.global.exception.Image.ImageAlreadyExistingException; +import com.isp.backend.global.exception.receipt.ReceiptNotFoundException; +import com.isp.backend.global.exception.schedule.ScheduleNotFoundException; +import com.isp.backend.global.s3.constant.S3BucketDirectory; +import com.isp.backend.global.s3.service.S3ImageService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + -@RequiredArgsConstructor @Service +@RequiredArgsConstructor public class ReceiptService { + private final ReceiptRepository receiptRepository; + private final ReceiptDetailRepository receiptDetailRepository; + private final ScheduleRepository scheduleRepository; + private final ReceiptMapper receiptMapper; + private final S3ImageService s3ImageService; + + + /** 영수증 저장 API **/ + @Transactional + public Long saveReceipt(SaveReceiptRequest request,MultipartFile receiptImg) { + // 일정 정보 확인 + Schedule findSchedule = validateSchedule(request.getScheduleId()); + + // 동일한 날짜의 가장 큰 orderNum 조회 + int maxOrderNum = receiptRepository.findMaxOrderNumByScheduleIdAndPurchaseDate( + request.getScheduleId(), request.getPurchaseDate() + ); + + // orderNum 설정 (해당 날짜에 영수증이 없으면 1로 설정, 있으면 maxOrderNum + 1) + int orderNum = maxOrderNum + 1; + + // 데이터 변환 및 저장 + Receipt receipt = receiptMapper.toEntity(request, findSchedule, orderNum); + + // 영수증 사진 저장 + if (receiptImg != null && !receiptImg.isEmpty()) { + String receiptImgUrl = s3ImageService.create(receiptImg, "RECEIPT" ); + receipt.setReceiptImg(receiptImgUrl); + } + + receiptRepository.save(receipt); + + for (ReceiptDetailRequest detailRequest : request.getReceiptDetails()) { + ReceiptDetail detail = receiptMapper.toEntity(detailRequest, receipt); + receiptDetailRepository.save(detail); + } + + return receipt.getId(); + } + + + + /** 영수증 삭제 메소드 **/ + @Transactional + public void deleteReceipt(Long receiptId) { + // 유효한 영수증 확인 + Receipt receipt = validateReceipt(receiptId); + + // 영수증에 연결된 세부 내역 삭제 + receiptDetailRepository.deleteAllByReceipt(receipt); + + // 영수증 삭제 + receiptRepository.delete(receipt); + } + + + + /** 영수증 수정 메소드 **/ + @Transactional + public Long updateReceipt(Long receiptId, SaveReceiptRequest request, MultipartFile receiptImg) { + // 유효한 영수증 확인 + Receipt receipt = validateReceipt(receiptId); + + // 영수증 정보 업데이트 + receipt.setStoreName(request.getStoreName()); + receipt.setStoreType(receiptMapper.getStoreType(request.getStoreType())); + receipt.setTotalPrice(request.getTotalPrice()); + receipt.setPurchaseDate(request.getPurchaseDate()); + + // 영수증 이미지가 있을 경우 업데이트 + if (receiptImg != null && !receiptImg.isEmpty()) { + String receiptImgUrl = s3ImageService.create(receiptImg, "RECEIPT"); + receipt.setReceiptImg(receiptImgUrl); + } + + // 기존 영수증 세부 내역 삭제 + receiptDetailRepository.deleteAllByReceipt(receipt); + + // 새 세부 내역 추가 + for (ReceiptDetailRequest detailRequest : request.getReceiptDetails()) { + ReceiptDetail detail = receiptMapper.toEntity(detailRequest, receipt); + receiptDetailRepository.save(detail); + } + + // 수정된 영수증 정보 저장 + receiptRepository.save(receipt); + return receipt.getId(); + } + + + + /** 여행 별 영수증 리스트 전체 조회 메소드 **/ + @Transactional(readOnly = true) + public ScheduleReceiptResponse getReceiptList(Long scheduleId) { + Schedule schedule = validateSchedule(scheduleId); + List receipts = receiptRepository.findByScheduleIdOrderByPurchaseDateAscOrderNumAsc(schedule.getId()); + + // 영수증의 합계 구하기 + double totalReceiptsPrice = receipts.stream() + .mapToDouble(Receipt::getTotalPrice) + .sum(); + // 영수증들 매핑 + List receiptList = receipts.stream() + .map(receiptMapper::toReceiptListResponse) + .collect(Collectors.toList()); + + return new ScheduleReceiptResponse( + schedule.getScheduleName(), + schedule.getStartDate(), + schedule.getEndDate(), + schedule.getCountry().getCurrencyName(), + totalReceiptsPrice, + receiptList + ); + } + + + + /** 영수증 별 상세 내역 조회 메소드 **/ + @Transactional(readOnly = true) + public ReceiptResponse getReceiptDetail(Long receiptId) { + Receipt receipt = validateReceipt(receiptId); + + // 영수증에 연관된 상세 내역 리스트를 조회 + List receiptDetails = receiptDetailRepository.findByReceiptId(receiptId); + + // ReceiptDetail을 ReceiptDetailResponse로 변환 + List receiptDetailResponses = receiptDetails.stream() + .map(receiptMapper::toReceiptDetailResponse) + .collect(Collectors.toList()); + + // ReceiptResponse DTO 생성 및 반환 + return receiptMapper.toReceiptResponse(receipt, receiptDetailResponses); + + } + + + + + // 영수증 순서 변경 메서드 수정 예정 + /** 예외처리 및 정확한 로직 분석 필요 **/ + @Transactional + public void changeOrderReceipt(Long scheduleId, List changeRequests) { + // 해당 스케줄에 존재하는 영수증 전체 목록 조회 + List existingReceipts = receiptRepository.findByScheduleId(scheduleId); + + // 클라이언트가 제공한 정보로 이루어진 Map 생성 (key: purchaseDate, receiptId, value: orderNum) + Map> providedReceiptsMap = changeRequests.stream() + .collect(Collectors.groupingBy( + ChangeReceiptOrderRequest::getPurchaseDate, + Collectors.toMap(ChangeReceiptOrderRequest::getReceiptId, ChangeReceiptOrderRequest::getOrderNum) + )); + + // 제공된 모든 receiptId와 날짜가 해당 스케줄의 영수증에 일치하는지 확인 + for (Receipt receipt : existingReceipts) { + Map dateSpecificReceipts = providedReceiptsMap.get(receipt.getPurchaseDate()); + if (dateSpecificReceipts == null || !dateSpecificReceipts.containsKey(receipt.getId())) { + throw new IllegalArgumentException("모든 영수증 ID와 날짜를 제공해야 합니다."); + } + } + + // 날짜별로 orderNum 값이 중복되지 않는지 확인 + for (Map receiptMap : providedReceiptsMap.values()) { + Set orderNums = new HashSet<>(receiptMap.values()); + if (orderNums.size() != receiptMap.size()) { + throw new IllegalArgumentException("날짜별 orderNum 값이 중복됩니다."); + } + } + + // orderNum 업데이트 + for (Receipt receipt : existingReceipts) { + int newOrderNum = providedReceiptsMap.get(receipt.getPurchaseDate()).get(receipt.getId()); + receipt.setOrderNum(newOrderNum); + receiptRepository.save(receipt); + } + } + + + /** 유효한 일정 확인 메소드 **/ + private Schedule validateSchedule(Long scheduleId) { + Schedule findSchedule = scheduleRepository.findByIdAndActivatedIsTrue(scheduleId) + .orElseThrow(ScheduleNotFoundException::new); + return findSchedule; + } + + + /** 유효한 영수증 확인 메서드**/ + private Receipt validateReceipt(Long receiptId){ + Receipt findReceipt = receiptRepository.findById(receiptId) + .orElseThrow(ReceiptNotFoundException::new); + return findReceipt; + } + + + /** test - 영수증 이미지 저장 API **/ + @Transactional + public void saveReceiptImg(Long receiptId, MultipartFile receiptImg) { + // 영수증 정보 확인 + Receipt receipt = validateReceipt(receiptId); + + // 영수증 사진 url이 이미 db에 있는지 여부 확인 + if (receipt.getReceiptImg() != null && !receipt.getReceiptImg().isEmpty()) { + throw new ImageAlreadyExistingException(); + } + + // 영수증 사진 저장 + if (receiptImg != null && !receiptImg.isEmpty()) { + String receiptImgUrl = s3ImageService.create(receiptImg, "RECEIPT" ); + receipt.setReceiptImg(receiptImgUrl); + receiptRepository.save(receipt); + } + + } + + } diff --git a/backend/src/main/java/com/isp/backend/domain/schedule/dto/response/ScheduleListResponse.java b/backend/src/main/java/com/isp/backend/domain/schedule/dto/response/ScheduleListResponse.java index e630037f..01164a4f 100644 --- a/backend/src/main/java/com/isp/backend/domain/schedule/dto/response/ScheduleListResponse.java +++ b/backend/src/main/java/com/isp/backend/domain/schedule/dto/response/ScheduleListResponse.java @@ -27,4 +27,6 @@ public class ScheduleListResponse { private double longitude ; + private String currencyName; + } diff --git a/backend/src/main/java/com/isp/backend/domain/schedule/mapper/ScheduleMapper.java b/backend/src/main/java/com/isp/backend/domain/schedule/mapper/ScheduleMapper.java index c355c6a5..d912fa36 100644 --- a/backend/src/main/java/com/isp/backend/domain/schedule/mapper/ScheduleMapper.java +++ b/backend/src/main/java/com/isp/backend/domain/schedule/mapper/ScheduleMapper.java @@ -86,7 +86,8 @@ public ScheduleListResponse toScheduleListResponseDTO(Schedule schedule) { schedule.getCountry().getImageUrl(), schedule.getCountry().getCity(), schedule.getCountry().getLatitude(), - schedule.getCountry().getLongitude() + schedule.getCountry().getLongitude(), + schedule.getCountry().getCurrencyName() ); } diff --git a/backend/src/main/java/com/isp/backend/global/exception/ErrorCode.java b/backend/src/main/java/com/isp/backend/global/exception/ErrorCode.java index 36e726af..60ebb2b0 100644 --- a/backend/src/main/java/com/isp/backend/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/isp/backend/global/exception/ErrorCode.java @@ -18,6 +18,7 @@ public enum ErrorCode { // Image DIRECTORY_NAME_NOTFOUND(HttpStatus.NOT_FOUND,"I001","S3에서 해당 디렉토리의 이름을 찾을 수 없습니다."), + IMAGE_ALREADY_EXISTING(HttpStatus.BAD_REQUEST, "I002", "이미지가 이미 저장되어 있습니다."), // Schedule COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "여행할 국가를 찾을 수 없습니다."), @@ -27,6 +28,9 @@ public enum ErrorCode { IATA_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "S005", "해당 국가의 공항 코드를 찾을 수 없습니다."), CHECK_LIST_NOT_FOUND(HttpStatus.NOT_FOUND, "S006", "체크리스트를 찾을 수 없습니다"), + // Receipt + RECEIPT_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "헤딩 영수증 ID를 찾을 수 없습니다."), + // Open API AMADEUS_SEARCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR,"F001", "아마데우스 요청을 가져오는 중 오류를 발생했습니다."), SKY_SCANNER_GENERATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR,"F002", "스카이스캐너 URL을 생성할 수 없습니다."), diff --git a/backend/src/main/java/com/isp/backend/global/exception/Image/ImageAlreadyExistingException.java b/backend/src/main/java/com/isp/backend/global/exception/Image/ImageAlreadyExistingException.java new file mode 100644 index 00000000..55c65e55 --- /dev/null +++ b/backend/src/main/java/com/isp/backend/global/exception/Image/ImageAlreadyExistingException.java @@ -0,0 +1,13 @@ +package com.isp.backend.global.exception.Image; + +import com.isp.backend.global.exception.CustomException; +import com.isp.backend.global.exception.ErrorCode; + + +public class ImageAlreadyExistingException extends CustomException { + + public ImageAlreadyExistingException() { + super(ErrorCode.IMAGE_ALREADY_EXISTING); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/isp/backend/global/exception/receipt/ReceiptNotFoundException.java b/backend/src/main/java/com/isp/backend/global/exception/receipt/ReceiptNotFoundException.java new file mode 100644 index 00000000..3a66b89b --- /dev/null +++ b/backend/src/main/java/com/isp/backend/global/exception/receipt/ReceiptNotFoundException.java @@ -0,0 +1,12 @@ +package com.isp.backend.global.exception.receipt; + +import com.isp.backend.global.exception.CustomException; +import com.isp.backend.global.exception.ErrorCode; + +public class ReceiptNotFoundException extends CustomException { + + public ReceiptNotFoundException() { + super(ErrorCode.RECEIPT_NOT_FOUND); + } + +} diff --git a/backend/src/main/java/com/isp/backend/global/s3/constant/S3BucketDirectory.java b/backend/src/main/java/com/isp/backend/global/s3/constant/S3BucketDirectory.java index 0b08f12e..ee28bbbf 100644 --- a/backend/src/main/java/com/isp/backend/global/s3/constant/S3BucketDirectory.java +++ b/backend/src/main/java/com/isp/backend/global/s3/constant/S3BucketDirectory.java @@ -1,12 +1,13 @@ package com.isp.backend.global.s3.constant; +import com.isp.backend.global.exception.Image.DirectoryNameNotFoundException; import lombok.Getter; @Getter public enum S3BucketDirectory { - IMAGE("HowAboutTrip-Backend-Image/"), // 상품 사진 + IMAGE("HowAboutTrip-Backend-Image/"), // 나라 사진 PHOTO("HowAboutTrip-Backend-Photo/"), // 여행 중 포토 - + RECEIPT("HowAboutTrip-Backend-Receipt/"), // 여행 영수증 WEATHER("HowAboutTrip-Backend-Weather/"), ; // 날씨 아이콘 private final String directory; @@ -15,4 +16,25 @@ public enum S3BucketDirectory { this.directory = directory; } + + // 입력된 s3 디렉토리명이 유효한지 확인 + public static boolean isValidDirectory(String directory) { + for (S3BucketDirectory bucketDirectory : values()) { + if (bucketDirectory.name().equalsIgnoreCase(directory)) { + return true; + } + } + return false; + } + + // 디렉터리 이름에 대응하는 실제 디렉터리 경로를 반환 + public static String getDirectoryByName(String name) { + for (S3BucketDirectory bucketDirectory : values()) { + if (bucketDirectory.name().equalsIgnoreCase(name)) { + return bucketDirectory.getDirectory(); + } + } + throw new DirectoryNameNotFoundException(); + } + } diff --git a/backend/src/main/java/com/isp/backend/global/s3/service/S3ImageService.java b/backend/src/main/java/com/isp/backend/global/s3/service/S3ImageService.java index b71c8576..b3a44d87 100644 --- a/backend/src/main/java/com/isp/backend/global/s3/service/S3ImageService.java +++ b/backend/src/main/java/com/isp/backend/global/s3/service/S3ImageService.java @@ -25,12 +25,12 @@ public String get(String directory, String fileName) { } private String mapDirectory(String directory) { - if (S3BucketDirectory.valueOf(directory) == S3BucketDirectory.IMAGE || - S3BucketDirectory.valueOf(directory) == S3BucketDirectory.PHOTO) { - return S3BucketDirectory.valueOf(directory).getDirectory(); + if (S3BucketDirectory.isValidDirectory(directory)) { + return S3BucketDirectory.getDirectoryByName(directory); } else { throw new DirectoryNameNotFoundException(); } } + } \ No newline at end of file