diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64028b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +.idea/sonarlint/ +.idea/sonarlint/** +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml new file mode 100644 index 0000000..72dbb27 --- /dev/null +++ b/.idea/checkstyle-idea.xml @@ -0,0 +1,15 @@ + + + + 10.12.4 + JavaOnly + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..14746e7 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..3f781c0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..fe0b0da --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..5bf8d30 --- /dev/null +++ b/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +group = 'org.example' +version = '1.0-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation platform('org.junit:junit-bom:5.9.1') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core:3.6.1' +} + +test { + useJUnitPlatform() +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..715fc2a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +**차가운 음료** + +``` +스프라이트 : 1,500원, 코카콜라 : 1,300원, 솔의눈 : 1,000원, 펩시 콜라 :1,100원 +``` + +**따뜻한 음료** + +``` +TOP커피 : 1,800원, 꿀물 : 1,500원, 홍삼차 : 1,700원, 단팥죽 :2,100원 +``` + +## 도메인 명세 + +- [x] 음료 + - [x] 이름을 가진다 + - [x] 가격을 가진다 + - [x] 온도를 가진다 +- [x] 결제 방식 + - [x] 현금 + - [x] 현금들을 가진다 + - [x] 금액을 받아서 현재 총액과 비교할 수 있다 + - [x] 거스름돈을 반환할 수 있다 (큰 금액권부터) + - [x] 카드 + - [x] 받은 총액에서 10% 추가한 가격을 가진다 +- [x] 현금 + - [x] 가격을 가진다 + +## 기능 명세 + +- [x] 차가운 또는 뜨거운 음료 중 선택하는 기능 +- [x] 해당 온도의 음료 중 고르는 기능 +- [x] 결제 방식을 선택하는 기능 +- [x] 현금을 투입하는 기능 +- [x] 반환하는 기능 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5f7e996 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Nov 26 10:41:12 KST 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..192809d --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'vending-machine' + diff --git a/src/main/java/Application.java b/src/main/java/Application.java new file mode 100644 index 0000000..a4bc17e --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,7 @@ +import controller.VendingMachineController; + +public class Application { + public static void main(final String[] args) { + new VendingMachineController().run(); + } +} diff --git a/src/main/java/controller/VendingMachineController.java b/src/main/java/controller/VendingMachineController.java new file mode 100644 index 0000000..581d511 --- /dev/null +++ b/src/main/java/controller/VendingMachineController.java @@ -0,0 +1,94 @@ +package controller; + +import domain.beverage.Drink; +import domain.beverage.Drinks; +import domain.beverage.DrinksFactory; +import domain.pay.CardPayment; +import domain.pay.CashPayment; +import domain.pay.Cashes; +import view.CashOption; +import view.PaymentOption; +import view.TemperatureOption; +import view.View; + +import java.math.BigDecimal; +import java.util.function.Supplier; + +import static java.util.Objects.isNull; + +public class VendingMachineController { + + public void run() { + View.printStartView(); + final TemperatureOption temperatureOption = retryOnFailure(View::readBeverageTemperature); + + final Drinks drinks = createDrinks(temperatureOption); + final PaymentOption paymentOption = retryOnFailure(View::readPaymentOption); + + final Drink drink = selectDrink(drinks); + if (paymentOption.equals(PaymentOption.CARD)) { + payWithCard(drink); + } + if (paymentOption.equals(PaymentOption.CASH)) { + payWithCash(drink); + } + } + + private T retryOnFailure(final Supplier supplier) { + try { + return supplier.get(); + } catch (final Exception e) { + System.out.println(e.getMessage()); + return retryOnFailure(supplier); + } + } + + private Drinks createDrinks(final TemperatureOption option) { + if (option.equals(TemperatureOption.HOT)) { + return DrinksFactory.createHot(); + } + if (option.equals(TemperatureOption.ICE)) { + return DrinksFactory.createIce(); + } + + throw new UnsupportedOperationException("지원하지 않는 옵션입니다."); + } + + private Drink selectDrink(final Drinks drinks) { + return retryOnFailure(() -> { + final int beverageSelection = retryOnFailure(() -> View.readBeverage(drinks.getValue())); + final Drink drink = drinks.indexOf(beverageSelection); + if (isNull(drink)) { + throw new IllegalArgumentException("존재하지 않는 상품입니다."); + } + + return drink; + }); + } + + private void payWithCard(final Drink drink) { + final CardPayment cardPayment = new CardPayment(drink.price()); + View.printCardPaymentResult(drink, cardPayment.getTotalPrice()); + } + + private void payWithCash(final Drink drink) { + final CashPayment cashPayment = new CashPayment(drink.price(), Cashes.empty()); + while (cashPayment.isNotAvailableToPay()) { + final BigDecimal sum = cashPayment.getSum(); + final CashOption cashOption = retryOnFailure(() -> View.readCashOption(sum)); + + addCashes(cashOption, cashPayment); + } + + View.printCashPaymentResult(drink, cashPayment.getSum(), cashPayment.getChange()); + } + + private void addCashes(final CashOption cashOption, final CashPayment cashPayment) { + if (cashOption.equals(CashOption.CLEAR)) { + cashPayment.clearTakenCashes(); + return; + } + + cashPayment.addCash(cashOption.getCash()); + } +} diff --git a/src/main/java/domain/beverage/BeverageTemperature.java b/src/main/java/domain/beverage/BeverageTemperature.java new file mode 100644 index 0000000..983946e --- /dev/null +++ b/src/main/java/domain/beverage/BeverageTemperature.java @@ -0,0 +1,5 @@ +package domain.beverage; + +public enum BeverageTemperature { + ICE, HOT, +} diff --git a/src/main/java/domain/beverage/Drink.java b/src/main/java/domain/beverage/Drink.java new file mode 100644 index 0000000..3bc3e76 --- /dev/null +++ b/src/main/java/domain/beverage/Drink.java @@ -0,0 +1,6 @@ +package domain.beverage; + +import java.math.BigDecimal; + +public record Drink(String name, BigDecimal price, BeverageTemperature hotOrIce) { +} diff --git a/src/main/java/domain/beverage/Drinks.java b/src/main/java/domain/beverage/Drinks.java new file mode 100644 index 0000000..44ba4a4 --- /dev/null +++ b/src/main/java/domain/beverage/Drinks.java @@ -0,0 +1,20 @@ +package domain.beverage; + +import java.util.Map; + +public class Drinks { + + private final Map value; + + public Drinks(final Map value) { + this.value = value; + } + + public Map getValue() { + return value; + } + + public Drink indexOf(final int index) { + return value.get(index); + } +} diff --git a/src/main/java/domain/beverage/DrinksFactory.java b/src/main/java/domain/beverage/DrinksFactory.java new file mode 100644 index 0000000..728d98f --- /dev/null +++ b/src/main/java/domain/beverage/DrinksFactory.java @@ -0,0 +1,30 @@ +package domain.beverage; + +import java.math.BigDecimal; +import java.util.Map; + +public class DrinksFactory { + + private DrinksFactory() { + } + + public static Drinks createIce() { + final Map drinks = Map.of( + 1, new Drink("스프라이트", BigDecimal.valueOf(1_500L), BeverageTemperature.ICE), + 2, new Drink("코카콜라", BigDecimal.valueOf(1_300L), BeverageTemperature.ICE), + 3, new Drink("솔의눈", BigDecimal.valueOf(1_000L), BeverageTemperature.ICE), + 4, new Drink("펩시 콜라", BigDecimal.valueOf(1_100L), BeverageTemperature.ICE)); + + return new Drinks(drinks); + } + + public static Drinks createHot() { + final Map drinks = Map.of( + 1, new Drink("TOP커피", BigDecimal.valueOf(1_800L), BeverageTemperature.HOT), + 2, new Drink("꿀물", BigDecimal.valueOf(1_500L), BeverageTemperature.HOT), + 3, new Drink("홍삼차", BigDecimal.valueOf(1_700L), BeverageTemperature.HOT), + 4, new Drink("단팥죽", BigDecimal.valueOf(2_100L), BeverageTemperature.HOT)); + + return new Drinks(drinks); + } +} diff --git a/src/main/java/domain/pay/CardPayment.java b/src/main/java/domain/pay/CardPayment.java new file mode 100644 index 0000000..3862980 --- /dev/null +++ b/src/main/java/domain/pay/CardPayment.java @@ -0,0 +1,16 @@ +package domain.pay; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public record CardPayment(BigDecimal requestedPrice) { + + public BigDecimal getTotalPrice() { + return getTaxedPrice(); + } + + private BigDecimal getTaxedPrice() { + final BigDecimal tax = requestedPrice.divide(BigDecimal.TEN, RoundingMode.FLOOR); + return requestedPrice.add(tax); + } +} diff --git a/src/main/java/domain/pay/Cash.java b/src/main/java/domain/pay/Cash.java new file mode 100644 index 0000000..5624697 --- /dev/null +++ b/src/main/java/domain/pay/Cash.java @@ -0,0 +1,34 @@ +package domain.pay; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +public enum Cash { + FIVE_HUNDRED_THOUSAND(BigDecimal.valueOf(500_000)), + ONE_HUNDRED_THOUSAND(BigDecimal.valueOf(100_000)), + TEN_THOUSAND(BigDecimal.valueOf(10_000)), + FIVE_HUNDRED(BigDecimal.valueOf(500)), + ONE_HUNDRED(BigDecimal.valueOf(100)), + ; + + private final BigDecimal price; + + Cash(final BigDecimal price) { + this.price = price; + } + + public static List getAllByDescending() { + return Arrays.stream(Cash.values()) + .sorted((cash1, cash2) -> cash2.price.compareTo(cash1.price)) + .toList(); + } + + public BigDecimal add(final Cash other) { + return price.add(other.price); + } + + public BigDecimal getPrice() { + return price; + } +} diff --git a/src/main/java/domain/pay/CashPayment.java b/src/main/java/domain/pay/CashPayment.java new file mode 100644 index 0000000..086e9ed --- /dev/null +++ b/src/main/java/domain/pay/CashPayment.java @@ -0,0 +1,62 @@ +package domain.pay; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +public record CashPayment(BigDecimal requestedPrice, Cashes cashes) { + + public boolean isNotAvailableToPay() { + return !cashes.isGreaterOrEqualThan(requestedPrice); + } + + public void addCash(final Cash cash) { + cashes.addCash(cash); + } + + public Cashes getChange() { + if (isNotAvailableToPay()) { + throw new IllegalStateException("현재의 금액 총합이 계산할 금액보다 적습니다."); + } + + final List changes = calculateChanges(); + return new Cashes(changes); + } + + private List calculateChanges() { + BigDecimal remains = cashes.minus(requestedPrice); + + final List result = new ArrayList<>(); + for (final Cash cash : Cash.getAllByDescending()) { + final BigDecimal quotient = divideBy(cash, remains); + + result.addAll(getCashesForAmount(cash, quotient)); + remains = recalculateRemains(cash, remains, quotient); + } + return result; + } + + private BigDecimal divideBy(final Cash cash, final BigDecimal remains) { + return remains.divide(cash.getPrice(), RoundingMode.FLOOR); + } + + private List getCashesForAmount(final Cash cash, final BigDecimal amount) { + return Stream.generate(() -> cash) + .limit(amount.longValue()) + .toList(); + } + + private BigDecimal recalculateRemains(final Cash cash, final BigDecimal remains, final BigDecimal quotient) { + return remains.subtract(cash.getPrice().multiply(quotient)); + } + + public void clearTakenCashes() { + cashes.value().clear(); + } + + public BigDecimal getSum() { + return cashes.sum(); + } +} diff --git a/src/main/java/domain/pay/Cashes.java b/src/main/java/domain/pay/Cashes.java new file mode 100644 index 0000000..652420d --- /dev/null +++ b/src/main/java/domain/pay/Cashes.java @@ -0,0 +1,32 @@ +package domain.pay; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +public record Cashes(List value) { + + public static Cashes empty() { + return new Cashes(new ArrayList<>()); + } + + public boolean isGreaterOrEqualThan(final BigDecimal price) { + final BigDecimal sum = sum(); + return sum.compareTo(price) >= 0; + } + + public BigDecimal sum() { + return value.stream() + .map(Cash::getPrice) + .reduce(BigDecimal::add) + .orElse(BigDecimal.ZERO); + } + + public BigDecimal minus(final BigDecimal price) { + return sum().subtract(price); + } + + public void addCash(final Cash cash) { + value.add(cash); + } +} diff --git a/src/main/java/view/CashOption.java b/src/main/java/view/CashOption.java new file mode 100644 index 0000000..8e99355 --- /dev/null +++ b/src/main/java/view/CashOption.java @@ -0,0 +1,34 @@ +package view; + +import domain.pay.Cash; + +import java.util.Arrays; + +public enum CashOption { + FIVE_HUNDRED_THOUSAND(1, Cash.FIVE_HUNDRED_THOUSAND), + ONE_HUNDRED_THOUSAND(2, Cash.ONE_HUNDRED_THOUSAND), + TEN_THOUSAND(3, Cash.TEN_THOUSAND), + FIVE_HUNDRED(4, Cash.FIVE_HUNDRED), + ONE_HUNDRED(5, Cash.ONE_HUNDRED), + CLEAR(0, null), + ; + + private final int selection; + private final Cash cash; + + CashOption(final int selection, final Cash cash) { + this.selection = selection; + this.cash = cash; + } + + public static CashOption from(final int selection) { + return Arrays.stream(CashOption.values()) + .filter(option -> option.selection == selection) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 옵션입니다.")); + } + + public Cash getCash() { + return cash; + } +} diff --git a/src/main/java/view/PaymentOption.java b/src/main/java/view/PaymentOption.java new file mode 100644 index 0000000..9f2b427 --- /dev/null +++ b/src/main/java/view/PaymentOption.java @@ -0,0 +1,22 @@ +package view; + +public enum PaymentOption { + CARD, + CASH; + + public static PaymentOption from(final String input) { + try { + final int selection = Integer.parseInt(input); + if (selection == 1) { + return CASH; + } + if (selection == 2) { + return CARD; + } + + throw new IllegalArgumentException("존재하지 않는 옵션입니다."); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException("숫자만 입력할 수 있습니다."); + } + } +} diff --git a/src/main/java/view/TemperatureOption.java b/src/main/java/view/TemperatureOption.java new file mode 100644 index 0000000..2ba8029 --- /dev/null +++ b/src/main/java/view/TemperatureOption.java @@ -0,0 +1,22 @@ +package view; + +public enum TemperatureOption { + ICE, + HOT; + + public static TemperatureOption from(final String input) { + try { + final int selection = Integer.parseInt(input); + if (selection == 1) { + return ICE; + } + if (selection == 2) { + return HOT; + } + + throw new IllegalArgumentException("존재하지 않는 옵션입니다."); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException("숫자만 입력할 수 있습니다."); + } + } +} diff --git a/src/main/java/view/View.java b/src/main/java/view/View.java new file mode 100644 index 0000000..200dc6d --- /dev/null +++ b/src/main/java/view/View.java @@ -0,0 +1,149 @@ +package view; + +import domain.beverage.BeverageTemperature; +import domain.beverage.Drink; +import domain.pay.Cash; +import domain.pay.Cashes; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.Map; +import java.util.Scanner; +import java.util.stream.Collectors; + +public class View { + + private static final Scanner CONSOLE = new Scanner(System.in); + + private View() { + } + + public static void printStartView() { + System.out.print(""" + [어서와요! GDSC 음료 자판기] + + 계속 하려면 아무키나 입력하세요 ... + """); + + CONSOLE.nextLine(); + } + + public static TemperatureOption readBeverageTemperature() { + System.out.print(""" + ------------------------------ + 음료를 선택 해주세요! + + [1] 차가운 음료 + [2] 따뜻한 음료 + + 사용자 입력> + """); + + return TemperatureOption.from(CONSOLE.nextLine()); + } + + public static int readBeverage(final Map drinks) { + System.out.println("------------------------------"); + + final BeverageTemperature hotOrIce = drinks.get(1).hotOrIce(); + if (hotOrIce.equals(BeverageTemperature.HOT)) { + System.out.println("[뜨거운 음료]"); + } + if (hotOrIce.equals(BeverageTemperature.ICE)) { + System.out.println("[차가운 음료]"); + } + System.out.println(); + + drinks.entrySet().stream() + .sorted(Comparator.comparingInt(Map.Entry::getKey)) + .map(entry -> String.format("[%d] ", entry.getKey()) + makeDrinkView(entry.getValue())) + .forEach(System.out::println); + System.out.println(); + + System.out.println("사용자 입력 > "); + return readAndParseToInt(); + } + + private static int readAndParseToInt() { + try { + return Integer.parseInt(CONSOLE.nextLine()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("숫자만 입력할 수 있습니다."); + } + } + + private static String makeDrinkView(final Drink drink) { + return drink.name() + " : " + drink.price() + "원"; + } + + public static PaymentOption readPaymentOption() { + System.out.print(""" + ------------------------------ + [결제 방식 선택] + + [1] 현금 + [2] 카드 (부가세 10% 적용) + + 사용자 입력 > + """); + + return PaymentOption.from(CONSOLE.nextLine()); + } + + public static CashOption readCashOption(final BigDecimal sumOfCurrentCashes) { + System.out.printf(""" + ------------------------------ + [현금 투입 : %d원] + """, sumOfCurrentCashes.intValue()); + + System.out.print(""" + [1] 50만원권 + [2] 10만원권 + [3] 1만원권 + [4] 500원 + [5] 100원 + [0] 반환 + + 사용자 입력 > + """); + final int selection = readAndParseToInt(); + return CashOption.from(selection); + } + + public static void printCardPaymentResult(final Drink drink, final BigDecimal totalPrice) { + System.out.printf(""" + ------------------------------- + 이용해주셔서 감사합니다. + + [주문 음료] + %s + + [결제 금액] + %d 원 + """, drink.name(), totalPrice.intValue()); + } + + public static void printCashPaymentResult(final Drink drink, + final BigDecimal takenCashAmount, + final Cashes changes) { + System.out.printf( + """ + ------------------------------ + 이용해주셔서 감사합니다. + + [주문 음료] + %s + + [투입 금액] + %d 원 + + [잔돈] + """, drink.name(), takenCashAmount.intValue()); + changes.value().stream() + .collect(Collectors.groupingBy(Cash::getPrice, Collectors.counting())) + .entrySet().stream() + .sorted((cash1, cash2) -> cash2.getKey().compareTo(cash1.getKey())) + .map(cash -> String.format("%d원 화폐 : %d개", cash.getKey().intValue(), cash.getValue())) + .forEach(System.out::println); + } +} diff --git a/src/test/java/domain/pay/CardPaymentTest.java b/src/test/java/domain/pay/CardPaymentTest.java new file mode 100644 index 0000000..054714a --- /dev/null +++ b/src/test/java/domain/pay/CardPaymentTest.java @@ -0,0 +1,24 @@ +package domain.pay; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class CardPaymentTest { + + @Test + @DisplayName("10% 추가된 금액을 계산할 수 있다") + void getTotalPrice() { + //given + final CardPayment cardPayment = new CardPayment(BigDecimal.valueOf(500000L)); + + //when + final BigDecimal totalPrice = cardPayment.getTotalPrice(); + + //then + assertThat(totalPrice).isEqualTo(BigDecimal.valueOf(550000L)); + } +} diff --git a/src/test/java/domain/pay/CashPaymentTest.java b/src/test/java/domain/pay/CashPaymentTest.java new file mode 100644 index 0000000..af4aa30 --- /dev/null +++ b/src/test/java/domain/pay/CashPaymentTest.java @@ -0,0 +1,57 @@ +package domain.pay; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CashPaymentTest { + + @Test + @DisplayName("거스름돈을 계산할 수 있다") + void getChange() { + //given + final Cashes cashes = new Cashes(List.of(Cash.FIVE_HUNDRED_THOUSAND)); + final CashPayment cashPayment = new CashPayment(BigDecimal.valueOf(100_000L), cashes); + + //when + final Cashes change = cashPayment.getChange(); + + //then + assertThat(change.value()).containsExactlyInAnyOrder( + Cash.ONE_HUNDRED_THOUSAND, + Cash.ONE_HUNDRED_THOUSAND, + Cash.ONE_HUNDRED_THOUSAND, + Cash.ONE_HUNDRED_THOUSAND); + } + + @Test + @DisplayName("거스름돈이 없는 경우는 빈 결과를 반환한다") + void getChange_empty() { + //given + final Cashes cashes = new Cashes(List.of(Cash.FIVE_HUNDRED_THOUSAND)); + final CashPayment cashPayment = new CashPayment(BigDecimal.valueOf(500_000L), cashes); + + //when + final Cashes change = cashPayment.getChange(); + + //then + assertThat(change.value()).isEmpty(); + } + + @Test + @DisplayName("현재 총액이 계산할 금액보다 적은 경우는 예외가 발생한다") + void getChange_fail() { + //given + final Cashes cashes = new Cashes(List.of(Cash.ONE_HUNDRED_THOUSAND)); + final CashPayment cashPayment = new CashPayment(BigDecimal.valueOf(500_000L), cashes); + + //when + assertThatThrownBy(cashPayment::getChange) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/domain/pay/CashTest.java b/src/test/java/domain/pay/CashTest.java new file mode 100644 index 0000000..9422214 --- /dev/null +++ b/src/test/java/domain/pay/CashTest.java @@ -0,0 +1,37 @@ +package domain.pay; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CashTest { + + @Test + @DisplayName("현금끼리 더할 수 있다") + void add() { + //given + final Cash cash = Cash.FIVE_HUNDRED_THOUSAND; + + //when + final BigDecimal added = cash.add(Cash.FIVE_HUNDRED_THOUSAND); + + //then + assertThat(added).isEqualTo(BigDecimal.valueOf(1_000_000)); + } + + @Test + @DisplayName("모든 현금을 금액권이 큰 순서대로 조회할 수 있다") + void getByDescending() { + //when + final List allCashes = Cash.getAllByDescending(); + + //then + assertThat(allCashes.stream().map(Cash::getPrice)) + .isSortedAccordingTo(Comparator.reverseOrder()); + } +} diff --git a/src/test/java/domain/pay/CashesTest.java b/src/test/java/domain/pay/CashesTest.java new file mode 100644 index 0000000..67644be --- /dev/null +++ b/src/test/java/domain/pay/CashesTest.java @@ -0,0 +1,65 @@ +package domain.pay; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CashesTest { + + @Test + @DisplayName("주어진 금액보다 작으면 false를 리턴한다") + void isGreaterOrEqualThan_false() { + //given + final Cashes cashes = new Cashes(Collections.emptyList()); + + //when + final boolean result = cashes.isGreaterOrEqualThan(BigDecimal.TEN); + + //then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("주어진 금액보다 크면 true를 리턴한다") + void isGreaterOrEqualThan_greater() { + //given + final Cashes cashes = new Cashes(List.of(Cash.FIVE_HUNDRED_THOUSAND)); + + //when + final boolean result = cashes.isGreaterOrEqualThan(BigDecimal.TEN); + + //then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("주어진 금액과 같으면 true를 리턴한다") + void isGreaterOrEqualThan_equal() { + //given + final Cashes cashes = new Cashes(List.of(Cash.FIVE_HUNDRED_THOUSAND)); + + //when + final boolean result = cashes.isGreaterOrEqualThan(BigDecimal.TEN); + + //then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("현재 총액에서 주어진 금액을 뺀 값을 계산할 수 있다") + void minus() { + //given + final Cashes cashes = new Cashes(List.of(Cash.FIVE_HUNDRED_THOUSAND)); + + //when + final BigDecimal result = cashes.minus(BigDecimal.valueOf(400_000L)); + + //then + assertThat(result).isEqualTo(BigDecimal.valueOf(100_000L)); + } +}