diff --git a/package-lock.json b/package-lock.json index bf608bc..4d0785d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,12 @@ "dependencies": { "@ngrok/ngrok": "^1.5.1", "amqplib": "^0.10.3", - "axios": "^1.4.0", + "axios": "^1.9.0", "bcrypt": "^5.1.1", "compression": "^1.7.4", "cookie-parser": "~1.4.4", "cors": "^2.8.5", + "dayjs": "^1.11.13", "debug": "~2.6.9", "dotenv": "^16.3.1", "ejs": "^3.1.9", @@ -35,8 +36,8 @@ "node-schedule": "^2.1.1", "nodemailer": "^6.10.0", "nodemon": "^3.1.9", - "pg": "^8.11.1", - "xlsx": "^0.18.5" + "papaparse": "^5.5.3", + "pg": "^8.11.1" }, "devDependencies": { "@types/bcrypt": "^5.0.2", @@ -48,6 +49,7 @@ "@types/morgan": "^1.9.9", "@types/multer": "^1.4.12", "@types/nodemailer": "^6.4.17", + "@types/papaparse": "^5.3.16", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.8.3" @@ -575,6 +577,16 @@ "@types/node": "*" } }, + "node_modules/@types/papaparse": { + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz", + "integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -695,15 +707,6 @@ "node": ">=0.4.0" } }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -865,9 +868,9 @@ } }, "node_modules/axios": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz", - "integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1072,19 +1075,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1134,15 +1124,6 @@ "node": ">=10" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1362,18 +1343,6 @@ "node": ">= 0.10" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1393,6 +1362,12 @@ "node": ">=12.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1837,15 +1812,6 @@ "node": ">= 0.6" } }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -3299,6 +3265,12 @@ "wrappy": "1" } }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3939,18 +3911,6 @@ "node": ">= 0.6" } }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -4376,51 +4336,12 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 78c51c2..c6a0df5 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "dependencies": { "@ngrok/ngrok": "^1.5.1", "amqplib": "^0.10.3", - "axios": "^1.4.0", + "axios": "^1.9.0", "bcrypt": "^5.1.1", "compression": "^1.7.4", "cookie-parser": "~1.4.4", "cors": "^2.8.5", + "dayjs": "^1.11.13", "debug": "~2.6.9", "dotenv": "^16.3.1", "ejs": "^3.1.9", @@ -36,8 +37,8 @@ "node-schedule": "^2.1.1", "nodemailer": "^6.10.0", "nodemon": "^3.1.9", - "pg": "^8.11.1", - "xlsx": "^0.18.5" + "papaparse": "^5.5.3", + "pg": "^8.11.1" }, "devDependencies": { "@types/bcrypt": "^5.0.2", @@ -49,6 +50,7 @@ "@types/morgan": "^1.9.9", "@types/multer": "^1.4.12", "@types/nodemailer": "^6.4.17", + "@types/papaparse": "^5.3.16", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.8.3" diff --git a/src/controller/api/dashboardController.ts b/src/controller/api/dashboardController.ts new file mode 100644 index 0000000..5c34cb5 --- /dev/null +++ b/src/controller/api/dashboardController.ts @@ -0,0 +1,93 @@ +import { NextFunction, Request, Response } from "express"; +import { getDashboardData } from "../../services/dashboardServices"; +import { TAPIResponse } from "../../types/core/http"; +import createHttpError from "http-errors"; +import { matchedData, param } from "express-validator"; +import { selectThisYearSalesTrendMonthly, selectThisYearSalesTrendWeekly } from "../../repository/analytics/sale-trend"; +import { selectThisYearRestockTrendMonthly, selectThisYearRestockTrendWeekly } from "../../repository/analytics/purchase-trend"; +import { selectLatestPredictions } from "../../repository/analytics/latest-prediction"; + +const dashboardDataController = [ + async (req: Request, res: Response, next: NextFunction) => { + try { + const totalProduct = await getDashboardData(req.user!.id) + const result: TAPIResponse = { + success: true, + data: totalProduct + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { cause: error })); + } + } +] + +const dashboardTrendController = [ + param('period_type') + .isIn(['weekly', 'monthly']) + .withMessage('period_type must be either "weekly" or "monthly"'), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { period_type } = matchedData(req) + + const sales = await ( + period_type === 'weekly' ? + selectThisYearSalesTrendWeekly() : + selectThisYearSalesTrendMonthly() + ) + + const purchase = await ( + period_type === 'weekly' ? + selectThisYearRestockTrendWeekly() : + selectThisYearRestockTrendMonthly() + ) + + const result: TAPIResponse = { + success: true, + data: { + sales, + purchase + } + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { cause: error })); + } + } +] + +const dashboardLatestPredictionController = [ + param('period_type') + .isIn(['weekly', 'monthly']) + .withMessage('period_type must be either "weekly" or "monthly"'), + param('trx_type') + .isIn(["sales", "purchases"]) + .withMessage('period_type must be either "sales" or "purchases"'), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { + period_type, + trx_type + } = matchedData(req) + const latest = await selectLatestPredictions( + req.user!.id, period_type, trx_type + ) + const result: TAPIResponse = { + success: true, + data: latest + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { cause: error })); + } + } +] + +export default { + dashboardDataController, + dashboardTrendController, + dashboardLatestPredictionController +} \ No newline at end of file diff --git a/src/controller/api/dummyController.ts b/src/controller/api/dummyController.ts new file mode 100644 index 0000000..cfb52dd --- /dev/null +++ b/src/controller/api/dummyController.ts @@ -0,0 +1,132 @@ +import { body, matchedData, param } from "express-validator" +import expressValidatorErrorHandler from "../../middleware/expressValidatorErrorHandler" +import { NextFunction, Request, Response } from "express" +import { insertDummy, selectAllDummyFromProduct, updateDummy } from "../../repository/dummyRepository" +import { TAPIResponse } from "../../types/core/http" +import createHttpError from "http-errors" +import { IDummiesTable } from "../../types/db-model" + +const getSalesDummy = [ + param('period_type') + .isIn(['weekly', 'monthly']) + .withMessage('period_type must be either "weekly" or "monthly"'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { period_type } = matchedData<{ period_type: 'weekly' | 'monthly' }>(req) + const dummySales = await selectAllDummyFromProduct(req.user!.id, period_type, 'sales') + const result: TAPIResponse = { + success: true, + data: dummySales + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { error })); + } + } +] +const getPurchasesDummy = [ + param('period_type') + .isIn(['weekly', 'monthly']) + .withMessage('period_type must be either "weekly" or "monthly"'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { period_type } = matchedData<{ period_type: 'weekly' | 'monthly' }>(req) + const dummyPurchase = await selectAllDummyFromProduct(req.user!.id, period_type, 'purchases') + const result: TAPIResponse = { + success: true, + data: dummyPurchase + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { error })); + } + } +] + +const createDummy = [ + body('product_id') + .isInt().withMessage('product_id must be an integer'), + + body('fake_json') + .custom((val) => { + try { + const parsed = JSON.parse(val); + if (!Array.isArray(parsed)) { + throw new Error('fake_json must be a JSON array'); + } + // Optional: Validasi bahwa semua item adalah number + if (!parsed.every((item) => typeof item === 'number')) { + throw new Error('All items in fake_json must be numbers'); + } + return true; + } catch (e) { + throw new Error('fake_json must be a valid JSON array'); + } + }), + + body('period_type') + .isIn(['weekly', 'monthly']) + .withMessage('period_type must be either "weekly" or "monthly"'), + + body('trx_type') + .isIn(['sales', 'purchases']) + .withMessage('trx_type must be either "sales" or "purchases"'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const reqData = matchedData(req) + const dummy_id = await insertDummy(reqData) + const result: TAPIResponse = { + success: true, + data: { dummy_id } + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { error })); + } + } +] +const updateDummyController = [ + param('id'), + + body('fake_json') + .custom((val) => { + try { + const parsed = JSON.parse(val); + if (!Array.isArray(parsed)) { + throw new Error('fake_json must be a JSON array'); + } + // Optional: Validasi bahwa semua item adalah number + if (!parsed.every((item) => typeof item === 'number')) { + throw new Error('All items in fake_json must be numbers'); + } + return true; + } catch (e) { + throw new Error('fake_json must be a valid JSON array'); + } + }), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = matchedData(req, { locations: ['params'] }) + const reqData = matchedData(req, { locations: ['body'] }) + await updateDummy(id, reqData) + const result: TAPIResponse = { + success: true, + message: 'Dummy data successfully updated', + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { error })); + } + } +] +export default { + getPurchasesDummy, getSalesDummy, createDummy, updateDummyController +} \ No newline at end of file diff --git a/src/controller/api/predictionController.ts b/src/controller/api/predictionController.ts new file mode 100644 index 0000000..46e36e3 --- /dev/null +++ b/src/controller/api/predictionController.ts @@ -0,0 +1,216 @@ +import { NextFunction, Request, Response } from "express"; +import { TAPIResponse } from "../../types/core/http"; +import createHttpError from "http-errors"; +import { selectAllProductNPrediction } from "../../repository/predictionRepository"; +import { body, matchedData, param } from "express-validator"; +import { makeAutoPrediction } from "../../services/predictionServices"; +import expressValidatorErrorHandler from "../../middleware/expressValidatorErrorHandler"; +import { IPredictionTable } from "../../types/db-model"; +import { preparePredictionData } from "../../services/prediction/prepareData"; +import { getPredictionModel } from "../../services/prediction/getPredictionModel"; +import { shouldUseAutoModel } from "../../services/prediction/shouldUseAutoModel"; +import { buildAutoPrediction } from "../../services/prediction/buildAutoPrediction"; +import { savePredictionModel } from "../../services/prediction/savePredictionModel"; +import { buildManualPrediction } from "../../services/prediction/buildManualPrediction"; +import { persistPrediction } from "../../services/prediction/persistPrediction"; +import { isErrorInstanceOfHttpError } from "../../utils/core/httpError"; + +const periodArray = ['daily', 'weekly', 'monthly'] + +const filePrediction = [ + body('csv_string') + .notEmpty().withMessage('csv_string tidak boleh kosong.') + .isString().withMessage('csv_string harus berupa string.'), + + body('record_period') + .notEmpty().withMessage('record_period tidak boleh kosong.') + .isIn(['daily', 'weekly', 'monthly']).withMessage('record_period harus bernilai "weekly" atau "monthly".'), + + body('prediction_period') + .notEmpty().withMessage('prediction_period tidak boleh kosong.') + .isIn(['weekly', 'monthly']).withMessage('prediction_period harus bernilai "weekly" atau "monthly".'), + + body('value_column') + .optional() + .isString().withMessage('value_column harus berupa string.'), + + body('date_column') + .optional() + .isString().withMessage('date_column harus berupa string.'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { + csv_string, + record_period, + prediction_period, + value_column, + date_column + } = matchedData(req) + const rp_index = periodArray.findIndex(v => v === record_period) + const pp_index = periodArray.findIndex(v => v === prediction_period) + if (pp_index < 0 || rp_index < 0) { + next(createHttpError(400, 'record_period atau prediction_period tidak valid.')); + return; + } + + if (pp_index < rp_index) { + next(createHttpError(400, 'prediction_period tidak boleh memiliki resolusi lebih kecil dari record_period.')); + return; + } + + const pythonResponse = await makeAutoPrediction({ + csv_string, + date_column, + value_column, + prediction_period, + date_regroup: record_period !== prediction_period, + }) + + if (!pythonResponse?.mape || pythonResponse.mape > 50) { + throw createHttpError(422, `Prediksi tidak layak karena MAPE terlalu tinggi (${pythonResponse?.mape.toFixed(2) || NaN}%). Harap periksa data input.`) + } + + if (pythonResponse) { + const result: TAPIResponse = { + success: true, + message: 'Prediksi berhasil dilakukan.', + data: pythonResponse, + }; + res.json(result); + return + } + + res.status(500).json({ + success: false, + message: 'Gagal melakukan prediksi. Silakan coba lagi atau cek data input.', + }); + } catch (error) { + next(createHttpError(500, "Internal Server Error", { cause: error })); + } + } +] + +const getSavedPurchasePredictions = [ + param('period_type') + .isIn(['weekly', 'monthly']) + .withMessage('period_type must be either "weekly" or "monthly"'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { period_type } = matchedData<{ period_type: 'weekly' | 'monthly' }>(req) + const productNPrediction = await selectAllProductNPrediction(req.user!.id, period_type, 'purchases') + const result: TAPIResponse = { + success: true, + data: productNPrediction + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { error })); + } + } +] + +const getSavedSalePredictions = [ + param('period_type') + .isIn(['weekly', 'monthly']) + .withMessage('period_type must be either "weekly" or "monthly"'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { period_type } = matchedData<{ period_type: 'weekly' | 'monthly' }>(req) + const productNPrediction = await selectAllProductNPrediction(req.user!.id, period_type, 'sales') + const result: TAPIResponse = { + success: true, + data: productNPrediction + } + res.json(result) + } catch (error) { + console.log(error) + next(createHttpError(500, "Internal Server Error", { error })); + } + } +] + +const purchasePrediction = [ + param('product_id').exists().withMessage('product_id harus disediakan.') + .isInt({ gt: 0 }).withMessage('product_id harus berupa angka bulat positif.') + .toInt(), + + body('prediction_period') + .notEmpty().withMessage('prediction_period tidak boleh kosong.') + .isIn(['weekly', 'monthly']).withMessage('prediction_period harus bernilai "weekly" atau "monthly".'), + + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const source: IPredictionTable['prediction_source'] = 'purchases' + const { product_id, prediction_period } = matchedData(req) + const csv_string = await preparePredictionData(product_id, prediction_period, source) + const { model, isExpired } = await getPredictionModel(product_id, prediction_period, source) + + let predictionResult + if (shouldUseAutoModel(model, isExpired)) { + predictionResult = await buildAutoPrediction(csv_string, prediction_period) + await savePredictionModel(model, predictionResult, product_id, prediction_period, source, req.user!.id) + } else { + const arimaModel: [number, number, number] = [model!.ar_p, model!.differencing_d, model!.ma_q] + predictionResult = await buildManualPrediction(csv_string, prediction_period, arimaModel) + } + + const finalResult = await persistPrediction(predictionResult, prediction_period, source, product_id, req.user!.id) + + res.status(200).json({ success: true, data: finalResult }) + } catch (err) { + if (isErrorInstanceOfHttpError(err)) + return next(err) + return next(createHttpError(500, 'Internal Server Error', { cause: err })) + } + } +] + +const salesPrediction = [ + param('product_id').exists().withMessage('product_id harus disediakan.') + .isInt({ gt: 0 }).withMessage('product_id harus berupa angka bulat positif.') + .toInt(), + + body('prediction_period') + .notEmpty().withMessage('prediction_period tidak boleh kosong.') + .isIn(['weekly', 'monthly']).withMessage('prediction_period harus bernilai "weekly" atau "monthly".'), + + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const source: IPredictionTable['prediction_source'] = 'sales' + const { product_id, prediction_period } = matchedData(req) + const csv_string = await preparePredictionData(product_id, prediction_period, source) + const { model, isExpired } = await getPredictionModel(product_id, prediction_period, source) + + let predictionResult + if (shouldUseAutoModel(model, isExpired)) { + predictionResult = await buildAutoPrediction(csv_string, prediction_period) + await savePredictionModel(model, predictionResult, product_id, prediction_period, source, req.user!.id) + } else { + const arimaModel: [number, number, number] = [model!.ar_p, model!.differencing_d, model!.ma_q] + predictionResult = await buildManualPrediction(csv_string, prediction_period, arimaModel) + } + + const finalResult = await persistPrediction(predictionResult, prediction_period, source, product_id, req.user!.id) + + res.status(200).json({ success: true, data: finalResult }) + } catch (err) { + if (isErrorInstanceOfHttpError(err)) + return next(err) + return next(createHttpError(500, 'Internal Server Error', { cause: err })) + } + } +] + +export default { + filePrediction, + getSavedPurchasePredictions, + getSavedSalePredictions, + salesPrediction, + purchasePrediction +} diff --git a/src/controller/api/productCategoriesController.ts b/src/controller/api/productCategoriesController.ts index 43b0238..e8da5e8 100644 --- a/src/controller/api/productCategoriesController.ts +++ b/src/controller/api/productCategoriesController.ts @@ -65,34 +65,6 @@ const deleteProductCategoryRoute = [ } ] -const updateProductCategory = [ - param("id").notEmpty(), - body("category_name").notEmpty(), - expressValidatorErrorHandler, - async (req: Request, res: Response, next: NextFunction) => { - const reqParam = matchedData(req, { locations: ['params'] }) - const reqBody = matchedData(req, { locations: ['body'] }) - try { - const newProductId = await updateProductCategoryById(reqParam.id, { - ...reqBody, - category_name: reqBody.category_name.toLowerCase() - }) - const result: TAPIResponse = { - success: true, - message: 'Product category successfully updated.', - data: { - productCategoryId: newProductId - } - } - res.json(result) - } catch (error: unknown) { - next(createHttpError(500, error as Error)) - } - - } -] - - export default { addProductCategory, getProductCategories, deleteProductCategoryRoute } \ No newline at end of file diff --git a/src/controller/api/productsController.ts b/src/controller/api/productsController.ts index 8868c30..0c28c71 100644 --- a/src/controller/api/productsController.ts +++ b/src/controller/api/productsController.ts @@ -5,7 +5,8 @@ import { TAPIResponse } from "../../types/core/http" import { IProductTable, ISupplierTable } from "../../types/db-model" import createHttpError from "http-errors" import parsePhoneNumberFromString from "libphonenumber-js" -import { createProduct, deleteProductById, getProductByProductCode, getProducts, showProductById, updateProductById } from "../../services/productServices" +import { createProduct, deleteProductById, getLowStockProducts, getProductByProductCode, getProducts, showProductById, updateProductById } from "../../services/productServices" +import { selectShortDataAllProduct } from "../../repository/productsRepository" const addProduct = [ body('product_code').notEmpty().isString(), @@ -163,4 +164,39 @@ const getProductByProductCodeRoute = [ } ] -export default { addProduct, showAllProducts, getProductDetail, updateProductRoute, deleteProductRoute, getProductByProductCodeRoute } \ No newline at end of file +const getLowStockProduct = [ + async (req: Request, res: Response, next: NextFunction) => { + try { + const product = await getLowStockProducts(req.user!.id) + const result: TAPIResponse = { + success: true, + data: product + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const getAllProduct = [ + async (req: Request, res: Response, next: NextFunction) => { + try { + const product = await selectShortDataAllProduct(req.user!.id) + const result: TAPIResponse = { + success: true, + data: product + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +export default { + addProduct, showAllProducts, + getProductDetail, updateProductRoute, + deleteProductRoute, getProductByProductCodeRoute, + getLowStockProduct, getAllProduct +} \ No newline at end of file diff --git a/src/controller/api/stockForecasting.ts b/src/controller/api/stockForecasting.ts deleted file mode 100644 index 917859a..0000000 --- a/src/controller/api/stockForecasting.ts +++ /dev/null @@ -1,84 +0,0 @@ -import express, { NextFunction, Request, Response } from "express"; -import { body, param, query, matchedData } from "express-validator"; -import createHttpError from "http-errors"; -import validate from "../../middleware/expressValidatorErrorHandler"; -import { fileValidate, multiFileValidate } from "../../middleware/fileUploader"; -import axios from "axios"; - -// 🟢 **POST: Tambah Prediksi Stok Baru** -// const createStockForecasting = [ -// body("stock_sold").notEmpty().isNumeric().withMessage("Stock sold must be a number"), -// body("stock_predicted").notEmpty().isNumeric().withMessage("Stock predicted must be a number"), -// body("type").notEmpty().isString().withMessage("Type is required"), -// body("accuracy").optional().isNumeric().withMessage("Accuracy must be a number"), -// body("user_id").notEmpty().isMongoId().withMessage("Invalid user ID"), -// body("product_id").notEmpty().isMongoId().withMessage("Invalid product ID"), -// validate, -// async (req: Request, res: Response, next: NextFunction) => { -// try { -// const data = matchedData(req); - -// // Cek apakah produk ada -// const productExists = await products.findById(data.product_id); -// if (!productExists) return next(createHttpError(404, "Product not found")); - -// // Simpan data prediksi -// const prediction = new predictions(data); -// await prediction.save(); -// res.status(201).json({ message: "Stock forecasting created", prediction }); -// } catch (err) { -// next(createHttpError(500, "Internal Server Error", { cause: err })); -// } -// } -// ]; - -// // 🔵 **GET: Ambil Prediksi Berdasarkan Tanggal** -// const getStockForecastingByDate = [ -// query("date").notEmpty().isISO8601().withMessage("Invalid date format"), -// validate, -// async (req: Request, res: Response, next: NextFunction) => { -// try { -// const { date } = matchedData(req); -// const startDate = new Date(date); -// const endDate = new Date(date); -// endDate.setDate(endDate.getDate() + 1); - -// const forecasts = await predictions.find({ -// createdAt: { $gte: startDate, $lt: endDate } -// }).populate("product_id user_id"); - -// if (forecasts.length === 0) return next(createHttpError(404, "No stock forecasting found for this date")); - -// res.json(forecasts); -// } catch (err) { -// next(createHttpError(500, "Internal Server Error", { cause: err })); -// } -// } -// ]; - -// // 🟠 **GET: Ambil Semua Prediksi (History)** -// const getStockForecastings = async (req: Request, res: Response, next: NextFunction) => { -// try { -// const forecasts = await predictions.find().populate("product_id user_id"); -// res.json(forecasts); -// } catch (err) { -// next(createHttpError(500, "Internal Server Error", { cause: err })); -// } -// }; - -const makePredictionFromSheet = [ - fileValidate('sheet'), - async (req: Request, res: Response, next: NextFunction) => { - res.json({ - status: true, - message: 'File Saved' - }) - } -] - -export default { - // createStockForecasting, - // getStockForecastingByDate, - // getStockForecastings - makePredictionFromSheet -}; diff --git a/src/controller/api/userFiles.ts b/src/controller/api/userFiles.ts deleted file mode 100644 index 7fd7c9a..0000000 --- a/src/controller/api/userFiles.ts +++ /dev/null @@ -1,103 +0,0 @@ -// controllers/userFilesController.ts - -import { body, param, matchedData } from "express-validator"; -import createHttpError from "http-errors"; -import validate from "../../middleware/expressValidatorErrorHandler"; // Validation middleware -import * as userFilesRepository from "../../repository/user-files"; // Repository untuk User Files -import { NextFunction, Request, Response } from "express"; -import { IUserFile } from "../../types/db-model"; - -// 🟢 **POST: Tambah User File Baru** -const createUserFile = [ - body("file_name").notEmpty().withMessage("File name is required"), - body("saved_file_name").notEmpty().withMessage("Saved file name is required"), - body("is_converted").optional().isBoolean().withMessage("is_converted must be a boolean"), - body("user_id").notEmpty().isInt().withMessage("Invalid user ID"), // ganti isMongoId() ke isInt() - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const data = matchedData(req); - const userFile = await userFilesRepository.createUserFile(data); - - res.status(201).json({ message: "User file created", userFile }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🟠 **PATCH: Perbarui File User** -const updateUserFile = [ - param("id").isInt().withMessage("Invalid file ID"), // ganti isMongoId() ke isInt() - body("file_name").optional().isString(), - body("saved_file_name").optional().isString(), - body("is_converted").optional().isBoolean(), - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const data = matchedData(req); - - const userFile = await userFilesRepository.updateUserFileById(Number(id), data); - if (!userFile) return next(createHttpError(404, "User file not found")); - - res.json({ message: "User file updated", userFile }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🔴 **DELETE: Hapus File User** -const deleteUserFile = [ - param("id").isInt().withMessage("Invalid file ID"), // ganti isMongoId() ke isInt() - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - - const userFile = await userFilesRepository.deleteUserFileById(Number(id)); - if (!userFile) return next(createHttpError(404, "User file not found")); - - res.json({ message: "User file deleted" }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🔵 **GET: Ambil Satu File User Berdasarkan ID** -const getUserFile = [ - param("id").isInt().withMessage("Invalid file ID"), // ganti isMongoId() ke isInt() - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const userFile = await userFilesRepository.getUserFileById(Number(id)); - - if (!userFile) return next(createHttpError(404, "User file not found")); - - res.json(userFile); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🟡 **GET: Ambil Semua File User** -const getUserFiles = async (req: Request, res: Response, next: NextFunction) => { - try { - const allUserFiles = await userFilesRepository.getAllUserFiles(); - res.json(allUserFiles); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } -}; - -export default { - createUserFile, - updateUserFile, - deleteUserFile, - getUserFile, - getUserFiles -}; \ No newline at end of file diff --git a/src/repository/analytics/latest-prediction.ts b/src/repository/analytics/latest-prediction.ts new file mode 100644 index 0000000..0cca54a --- /dev/null +++ b/src/repository/analytics/latest-prediction.ts @@ -0,0 +1,26 @@ +import { db } from "../../database/MySQL" +import { IPredictionTable } from "../../types/db-model" + +export function selectLatestPredictions( + user_id: IPredictionTable['user_id'], + periodType: IPredictionTable['period_type'], + predictionSource: IPredictionTable['prediction_source'] +) { + return db('predictions as p') + .join('products as pr', 'p.product_id', 'pr.id') + .join('product_categories as pc', 'pr.product_category_id', 'pc.id') + .select( + 'pr.product_name', + 'p.mape', + 'p.prediction', + 'pc.category_name', + ) + .where({ + 'p.prediction_source': predictionSource, + 'p.period_type': periodType, + 'p.user_id': user_id + }) + .andWhere('p.expired', '>', db.fn.now()) + .orderBy('p.expired', 'desc') + .limit(4); +} \ No newline at end of file diff --git a/src/repository/analytics/purchase-trend.ts b/src/repository/analytics/purchase-trend.ts new file mode 100644 index 0000000..776da10 --- /dev/null +++ b/src/repository/analytics/purchase-trend.ts @@ -0,0 +1,25 @@ +import { db } from "../../database/MySQL"; + +export function selectThisYearRestockTrendMonthly() { + return db('restocks') + .select( + db.raw('YEAR(buying_date) AS year'), + db.raw('MONTH(buying_date) AS month'), + db.raw('SUM(amount) AS amount') + ) + .whereRaw('YEAR(buying_date) = YEAR(CURDATE())') + .groupByRaw('YEAR(buying_date), MONTH(buying_date)') + .orderByRaw('YEAR(buying_date), MONTH(buying_date)') +} + +export function selectThisYearRestockTrendWeekly() { + return db('restocks') + .select( + db.raw('YEAR(buying_date) AS year'), + db.raw('WEEK(buying_date, 1) AS week'), + db.raw('SUM(amount) AS amount') + ) + .whereRaw('YEAR(buying_date) = YEAR(CURDATE())') + .groupByRaw('YEAR(buying_date), WEEK(buying_date, 1)') + .orderByRaw('YEAR(buying_date), WEEK(buying_date, 1)') +} diff --git a/src/repository/analytics/sale-trend.ts b/src/repository/analytics/sale-trend.ts new file mode 100644 index 0000000..644ee1e --- /dev/null +++ b/src/repository/analytics/sale-trend.ts @@ -0,0 +1,25 @@ +import { db } from "../../database/MySQL"; + +export function selectThisYearSalesTrendMonthly() { + return db('transactions') + .select( + db.raw("YEAR(transaction_date) AS year"), + db.raw("MONTH(transaction_date) AS month"), + db.raw("SUM(amount) AS amount") + ) + .whereRaw("YEAR(transaction_date) = YEAR(CURDATE())") // filter tahun ini + .groupByRaw("YEAR(transaction_date), MONTH(transaction_date)") + .orderByRaw("YEAR(transaction_date), MONTH(transaction_date)") +} + +export function selectThisYearSalesTrendWeekly() { + return db('transactions') + .select( + db.raw("YEAR(transaction_date) AS year"), + db.raw("WEEK(transaction_date, 1) AS week"), // mode 1: ISO week (Senin awal minggu) + db.raw("SUM(amount) AS amount") + ) + .whereRaw("YEAR(transaction_date) = YEAR(CURDATE())") + .groupByRaw("YEAR(transaction_date), WEEK(transaction_date, 1)") + .orderByRaw("YEAR(transaction_date), WEEK(transaction_date, 1)") +} diff --git a/src/repository/dummyRepository.ts b/src/repository/dummyRepository.ts new file mode 100644 index 0000000..fe9a22d --- /dev/null +++ b/src/repository/dummyRepository.ts @@ -0,0 +1,38 @@ +import { db } from "../database/MySQL"; +import { IDummiesTable, IProductTable } from "../types/db-model"; + +export function selectAllDummyFromProduct( + user_id: IProductTable['user_id'], + period_type: IDummiesTable['period_type'], + trx_type: IDummiesTable['trx_type'] +) { + return db('products') + .leftJoin('dummies', function () { + this.on('dummies.product_id', '=', 'products.id') + .andOn(db.raw('dummies.period_type = ?', [period_type])) + .andOn(db.raw('dummies.trx_type = ?', [trx_type])) + }) + .where('products.user_id', user_id) + .andWhere('products.deleted', false) + .select( + 'products.id as product_id', + 'products.product_name', + 'dummies.id as dummy_id', + 'dummies.fake_json' + ) +} + +export async function insertDummy(data: IDummiesTable) { + const [id] = await db('dummies').insert(data) + return id +} + +export function updateDummy(id: IDummiesTable['id'], data: IDummiesTable) { + return db('dummies').where({ id }).update(data) +} + +export function selectDummy(product_id: IDummiesTable['product_id'], period_type: IDummiesTable['period_type'], trx_type: IDummiesTable['trx_type']) { + return db('dummies') + .where({ product_id, period_type, trx_type }) + .first() +} \ No newline at end of file diff --git a/src/repository/predictionModelRepository.ts b/src/repository/predictionModelRepository.ts new file mode 100644 index 0000000..8239b11 --- /dev/null +++ b/src/repository/predictionModelRepository.ts @@ -0,0 +1,26 @@ +import { db } from "../database/MySQL"; +import { IPredictionModelTable } from "../types/db-model"; + +export function selectPredictionModel( + product_id: IPredictionModelTable['product_id'], + period_type: IPredictionModelTable['period_type'], + prediction_source: IPredictionModelTable['prediction_source'] +) { + return db('prediction_models') + .where({ product_id, period_type, prediction_source }) + .first() +} + +export async function insertPredictionModel( + data: Partial +) { + const [id] = await db('prediction_models').insert(data) + return id +} + +export async function updatePredictionModel( + id: IPredictionModelTable['id'], + data: Partial +) { + return await db('prediction_models').where({ id }).update(data) +} \ No newline at end of file diff --git a/src/repository/predictionRepository.ts b/src/repository/predictionRepository.ts new file mode 100644 index 0000000..6c3bca0 --- /dev/null +++ b/src/repository/predictionRepository.ts @@ -0,0 +1,89 @@ +import { db } from "../database/MySQL"; +import { IPredictionTable, IProductTable, ITransactionTable } from "../types/db-model"; +import { TGroupedProductSales } from "../types/db/product-sales"; +import { selectProductsByUserId } from "./productsRepository"; + +export async function selectWeeklyProductSales( + product_id: ITransactionTable['product_id'] +) { + return db.raw(` + SELECT + DATE_SUB(DATE(t.transaction_date), INTERVAL WEEKDAY(t.transaction_date) DAY) AS group_date, + COUNT(*) AS total_transactions + FROM transactions t + WHERE t.product_id = ? + GROUP BY group_date + ORDER BY group_date ASC + `, [product_id]); +} + +export async function selectMonthlyProductSales( + product_id: ITransactionTable['product_id'] +) { + return db.raw(` + SELECT + DATE_FORMAT(t.created_at, '%Y-%m') AS group_date, + p.product_name, + p.product_code, + t.product_id, + COUNT(*) AS total_transactions + FROM transactions t + LEFT JOIN products p ON t.product_id = p.id + WHERE t.product_id = ? + GROUP BY group_date, p.product_name, p.product_code, t.product_id + ORDER BY group_date + `, [product_id]) +} + +export async function selectAllProductNPrediction( + user_id: IProductTable['user_id'], + period_type: IPredictionTable['period_type'], + prediction_source: IPredictionTable['prediction_source'] +) { + return db('products') + .leftJoin('predictions', function () { + this.on('predictions.product_id', '=', 'products.id') + .andOn(db.raw('predictions.period_type = ?', [period_type])) + .andOn(db.raw('predictions.prediction_source = ?', [prediction_source])) + .andOn(db.raw('predictions.expired >= ?', [new Date().toISOString()])); + }) + // .leftJoin('dummies', function () { + // this.on('dummies.product_id', '=', 'products.id') + // .andOn(db.raw('dummies.period_type = ?', [period_type])) + // .andOn(db.raw('dummies.trx_type = ?', [prediction_source])) + // }) + .where('products.user_id', user_id) + .andWhere('products.deleted', false) + .select( + 'products.id', + 'products.product_name', + 'products.buying_price', + 'products.stock', + 'products.low_stock_limit', + 'predictions.prediction', + 'predictions.lower_bound', + 'predictions.upper_bound', + 'predictions.rmse', + 'predictions.mape', + // 'dummies.fake_json', + ) +} + +export function selectPrediction( + product_id: IPredictionTable['product_id'], + period_type: IPredictionTable['period_type'], + prediction_source: IPredictionTable['prediction_source'] +) { + return db('predictions') + .where({ product_id, period_type, prediction_source }) + .first() +} + +export async function insertPrediction(data: Partial) { + const [id] = await db('predictions').insert(data) + return id +} + +export function updatePrediction(id: IPredictionTable['id'], data: Partial) { + return db('predictions').where({ id }).update(data) +} \ No newline at end of file diff --git a/src/repository/productsRepository.ts b/src/repository/productsRepository.ts index fb8336b..f936560 100644 --- a/src/repository/productsRepository.ts +++ b/src/repository/productsRepository.ts @@ -7,7 +7,7 @@ export const insertProduct = async (data: IProductTable) => { } export const selectProductsByUserId = (user_id: IProductTable['user_id']) => { - return db('products').where({ user_id }) + return db('products').where('products.user_id', user_id) } export const selectProductById = (id: IProductTable['id']) => { @@ -61,4 +61,34 @@ export const selectProductByProductCodes = (product_codes: IProductTable['produc return db('products').where({ user_id, }).whereIn('product_code', product_codes) +} + +export function selectLowStockProducts(user_id: IProductTable['user_id']) { + return db('products as p') + .leftJoin('product_categories as pc', 'pc.id', 'p.product_category_id') + .select('p.id', 'p.product_name', 'p.stock', 'p.low_stock_limit', 'p.buying_price', 'pc.category_name') + .whereRaw('p.stock < p.low_stock_limit') + .andWhere('p.deleted', 0) + .andWhere('p.user_id', user_id) +} + +export function selectLowStockProductCount(user_id: IProductTable['user_id']) { + return db('products') + .where({ user_id: user_id, deleted: false }) + .whereRaw('stock < low_stock_limit') + .count('id as total') + .first<{ + total: number + }>(); +} + +export function selectShortDataAllProduct( + user_id: IProductTable['user_id'] +) { + return db('products') + .where({ user_id, deleted: false }) + .select( + 'product_code', + 'product_name' + ) } \ No newline at end of file diff --git a/src/repository/restockRepository.ts b/src/repository/restockRepository.ts index a6b45ec..4893b85 100644 --- a/src/repository/restockRepository.ts +++ b/src/repository/restockRepository.ts @@ -1,5 +1,6 @@ import { db } from "../database/MySQL"; import { IPurchaseTable } from "../types/db-model"; +import { getStartOfMonth, getStartOfWeek } from "../utils/core/date"; export function insertsDataToRestock(data: Partial[]) { return db('restocks').insert(data) @@ -26,4 +27,50 @@ export const countRestocks = async (id_user: number) => { }>(); return parseInt(result?.count || '0'); -}; \ No newline at end of file +}; + +export const selectMonthlyRestockCount = (user_id: number) => { + return db('restocks') + .whereRaw('MONTH(buying_date) = MONTH(CURRENT_DATE()) AND YEAR(buying_date) = YEAR(CURRENT_DATE())') + .andWhere('user_id', user_id) + .sum('total_price as total') + .first<{ + total: number + }>(); +} + +export const selectLastMonthRestockCount = (user_id: number) => { + return db('restocks') + .whereRaw('MONTH(buying_date) = MONTH(CURRENT_DATE() - INTERVAL 1 MONTH) AND YEAR(buying_date) = YEAR(CURRENT_DATE() - INTERVAL 1 MONTH)') + .andWhere('user_id', user_id) + .sum('total_price as total') + .first<{ + total: number + }>(); +} + +export function selectGroupedWeeklyPurchase(productId: IPurchaseTable['product_id']) { + return db('restocks') + .select( + db.raw("YEAR(buying_date) as year"), + db.raw("WEEK(buying_date, 1) as week"), + db.raw("SUM(amount) as amount") + ) + .where('product_id', productId) + .andWhere('buying_date', '<', getStartOfWeek(new Date()).toISOString()) + .groupBy(['year', 'week']) + .orderBy(['year', 'week']) +} + +export function selectGroupedMonthlyPurchase(productId: IPurchaseTable['product_id']) { + return db('restocks') + .select( + db.raw("YEAR(buying_date) as year"), + db.raw("MONTH(buying_date) as month"), + db.raw("SUM(amount) as amount") + ) + .where('product_id', productId) + .andWhere('buying_date', '<', getStartOfMonth(new Date()).toISOString()) + .groupBy(['year', 'month']) + .orderBy(['year', 'month']) +} \ No newline at end of file diff --git a/src/repository/transaction.ts b/src/repository/transaction.ts deleted file mode 100644 index d5a20f5..0000000 --- a/src/repository/transaction.ts +++ /dev/null @@ -1,55 +0,0 @@ -// repository/transactionsRepository.ts - -import { db } from '../database/MySQL'; -import { ITransaction } from '../types/db-model'; - -export const createTransaction = async (data: ITransaction) => { - try { - const [transaction] = await db('transactions').insert(data).returning('*'); - return transaction; - } catch (err) { - throw new Error('Error creating transaction'); - } -}; - -export const getTransactionById = async (id: number) => { - try { - const transaction = await db('transactions') - .where({ id }) - .join('products', 'transactions.product_id', '=', 'products.id') - .join('users', 'transactions.user_id', '=', 'users.id') - .select('transactions.*', 'products.product_name', 'users.username'); - return transaction[0]; - } catch (err) { - throw new Error('Error fetching transaction'); - } -}; - -export const updateTransactionById = async (id: number, data: ITransaction) => { - try { - const [updatedTransaction] = await db('transactions').where({ id }).update(data).returning('*'); - return updatedTransaction; - } catch (err) { - throw new Error('Error updating transaction'); - } -}; - -export const deleteTransactionById = async (id: number) => { - try { - const transaction = await db('transactions').where({ id }).del(); - return transaction; - } catch (err) { - throw new Error('Error deleting transaction'); - } -}; - -export const getAllTransactions = async () => { - try { - return await db('transactions') - .join('products', 'transactions.product_id', '=', 'products.id') - .join('users', 'transactions.user_id', '=', 'users.id') - .select('transactions.*', 'products.product_name', 'users.username'); - } catch (err) { - throw new Error('Error fetching all transactions'); - } -}; diff --git a/src/repository/transactionRepository.ts b/src/repository/transactionRepository.ts index e567a22..8d4eb15 100644 --- a/src/repository/transactionRepository.ts +++ b/src/repository/transactionRepository.ts @@ -1,5 +1,6 @@ import { db } from "../database/MySQL"; import { ITransactionTable } from "../types/db-model"; +import { getStartOfMonth, getStartOfWeek } from "../utils/core/date"; export function insertsDataToTransaction(data: Partial[]) { return db('transactions').insert(data) @@ -26,4 +27,52 @@ export const countSales = async (id_user: number) => { }>(); return parseInt(result?.count || '0'); -}; \ No newline at end of file +}; + +export const selectLastMonthSales = async (user_id: number) => { + return db('transactions') + .whereRaw('MONTH(transaction_date) = MONTH(CURRENT_DATE() - INTERVAL 1 MONTH) AND YEAR(transaction_date) = YEAR(CURRENT_DATE() - INTERVAL 1 MONTH)') + .andWhere('user_id', user_id) + .sum('total_price as total') + .first<{ + total: number + }>(); +} + +export const selectCurrentMonthSales = async (user_id: number) => { + return db('transactions') + .whereRaw('MONTH(transaction_date) = MONTH(CURRENT_DATE()) AND YEAR(transaction_date) = YEAR(CURRENT_DATE())') + .andWhere('user_id', user_id) + .sum('total_price as total') + .first<{ + total: number + }>(); +} + +export function selectGroupedWeeklySales(productId: ITransactionTable['product_id']) { + return db('transactions') + .select( + db.raw("YEAR(transaction_date) as year"), + db.raw("WEEK(transaction_date, 1) as week"), + db.raw("SUM(amount) as amount") + ) + .where('product_id', productId) + .andWhere('transaction_date', '<', getStartOfWeek(new Date()).toISOString()) + + .groupBy(['year', 'week']) + .orderBy(['year', 'week']) +} + +export function selectGroupedMonthlySales(productId: ITransactionTable['product_id']) { + return db('transactions') + .select( + db.raw("YEAR(transaction_date) as year"), + db.raw("MONTH(transaction_date) as month"), + db.raw("SUM(amount) as amount") + ) + .where('product_id', productId) + .andWhere('transaction_date', '<', getStartOfMonth(new Date()).toISOString()) + + .groupBy(['year', 'month']) + .orderBy(['year', 'month']) +} \ No newline at end of file diff --git a/src/routes/api/auth.ts b/src/routes/api/auth.ts index 925f753..c07f9bd 100644 --- a/src/routes/api/auth.ts +++ b/src/routes/api/auth.ts @@ -1,7 +1,6 @@ import express from 'express' var router = express.Router(); import authRoute from '../../controller/api/authController'; -import authenticate from '../../middleware/authMiddleware'; router.post('/register', authRoute.register); router.post('/login', authRoute.login); diff --git a/src/routes/api/dashboard.ts b/src/routes/api/dashboard.ts new file mode 100644 index 0000000..dd6d14d --- /dev/null +++ b/src/routes/api/dashboard.ts @@ -0,0 +1,10 @@ +import express from 'express' +var router = express.Router(); +import dashboardController from '../../controller/api/dashboardController' +import authenticate from '../../middleware/authMiddleware'; + +router.get('/dashboard', authenticate, dashboardController.dashboardDataController) +router.get('/dashboard/trend/:period_type', authenticate, dashboardController.dashboardTrendController) +router.get('/dashboard/latest/:trx_type/:period_type', authenticate, dashboardController.dashboardLatestPredictionController) + +export default router \ No newline at end of file diff --git a/src/routes/api/dummy.ts b/src/routes/api/dummy.ts new file mode 100644 index 0000000..6bbe88a --- /dev/null +++ b/src/routes/api/dummy.ts @@ -0,0 +1,11 @@ +import express from 'express' +var router = express.Router(); +import dummyController from '../../controller/api/dummyController' +import authenticate from '../../middleware/authMiddleware'; + +router.get('/dummies/purchases/:period_type', authenticate, dummyController.getPurchasesDummy) +router.get('/dummies/sales/:period_type', authenticate, dummyController.getSalesDummy) +router.post('/dummy', authenticate, dummyController.createDummy) +router.patch('/dummy/:id', authenticate, dummyController.updateDummyController) + +export default router \ No newline at end of file diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 4de5e17..b36a17f 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -5,7 +5,10 @@ import authEndpoint from './auth' import supplierEndpoint from './supplier' import productCategoryEndpoint from './product_category' import trxEndpoint from './trx' +import dashboardEndpoint from './dashboard' +import predictionEndpoint from './prediction' import productEndpoint from './product' +import dummyEndpoint from './dummy' import authenticate from '../../middleware/authMiddleware'; router.get('/', function (req, res, next) { @@ -25,5 +28,8 @@ router.use('/', supplierEndpoint) router.use('/', productEndpoint) router.use('/', productCategoryEndpoint) router.use('/', trxEndpoint) +router.use('/', dashboardEndpoint) +router.use('/', predictionEndpoint) +router.use('/', dummyEndpoint) export default router; diff --git a/src/routes/api/prediction.ts b/src/routes/api/prediction.ts index e69de29..b2a82dc 100644 --- a/src/routes/api/prediction.ts +++ b/src/routes/api/prediction.ts @@ -0,0 +1,11 @@ +import express from 'express' +var router = express.Router(); +import predictionController from '../../controller/api/predictionController' +import authenticate from '../../middleware/authMiddleware'; + +router.post('/prediction-from-file', predictionController.filePrediction) +router.get('/saved-predictions/purchases/:period_type', authenticate, predictionController.getSavedPurchasePredictions) +router.get('/saved-predictions/sales/:period_type', authenticate, predictionController.getSavedSalePredictions) +router.post('/purchase-prediction/:product_id', authenticate, predictionController.purchasePrediction) +router.post('/sales-prediction/:product_id', authenticate, predictionController.salesPrediction) +export default router \ No newline at end of file diff --git a/src/routes/api/product.ts b/src/routes/api/product.ts index fec5f8a..79db96f 100644 --- a/src/routes/api/product.ts +++ b/src/routes/api/product.ts @@ -3,12 +3,13 @@ var router = express.Router(); import productController from '../../controller/api/productsController'; import authenticate from '../../middleware/authMiddleware'; -router.use('/', authenticate) -router.post('/product', productController.addProduct); -router.get('/product/:product_code', productController.getProductByProductCodeRoute); -router.get('/products', productController.showAllProducts); -router.get('/product/:id', productController.getProductDetail); -router.patch('/product/:id', productController.updateProductRoute); -router.delete('/product/:id', productController.deleteProductRoute); +router.post('/product', authenticate, productController.addProduct); +router.get('/product/:product_code', authenticate, productController.getProductByProductCodeRoute); +router.get('/products', authenticate, productController.showAllProducts); +router.get('/all-products/limited', authenticate, productController.getAllProduct); +router.get('/product-get-by-id/:id', authenticate, productController.getProductDetail); +router.patch('/product/:id', authenticate, productController.updateProductRoute); +router.delete('/product/:id', authenticate, productController.deleteProductRoute); +router.get('/product-low-stock', authenticate, productController.getLowStockProduct) export default router \ No newline at end of file diff --git a/src/routes/api/product_category.ts b/src/routes/api/product_category.ts index aa1e696..898ed67 100644 --- a/src/routes/api/product_category.ts +++ b/src/routes/api/product_category.ts @@ -3,10 +3,9 @@ var router = express.Router(); import productController from '../../controller/api/productCategoriesController'; import authenticate from '../../middleware/authMiddleware'; -router.use('/', authenticate) -router.post('/product-category', productController.addProductCategory); -router.post('/product-category/:id', productController.addProductCategory); -router.get('/product-categories', productController.getProductCategories); -router.delete('/product-category/:id', productController.deleteProductCategoryRoute); +router.post('/product-category', authenticate, productController.addProductCategory); +router.post('/product-category/:id', authenticate, productController.addProductCategory); +router.get('/product-categories', authenticate, productController.getProductCategories); +router.delete('/product-category/:id', authenticate, productController.deleteProductCategoryRoute); export default router \ No newline at end of file diff --git a/src/routes/api/supplier.ts b/src/routes/api/supplier.ts index 4118b9d..6e7865f 100644 --- a/src/routes/api/supplier.ts +++ b/src/routes/api/supplier.ts @@ -3,11 +3,10 @@ var router = express.Router(); import supplierController from '../../controller/api/suppliersController'; import authenticate from '../../middleware/authMiddleware'; -router.use('/', authenticate) -router.post('/supplier', supplierController.addSupplier); -router.get('/suppliers', supplierController.showAllSupplier); -router.get('/supplier/:id', supplierController.getSupplierDetail); -router.patch('/supplier/:id', supplierController.updateSupplierRoute); -router.delete('/supplier/:id', supplierController.deleteSupplierRoute); +router.post('/supplier', authenticate, supplierController.addSupplier); +router.get('/suppliers', authenticate, supplierController.showAllSupplier); +router.get('/supplier/:id', authenticate, supplierController.getSupplierDetail); +router.patch('/supplier/:id', authenticate, supplierController.updateSupplierRoute); +router.delete('/supplier/:id', authenticate, supplierController.deleteSupplierRoute); export default router \ No newline at end of file diff --git a/src/routes/api/trx.ts b/src/routes/api/trx.ts index edefaec..cd5051b 100644 --- a/src/routes/api/trx.ts +++ b/src/routes/api/trx.ts @@ -3,10 +3,9 @@ var router = express.Router(); import transactionController from '../../controller/api/transactionController'; import authenticate from '../../middleware/authMiddleware'; -router.use('/', authenticate) -router.post('/restocks', transactionController.createRestockRecord); -router.post('/transactions', transactionController.createTransactionRecord); -router.get('/restocks-history', transactionController.showRestockHistoryRoute); -router.get('/sales-history', transactionController.showSalesHistoryRoute); +router.post('/restocks', authenticate, transactionController.createRestockRecord); +router.post('/transactions', authenticate, transactionController.createTransactionRecord); +router.get('/restocks-history', authenticate, transactionController.showRestockHistoryRoute); +router.get('/sales-history', authenticate, transactionController.showSalesHistoryRoute); export default router \ No newline at end of file diff --git a/src/services/dashboardServices.ts b/src/services/dashboardServices.ts new file mode 100644 index 0000000..deddc23 --- /dev/null +++ b/src/services/dashboardServices.ts @@ -0,0 +1,23 @@ +import { countProducts, selectLowStockProductCount } from "../repository/productsRepository" +import { selectLastMonthRestockCount, selectMonthlyRestockCount } from "../repository/restockRepository" +import { selectLastMonthSales, selectCurrentMonthSales } from "../repository/transactionRepository" +import { calculateGrowth } from '../utils/math/calculateGrowth' + +export async function getDashboardData(user_id: number) { + const total_product = await countProducts(user_id) + const { total: total_low_stock } = await selectLowStockProductCount(user_id) + const { total: total_last_month_sales } = await selectLastMonthSales(user_id) + const { total: total_monthly_sales } = await selectCurrentMonthSales(user_id) + const { total: total_last_month_restock } = await selectLastMonthRestockCount(user_id) + const { total: total_monthly_restock } = await selectMonthlyRestockCount(user_id) + return { + total_product, + total_low_stock, + total_last_month_restock, + total_monthly_restock, + restock_growth: Number(calculateGrowth(Number(selectLastMonthRestockCount || 0), Number(total_monthly_restock || 0)).toFixed(2)), + total_last_month_sales, + total_monthly_sales, + salesGrowth: Number(calculateGrowth(Number(total_last_month_sales || 0), Number(total_monthly_sales || 0)).toFixed(2)), + } +} \ No newline at end of file diff --git a/src/services/prediction/buildAutoPrediction.ts b/src/services/prediction/buildAutoPrediction.ts new file mode 100644 index 0000000..9039b32 --- /dev/null +++ b/src/services/prediction/buildAutoPrediction.ts @@ -0,0 +1,22 @@ +import createHttpError from "http-errors" +import { makePrivateAutoPrediction } from "../predictionServices" +import { IPredictionTable } from "../../types/db-model" + +export async function buildAutoPrediction( + csv_string: string, + prediction_period: IPredictionTable['period_type'] +) { + const result = await makePrivateAutoPrediction({ + csv_string, + prediction_period, + value_column: 'amount', + }) + + if (!result) throw createHttpError(422, 'Prediksi gagal: model tidak mengembalikan hasil.') + + if (result.mape > 50) { + throw createHttpError(422, `Prediksi tidak layak karena MAPE terlalu tinggi (${result.mape.toFixed(2)}%). Harap periksa data input.`) + } + + return result +} \ No newline at end of file diff --git a/src/services/prediction/buildManualPrediction.ts b/src/services/prediction/buildManualPrediction.ts new file mode 100644 index 0000000..4cc1367 --- /dev/null +++ b/src/services/prediction/buildManualPrediction.ts @@ -0,0 +1,25 @@ +import createHttpError from "http-errors" +import { makePrivateManualPrediction } from "../predictionServices" +import { IPredictionTable } from "../../types/db-model" + +export async function buildManualPrediction( + csv_string: string, + prediction_period: IPredictionTable['period_type'], + model: [number, number, number] +) { + const result = await makePrivateManualPrediction({ + csv_string, + prediction_period, + value_column: 'amount', + arima_model: model + }) + + if (!result) throw createHttpError(422, 'Prediksi gagal: model tidak mengembalikan hasil.') + + if ( + result.prediction[0] < 1 + ) + throw createHttpError(422, 'Hasil prediksi tidak valid: nilai prediksi terlalu kecil atau negatif. Jika menggunakan data dummy, pastikan data tersebut tidak fluktuatif dan masih relevan dengan data asli. Jika tidak menggunakan data dummy, kemungkinan produk ini tidak dapat diprediksi secara akurat.'); + + return result +} diff --git a/src/services/prediction/getPredictionModel.ts b/src/services/prediction/getPredictionModel.ts new file mode 100644 index 0000000..41e2765 --- /dev/null +++ b/src/services/prediction/getPredictionModel.ts @@ -0,0 +1,13 @@ +import { selectPredictionModel } from "../../repository/predictionModelRepository" +import { IPredictionTable } from "../../types/db-model" + +export async function getPredictionModel( + product_id: number, + prediction_period: IPredictionTable['period_type'], + source: IPredictionTable['prediction_source'] +) { + const model = await selectPredictionModel(product_id, prediction_period, source) + const isExpired = !model?.expired || new Date(model.expired) < new Date() + + return { model, isExpired } +} diff --git a/src/services/prediction/persistPrediction.ts b/src/services/prediction/persistPrediction.ts new file mode 100644 index 0000000..80a75a9 --- /dev/null +++ b/src/services/prediction/persistPrediction.ts @@ -0,0 +1,40 @@ +import { insertPrediction, selectPrediction, updatePrediction } from "../../repository/predictionRepository" +import { IPredictionTable } from "../../types/db-model" +import { TPythonAutoPredictionResponse, TPythonManualPredictionResponse } from "../../types/request/python-api" +import { getExpiredDateFromMonth, getExpiredDateFromWeek } from "../../utils/core/date" + +export async function persistPrediction( + prediction_result: TPythonAutoPredictionResponse | TPythonManualPredictionResponse, + prediction_period: IPredictionTable['period_type'], + source: IPredictionTable['prediction_source'], + product_id: IPredictionTable['product_id'], + user_id: IPredictionTable['user_id'] +) { + const previous = await selectPrediction(product_id, prediction_period, source) + const expired = prediction_period === 'monthly' ? + getExpiredDateFromMonth(new Date(), 1) : + getExpiredDateFromWeek(new Date(), 1) + + const record = { + ...previous, + lower_bound: prediction_result.lower.map(v => Math.round(v))[0], + prediction: prediction_result.prediction.map(v => Math.round(v))[0], + upper_bound: prediction_result.upper.map(v => Math.round(v))[0], + period_type: prediction_period, + prediction_source: source, + product_id, + user_id, + mape: Math.round((prediction_result as TPythonAutoPredictionResponse)?.mape || previous?.mape || 0), + rmse: Math.round((prediction_result as TPythonAutoPredictionResponse)?.rmse || previous?.rmse || 0), + expired + } + + if (!!previous?.id) { + await updatePrediction(previous.id, record) + } else { + const id = await insertPrediction(record) + record.id = id + } + + return record +} diff --git a/src/services/prediction/prepareData.ts b/src/services/prediction/prepareData.ts new file mode 100644 index 0000000..07a919e --- /dev/null +++ b/src/services/prediction/prepareData.ts @@ -0,0 +1,36 @@ +import createHttpError from 'http-errors' +import papaparse from 'papaparse' +import { selectGroupedMonthlyPurchase, selectGroupedWeeklyPurchase } from '../../repository/restockRepository' +import { selectDummy } from '../../repository/dummyRepository' + +export async function preparePredictionData(product_id: number, prediction_period: 'weekly' | 'monthly', source: 'purchases' | 'sales') { + let groupedData + if (prediction_period === 'weekly') { + groupedData = await selectGroupedWeeklyPurchase(product_id) + } else { + groupedData = await selectGroupedMonthlyPurchase(product_id) + } + + if (!groupedData || groupedData.length === 0) { + throw createHttpError(404, `Minimal lakukan 1 ${source === 'purchases' ? 'pembelian' : 'penjualan'} product dan lakukan prediksi di bulan berikutnya`) + } + + let data = groupedData.map(v => ({ amount: v.amount })) + + if (groupedData.length < 10) { + const dummy = await selectDummy(product_id, prediction_period, source) + if (!dummy?.fake_json) { + throw createHttpError(422, 'Prediksi tidak dapat dilakukan: data asli terlalu sedikit dan fallback data dummy tidak tersedia.') + } + + const fakeJSON = dummy.fake_json.filter(v => v >= 1).map(v => ({ amount: v })) + if (fakeJSON.length + groupedData.length < 11) { + throw createHttpError(422, 'Prediksi tidak dapat dilakukan: total data (asli + dummy) masih kurang dari 11 periode.') + } + + data.unshift(...fakeJSON) + } + + const csv_string = papaparse.unparse(data) + return csv_string +} diff --git a/src/services/prediction/savePredictionModel.ts b/src/services/prediction/savePredictionModel.ts new file mode 100644 index 0000000..c8a69a5 --- /dev/null +++ b/src/services/prediction/savePredictionModel.ts @@ -0,0 +1,42 @@ +import { insertPredictionModel, updatePredictionModel } from "../../repository/predictionModelRepository" +import { IPredictionModelTable, IPredictionTable } from "../../types/db-model" +import { TPythonAutoPredictionResponse, TPythonManualPredictionResponse } from "../../types/request/python-api" +import { getExpiredDateFromMonth, getExpiredDateFromWeek } from "../../utils/core/date" + +export async function savePredictionModel( + model: IPredictionModelTable | undefined, + prediction_result: TPythonAutoPredictionResponse | TPythonManualPredictionResponse, + product_id: IPredictionTable['product_id'], + prediction_period: IPredictionTable['period_type'], + source: IPredictionTable['prediction_source'], + user_id: IPredictionTable['user_id'] +) { + const [ar_p, differencing_d, ma_q] = prediction_result.arima_order + const expired = prediction_period === 'monthly' ? + getExpiredDateFromMonth(new Date(), 3) : + getExpiredDateFromWeek(new Date(), 3) + + if (!model?.id) { + await insertPredictionModel({ + ar_p, + differencing_d, + ma_q, + expired, + period_type: prediction_period, + prediction_source: source, + product_id, + user_id, + }) + } else { + await updatePredictionModel(model.id, { + ar_p, + differencing_d, + ma_q, + expired, + period_type: prediction_period, + prediction_source: source, + product_id, + user_id, + }) + } +} \ No newline at end of file diff --git a/src/services/prediction/shouldUseAutoModel.ts b/src/services/prediction/shouldUseAutoModel.ts new file mode 100644 index 0000000..032112c --- /dev/null +++ b/src/services/prediction/shouldUseAutoModel.ts @@ -0,0 +1,5 @@ +import { IPredictionModelTable } from "../../types/db-model"; + +export function shouldUseAutoModel(model: IPredictionModelTable | undefined, isExpired: boolean) { + return !model || isExpired +} \ No newline at end of file diff --git a/src/services/predictionServices.ts b/src/services/predictionServices.ts new file mode 100644 index 0000000..031c363 --- /dev/null +++ b/src/services/predictionServices.ts @@ -0,0 +1,80 @@ +import axios from "axios"; +import { TGroupedProductSales } from "../types/db/product-sales"; +import { config } from "dotenv"; +import { TPythonAutoPredictionRequest, TPythonAutoPredictionResponse, TPythonManualPredictionRequest, TPythonManualPredictionResponse } from "../types/request/python-api"; +import { IPurchaseTable } from "../types/db-model"; +config() + +export async function makeAutoPrediction(data: TPythonAutoPredictionRequest) { + try { + const response = await axios.post(`/predict/auto`, data, { + method: 'post', + baseURL: process.env.PYTHON_API_HOST, + }) + + if (response.status === 200 && response.data.success) { + return response.data; + } else { + console.warn('Prediksi gagal atau response tidak sesuai:', response.data); + return null; + } + } catch (error: any) { + console.error('Gagal memanggil /predict/auto:', error?.response?.data || error.message); + return null; + } +} + +export async function makeManualPrediction(data: TPythonManualPredictionRequest) { + try { + const response = await axios.post(`/predict/manual`, data, { + method: 'post', + baseURL: process.env.PYTHON_API_HOST, + }) + if (response.status === 200 && response.data.success) { + return response.data; + } else { + console.warn('Prediksi gagal atau response tidak sesuai:', response.data); + return null; + } + } catch (error: any) { + console.error('Gagal memanggil /predict/manual:', error?.response?.data || error.message); + return null; + } +} + +export async function makePrivateAutoPrediction(data: TPythonAutoPredictionRequest) { + try { + const response = await axios.post(`/predict/private/auto`, data, { + method: 'post', + baseURL: process.env.PYTHON_API_HOST, + }) + + if (response.status === 200 && response.data.success) { + return response.data; + } else { + console.warn('Prediksi gagal atau response tidak sesuai:', response.data); + return null; + } + } catch (error: any) { + console.error('Gagal memanggil /predict/auto:', error?.response?.data || error.message); + return null; + } +} + +export async function makePrivateManualPrediction(data: TPythonManualPredictionRequest) { + try { + const response = await axios.post(`/predict/private/manual`, data, { + method: 'post', + baseURL: process.env.PYTHON_API_HOST, + }) + if (response.status === 200 && response.data.success) { + return response.data; + } else { + console.warn('Prediksi gagal atau response tidak sesuai:', response.data); + return null; + } + } catch (error: any) { + console.error('Gagal memanggil /predict/manual:', error?.response?.data || error.message); + return null; + } +} \ No newline at end of file diff --git a/src/services/productServices.ts b/src/services/productServices.ts index fcf504c..714e745 100644 --- a/src/services/productServices.ts +++ b/src/services/productServices.ts @@ -1,4 +1,4 @@ -import { countProducts, getAllProducts, insertProduct, selectProductById, selectProductByProductCode, selectProductByProductCodes, selectProductsByUserId } from "../repository/productsRepository" +import { countProducts, getAllProducts, insertProduct, selectLowStockProducts, selectProductById, selectProductByProductCode, selectProductByProductCodes, selectProductsByUserId } from "../repository/productsRepository" import { IProductTable } from "../types/db-model" export const createProduct = async (data: IProductTable) => { @@ -50,4 +50,8 @@ export const getProductByProductCode = ( export const getProductByProductCodes = ( user_id: IProductTable['user_id'], product_codes: IProductTable['product_code'][] -) => selectProductByProductCodes(product_codes, user_id) \ No newline at end of file +) => selectProductByProductCodes(product_codes, user_id) + +export const getLowStockProducts = (user_id: IProductTable['user_id']) => { + return selectLowStockProducts(user_id) +} \ No newline at end of file diff --git a/src/services/transactionServices.ts b/src/services/transactionServices.ts index bc80425..5f725a1 100644 --- a/src/services/transactionServices.ts +++ b/src/services/transactionServices.ts @@ -21,8 +21,9 @@ export async function addTransactionRecords(data: { price: Number(p.selling_price), }) + const stockAfter = (Number(dataMatched?.amount) || 0) - p.stock updateProductById(p.id, { - stock: (Number(dataMatched?.amount) || 0) - p.stock + stock: stockAfter < 1 ? 0 : stockAfter }) }) return insertsDataToTransaction(mergedData) diff --git a/src/types/db-model.ts b/src/types/db-model.ts index aab4567..b8995ce 100644 --- a/src/types/db-model.ts +++ b/src/types/db-model.ts @@ -24,7 +24,8 @@ export interface IProductTable { selling_price: number | null; product_category_id: number | null; user_id: number; - deleted: boolean + deleted: boolean; + low_stock_limit: number; } export interface ISupplierTable { @@ -58,18 +59,34 @@ export interface ITransactionTable { export interface IPredictionTable { id: number; - stock_sold: number; - stock_predicted: number; - type: string; - accuracy: number; - user_id: number; + prediction: number; + lower_bound: number; + upper_bound: number; + rmse: number; + mape: number; product_id: number; + user_id: number; + expired: string; // bisa juga pakai `Date` kalau langsung di-parse + period_type: 'weekly' | 'monthly'; + prediction_source: 'sales' | 'purchases'; } -export interface IUserFileTable { +export interface IPredictionModelTable { id: number; - file_name: string; - saved_file_name: string; - is_converted: boolean; + ar_p: number; + ma_q: number; + differencing_d: number; + period_type: 'weekly' | 'monthly'; + prediction_source: 'sales' | 'purchases'; + expired: string; // bisa juga pakai `Date` kalau langsung di-parse + product_id: number; user_id: number; } + +export interface IDummiesTable { + id: number; + product_id: number; + fake_json: number[] | null; + period_type: 'weekly' | 'monthly'; + trx_type: 'sales' | 'purchases'; +} \ No newline at end of file diff --git a/src/types/db/product-sales.ts b/src/types/db/product-sales.ts new file mode 100644 index 0000000..80d3730 --- /dev/null +++ b/src/types/db/product-sales.ts @@ -0,0 +1,9 @@ +import { IProductTable, ITransactionTable } from "../db-model"; + +export type TGroupedProductSales = { + group_date: string; + product_name: IProductTable['product_name']; + product_code: IProductTable['product_code']; + product_id: ITransactionTable['product_id']; + total_transactions: number; +}; diff --git a/src/types/request/python-api.ts b/src/types/request/python-api.ts new file mode 100644 index 0000000..ceafebb --- /dev/null +++ b/src/types/request/python-api.ts @@ -0,0 +1,26 @@ +type TPythonBasePredictionRequest = { + csv_string: string + prediction_period: "weekly" | "monthly" + value_column: string + date_column?: string + date_regroup?: boolean +} + +type TPythonBasePredictionResponse = { + arima_order: [number, number, number] + upper: number[] + lower: number[] + prediction: number[] + success: boolean +} + +export type TPythonAutoPredictionRequest = TPythonBasePredictionRequest +export type TPythonAutoPredictionResponse = TPythonBasePredictionResponse & { + rmse: number + mape: number +} + +export type TPythonManualPredictionResponse = TPythonBasePredictionResponse +export type TPythonManualPredictionRequest = TPythonBasePredictionRequest & { + arima_model?: [number, number, number] +} \ No newline at end of file diff --git a/src/utils/core/date.ts b/src/utils/core/date.ts new file mode 100644 index 0000000..f2163d8 --- /dev/null +++ b/src/utils/core/date.ts @@ -0,0 +1,28 @@ +import dayjs from 'dayjs' + +export function getStartOfWeek(dateInput: string | Date) { + const date = dayjs(dateInput) + const day = date.day() === 0 ? 7 : date.day() // 1 = Senin, 7 = Minggu + return date.subtract(day - 1, 'day').startOf('day')// Return: dayjs('YYYY-MM-Monday 00:00:00.000') +} + +export function getEndOfWeek(dateInput: string | Date) { + const monday = getStartOfWeek(dateInput) + return monday.add(6, 'day').endOf('day')// Return: dayjs('YYYY-MM-Sunday 23:59:59.999') +} + +export function getStartOfMonth(dateInput: string | Date) { + return dayjs(dateInput).startOf('month') // awal bulan: YYYY-MM-01 00:00:00.000 +} + +export function getEndOfMonth(dateInput: string | Date) { + return dayjs(dateInput).endOf('month') // akhir bulan: YYYY-MM-lastDay 23:59:59.999 +} + +export function getExpiredDateFromMonth(dateInput: string | Date, period = 1) { + return getStartOfMonth(dateInput).add(period, 'month').endOf('day').format('YYYY-MM-DD HH:mm:ss') +} + +export function getExpiredDateFromWeek(dateInput: string | Date, period = 1) { + return getStartOfWeek(dateInput).add(period, 'week').endOf('day').format('YYYY-MM-DD HH:mm:ss') +} \ No newline at end of file diff --git a/src/utils/core/httpError.ts b/src/utils/core/httpError.ts new file mode 100644 index 0000000..2e8ed81 --- /dev/null +++ b/src/utils/core/httpError.ts @@ -0,0 +1,5 @@ +import { HttpError } from 'http-errors' + +export function isErrorInstanceOfHttpError(error: unknown): error is HttpError { + return error instanceof Error && 'status' in error +} diff --git a/src/utils/math/calculateGrowth.ts b/src/utils/math/calculateGrowth.ts new file mode 100644 index 0000000..3eef3e5 --- /dev/null +++ b/src/utils/math/calculateGrowth.ts @@ -0,0 +1,4 @@ +export function calculateGrowth(current: number, previous: number): number { + if (previous === 0) return current === 0 ? 0 : 100; + return ((current - previous) / previous) * 100; +} \ No newline at end of file