This commit is contained in:
fhm 2025-07-08 08:32:09 +07:00
parent 0e416be400
commit 9d689bdad3
19 changed files with 307 additions and 47 deletions

View File

@ -1,12 +1,12 @@
import { NextFunction, Request, Response } from "express";
import { TAPIResponse } from "../../types/core/http";
import createHttpError from "http-errors";
import { selectAllProductNPrediction } from "../../repository/predictionRepository";
import { selectAllProductNPrediction, selectDetailPrediction } 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 { getPreparedCSVString, preparePredictionData } from "../../services/prediction/prepareData";
import { getPredictionModel } from "../../services/prediction/getPredictionModel";
import { shouldUseAutoModel } from "../../services/prediction/shouldUseAutoModel";
import { buildAutoPrediction } from "../../services/prediction/buildAutoPrediction";
@ -14,6 +14,8 @@ import { savePredictionModel } from "../../services/prediction/savePredictionMod
import { buildManualPrediction } from "../../services/prediction/buildManualPrediction";
import { persistPrediction } from "../../services/prediction/persistPrediction";
import { isErrorInstanceOfHttpError } from "../../utils/core/httpError";
import { sendResponseError } from "../../utils/api/responseError";
import { getTotalDaysInNextMonth } from "../../utils/core/date";
const periodArray = ['daily', 'weekly', 'monthly']
@ -65,6 +67,7 @@ const filePrediction = [
value_column,
prediction_period,
date_regroup: record_period !== prediction_period,
future_step: 1
})
if (!pythonResponse?.mape || pythonResponse.mape > 50) {
@ -86,7 +89,7 @@ const filePrediction = [
message: 'Gagal melakukan prediksi. Silakan coba lagi atau cek data input.',
});
} catch (error) {
next(createHttpError(500, "Internal Server Error", { cause: error }));
next(sendResponseError(error))
}
}
]
@ -106,8 +109,7 @@ const getSavedPurchasePredictions = [
}
res.json(result)
} catch (error) {
console.log(error)
next(createHttpError(500, "Internal Server Error", { error }));
next(sendResponseError(error));
}
}
]
@ -127,8 +129,7 @@ const getSavedSalePredictions = [
}
res.json(result)
} catch (error) {
console.log(error)
next(createHttpError(500, "Internal Server Error", { error }));
next(sendResponseError(error));
}
}
]
@ -153,7 +154,7 @@ const purchasePrediction = [
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)
await savePredictionModel(model, predictionResult, product_id, prediction_period, source, req.user!.id, 3)
} else {
const arimaModel: [number, number, number] = [model!.ar_p, model!.differencing_d, model!.ma_q]
predictionResult = await buildManualPrediction(csv_string, prediction_period, arimaModel)
@ -163,9 +164,7 @@ const purchasePrediction = [
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 }))
next(sendResponseError(err))
}
}
]
@ -190,7 +189,7 @@ const salesPrediction = [
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)
await savePredictionModel(model, predictionResult, product_id, prediction_period, source, req.user!.id, 3)
} else {
const arimaModel: [number, number, number] = [model!.ar_p, model!.differencing_d, model!.ma_q]
predictionResult = await buildManualPrediction(csv_string, prediction_period, arimaModel)
@ -200,9 +199,101 @@ const salesPrediction = [
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 }))
next(sendResponseError(err))
}
}
]
const smartPrediction = [
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".'),
body('prediction_source')
.notEmpty().withMessage('prediction_source tidak boleh kosong.')
.isIn(['sales', 'purchases']).withMessage('prediction_source harus bernilai "sales" atau "purchases".'),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { product_id, prediction_period, prediction_source } = matchedData(req)
const prediction_period_days = prediction_period === 'weekly' ? 7 : getTotalDaysInNextMonth(new Date())
const {
csv_string, data_freq
} = await getPreparedCSVString(product_id, prediction_period, prediction_source)
const { model, isExpired } = await getPredictionModel(
product_id,
prediction_period,
prediction_source
)
let predictionResult
if (shouldUseAutoModel(model, isExpired)) {
predictionResult = await buildAutoPrediction(
csv_string,
prediction_period,
data_freq === 'daily' ? prediction_period_days : 1
)
await savePredictionModel(
model,
predictionResult,
product_id,
prediction_period,
prediction_source,
req.user!.id,
data_freq === 'daily' ? 1 : 3
)
} else {
const arimaModel: [number, number, number] = [model!.ar_p, model!.differencing_d, model!.ma_q]
predictionResult = await buildManualPrediction(
csv_string,
prediction_period,
arimaModel,
data_freq === 'daily' ? prediction_period_days : 1
)
}
const finalResult = await persistPrediction(
predictionResult,
prediction_period,
prediction_source,
product_id,
req.user!.id
)
res.status(200).json({ success: true, data: finalResult })
} catch (error) {
next(sendResponseError(error))
}
}
]
const predictionDetail = [
body('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".'),
body('source_type')
.notEmpty().withMessage('source_type tidak boleh kosong.')
.isIn(['sales', 'purchases']).withMessage('source_type harus bernilai "sales" atau "purchases".'),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { product_id, prediction_period, source_type } = matchedData(req)
const detailPrediction = await selectDetailPrediction(
product_id, prediction_period, source_type
)
res.status(200).json({ success: true, data: detailPrediction })
} catch (err) {
next(sendResponseError(err))
}
}
]
@ -212,5 +303,7 @@ export default {
getSavedPurchasePredictions,
getSavedSalePredictions,
salesPrediction,
purchasePrediction
purchasePrediction,
predictionDetail,
smartPrediction
}

View File

@ -11,6 +11,7 @@ export function selectLatestPredictions(
.join('product_categories as pc', 'pr.product_category_id', 'pc.id')
.select(
'pr.product_name',
'pr.stock',
'p.mape',
'p.prediction',
'pc.category_name',

View File

@ -1,5 +1,5 @@
import { db } from "../database/MySQL";
import { IPredictionTable, IProductTable, ITransactionTable } from "../types/db-model";
import { IPredictionModelTable, IPredictionTable, IProductTable, ITransactionTable } from "../types/db-model";
import { TGroupedProductSales } from "../types/db/product-sales";
import { selectProductsByUserId } from "./productsRepository";
@ -86,4 +86,31 @@ export async function insertPrediction(data: Partial<IPredictionTable>) {
export function updatePrediction(id: IPredictionTable['id'], data: Partial<IPredictionTable>) {
return db<IPredictionTable>('predictions').where({ id }).update(data)
}
export function selectDetailPrediction(
product_id: IPredictionTable['product_id'],
period_type: IPredictionTable['period_type'],
prediction_source: IPredictionTable['prediction_source'],
) {
return db<IProductTable>('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()]));
})
.where('products.id', product_id)
.select(
'products.product_code',
'products.product_name',
'products.stock',
'products.selling_price',
'products.buying_price',
'predictions.prediction',
'predictions.lower_bound',
'predictions.upper_bound',
'predictions.mape',
'predictions.rmse'
).first();
}

View File

@ -1,6 +1,6 @@
import { db } from "../database/MySQL";
import { IPurchaseTable } from "../types/db-model";
import { getStartOfMonth, getStartOfWeek } from "../utils/core/date";
import { db } from "../../database/MySQL";
import { IPurchaseTable } from "../../types/db-model";
import { getStartOfMonth, getStartOfWeek } from "../../utils/core/date";
export function insertsDataToRestock(data: Partial<IPurchaseTable>[]) {
return db<IPurchaseTable>('restocks').insert(data)
@ -49,6 +49,27 @@ export const selectLastMonthRestockCount = (user_id: number) => {
}>();
}
export function selectGroupedDailyPurchase(
productId: IPurchaseTable['product_id'],
endOfPeriod: 'last-week' | 'last-month'
) {
let startOfDay
if (endOfPeriod === 'last-month')
startOfDay = getStartOfMonth(new Date()).toISOString()
else (endOfPeriod === 'last-week')
startOfDay = getStartOfWeek(new Date()).toISOString()
return db<IPurchaseTable>('restocks')
.select(
db.raw('DATE(buying_date) AS date'),
db.raw('SUM(amount) AS total_amount')
)
.where('product_id', productId)
.andWhere('buying_date', '<', startOfDay)
.groupByRaw('DATE(buying_date)')
.orderBy('date');
}
export function selectGroupedWeeklyPurchase(productId: IPurchaseTable['product_id']) {
return db<IPurchaseTable>('restocks')
.select(

View File

@ -1,6 +1,6 @@
import { db } from "../database/MySQL";
import { ITransactionTable } from "../types/db-model";
import { getStartOfMonth, getStartOfWeek } from "../utils/core/date";
import { db } from "../../database/MySQL";
import { ITransactionTable } from "../../types/db-model";
import { getStartOfMonth, getStartOfWeek } from "../../utils/core/date";
export function insertsDataToTransaction(data: Partial<ITransactionTable>[]) {
return db<ITransactionTable>('transactions').insert(data)
@ -49,6 +49,27 @@ export const selectCurrentMonthSales = async (user_id: number) => {
}>();
}
export function selectGroupedDailySales(
productId: ITransactionTable['product_id'],
endOfPeriod: 'last-week' | 'last-month'
) {
let startOfDay
if (endOfPeriod === 'last-month')
startOfDay = getStartOfMonth(new Date()).toISOString()
else (endOfPeriod === 'last-week')
startOfDay = getStartOfWeek(new Date()).toISOString()
return db<ITransactionTable>('transactions')
.select(
db.raw('DATE(transaction_date) AS date'),
db.raw('SUM(amount) AS total_amount')
)
.where('product_id', productId)
.andWhere('transaction_date', '<', startOfDay)
.groupByRaw('DATE(transaction_date)')
.orderBy('date');
}
export function selectGroupedWeeklySales(productId: ITransactionTable['product_id']) {
return db('transactions')
.select(
@ -73,6 +94,7 @@ export function selectGroupedMonthlySales(productId: ITransactionTable['product_
.where('product_id', productId)
.andWhere('transaction_date', '<', getStartOfMonth(new Date()).toISOString())
.groupBy(['year', 'month'])
.orderBy(['year', 'month'])
}

View File

@ -1,6 +1,7 @@
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);
@ -10,6 +11,6 @@ router.patch('/forgot-password/:token', authRoute.forgotPasswordChangePassword);
router.get('/verify/:token', authRoute.verify);
router.post('/re-send-email-activation/:token', authRoute.resendEmailVerification);
router.get('/refresh-token', authRoute.refreshToken);
router.get('/logout', authRoute.logout);
router.get('/logout', authenticate, authRoute.logout);
export default router

View File

@ -8,4 +8,6 @@ router.get('/saved-predictions/purchases/:period_type', authenticate, prediction
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)
router.post('/smart-prediction/:product_id', authenticate, predictionController.smartPrediction)
router.post('/detail-prediction', authenticate, predictionController.predictionDetail)
export default router

View File

@ -1,6 +1,6 @@
import { countProducts, selectLowStockProductCount } from "../repository/productsRepository"
import { selectLastMonthRestockCount, selectMonthlyRestockCount } from "../repository/restockRepository"
import { selectLastMonthSales, selectCurrentMonthSales } from "../repository/transactionRepository"
import { selectLastMonthRestockCount, selectMonthlyRestockCount } from "../repository/transaction/purchase"
import { selectCurrentMonthSales, selectLastMonthSales } from "../repository/transaction/sales"
import { calculateGrowth } from '../utils/math/calculateGrowth'
export async function getDashboardData(user_id: number) {

View File

@ -4,12 +4,14 @@ import { IPredictionTable } from "../../types/db-model"
export async function buildAutoPrediction(
csv_string: string,
prediction_period: IPredictionTable['period_type']
prediction_period: IPredictionTable['period_type'],
future_step: number = 1
) {
const result = await makePrivateAutoPrediction({
csv_string,
prediction_period,
value_column: 'amount',
future_step
})
if (!result) throw createHttpError(422, 'Prediksi gagal: model tidak mengembalikan hasil.')

View File

@ -5,13 +5,15 @@ import { IPredictionTable } from "../../types/db-model"
export async function buildManualPrediction(
csv_string: string,
prediction_period: IPredictionTable['period_type'],
model: [number, number, number]
model: [number, number, number],
future_step: number = 1
) {
const result = await makePrivateManualPrediction({
csv_string,
prediction_period,
value_column: 'amount',
arima_model: model
arima_model: model,
future_step
})
if (!result) throw createHttpError(422, 'Prediksi gagal: model tidak mengembalikan hasil.')

View File

@ -17,9 +17,9 @@ export async function persistPrediction(
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],
lower_bound: prediction_result.lower.map(v => Math.round(v), 0),
prediction: prediction_result.prediction.map(v => Math.round(v)),
upper_bound: prediction_result.upper.map(v => Math.round(v)),
period_type: prediction_period,
prediction_source: source,
product_id,

View File

@ -1,18 +1,27 @@
import createHttpError from 'http-errors'
import papaparse from 'papaparse'
import { selectGroupedMonthlyPurchase, selectGroupedWeeklyPurchase } from '../../repository/restockRepository'
import { selectDummy } from '../../repository/dummyRepository'
import { selectGroupedDailyPurchase, selectGroupedMonthlyPurchase, selectGroupedWeeklyPurchase } from '../../repository/transaction/purchase'
import { selectGroupedDailySales, selectGroupedMonthlySales, selectGroupedWeeklySales } from '../../repository/transaction/sales'
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 (source === 'purchases') {
groupedData = await selectGroupedWeeklyPurchase(product_id)
} else if (source === 'sales') {
groupedData = await selectGroupedWeeklySales(product_id)
}
} else if (prediction_period === 'monthly') {
if (source === 'purchases') {
groupedData = await selectGroupedMonthlyPurchase(product_id)
} else if (source === 'sales') {
groupedData = await selectGroupedMonthlySales(product_id)
}
}
if (!groupedData || groupedData.length === 0) {
throw createHttpError(404, `Minimal lakukan 1 ${source === 'purchases' ? 'pembelian' : 'penjualan'} product dan lakukan prediksi di bulan berikutnya`)
throw createHttpError(404, `Minimal lakukan 1 ${source === 'purchases' ? 'pembelian' : 'penjualan'} product dan lakukan prediksi di ${prediction_period === 'monthly' ? 'bulan' : 'minggu'} berikutnya`)
}
let data = groupedData.map(v => ({ amount: v.amount }))
@ -34,3 +43,58 @@ export async function preparePredictionData(product_id: number, prediction_perio
const csv_string = papaparse.unparse(data)
return csv_string
}
type PreparedDataReturnValue = {
data_freq: 'daily' | 'weekly' | 'monthly'
csv_string: string
}
export async function getPreparedCSVString(
product_id: number,
prediction_period: 'weekly' | 'monthly',
source: 'purchases' | 'sales'
): Promise<PreparedDataReturnValue> {
const returnValue: PreparedDataReturnValue = {
data_freq: prediction_period,
csv_string: ''
}
let groupedData
if (prediction_period === 'weekly') {
if (source === 'purchases') {
groupedData = await selectGroupedWeeklyPurchase(product_id)
} else {
groupedData = await selectGroupedWeeklySales(product_id)
}
} else {
if (source === 'purchases') {
groupedData = await selectGroupedMonthlyPurchase(product_id)
} else {
groupedData = await selectGroupedMonthlySales(product_id)
}
}
if (groupedData.length < 10) {
if (source === 'purchases') {
groupedData = await selectGroupedDailyPurchase(
product_id,
prediction_period === 'weekly' ? 'last-week' : 'last-month'
)
} else {
groupedData = await selectGroupedDailySales(
product_id,
prediction_period === 'weekly' ? 'last-week' : 'last-month'
)
}
returnValue.data_freq = 'daily'
}
if (groupedData.length < 30) {
throw createHttpError(422, `Minimal lakukan 30 transaksi ${source === 'sales' ? 'penjualan' : 'pembelian'} harian pada ${prediction_period === 'weekly' ? 'minggu' : 'bulan'} sebelumnya untuk dapat melakukan prediksi`)
}
let data = groupedData.map(v => ({ amount: v.amount }))
returnValue.csv_string = papaparse.unparse(data)
return returnValue
}

View File

@ -9,12 +9,13 @@ export async function savePredictionModel(
product_id: IPredictionTable['product_id'],
prediction_period: IPredictionTable['period_type'],
source: IPredictionTable['prediction_source'],
user_id: IPredictionTable['user_id']
user_id: IPredictionTable['user_id'],
expiration_interval: number
) {
const [ar_p, differencing_d, ma_q] = prediction_result.arima_order
const expired = prediction_period === 'monthly' ?
getExpiredDateFromMonth(new Date(), 3) :
getExpiredDateFromWeek(new Date(), 3)
getExpiredDateFromMonth(new Date(), expiration_interval) :
getExpiredDateFromWeek(new Date(), expiration_interval)
if (!model?.id) {
await insertPredictionModel({

View File

@ -1,4 +1,4 @@
import { countRestocks, insertsDataToRestock, selectAllRestockHistory } from "../repository/restockRepository";
import { countRestocks, insertsDataToRestock, selectAllRestockHistory } from "../repository/transaction/purchase";
import { IProductTable, IPurchaseTable } from "../types/db-model";
import { getProductByProductCodes, updateProductById } from "./productServices";
@ -51,4 +51,4 @@ export async function showRestockHistory(
}
};
return
}
}

View File

@ -1,4 +1,4 @@
import { countSales, insertsDataToTransaction, selectAllSalesHistory } from "../repository/transactionRepository";
import { countSales, insertsDataToTransaction, selectAllSalesHistory } from "../repository/transaction/sales";
import { IProductTable, ITransactionTable } from "../types/db-model";
import { getProductByProductCodes, updateProductById } from "./productServices";
@ -21,7 +21,7 @@ export async function addTransactionRecords(data: {
price: Number(p.selling_price),
})
const stockAfter = (Number(dataMatched?.amount) || 0) - p.stock
const stockAfter = p.stock - (Number(dataMatched?.amount) || 0)
updateProductById(p.id, {
stock: stockAfter < 1 ? 0 : stockAfter
})

View File

@ -59,9 +59,9 @@ export interface ITransactionTable {
export interface IPredictionTable {
id: number;
prediction: number;
lower_bound: number;
upper_bound: number;
prediction: number[];
lower_bound: number[];
upper_bound: number[];
rmse: number;
mape: number;
product_id: number;

View File

@ -4,6 +4,7 @@ type TPythonBasePredictionRequest = {
value_column: string
date_column?: string
date_regroup?: boolean
future_step: number
}
type TPythonBasePredictionResponse = {

View File

@ -0,0 +1,19 @@
import createHttpError, { HttpError } from "http-errors";
import { isErrorInstanceOfHttpError } from "../core/httpError";
import dotenv from 'dotenv'
dotenv.config()
export function sendResponseError(error: unknown): HttpError {
let newError: HttpError;
if (isErrorInstanceOfHttpError(error)) {
newError = error;
} else {
newError = createHttpError(500, "Internal Server Error", { cause: error });
}
if (process.env.NODE_ENV === 'development') {
console.error(newError);
}
return newError;
}

View File

@ -25,4 +25,8 @@ export function getExpiredDateFromMonth(dateInput: string | Date, period = 1) {
export function getExpiredDateFromWeek(dateInput: string | Date, period = 1) {
return getStartOfWeek(dateInput).add(period, 'week').endOf('day').format('YYYY-MM-DD HH:mm:ss')
}
export function getTotalDaysInNextMonth(dateInput: string | Date = new Date()) {
return getStartOfMonth(dateInput).add(1, 'month').daysInMonth()
}