From cca12baccdb7592f567252ff0f859fcd95e834d8 Mon Sep 17 00:00:00 2001 From: ParkHyeonkyu <162525426+ParkHyeonkyu@users.noreply.github.com> Date: Thu, 27 Feb 2025 15:43:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20README.md=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기능 구현, 예시, 예외처리 부분를 정리하여 README.md 작성 --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/README.md b/README.md index 67de355..a18f922 100644 --- a/README.md +++ b/README.md @@ -1 +1,74 @@ # javascript-convenience-store-precourse + +## 기능 구현 +1. 물건 구매 + 1-1. 총 구매액 계산 (상품별 가격 * 수량) + 1-2. 프로모션 할인 (Buy N Get 1 Free, 프로모션 재고 내에서 우선적 차감감) + 1-3. 멤버십 할인 (프로모션 미적용 금액의 30%, 최대 8,000원) + 1-4. 영수증 출력 +2. 재고 차감 및 수량 관리, 재고 정보 갱신 + +## 예시 +- 재고 소개 +'안녕하세요. w편의점입니다. +현재 보유하고 있는 상품입니다.' +[상품 나열, 프로모션 상품과 일반 상품의 재고 및 수량을 제시] +ex) 콜라 1,000원 10개 탄산2+1 + 콜라 1,000원 10개개 + +- 구매 제품 파악 +내용: 구매할 상품과 수량 +형식: 상품([]), 수량(-), 열거(,) +ex) [콜라-10] + +- 멤버십 할인 적용 유무 +'멤버십 할인을 받으시겠습니까?(Y,N)' +Y: 프로모션 미적용 금액의 30%, 최대 8,000원 할인 +N: 멤버십 할인 미 적용 + +- if, 프로모션 적용이 가능한 상품에 대해 고객이 해당 수량보다 적게 가져온 경우 +필요한 수량을 추가로 가져오면 혜택을 받을 수 있음을 안내한다. +ex) 1+1 프로모션 상품인 오렌지주스를 하나만 구입하는 경우 +-> 현재 오렌지주스은(는) 1개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N) +Y: 오렌지주스 프로모션 상품 1개 차감 +N: 하나만 구매 + +- if, 프로모션 재고가 부족하여 일부 수량을 프로모션 혜택 없이 결제해야 하는 경우 +일부 수량에 대해 정가로 결제하게 됨을 안내 +ex) 사용자가 콜라를 10개 구매하려고 하나 프로모션 상품이 7개 뿐인 경우 +안녕하세요. w편의점입니다. +현재 보유하고 있는 상품입니다. + +- 콜라 1,000원 7개 탄산2+1 +- 콜라 1,000원 10개 + +구매하실 상품명과 수량을 입력해 주세요.(예: [사이다-2],[감자칩-1]) +[콜라-10] + +-> 현재 콜라 4개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니끼? (Y/N) + +- 영수증 출력 +ex) 사용자가 콜라를 10개 구매하려고 하나 프로모션 상품이 7개 뿐인 경우, 멤버십 할인을 받지 않는 경우우 +===========W 편의점============= +상품명 수량 금액 +콜라 10 10,000 +===========증 정============= +콜라 2 +==============================. +총구매액 10 10,000 +행사할인 -2,000 +멤버십할인 -0 +내실돈 8,000 + +- 추가 구매 여부 +감사합니다. 구매하고 싶은 다른 상품이 있나요? (Y/N) +Y: 다시 처음으로 돌아가 구매 작업을 반복하며, 이때 재고 수량은 이전 구매에서 차감된 부분을 반영 +N: 종료 + +## 예외처리 +1. 구매 상품명과 수량의 입력 양식이 올바르지 않은 경우 + `[${상품}-${수량}]`의 형식이 아닌 경우 +2. 구매하고자 하는 상품명과 수량이 올바르지 않은 경우 + 2-1. 존재하지 않는 상품명 + 2-2. 0이하 또는 재고 갯수를 초과하는 수량 +3. Y/N 입력 형식에서 잘못된 값을 입력하는 경우 From 32933b93241f582e162162a98c4c99e63c4e1683 Mon Sep 17 00:00:00 2001 From: ParkHyeonkyu <162525426+ParkHyeonkyu@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:32:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EA=B8=B0=EB=8A=A5=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVC패턴을 고려한 코드 분리 및 기능 구현 --- src/App.js | 9 +- src/constants/error.js | 4 + src/constants/input.js | 3 + src/constants/output.js | 7 ++ src/controllers/Store.js | 113 +++++++++++++++++++++ src/models/Promotion.js | 183 +++++++++++++++++++++++++++++++++++ src/models/Receipt.js | 76 +++++++++++++++ src/models/StoreMachine.js | 144 +++++++++++++++++++++++++++ src/utils/mergeSameItems.js | 14 +++ src/utils/readFile.js | 8 ++ src/utils/retryOnError.js | 12 +++ src/utils/stringFormatter.js | 29 ++++++ src/utils/throwError.js | 3 + src/utils/validator.js | 38 ++++++++ src/views/Input.js | 45 +++++++++ src/views/Output.js | 40 ++++++++ 16 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 src/constants/error.js create mode 100644 src/constants/input.js create mode 100644 src/constants/output.js create mode 100644 src/controllers/Store.js create mode 100644 src/models/Promotion.js create mode 100644 src/models/Receipt.js create mode 100644 src/models/StoreMachine.js create mode 100644 src/utils/mergeSameItems.js create mode 100644 src/utils/readFile.js create mode 100644 src/utils/retryOnError.js create mode 100644 src/utils/stringFormatter.js create mode 100644 src/utils/throwError.js create mode 100644 src/utils/validator.js create mode 100644 src/views/Input.js create mode 100644 src/views/Output.js diff --git a/src/App.js b/src/App.js index 091aa0a..08ff089 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import Store from './controllers/Store.js'; + class App { - async run() {} + async run() { + const store = new Store(); + await store.run(); + } } -export default App; +export default App; \ No newline at end of file diff --git a/src/constants/error.js b/src/constants/error.js new file mode 100644 index 0000000..01488e4 --- /dev/null +++ b/src/constants/error.js @@ -0,0 +1,4 @@ +export const INVALID_FORMAT = '올바르지 않은 형식으로 입력했습니다. 다시 입력해 주세요.\n'; +export const NOT_EXIST_ITEM = '존재하지 않는 상품입니다. 다시 입력해 주세요.\n'; +export const OVER_PURCHASE_COUNT = '재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요.\n'; +export const INVALID_INPUT = '잘못된 입력입니다. 다시 입력해 주세요.\n'; \ No newline at end of file diff --git a/src/constants/input.js b/src/constants/input.js new file mode 100644 index 0000000..620e2c3 --- /dev/null +++ b/src/constants/input.js @@ -0,0 +1,3 @@ +export const PURCHASE = '구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])\n'; +export const MEMBERSHIP = '\n멤버십 할인을 받으시겠습니까? (Y/N)\n'; +export const MORE_SHOPPING = '감사합니다. 구매하고 싶은 다른 상품이 있나요? (Y/N)\n'; \ No newline at end of file diff --git a/src/constants/output.js b/src/constants/output.js new file mode 100644 index 0000000..2f23294 --- /dev/null +++ b/src/constants/output.js @@ -0,0 +1,7 @@ +export const GREETING = '안녕하세요. W편의점입니다.\n현재 보유하고 있는 상품입니다.\n'; + +export const STORE = '\n==============W 편의점================'; +export const GIFT = '=============증 정==============='; +export const LINE = '======================================'; + +export const COLUMN = '상품명\t\t수량\t\t금액'; \ No newline at end of file diff --git a/src/controllers/Store.js b/src/controllers/Store.js new file mode 100644 index 0000000..92db366 --- /dev/null +++ b/src/controllers/Store.js @@ -0,0 +1,113 @@ +import StoreMachine from '../models/StoreMachine.js'; +import Promotion from '../models/Promotion.js'; +import Output from '../views/Output.js'; +import Input from '../views/Input.js'; +import retryOnError from '../utils/retryOnError.js'; +import mergeSameItems from '../utils/mergeSameItems.js'; +import Receipt from '../models/Receipt.js'; + +class Store { + #storeMachine; + /** + * @type {Promotion} + */ + #promotion; + + constructor() { + this.#storeMachine = new StoreMachine(); + } + + async run() { + let continueShoppingFlag = true; + + while (continueShoppingFlag) { + await this.#runStore(); + continueShoppingFlag = await this.#checkMoreShopping(); + } + } + + async #runStore() { + this.openStore(); + await retryOnError(this.#purchase.bind(this)); + await this.#applyPromotion(); + const membership = await this.#checkMembership(); + this.#managePurchaseResult(membership); + } + + openStore() { + Output.greeting(); + const products = this.#storeMachine.getProducts(); + Output.productsInfo(products); + } + + async #purchase() { + const inputList = await Input.purchase(); + const purchaseList = mergeSameItems(inputList); + + this.#storeMachine.checkCanBuy(purchaseList); + } + + async #applyPromotion() { + const promotionItems = this.#storeMachine.getPromotionItemList(); + this.#promotion = new Promotion(promotionItems); + + const availableMoreGiftList = this.#promotion.getAvailableMoreGiftList(); + await this.#noticeMissingItems(availableMoreGiftList); + + const disablePromotionList = this.#promotion.getDisablePromotionList(); + await this.#noticeDisablePromotions(disablePromotionList); + this.#storeMachine.applyPromotion(this.#promotion.getBuyAndGetList()); + } + + async #checkMembership() { + const answer = await retryOnError(() => Input.noticeMembership()); + if (answer === 'N') return false; + return true; + } + + #managePurchaseResult(membership) { + this.#storeMachine.updateQuantity(); + const receipt = this.#storeMachine.noticeReceipt( + (purchase) => new Receipt(purchase, membership) + ); + this.#printReceipt(receipt); + } + + async #checkMoreShopping() { + const answer = await retryOnError(() => Input.moreShopping()); + if (answer === 'N') return false; + return true; + } + + async #noticeMissingItems(availableMoreGiftList) { + for (const item of availableMoreGiftList) { + const { name, canGetMoreCount: giftCount } = item; + const answer = await retryOnError(() => Input.noticeMoreGift(name, giftCount)); + + if (answer === 'N') return; + this.#promotion.manageExtraGift(name); + } + } + + async #noticeDisablePromotions(disablePromotionList) { + for (const item of disablePromotionList) { + const { name, disablePromotionCount: itemCount } = item; + const answer = await retryOnError(() => Input.noticeDisablePromotion(name, itemCount)); + + if (answer === 'Y') return; + this.#promotion.manageDisablePromotionItem(name); + } + } + + /** + * + * @param {Receipt} receipt + */ + #printReceipt(receipt) { + receipt.purchaseItems((list) => Output.purchaseItems(list)); + receipt.giftItems((list) => Output.giftItems(list)); + receipt.receiptResult((list) => Output.receiptResult(list)); + } +} + +export default Store; \ No newline at end of file diff --git a/src/models/Promotion.js b/src/models/Promotion.js new file mode 100644 index 0000000..5c0e644 --- /dev/null +++ b/src/models/Promotion.js @@ -0,0 +1,183 @@ +import readFile from '../utils/readFile.js'; +import { DateTimes } from '@woowacourse/mission-utils'; +import { parseDateTime } from '../utils/stringFormatter.js'; + +const PROMOTION_FILE_PATH = './public/promotions.md'; + +class Promotion { + /** + * @type {Object.} 프로모션 이름 : {} + */ + #promotions; + /** + * @type {Array<{name: string, buyCount: number, getCount: number, isInPromotion:bool, canGetMoreCount?: number, cannotApplyPromotionCount?: number}>} + */ + #promotionItems; + + /** + * @constructor + * @param {Array<{name, buyCount, maxPromotionCount, promotionName}>} items - 상품명, 구매 개수, 프로모션 적용 최대 갯수, 프로모션 이름 + */ + constructor(items) { + this.#initPromotions(); + this.#promotionItems = this.#applyPromotion(items); + } + + /** + * @return {Array<{name: string, buyCount: number, getCount: number, isInPromotion: bool canGetMoreCount?: number, cannotApplyPromotionCount?: number}>} - 상품명, 구매 수량, 증정품 수량, 누락 증정품 수량, 프로모션 적용 불가 수량, 프로모션션 기간 중 + */ + #applyPromotion(items) { + const applyPromotion = []; + items.forEach((item) => { + const promotionItemObject = { name: item.name, buyCount: item.buyCount, getCount: 0 }; + if (!this.#isPromotionAvailable(item)) { + promotionItemObject.isInPromotion = false; + applyPromotion.push(promotionItemObject); + return; + } + + promotionItemObject.isInPromotion = true; + applyPromotion.push(promotionItemObject); + + const giftList = this.#checkGift(item); + applyPromotion[applyPromotion.length - 1] = Object.assign( + applyPromotion[applyPromotion.length - 1], + giftList + ); + }); + return applyPromotion; + } + + /** + * 프로모션 적용이 가능한 상품에 대해 고객이 해당 수량보다 적게 가져온 경우의 상품명과 누락된 증정 상품 수량을 View로 전달 + * @return {Array<{name:string, canGetMoreCount:number}>} + */ + getAvailableMoreGiftList() { + return this.#promotionItems + .filter((item) => item.canGetMoreCount !== undefined) + .map((item) => ({ + name: item.name, + canGetMoreCount: item.canGetMoreCount, + })); + } + + /** + * 프로모션 혜택 없이 결제해야하는 상품의 상품명과 수량을 View로 전달 + * @returns {Array<{name:string, disablePromotionCount:number}>} + */ + getDisablePromotionList() { + return this.#promotionItems + .filter((item) => item.cannotApplyPromotionCount !== undefined) + .map((item) => ({ + name: item.name, + disablePromotionCount: item.cannotApplyPromotionCount, + })); + } + + /** + * 최종 프로모션 적용 결과 전달 + * @returns {Array<{name: string, count: number, giftCount?: number, isInPromotion:bool}>} + */ + getBuyAndGetList() { + return this.#promotionItems.map((item) => ({ + name: item.name, + count: item.buyCount, + giftCount: item.getCount, + isInPromotion: item.isInPromotion, + })); + } + + /** + * 누락된 증정 상품에 대한 응답 처리 + * @param {string} name + */ + manageExtraGift(name) { + const item = this.#promotionItems.find((item) => item.name === name); + item.getCount += item.canGetMoreCount; + } + + /** + * 프로모션 적용 불가 상품의 정가 결제 여부에 대한 응답 처리 + * @param {string} name + */ + manageDisablePromotionItem(name) { + const item = this.#promotionItems.find((item) => item.name === name); + item.buyCount -= item.cannotApplyPromotionCount; + } + + /** + * 프로모션 적용이 가능한 상품에 대해 구매 수량, 증정 상품 수량, 증정 상품 누락 수량 확인 + * @param {{name, buyCount, maxPromotionCount,promotionName}} item 상품 정보 + * @return {{name, buyCount, getCount, canGetMoreCount?:number, cannotApplyPromotionCount?: number}} + */ + #checkGift({ name, buyCount, maxPromotionCount, promotionName } = item) { + const promotion = this.#promotions[promotionName]; + const result = this.#checkPromotion(name, buyCount, maxPromotionCount, promotion); + + if (!this.#hasMoreGift(buyCount, maxPromotionCount, promotion)) return result; + return Object.assign(result, { canGetMoreCount: promotion.get }); + } + + /** + * 구매 상품과 증정 상품의 개수 및 프로모션 적용 불가 개수 확인 + * @param {*} name 구매 상품명 + * @param {*} buyCount 구매 개수 + * @param {*} maxPromotionCount 프로모션 재고 + * @param {{buy, get, startDate, endDate}} promotion 구매 상품의 프로모션 정보 + * @returns {{name:string, buyCount:number, getCount:number, cannotApplyPromotionCount?:number}} + */ + #checkPromotion(name, buyCount, maxPromotionCount, promotion) { + const bundle = promotion.buy + promotion.get; + const set = Math.floor(Math.min(buyCount, maxPromotionCount) / bundle); + const getCount = promotion.get * set; + const remains = buyCount - set * bundle; + + if (buyCount >= maxPromotionCount) { + return { name, buyCount: buyCount - getCount, getCount, cannotApplyPromotionCount: remains }; + } + buyCount -= getCount; + return { name, buyCount, getCount }; + } + + /** + * 증정 상품을 더 받을 수 있는지 확인 + * @param {int} buyCount 구매 개수 + * @param {int} maxPromotionCount 프로모션 재고 + * @param {{buy, get, startDate, endDate}} promotion 구매 상품의 프로모션 정보 + * @return {bool} + */ + #hasMoreGift(buyCount, maxPromotionCount, promotion) { + const canGetMoreCount = buyCount % (promotion.buy + promotion.get) === promotion.buy; + const notOverPromotionStock = buyCount + promotion.get <= maxPromotionCount; + return canGetMoreCount && notOverPromotionStock; + } + + /** + * 해당 상품이 프로모션 적용 가능한지 확인 + * @param {name, buyCount, maxPromotionCount,promotionName} item - 상품 정보 + * @returns {boolean} 프로모션 적용 가능 여부 + */ + #isPromotionAvailable(item) { + const promotion = this.#promotions[item.promotionName]; + return promotion && this.#isInPromotionDuration(item.promotionName); + } + + #isInPromotionDuration(promotionName) { + const today = parseDateTime(DateTimes.now()); + const startDate = this.#promotions[promotionName].startDate; + const endDate = this.#promotions[promotionName].endDate; + + return today >= startDate && today <= endDate; + } + + #initPromotions() { + const content = readFile(PROMOTION_FILE_PATH); + this.#promotions = content.reduce((acc, line) => { + const [name, buy, get, startDate, endDate] = line.split(','); + acc[name] = { buy: Number(buy), get: Number(get), startDate, endDate }; + return acc; + }, {}); + } +} + +export default Promotion; \ No newline at end of file diff --git a/src/models/Receipt.js b/src/models/Receipt.js new file mode 100644 index 0000000..fc92b3b --- /dev/null +++ b/src/models/Receipt.js @@ -0,0 +1,76 @@ +class Receipt { + /** + * @type {Array<{name:string, count: number, giftCount: number, price:number}>} + */ + #purchase; + #membership; + + constructor(purchase, membership) { + this.#purchase = purchase; + this.#membership = membership; + } + + purchaseItems(outputCallback) { + const list = this.#purchase.map((item) => { + return { + name: item.name, + count: item.count + item.giftCount, + price: item.price * (item.count + item.giftCount), + }; + }); + outputCallback(list); + } + + giftItems(outputCallback) { + const result = this.#purchase + .filter((item) => item.giftCount !== 0) + .map((item) => ({ name: item.name, giftCount: item.giftCount })); + outputCallback(result); + } + + receiptResult(outputCallback) { + outputCallback(this.#calculateReceiptResult()); + } + + #calculateReceiptResult() { + const { quantity, amount } = this.#calculateTotalPurchaseAmount(); + const promotionDiscount = this.#calculatePromotionDiscount(); + const membershipDiscount = this.#calculateMembershipDiscount(); + const payment = amount - promotionDiscount - membershipDiscount; + return { quantity, amount, promotionDiscount, membershipDiscount, payment }; + } + + #calculateTotalPurchaseAmount() { + return this.#purchase.reduce( + (acc, curr) => { + acc.quantity += curr.count + curr.giftCount; + acc.amount += curr.price * (curr.count + curr.giftCount); + return acc; + }, + { quantity: 0, amount: 0 } + ); + } + + #calculatePromotionDiscount() { + return this.#purchase + .filter((item) => item.giftCount !== 0) + .reduce((acc, curr) => { + acc += curr.price * curr.giftCount; + return acc; + }, 0); + } + + #calculateMembershipDiscount() { + if (!this.#membership) return 0; + const nonPromotionAmount = this.#purchase + .filter((item) => item.giftCount === 0) + .reduce((acc, curr) => { + acc += curr.price * curr.count; + return acc; + }, 0); + const discount = Math.floor(nonPromotionAmount * 0.3); + return Math.min(discount, 8000); + } + } + + export default Receipt; \ No newline at end of file diff --git a/src/models/StoreMachine.js b/src/models/StoreMachine.js new file mode 100644 index 0000000..6f1e154 --- /dev/null +++ b/src/models/StoreMachine.js @@ -0,0 +1,144 @@ +import { nullFormatter } from '../utils/stringFormatter.js'; +import throwError from '../utils/throwError.js'; +import * as e from '../constants/error.js'; +import readFile from '../utils/readFile.js'; +import Promotion from './Promotion.js'; + +const PRODUCT_FILE_PATH = './public/products.md'; + +class StoreMachine { + #products; + #purchase; + + constructor() { + this.#initProducts(); + } + + getProducts() { + return this.#products; + } + + checkCanBuy(purchaseList) { + for (const { name, count } of purchaseList) { + this.#validatePurchase(name, count); + } + this.#purchase = purchaseList; + } + + getPromotionItemList() { + const promotionItems = []; + this.#purchase.forEach((purchaseItem) => { + const product = this.#products.find((line) => line.name === purchaseItem.name); + if (!product || !product.promotion) return; + promotionItems.push({ + name: product.name, + buyCount: purchaseItem.count, + maxPromotionCount: product.quantity, + promotionName: product.promotion, + }); + }); + return promotionItems; + } + + applyPromotion(buyAndGetList) { + this.#purchase = this.#purchase.map((item) => { + const matchingPromoItem = buyAndGetList.find((promoItem) => promoItem.name === item.name); + return matchingPromoItem || item; + }); + } + + noticeReceipt(noticeCallback) { + const receipt = this.#purchase.map((item) => { + const product = this.#products.find((product) => product.name === item.name); + return { + name: item.name, + count: item.count, + giftCount: item.giftCount || 0, + price: product.price, + }; + }); + return noticeCallback(receipt); + } + + updateQuantity() { + this.#purchase.forEach((purchaseItem) => { + const totalPurchaseCount = this.#calculateTotalCount(purchaseItem); + this.#processQuantityDeduction(purchaseItem, totalPurchaseCount); + }); + } + + #calculateTotalCount(purchaseItem) { + return purchaseItem.count + (purchaseItem.giftCount || 0); + } + + #processQuantityDeduction(purchaseItem, totalPurchaseCount) { + if (purchaseItem.isInPromotion) { + this.#processPromotionPurchase(purchaseItem, totalPurchaseCount); + return; + } + this.#processRegularPurchase(purchaseItem, totalPurchaseCount); + } + + #processPromotionPurchase(purchaseItem, totalPurchaseCount) { + const promotionProduct = this.#findProduct(purchaseItem.name, true); + if (!promotionProduct) return; + this.#deductPromotionQuantityFirst(promotionProduct, totalPurchaseCount); + } + + #processRegularPurchase(purchaseItem, totalPurchaseCount) { + const regularProduct = this.#findProduct(purchaseItem.name, false); + if (!regularProduct) return; + this.#deductRegularQuantityFirst(regularProduct, totalPurchaseCount); + } + + #findProduct(name, isPromotion) { + return this.#products.find((p) => p.name === name && !!p.promotion === isPromotion); + } + + #validatePurchase(item, count) { + const filteredProducts = this.#products.filter((line) => line.name === item); + if (filteredProducts.length === 0) throwError(e.NOT_EXIST_ITEM); + + const totalQuantity = filteredProducts.reduce((acc, line) => acc + line.quantity, 0); + if (totalQuantity < count) throwError(e.OVER_PURCHASE_COUNT); + } + + #initProducts() { + const content = readFile(PRODUCT_FILE_PATH); + this.#products = content.map((line) => { + const [name, price, quantity, promo] = line.split(','); + const promotion = nullFormatter(promo); + return { name, price: Number(price), quantity: Number(quantity), promotion: promotion }; + }); + } + + #deductPromotionQuantityFirst(product, totalPurchaseCount) { + if (product.quantity >= totalPurchaseCount) { + product.quantity -= totalPurchaseCount; + return; + } + + const remainingCount = totalPurchaseCount - product.quantity; + product.quantity = 0; + + const regularProduct = this.#findProduct(product.name, false); + if (!regularProduct) return; + regularProduct.quantity -= remainingCount; + } + + #deductRegularQuantityFirst(product, totalPurchaseCount) { + if (product.quantity >= totalPurchaseCount) { + product.quantity -= totalPurchaseCount; + return; + } + + const remainingCount = totalPurchaseCount - product.quantity; + product.quantity = 0; + + const promotionProduct = this.#findProduct(product.name, true); + if (!promotionProduct) return; + promotionProduct.quantity -= remainingCount; + } +} + +export default StoreMachine; \ No newline at end of file diff --git a/src/utils/mergeSameItems.js b/src/utils/mergeSameItems.js new file mode 100644 index 0000000..7cd36b7 --- /dev/null +++ b/src/utils/mergeSameItems.js @@ -0,0 +1,14 @@ +function mergeSameItems(purchaseList) { + const merged = purchaseList.reduce((acc, value) => { + if (acc[value.name]) { + acc[value.name].count += value.count; + } else { + acc[value.name] = { name: value.name, count: value.count }; + } + return acc; + }, {}); + + return Object.values(merged); + } + + export default mergeSameItems; \ No newline at end of file diff --git a/src/utils/readFile.js b/src/utils/readFile.js new file mode 100644 index 0000000..8d658a1 --- /dev/null +++ b/src/utils/readFile.js @@ -0,0 +1,8 @@ +import fs from 'fs'; + +function readFile(filePath) { + const content = fs.readFileSync(filePath, 'utf-8').trim().split('\n').slice(1); + return content; +} + +export default readFile; \ No newline at end of file diff --git a/src/utils/retryOnError.js b/src/utils/retryOnError.js new file mode 100644 index 0000000..ce54e20 --- /dev/null +++ b/src/utils/retryOnError.js @@ -0,0 +1,12 @@ +import { Console } from '@woowacourse/mission-utils'; + +const retryOnError = async (func) => { + try { + return await func(); + } catch (error) { + Console.print(error.message); + return await retryOnError(func); + } +}; + +export default retryOnError; \ No newline at end of file diff --git a/src/utils/stringFormatter.js b/src/utils/stringFormatter.js new file mode 100644 index 0000000..4e0c0d6 --- /dev/null +++ b/src/utils/stringFormatter.js @@ -0,0 +1,29 @@ +export function nullFormatter(string) { + if (string === 'null') return null; + return string; + } + + export function toPrintInfo(product) { + let info = `- ${product.name} ${thousandComma(product.price)}원`; + + if (product.quantity === 0) info += ' 재고 없음'; + else info += ` ${product.quantity}개`; + + if (product.promotion === null) return info; + + info += ` ${product.promotion}`; + return info; + } + + /** + * DateTimes.now()에서 날짜만 파싱 + * @returns yyyy-mm-dd + */ + export function parseDateTime(value) { + const date = JSON.stringify(value).replace('"', '').split('T'); + return date[0]; + } + + export function thousandComma(string) { + return string.toLocaleString('ko-kr'); + } \ No newline at end of file diff --git a/src/utils/throwError.js b/src/utils/throwError.js new file mode 100644 index 0000000..2c259fb --- /dev/null +++ b/src/utils/throwError.js @@ -0,0 +1,3 @@ +export default function throwError(message) { + throw new Error(`[ERROR] ${message}`); + } \ No newline at end of file diff --git a/src/utils/validator.js b/src/utils/validator.js new file mode 100644 index 0000000..33ecc19 --- /dev/null +++ b/src/utils/validator.js @@ -0,0 +1,38 @@ +import throwError from './throwError.js'; +import * as e from '../constants/error.js'; + +export function validatePurchase(purchase) { + const purchaseList = []; + if (purchase.trim() === '') throwError(e.INVALID_FORMAT); + + purchase = purchase.split(',').map((element) => element); + purchase.forEach((element) => { + if (element[0] !== '[' || element[element.length - 1] !== ']') throwError(e.INVALID_FORMAT); + purchaseList.push(validateEachPurchase(element)); + }); + + return purchaseList; +} + +export function validateYNAnswer(answer) { + answer = answer; + if (answer !== 'Y' && answer !== 'N') throwError(e.INVALID_INPUT); + return answer; +} + +function validateEachPurchase(element) { + element = element.slice(1, element.length - 1).split('-'); + if (element.length !== 2) throwError(e.INVALID_FORMAT); + if (!isInteger(element[1])) throwError(e.INVALID_FORMAT); + + return { name: element[0], count: Number(element[1]) }; +} + +function isInteger(value) { + if (isNaN(value)) return false; + + const number = Number(value); + const integer = parseInt(value); + if (number !== integer) return false; + return true; +} \ No newline at end of file diff --git a/src/views/Input.js b/src/views/Input.js new file mode 100644 index 0000000..6e928a4 --- /dev/null +++ b/src/views/Input.js @@ -0,0 +1,45 @@ +import { Console } from '@woowacourse/mission-utils'; +import { PURCHASE, MEMBERSHIP, MORE_SHOPPING } from '../constants/input.js'; +import { validatePurchase, validateYNAnswer } from '../utils/validator.js'; + +const Input = { + async purchase() { + const input = await this.getUserInput(PURCHASE); + return validatePurchase(input); + }, + + /** + * @returns {'Y'|'N'} + */ + async noticeMoreGift(name, number) { + const caption = `\n현재 ${name}은(는) ${number}개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N)\n`; + const input = await this.getUserInput(caption); + return validateYNAnswer(input); + }, + + /** + * @returns {'Y'|'N'} + */ + async noticeDisablePromotion(name, number) { + const caption = `\n현재 ${name} ${number}개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)\n`; + const input = await this.getUserInput(caption); + return validateYNAnswer(input); + }, + + async noticeMembership() { + const input = await this.getUserInput(MEMBERSHIP); + return validateYNAnswer(input); + }, + + async moreShopping() { + const input = await this.getUserInput(MORE_SHOPPING); + return validateYNAnswer(input); + }, + + async getUserInput(caption) { + const input = await Console.readLineAsync(caption); + return input; + }, +}; + +export default Input; \ No newline at end of file diff --git a/src/views/Output.js b/src/views/Output.js new file mode 100644 index 0000000..92ff39e --- /dev/null +++ b/src/views/Output.js @@ -0,0 +1,40 @@ +import { Console } from '@woowacourse/mission-utils'; +import { toPrintInfo, thousandComma } from '../utils/stringFormatter.js'; +import * as o from '../constants/output.js'; + +const Output = { + greeting() { + Console.print(o.GREETING); + }, + + productsInfo(products) { + products.map((product) => Console.print(toPrintInfo(product))); + Console.print(''); + }, + + purchaseItems(purchase) { + Console.print(o.STORE); + Console.print(o.COLUMN); + + purchase.forEach((item) => { + Console.print(`${item.name}\t\t${item.count}\t\t${thousandComma(item.price)}`); + }); + }, + + giftItems(gift) { + Console.print(o.GIFT); + gift.forEach((item) => { + Console.print(`${item.name}\t\t${item.giftCount}`); + }); + }, + + receiptResult(result) { + Console.print(o.LINE); + Console.print(`총구매액\t${result.quantity}\t\t${thousandComma(result.amount)}`); + Console.print(`행사할인\t\t\t-${thousandComma(result.promotionDiscount)}`); + Console.print(`멤버십할인\t\t\t-${thousandComma(result.membershipDiscount)}`); + Console.print(`내실돈\t\t\t${thousandComma(result.payment)}`); + }, +}; + +export default Output; \ No newline at end of file From 6cd2660ca9ba83003d100130ae663cfee6fe10bd Mon Sep 17 00:00:00 2001 From: ParkHyeonkyu <162525426+ParkHyeonkyu@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:55:44 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PromotionTest 테스트 코드 작성 --- __tests__/PromotionTest.js | 139 +++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 __tests__/PromotionTest.js diff --git a/__tests__/PromotionTest.js b/__tests__/PromotionTest.js new file mode 100644 index 0000000..d2cf420 --- /dev/null +++ b/__tests__/PromotionTest.js @@ -0,0 +1,139 @@ +import { DateTimes } from '@woowacourse/mission-utils'; +import Promotion from '../src/models/Promotion.js'; + +jest.mock('@woowacourse/mission-utils'); + +describe('Promotion', () => { + beforeEach(() => { + // readFile 모킹을 직접 Promotion 내부에서 처리 + jest.spyOn(DateTimes, 'now').mockReturnValue('2024-06-15T00:00:00'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('정상적인 프로모션 적용', () => { + test('2+1 프로모션이 정상적으로 적용되어야 한다', async () => { + // given + const items = [ + { + name: '콜라', + buyCount: 3, + maxPromotionCount: 10, + promotionName: '탄산2+1', + }, + ]; + + // when + const promotion = new Promotion(items); + const result = promotion.getBuyAndGetList(); + + // then + expect(result).toEqual([ + { + name: '콜라', + count: 2, + giftCount: 1, + isInPromotion: true, + }, + ]); + }); + }); + + describe('프로모션 기간 검증', () => { + test('프로모션 기간이 지난 상품은 프로모션이 적용되지 않아야 한다', async () => { + // given + jest.spyOn(DateTimes, 'now').mockReturnValue('2024-02-01T00:00:00'); + const items = [ + { + name: '감자칩', + buyCount: 2, + maxPromotionCount: 10, + promotionName: '반짝할인', + }, + ]; + + // when + const promotion = new Promotion(items); + const result = promotion.getBuyAndGetList(); + + // then + expect(result).toEqual([ + { + name: '감자칩', + count: 2, + giftCount: 0, + isInPromotion: false, + }, + ]); + }); + }); + + describe('추가 증정품 확인', () => { + test('증정품을 추가로 받을 수 있는 상품 목록을 반환해야 한다', async () => { + // given + const items = [ + { + name: '콜라', + buyCount: 2, + maxPromotionCount: 10, + promotionName: '탄산2+1', + }, + ]; + + // when + const promotion = new Promotion(items); + const result = promotion.getAvailableMoreGiftList(); + + // then + expect(result).toEqual([ + { + name: '콜라', + canGetMoreCount: 1, + }, + ]); + }); + + test('재고가 부족한 경우 추가 증정품을 받을 수 없다', async () => { + // given + const items = [ + { + name: '콜라', + buyCount: 2, + maxPromotionCount: 2, // 재고가 2개뿐 + promotionName: '탄산2+1', + }, + ]; + + // when + const promotion = new Promotion(items); + const result = promotion.getAvailableMoreGiftList(); + + // then + expect(result).toEqual([]); + }); + }); + + describe('프로모션 관리 기능', () => { + test('누락된 증정품을 추가할 수 있다', async () => { + // given + const items = [ + { + name: '콜라', + buyCount: 2, + maxPromotionCount: 10, + promotionName: '탄산2+1', + }, + ]; + const promotion = new Promotion(items); + + // when + promotion.manageExtraGift('콜라'); + const result = promotion.getBuyAndGetList(); + + // then + expect(result[0].giftCount).toBe(1); + }); + }); +}); \ No newline at end of file