From bd7b4fc502cd2276b4c46b979de11afba600aef9 Mon Sep 17 00:00:00 2001 From: Jiwon <87058411+raipen@users.noreply.github.com> Date: Wed, 22 Nov 2023 14:11:03 +0900 Subject: [PATCH] Feat: stock change history (#89) * feat: add log at stock, menu detail dto * test: add stock history * feat: add stock history model * feat: implement stock history * feat: implement menu cost history --- .../20231121112401_init/migration.sql | 13 ++ src/DTO/menu.dto.ts | 13 +- src/DTO/stock.dto.ts | 18 ++- src/api/hooks/onError.ts | 2 - src/models/Stock.prisma | 10 ++ src/services/menuService.ts | 132 +++++++++++++++++- src/services/stockService.ts | 28 ++++ test/integration/0. init/getBeforeRegister.ts | 2 + test/integration/1. register/stock.ts | 11 +- .../integration/3. update/checkAfterUpdate.ts | 51 +++++++ test/integration/3. update/updateStock.ts | 20 +++ test/integration/4. delete/deleteStock.ts | 2 + 12 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20231121112401_init/migration.sql diff --git a/prisma/migrations/20231121112401_init/migration.sql b/prisma/migrations/20231121112401_init/migration.sql new file mode 100644 index 0000000..9fe0a6d --- /dev/null +++ b/prisma/migrations/20231121112401_init/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `StockHistory` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `stockId` INTEGER NOT NULL, + `amount` INTEGER NOT NULL, + `price` DECIMAL(65, 30) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `StockHistory` ADD CONSTRAINT `StockHistory_stockId_fkey` FOREIGN KEY (`stockId`) REFERENCES `Stock`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/DTO/menu.dto.ts b/src/DTO/menu.dto.ts index b409db7..32b88e4 100644 --- a/src/DTO/menu.dto.ts +++ b/src/DTO/menu.dto.ts @@ -69,7 +69,7 @@ export const getMenuSchema = { 200: { type: 'object', description: 'success response', - required: ['category', 'categoryId', 'name', 'price', 'option', 'recipe'], + required: ['category', 'categoryId', 'name', 'price', 'option', 'recipe','history'], properties: { category: { type: 'string' }, categoryId: { type: 'number' }, @@ -124,6 +124,17 @@ export const getMenuSchema = { }, }, }, + history: { + type: 'array', + items: { + type: 'object', + required: ['date', 'price'], + properties: { + date: { type: 'string', format: 'date-time' }, + price: { type: 'string' }, + }, + }, + }, }, }, ...errorSchema( diff --git a/src/DTO/stock.dto.ts b/src/DTO/stock.dto.ts index 8f5cc5d..e569fa3 100644 --- a/src/DTO/stock.dto.ts +++ b/src/DTO/stock.dto.ts @@ -159,6 +159,7 @@ export const getStockSchema = { 'currentAmount', 'noticeThreshold', 'updatedAt', + 'history' ], properties: { name: { type: 'string' }, @@ -168,6 +169,18 @@ export const getStockSchema = { currentAmount: { type: 'number', nullable: true }, noticeThreshold: { type: 'number' }, updatedAt: { type: 'string' }, + history: { + type: 'array', + items: { + type: 'object', + required: ['amount', 'date', 'price'], + properties: { + amount: { type: 'number' }, + date: { type: 'string', format: 'date-time' }, + price: { type: 'string' }, + }, + }, + } }, }, ...errorSchema( @@ -460,7 +473,10 @@ export type softDeleteStockInterface = SchemaToInterface< export type getStockListInterface = SchemaToInterface< typeof getStockListSchema > & { Body: { storeId: number; userId: number } }; -export type getStockInterface = SchemaToInterface & { +export type getStockInterface = SchemaToInterface< + typeof getStockSchema, + [{ pattern: { type: 'string'; format: 'date-time' }; output: Date }] +> & { Body: { storeId: number; userId: number }; }; export type createMixedStockInterface = SchemaToInterface< diff --git a/src/api/hooks/onError.ts b/src/api/hooks/onError.ts index 04eaa93..e146a94 100644 --- a/src/api/hooks/onError.ts +++ b/src/api/hooks/onError.ts @@ -38,6 +38,4 @@ export default ( } reply.code(500).send(); - // test 폴더에 있는 hookTest.test.ts 파일에서 test - // test 이름은 human error }; diff --git a/src/models/Stock.prisma b/src/models/Stock.prisma index 4f0d85a..51c06bb 100644 --- a/src/models/Stock.prisma +++ b/src/models/Stock.prisma @@ -5,6 +5,7 @@ model Stock { storeId Int recipes Recipe[] mixings Mixing[] + history StockHistory[] name String amount Int? unit String? @@ -14,6 +15,15 @@ model Stock { deletedAt DateTime? } +model StockHistory { + id Int @id @default(autoincrement()) + stockId Int + stock Stock @relation(fields: [stockId], references: [id]) + amount Int + price Decimal + createdAt DateTime @default(now()) +} + model MixedStock { id Int @id @default(autoincrement()) store Store @relation(fields: [storeId], references: [id]) diff --git a/src/services/menuService.ts b/src/services/menuService.ts index b8b0b4e..ffac65a 100644 --- a/src/services/menuService.ts +++ b/src/services/menuService.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { Prisma,PrismaClient } from '@prisma/client'; import { NotFoundError } from '@errors'; import * as Menu from '@DTO/menu.dto'; import { STATUS, getStockStatus } from '@utils/stockStatus'; @@ -90,6 +90,109 @@ export default { return { categories: result }; }, + async getCostHistory(recipes: Prisma.RecipeGetPayload<{ + include: { + stock: { + include: { + history: true, + }, + }, + mixedStock: { + include: { + mixings: { + include: { + stock: { + include: { + history: true, + } + } + } + } + }, + } + } + }>[]) + :Promise { + const usingStocks = recipes.filter(({ stock }) => stock!==null&&stock!.noticeThreshold>=0); + if(usingStocks.some(({ stock }) => stock!.currentAmount===null || stock!.amount===null || stock!.price===null)) + return []; + + const usingMixedStocks = recipes.filter(({ mixedStock }) => mixedStock!==null); + if(usingMixedStocks.some(({ mixedStock }) => mixedStock!.totalAmount===null)) + return []; + + if(usingMixedStocks.some(({ mixedStock }) => mixedStock!.mixings.some(({ stock }) => stock.currentAmount===null || stock.amount===null || stock.price===null))) + return []; + + const stockHistory = usingStocks + .map(({ stock,coldRegularAmount }) => ({ + id: stock!.id, + history: stock!.history + .map(({ createdAt,amount,price }) => ({ + date: createdAt.toISOString().split('T')[0], + price: coldRegularAmount!*price.toNumber()/amount, + })), + })); + + const stockInMixedStocksHistory = usingMixedStocks + .flatMap(({ mixedStock,coldRegularAmount }) =>{ + if(mixedStock===null) return []; + const totalAmount = mixedStock!.totalAmount!; + return mixedStock!.mixings.map(({ stock,amount }) => ( + { + id: stock.id, + history: stock.history.map(({ createdAt,amount: historyAmount,price }) => ({ + date: createdAt.toISOString().split('T')[0], + price: coldRegularAmount!*amount*price.toNumber()/historyAmount/totalAmount, + })), + } + )); + }); + + const allHistory = stockHistory.concat(stockInMixedStocksHistory).map(({history})=>history) + .map((history) => history.reduce((acc, {date,price})=>{ + const sameDateIndex = acc.findIndex((history)=>history.date===date); + if(sameDateIndex!==-1){ + acc[sameDateIndex].price=price; + return acc; + } + acc.push({date,price}); + return acc; + },[] as {date:string,price:number}[]) + ); + const [initPrice,initDate] = allHistory.reduce((acc, history) => { + const initPrice = history[0].price; + const initDate = history[0].date; + return [acc[0]+initPrice,acc[1].localeCompare(initDate)<0?initDate:acc[1]]; + }, [0,'1900-01-01'] as [number,string]); + const updateHistory = allHistory.reduce((acc, history) => { + for (let i = 1; i < history.length; i++) { + const currentDate = history[i].date; + const currentPrice = history[i].price; + const previousPrice = history[i-1].price; + const priceDifference = currentPrice - previousPrice; + if (acc[currentDate]) { + acc[currentDate] += priceDifference; + } else { + acc[currentDate] = priceDifference; + } + } + return acc; + }, {} as Record); + const sortedHistory = Object.entries(updateHistory).sort(([date1], [date2]) => { + return date1.localeCompare(date2); + }) + const accumulatedHistory = sortedHistory.reduce((acc, [date, price]) => { + const curPrice = acc[acc.length - 1].price; + acc.push({ + date, + price: curPrice + price, + }); + return acc; + }, [{ date: initDate, price: initPrice }]); + return accumulatedHistory.filter(({date}) => date.localeCompare(initDate)>=0).map(({date,price})=>({date,price:price.toFixed(2)})); + }, + async getMenu( { storeId }: Menu.getMenuInterface['Body'], { menuId }: Menu.getMenuInterface['Params'] @@ -97,7 +200,6 @@ export default { const menu = await prisma.menu.findUnique({ where: { id: menuId, - deletedAt: null, }, include: { optionMenu: { @@ -110,8 +212,29 @@ export default { category: true, recipes: { include: { - stock: true, - mixedStock: true, + stock: { + include: { + history: true, + }, + }, + mixedStock: { + include: { + mixings: { + include: { + stock: { + include: { + history: true, + } + } + }, + where: { + stock: { + deletedAt: null, + }, + }, + } + }, + } }, where: { OR: [ @@ -197,6 +320,7 @@ export default { }) ), recipe, + history: await this.getCostHistory(menu.recipes), }; }, async getOptionList({ diff --git a/src/services/stockService.ts b/src/services/stockService.ts index f93a89e..6fb3b8b 100644 --- a/src/services/stockService.ts +++ b/src/services/stockService.ts @@ -29,6 +29,16 @@ export default { }, }); + if(price !== null&& price !== undefined&& amount !== null&& amount !== undefined){ + await prisma.stockHistory.create({ + data: { + stockId: result.id, + amount, + price, + }, + }); + } + return { stockId: result.id, }; @@ -61,6 +71,16 @@ export default { }, }); + if(price !== null&& price !== undefined&& amount !== null&& amount !== undefined){ + await prisma.stockHistory.create({ + data: { + stockId: result.id, + amount, + price, + }, + }); + } + return { stockId: result.id, }; @@ -168,6 +188,9 @@ export default { storeId, deletedAt: null, }, + include: { + history: true, + }, }); if (!result) { throw new NotFoundError('재고가 존재하지 않습니다.', '재고'); @@ -181,6 +204,11 @@ export default { noticeThreshold: result.noticeThreshold, unit: result.unit, updatedAt: result.updatedAt.toISOString(), + history: result.history.map(({ createdAt, amount, price }) => ({ + date: createdAt, + amount, + price: price.toString(), + })), }; }, diff --git a/test/integration/0. init/getBeforeRegister.ts b/test/integration/0. init/getBeforeRegister.ts index eb360fb..b28b13f 100644 --- a/test/integration/0. init/getBeforeRegister.ts +++ b/test/integration/0. init/getBeforeRegister.ts @@ -55,6 +55,7 @@ export default (app: FastifyInstance) => () => { currentAmount: null, noticeThreshold: -1, updatedAt: expect.any(String), + history: [] } ); }); @@ -139,6 +140,7 @@ export default (app: FastifyInstance) => () => { price: "2500", category: '커피', categoryId: testValues.coffeeCategoryId, + history: [], option: [ { optionType: "온도", diff --git a/test/integration/1. register/stock.ts b/test/integration/1. register/stock.ts index 2018346..f022f3c 100644 --- a/test/integration/1. register/stock.ts +++ b/test/integration/1. register/stock.ts @@ -16,7 +16,7 @@ export default (app: FastifyInstance) => () => { currentAmount: 3000, noticeThreshold: 500, amount: 2800, - price: 26900, + price: "26900", }, }); const data = JSON.parse(res.body) as Stock.createStockInterface['Reply']['201']; @@ -65,6 +65,8 @@ export default (app: FastifyInstance) => () => { unit: 'g', currentAmount: 3000, noticeThreshold: 500, + amount: 1000, + price: "2400", }, }); const data = JSON.parse(res.body) as Stock.createStockInterface['Reply']['201']; @@ -106,6 +108,13 @@ export default (app: FastifyInstance) => () => { const data = JSON.parse(res.body) as Stock.getStockInterface['Reply']['200']; expect(res.statusCode).toEqual(200); expect(data).toHaveProperty('name', '자몽'); + expect(data.history).toEqual([ + { + date: expect.any(String), + amount: 2800, + price: "26900", + } + ]); }); test('get stock detail:lemon', async () => { diff --git a/test/integration/3. update/checkAfterUpdate.ts b/test/integration/3. update/checkAfterUpdate.ts index 7eb5937..93d225f 100644 --- a/test/integration/3. update/checkAfterUpdate.ts +++ b/test/integration/3. update/checkAfterUpdate.ts @@ -39,6 +39,57 @@ export default (app: FastifyInstance) => () => { expect(lemon.usingMenuCount).toBe(1); }); + test('check stock detail after update', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/stock/${testValues.lemonId}`, + headers: testValues.storeHeader, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as Stock.getStockInterface['Reply']['200']; + expect(body.name).toBe('레몬즙'); + expect(body.price).toBe('5000'); + expect(body.amount).toBe(1000); + expect(body.unit).toBe('ml'); + expect(body.noticeThreshold).toBe(500); + expect(body.currentAmount).toBe(1000); + expect(body.history).toEqual([ + { + date: expect.any(String), + amount: 1000, + price: "5000", + } + ]); + }); + + test('check stock detail after update', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/stock/${testValues.grapefruitId}`, + headers: testValues.storeHeader, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as Stock.getStockInterface['Reply']['200']; + expect(body.name).toBe('자몽'); + expect(body.price).toBe('30000'); + expect(body.amount).toBe(2800); + expect(body.unit).toBe('g'); + expect(body.noticeThreshold).toBe(500); + expect(body.currentAmount).toBe(1000); + expect(body.history).toEqual([ + { + date: expect.any(String), + amount: 2800, + price: "26900", + }, + { + date: expect.any(String), + amount: 2800, + price: "30000", + } + ]); + }); + test('check mixed stock after update', async () => { const response = await app.inject({ method: 'GET', diff --git a/test/integration/3. update/updateStock.ts b/test/integration/3. update/updateStock.ts index 97e9c20..d5ec9b9 100644 --- a/test/integration/3. update/updateStock.ts +++ b/test/integration/3. update/updateStock.ts @@ -45,6 +45,26 @@ export default (app: FastifyInstance) => () => { expect(body.stockId).toBe(testValues.lemonId); }); + test('update grapefruit stock', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/api/stock`, + headers: testValues.storeHeader, + payload: { + id: testValues.grapefruitId, + name: '자몽', + amount: 2800, + price: "30000", + noticeThreshold: 500, + unit: 'g', + currentAmount: 1000, + } as Stock.updateStockInterface['Body'] + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body) as Stock.updateStockInterface['Reply']['201']; + expect(body.stockId).toBe(testValues.grapefruitId); + }); + test('update sparkling water stock', async () => { const response = await app.inject({ method: 'PUT', diff --git a/test/integration/4. delete/deleteStock.ts b/test/integration/4. delete/deleteStock.ts index e63e415..4a473b7 100644 --- a/test/integration/4. delete/deleteStock.ts +++ b/test/integration/4. delete/deleteStock.ts @@ -146,6 +146,8 @@ export default (app: FastifyInstance) => () => { expect(body2.recipe).toHaveLength(2); expect(body2.recipe[0].id).toBe(testValues.preservedLemonId); expect(body2.recipe[1].id).toBe(testValues.sparklingWaterId); + expect(body2.history).toHaveLength(1); + console.log(body2.history); const response3 = await app.inject({ method: 'GET',