Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 입력 형식에서 잘못된 값을 입력하는 경우
139 changes: 139 additions & 0 deletions __tests__/PromotionTest.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
9 changes: 7 additions & 2 deletions src/App.js
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions src/constants/error.js
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions src/constants/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const PURCHASE = '구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])\n';
export const MEMBERSHIP = '\n멤버십 할인을 받으시겠습니까? (Y/N)\n';
export const MORE_SHOPPING = '감사합니다. 구매하고 싶은 다른 상품이 있나요? (Y/N)\n';
7 changes: 7 additions & 0 deletions src/constants/output.js
Original file line number Diff line number Diff line change
@@ -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금액';
113 changes: 113 additions & 0 deletions src/controllers/Store.js
Original file line number Diff line number Diff line change
@@ -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;
Loading