MIF_E31232435/ml_model/app.py

2228 lines
74 KiB
Python

"""
Flask API untuk Prediksi Permintaan Stok Bahan
Menggunakan Random Forest Model
"""
from flask import Flask, request, jsonify
from flask_cors import CORS
from email.message import EmailMessage
import joblib
import pandas as pd
import numpy as np
import logging
import math
import hashlib
import secrets
import os
import smtplib
from datetime import datetime, timedelta
import mysql.connector
from mysql.connector import Error
def load_env_file():
"""Load simple KEY=VALUE pairs from ml_model/.env if present."""
env_path = os.path.join(os.path.dirname(__file__), '.env')
if not os.path.exists(env_path):
return
try:
with open(env_path, 'r', encoding='utf-8') as env_file:
for raw_line in env_file:
line = raw_line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
os.environ[key] = value
except OSError as e:
print(f"Failed to load .env file: {e}")
load_env_file()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app)
# ============================================================================
# DATABASE CONFIGURATION
# ============================================================================
DB_HOST = 'localhost'
DB_USER = 'root'
DB_PASSWORD = '' # Ganti dengan password MySQL Anda jika ada
DB_NAME = 'prediksi_stok_db'
# Email/SMTP configuration for OTP delivery.
# For Gmail, use an App Password, not the regular Gmail password.
SMTP_HOST = os.getenv('SMTP_HOST', 'smtp.gmail.com')
SMTP_PORT = int(os.getenv('SMTP_PORT', '587'))
SMTP_EMAIL = os.getenv('SMTP_EMAIL', '')
SMTP_PASSWORD = os.getenv('SMTP_APP_PASSWORD', '').replace(' ', '')
SMTP_SENDER_NAME = os.getenv('SMTP_SENDER_NAME', 'Tobaku Sulastri')
# Table names (use backticks for names with spaces)
TRANSACTIONS_TABLE = "`Stock In`"
def get_db_connection():
"""Get MySQL database connection"""
try:
connection = mysql.connector.connect(
host=DB_HOST,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME
)
return connection
except Error as e:
logger.error(f"Database connection error: {e}")
return None
def table_exists(connection, table_name: str) -> bool:
try:
cursor = connection.cursor()
cursor.execute("SHOW TABLES LIKE %s", (table_name,))
exists = cursor.fetchone() is not None
cursor.close()
return exists
except Error:
return False
def get_product_name_column(connection) -> str:
cursor = connection.cursor()
cursor.execute("SHOW COLUMNS FROM products LIKE 'name'")
has_name = cursor.fetchone() is not None
cursor.close()
return 'name' if has_name else 'product_name'
def has_product_unit_column(connection) -> bool:
cursor = connection.cursor()
cursor.execute("SHOW COLUMNS FROM products LIKE 'unit'")
has_unit = cursor.fetchone() is not None
cursor.close()
return has_unit
def ensure_product_stock_precision(connection):
if not table_exists(connection, 'products'):
return
stock_col = get_existing_column(connection, 'products', ['current_stock'])
if not stock_col:
return
cursor = connection.cursor(dictionary=True)
try:
cursor.execute("SHOW COLUMNS FROM products LIKE 'current_stock'")
column = cursor.fetchone()
column_type = (column or {}).get('Type', '').lower()
if any(kind in column_type for kind in ['decimal', 'float', 'double']):
return
cursor.execute(
"ALTER TABLE products MODIFY current_stock DECIMAL(10,3) NOT NULL DEFAULT 0"
)
connection.commit()
except Error as e:
logger.warning(f"Could not update current_stock precision: {e}")
finally:
cursor.close()
def ensure_product_min_stock_column(connection):
if not table_exists(connection, 'products'):
return
min_col = get_existing_column(connection, 'products', ['min_stock'])
if min_col:
return
cursor = connection.cursor()
try:
cursor.execute(
"ALTER TABLE products ADD COLUMN min_stock DECIMAL(10,3) NOT NULL DEFAULT 0"
)
connection.commit()
except Error as e:
logger.warning(f"Could not add min_stock column: {e}")
finally:
cursor.close()
def backfill_default_min_stock(connection):
if not table_exists(connection, 'products'):
return
min_col = get_existing_column(connection, 'products', ['min_stock'])
if not min_col:
return
name_col = get_product_name_column(connection)
default_minimums = {
'Tepung Terigu 1kg': 10,
'Telur 1kg': 5,
'Gula Pasir 1kg': 8,
'Susu Bubuk': 4,
'Cokelat Bubuk 250gr': 3,
'Mentega 500gr': 5,
'Keju Parut 250gr': 2,
'Baking Powder': 2,
}
cursor = connection.cursor()
try:
for product_name, min_stock in default_minimums.items():
cursor.execute(
f"""
UPDATE products
SET {min_col} = %s
WHERE {name_col} = %s AND COALESCE({min_col}, 0) = 0
""",
(min_stock, product_name)
)
connection.commit()
except Error as e:
logger.warning(f"Could not backfill min_stock defaults: {e}")
finally:
cursor.close()
def escape_table_name(table_name: str) -> str:
return f"`{table_name}`"
def get_existing_table(connection, candidates: list[str]) -> str | None:
for table_name in candidates:
if table_exists(connection, table_name):
return table_name
return None
def get_existing_column(connection, table_name: str, candidates: list[str]) -> str | None:
cursor = connection.cursor()
try:
for column in candidates:
cursor.execute(f"SHOW COLUMNS FROM {escape_table_name(table_name)} LIKE %s", (column,))
if cursor.fetchone() is not None:
return column
finally:
cursor.close()
return None
def build_report_response(success: bool, message: str, data: list | None = None, status_code: int = 200):
return jsonify({
'status': success,
'message': message,
'data': data or []
}), status_code
def verify_login_password(input_password: str, stored_password: str) -> bool:
"""Accept plain text passwords and SHA-256 hashes for simple local auth."""
if input_password == stored_password:
return True
hashed_input = hashlib.sha256(input_password.encode('utf-8')).hexdigest()
return hashed_input == stored_password
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode('utf-8')).hexdigest()
def send_otp_email(recipient_email: str, otp_code: str) -> None:
if not SMTP_EMAIL or not SMTP_PASSWORD:
raise RuntimeError(
'SMTP_EMAIL dan SMTP_APP_PASSWORD belum dikonfigurasi'
)
message = EmailMessage()
message['Subject'] = 'Kode OTP Reset Password Tobaku Sulastri'
message['From'] = f'{SMTP_SENDER_NAME} <{SMTP_EMAIL}>'
message['To'] = recipient_email
message.set_content(
f"""Halo,
Kode OTP untuk reset password aplikasi Tobaku Sulastri adalah:
{otp_code}
Kode ini berlaku selama 10 menit. Abaikan email ini jika Anda tidak meminta reset password.
Terima kasih,
Tobaku Sulastri
"""
)
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as server:
server.starttls()
server.login(SMTP_EMAIL, SMTP_PASSWORD)
server.send_message(message)
def mask_email(email: str) -> str:
if '@' not in email:
return email
name, domain = email.split('@', 1)
if len(name) <= 2:
masked_name = name[0] + '*'
else:
masked_name = name[:2] + ('*' * max(2, len(name) - 2))
return f'{masked_name}@{domain}'
def ensure_prediction_needs_column(connection, table_name: str = 'predictions') -> str | None:
needs_col = get_existing_column(
connection,
table_name,
['estimated_needs', 'estimasi_kebutuhan_bahan']
)
if needs_col:
return needs_col
if not table_exists(connection, table_name):
return None
cursor = connection.cursor()
try:
cursor.execute(
f"ALTER TABLE {escape_table_name(table_name)} ADD COLUMN estimated_needs TEXT"
)
connection.commit()
return 'estimated_needs'
except Error as e:
logger.warning(f"Could not add estimated_needs column: {e}")
return None
finally:
cursor.close()
def ensure_stock_usage_tables(connection):
cursor = connection.cursor()
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS stock_usage_history (
id INT AUTO_INCREMENT PRIMARY KEY,
recipe_name VARCHAR(255),
production_quantity INT,
product_id INT,
product_name VARCHAR(255) NOT NULL,
quantity_used FLOAT NOT NULL,
unit VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS stok_keluar (
id INT AUTO_INCREMENT PRIMARY KEY,
bahan_id INT,
jumlah_keluar FLOAT NOT NULL,
satuan VARCHAR(50) DEFAULT 'kg',
tanggal_keluar DATE NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
connection.commit()
finally:
cursor.close()
def compute_stock_status(stok: float, stok_minimum: float) -> str:
if stok > stok_minimum:
return 'Aman'
if stok == stok_minimum:
return 'Rendah'
return 'Kritis'
def fetch_stock_report(connection):
# Prioritaskan tabel products agar stok kritis mengikuti data produk aktif.
table_name = get_existing_table(connection, ['products', 'bahan'])
if not table_name:
return None, 'Tabel bahan atau products tidak ditemukan'
if table_name == 'products':
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
backfill_default_min_stock(connection)
name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name'])
stock_col = get_existing_column(connection, table_name, ['stok', 'current_stock', 'stock'])
min_col = get_existing_column(connection, table_name, ['stok_minimum', 'min_stock', 'minimum_stock'])
unit_col = get_existing_column(connection, table_name, ['unit'])
if not name_col or not stock_col:
return None, 'Kolom bahan/stok tidak ditemukan'
select_min = min_col if min_col else '0'
select_unit = unit_col if unit_col else "''"
cursor = connection.cursor(dictionary=True)
cursor.execute(
f"""
SELECT {name_col} AS nama_bahan,
{stock_col} AS stok,
{select_min} AS stok_minimum,
{select_unit} AS unit
FROM {escape_table_name(table_name)}
ORDER BY {name_col}
"""
)
rows = cursor.fetchall()
cursor.close()
data = []
for row in rows:
stok = float(row.get('stok') or 0)
stok_minimum = float(row.get('stok_minimum') or 0)
status = compute_stock_status(stok, stok_minimum)
data.append({
'nama_bahan': row.get('nama_bahan'),
'stok': stok,
'stok_minimum': stok_minimum,
'status': status,
'unit': row.get('unit') or 'kg'
})
return data, None
def fetch_penggunaan_bahan(connection):
table_name = get_existing_table(connection, ['stok_keluar', 'stock_usage_history'])
if not table_name:
return []
cursor = connection.cursor(dictionary=True)
if table_name == 'stock_usage_history':
cursor.execute(
f"""
SELECT product_name AS nama_bahan,
COALESCE(SUM(quantity_used), 0) AS total_digunakan,
COALESCE(unit, 'kg') AS satuan
FROM {escape_table_name(table_name)}
GROUP BY product_name, unit
ORDER BY total_digunakan DESC
LIMIT 5
"""
)
else:
name_col = get_existing_column(connection, table_name, ['nama_bahan'])
qty_col = get_existing_column(connection, table_name, ['jumlah_keluar', 'quantity_used', 'jumlah'])
unit_col = get_existing_column(connection, table_name, ['satuan', 'unit'])
bahan_id_col = get_existing_column(connection, table_name, ['bahan_id', 'product_id'])
if name_col and qty_col:
unit_select = unit_col if unit_col else "'kg'"
cursor.execute(
f"""
SELECT {name_col} AS nama_bahan,
COALESCE(SUM({qty_col}), 0) AS total_digunakan,
{unit_select} AS satuan
FROM {escape_table_name(table_name)}
GROUP BY {name_col}, {unit_select}
ORDER BY total_digunakan DESC
LIMIT 5
"""
)
elif bahan_id_col and qty_col:
bahan_table = get_existing_table(connection, ['bahan', 'products'])
if not bahan_table:
cursor.close()
return []
bahan_name_col = get_existing_column(connection, bahan_table, ['nama_bahan', 'product_name', 'name'])
bahan_unit_col = get_existing_column(connection, bahan_table, ['satuan', 'unit'])
if not bahan_name_col:
cursor.close()
return []
unit_select = f"b.{bahan_unit_col}" if bahan_unit_col else "'kg'"
cursor.execute(
f"""
SELECT b.{bahan_name_col} AS nama_bahan,
COALESCE(SUM(k.{qty_col}), 0) AS total_digunakan,
{unit_select} AS satuan
FROM {escape_table_name(table_name)} k
JOIN {escape_table_name(bahan_table)} b ON b.id = k.{bahan_id_col}
GROUP BY b.{bahan_name_col}, {unit_select}
ORDER BY total_digunakan DESC
LIMIT 5
"""
)
else:
cursor.close()
return []
rows = cursor.fetchall()
cursor.close()
data = []
for row in rows:
data.append({
'nama_bahan': row.get('nama_bahan'),
'total_digunakan': float(row.get('total_digunakan') or 0),
'satuan': row.get('satuan') or 'kg'
})
return data
def getBahanDigunakanHariIni(connection):
"""Total bahan keluar hari ini dari stok_keluar, fallback ke stock_usage_history."""
table_name = get_existing_table(connection, ['stok_keluar'])
totalBahanDigunakanHariIni = 0
if table_name:
qty_col = get_existing_column(connection, table_name, ['jumlah_keluar'])
date_col = get_existing_column(connection, table_name, ['tanggal_keluar'])
created_col = get_existing_column(connection, table_name, ['created_at'])
if qty_col:
table_sql = escape_table_name(table_name)
cursor = connection.cursor()
if date_col and created_col:
cursor.execute(
f"""
SELECT COALESCE(SUM({qty_col}), 0)
FROM {table_sql}
WHERE (
{date_col} >= CURDATE()
AND {date_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
)
OR (
{date_col} IS NULL
AND {created_col} >= CURDATE()
AND {created_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
)
"""
)
elif date_col:
cursor.execute(
f"""
SELECT COALESCE(SUM({qty_col}), 0)
FROM {table_sql}
WHERE {date_col} >= CURDATE()
AND {date_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
"""
)
elif created_col:
cursor.execute(
f"""
SELECT COALESCE(SUM({qty_col}), 0)
FROM {table_sql}
WHERE {created_col} >= CURDATE()
AND {created_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
"""
)
else:
cursor.close()
cursor = None
if cursor:
totalBahanDigunakanHariIni = cursor.fetchone()[0] or 0
cursor.close()
if float(totalBahanDigunakanHariIni or 0) == 0 and table_exists(connection, 'stock_usage_history'):
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT quantity_used, unit
FROM stock_usage_history
WHERE created_at >= CURDATE()
AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
"""
)
rows = cursor.fetchall()
cursor.close()
totalBahanDigunakanHariIni = sum(
convert_stock_quantity(
float(row.get('quantity_used') or 0),
row.get('unit') or 'kg',
'kg'
)
for row in rows
)
total = float(totalBahanDigunakanHariIni)
return {
'total': int(total) if total.is_integer() else total,
'satuan': 'kg',
'keterangan': 'total penggunaan hari ini'
}
def grams_to_kg_rounded(grams: float) -> float:
"""
Convert grams to kilograms with rounding rules:
- <= 500g -> 0.5kg
- 500g < g <= 1000g -> 1kg
- > 1000g -> round up to nearest 0.5kg
"""
if grams <= 0:
raise ValueError("grams must be greater than 0")
if grams <= 500:
return 0.5
if grams <= 1000:
return 1.0
return math.ceil(grams / 500.0) * 0.5
def normalize_stock_unit(unit: str | None) -> str:
normalized = (unit or '').strip().lower()
if normalized in ['g', 'gr', 'gram', 'grams']:
return 'gr'
if normalized in ['kg', 'kilogram', 'kilograms']:
return 'kg'
if normalized in ['ml', 'mili', 'mililiter', 'milliliter']:
return 'ml'
if normalized in ['l', 'lt', 'ltr', 'liter', 'litre']:
return 'l'
if normalized in ['butir', 'pcs', 'piece', 'pieces']:
return 'butir'
return normalized
def convert_stock_quantity(quantity: float, from_unit: str, to_unit: str) -> float:
unit = normalize_stock_unit(from_unit)
stock_unit = normalize_stock_unit(to_unit)
if unit == stock_unit or not unit or not stock_unit:
return quantity
if unit == 'gr' and stock_unit == 'kg':
return quantity / 1000
if unit == 'kg' and stock_unit == 'gr':
return quantity * 1000
if unit == 'ml' and stock_unit == 'kg':
return quantity / 1000
if unit == 'l' and stock_unit == 'kg':
return quantity
if unit == 'kg' and stock_unit == 'ml':
return quantity * 1000
if unit == 'kg' and stock_unit == 'l':
return quantity
if unit == 'ml' and stock_unit == 'gr':
return quantity
if unit == 'gr' and stock_unit == 'ml':
return quantity
if unit == 'l' and stock_unit == 'ml':
return quantity * 1000
if unit == 'ml' and stock_unit == 'l':
return quantity / 1000
return quantity
# ============================================================================
# LOAD MODELS AT STARTUP
# ============================================================================
try:
model = joblib.load('model_prediksi.pkl')
encoders = joblib.load('encoders.pkl')
feature_columns = joblib.load('feature_columns.pkl')
metadata = joblib.load('model_metadata.pkl')
logger.info("Models loaded successfully")
logger.info(f"Model Type: {metadata['model_type']}")
logger.info(f"R² Score: {metadata['r2_score']:.4f}")
except Exception as e:
logger.error(f"Failed to load models: {e}")
raise
# ============================================================================
# ROUTES
# ============================================================================
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'model_type': metadata['model_type'],
'r2_score': round(metadata['r2_score'], 4),
'timestamp': datetime.now().isoformat()
}), 200
@app.route('/metadata', methods=['GET'])
def get_metadata():
"""Get model metadata"""
return jsonify({
'status': 'success',
'model_info': {
'type': metadata['model_type'],
'r2_score': round(metadata['r2_score'], 4),
'mae': round(metadata['mae'], 4),
'rmse': round(metadata['rmse'], 4),
'features': feature_columns,
'target': metadata['target_column'],
'total_samples': metadata['total_samples']
}
}), 200
@app.route('/api/login', methods=['POST'])
def login():
"""Login using email/username and password stored in the login table."""
try:
data = request.json or {}
username_or_email = (data.get('email') or data.get('username') or '').strip()
password = data.get('password') or ''
if not username_or_email or not password:
return jsonify({
'status': 'error',
'message': 'Email/username dan password wajib diisi'
}), 400
connection = get_db_connection()
if connection is None:
return jsonify({
'status': 'error',
'message': 'Tidak dapat terhubung ke database'
}), 500
cursor = connection.cursor(dictionary=True)
cursor.execute("""
SELECT id, name, email, username, password
FROM login
WHERE email = %s OR username = %s
LIMIT 1
""", (username_or_email, username_or_email))
user = cursor.fetchone()
cursor.close()
connection.close()
if user is None or not verify_login_password(password, user['password']):
return jsonify({
'status': 'error',
'message': 'Email/username atau password salah'
}), 401
user.pop('password', None)
return jsonify({
'status': 'success',
'message': 'Login berhasil',
'data': user
}), 200
except Error as e:
logger.error(f"Login database error: {e}")
return jsonify({
'status': 'error',
'message': 'Tabel login belum tersedia atau database bermasalah'
}), 500
except Exception as e:
logger.error(f"Login error: {e}")
return jsonify({
'status': 'error',
'message': 'Terjadi kesalahan saat login'
}), 500
@app.route('/api/forgot-password/send-otp', methods=['POST'])
def send_forgot_password_otp():
"""Create OTP for password reset and send it to the account email."""
try:
data = request.json or {}
username_or_email = (data.get('email') or data.get('username') or '').strip()
if not username_or_email:
return jsonify({
'status': 'error',
'message': 'Email/username wajib diisi'
}), 400
connection = get_db_connection()
if connection is None:
return jsonify({
'status': 'error',
'message': 'Tidak dapat terhubung ke database'
}), 500
cursor = connection.cursor(dictionary=True)
cursor.execute("""
SELECT id, name, email, username
FROM login
WHERE email = %s OR username = %s
LIMIT 1
""", (username_or_email, username_or_email))
user = cursor.fetchone()
cursor.close()
connection.close()
if user is None:
return jsonify({
'status': 'error',
'message': 'Akun tidak ditemukan'
}), 404
otp_code = f"{secrets.randbelow(1000000):06d}"
expires_at = datetime.now() + timedelta(minutes=10)
send_otp_email(user['email'], otp_code)
connection = get_db_connection()
if connection is None:
return jsonify({
'status': 'error',
'message': 'Tidak dapat terhubung ke database'
}), 500
cursor = connection.cursor(dictionary=True)
cursor.execute("""
INSERT INTO password_reset_otps (login_id, email, otp_code, expires_at)
VALUES (%s, %s, %s, %s)
""", (user['id'], user['email'], otp_code, expires_at))
connection.commit()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'message': 'Kode OTP berhasil dikirim ke email. Berlaku selama 10 menit.',
'data': {
'email': mask_email(user['email'])
}
}), 200
except RuntimeError as e:
logger.error(f"Send OTP configuration error: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
except smtplib.SMTPAuthenticationError as e:
logger.error(f"Send OTP SMTP authentication error: {e}")
return jsonify({
'status': 'error',
'message': 'Gagal login ke Gmail. Gunakan App Password Gmail, bukan password email biasa.'
}), 500
except smtplib.SMTPException as e:
logger.error(f"Send OTP SMTP error: {e}")
return jsonify({
'status': 'error',
'message': 'Gagal mengirim OTP ke email. Periksa konfigurasi SMTP dan koneksi internet.'
}), 500
except Error as e:
logger.error(f"Send OTP database error: {e}")
return jsonify({
'status': 'error',
'message': 'Tabel OTP belum tersedia atau database bermasalah'
}), 500
except Exception as e:
logger.error(f"Send OTP error: {e}")
return jsonify({
'status': 'error',
'message': 'Terjadi kesalahan saat membuat OTP'
}), 500
@app.route('/api/change-password', methods=['POST'])
def change_password():
"""Change password for a logged-in account using the current password."""
try:
data = request.json or {}
username_or_email = (data.get('email') or data.get('username') or '').strip()
current_password = data.get('current_password') or ''
new_password = data.get('new_password') or ''
if not username_or_email or not current_password or not new_password:
return jsonify({
'status': 'error',
'message': 'Email/username, password lama, dan password baru wajib diisi'
}), 400
if len(new_password) < 6:
return jsonify({
'status': 'error',
'message': 'Password baru minimal 6 karakter'
}), 400
connection = get_db_connection()
if connection is None:
return jsonify({
'status': 'error',
'message': 'Tidak dapat terhubung ke database'
}), 500
cursor = connection.cursor(dictionary=True)
cursor.execute("""
SELECT id, password
FROM login
WHERE email = %s OR username = %s
LIMIT 1
""", (username_or_email, username_or_email))
user = cursor.fetchone()
if user is None:
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': 'Akun tidak ditemukan'
}), 404
if not verify_login_password(current_password, user['password']):
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': 'Password lama tidak sesuai'
}), 400
cursor.execute(
"UPDATE login SET password = %s WHERE id = %s",
(hash_password(new_password), user['id'])
)
connection.commit()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'message': 'Password berhasil diperbarui'
}), 200
except Error as e:
logger.error(f"Change password database error: {e}")
return jsonify({
'status': 'error',
'message': 'Database bermasalah saat mengubah password'
}), 500
except Exception as e:
logger.error(f"Change password error: {e}")
return jsonify({
'status': 'error',
'message': 'Terjadi kesalahan saat mengubah password'
}), 500
@app.route('/api/forgot-password/verify-otp', methods=['POST'])
def verify_forgot_password_otp():
"""Verify OTP before allowing password reset."""
try:
data = request.json or {}
username_or_email = (data.get('email') or data.get('username') or '').strip()
otp_code = (data.get('otp') or '').strip()
if not username_or_email or not otp_code:
return jsonify({
'status': 'error',
'message': 'Email/username dan OTP wajib diisi'
}), 400
connection = get_db_connection()
if connection is None:
return jsonify({
'status': 'error',
'message': 'Tidak dapat terhubung ke database'
}), 500
cursor = connection.cursor(dictionary=True)
cursor.execute("""
SELECT o.id
FROM password_reset_otps o
JOIN login l ON l.id = o.login_id
WHERE (l.email = %s OR l.username = %s)
AND o.otp_code = %s
AND o.is_used = 0
AND o.expires_at >= NOW()
ORDER BY o.created_at DESC
LIMIT 1
""", (username_or_email, username_or_email, otp_code))
otp = cursor.fetchone()
cursor.close()
connection.close()
if otp is None:
return jsonify({
'status': 'error',
'message': 'OTP salah atau sudah kedaluwarsa'
}), 400
return jsonify({
'status': 'success',
'message': 'OTP valid'
}), 200
except Error as e:
logger.error(f"Verify OTP database error: {e}")
return jsonify({
'status': 'error',
'message': 'Tabel OTP belum tersedia atau database bermasalah'
}), 500
except Exception as e:
logger.error(f"Verify OTP error: {e}")
return jsonify({
'status': 'error',
'message': 'Terjadi kesalahan saat verifikasi OTP'
}), 500
@app.route('/api/forgot-password/reset', methods=['POST'])
def reset_password_with_otp():
"""Reset account password after OTP verification."""
try:
data = request.json or {}
username_or_email = (data.get('email') or data.get('username') or '').strip()
otp_code = (data.get('otp') or '').strip()
new_password = data.get('new_password') or ''
if not username_or_email or not otp_code or not new_password:
return jsonify({
'status': 'error',
'message': 'Email/username, OTP, dan password baru wajib diisi'
}), 400
if len(new_password) < 6:
return jsonify({
'status': 'error',
'message': 'Password baru minimal 6 karakter'
}), 400
connection = get_db_connection()
if connection is None:
return jsonify({
'status': 'error',
'message': 'Tidak dapat terhubung ke database'
}), 500
cursor = connection.cursor(dictionary=True)
cursor.execute("""
SELECT o.id AS otp_id, l.id AS login_id
FROM password_reset_otps o
JOIN login l ON l.id = o.login_id
WHERE (l.email = %s OR l.username = %s)
AND o.otp_code = %s
AND o.is_used = 0
AND o.expires_at >= NOW()
ORDER BY o.created_at DESC
LIMIT 1
""", (username_or_email, username_or_email, otp_code))
otp = cursor.fetchone()
if otp is None:
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': 'OTP salah atau sudah kedaluwarsa'
}), 400
cursor.execute(
"UPDATE login SET password = %s WHERE id = %s",
(hash_password(new_password), otp['login_id'])
)
cursor.execute(
"UPDATE password_reset_otps SET is_used = 1 WHERE id = %s",
(otp['otp_id'],)
)
connection.commit()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'message': 'Password berhasil diperbarui'
}), 200
except Error as e:
logger.error(f"Reset password database error: {e}")
return jsonify({
'status': 'error',
'message': 'Tabel OTP belum tersedia atau database bermasalah'
}), 500
except Exception as e:
logger.error(f"Reset password error: {e}")
return jsonify({
'status': 'error',
'message': 'Terjadi kesalahan saat reset password'
}), 500
@app.route('/prediksi', methods=['POST'])
def prediksi():
"""
Predict stock demand
Body: {
"tahun": 2024,
"bulan": 4,
"hari": 4,
"hari_dalam_minggu": 3,
"harga_satuan_update": 50000,
"total_harga_update": 250000,
"produk_encoded": 2,
"nama_produk_encoded": 2,
"kategori_produk_encoded": 1,
"hari_minggu": 3
}
"""
try:
data = request.json
# Validate required fields
required_fields = feature_columns
missing_fields = [f for f in required_fields if f not in data]
if missing_fields:
return jsonify({
'status': 'error',
'message': f'Missing fields: {", ".join(missing_fields)}',
'required_fields': required_fields
}), 400
# Create feature array
X_pred = np.array([[data[f] for f in feature_columns]])
# Predict
prediksi_raw = model.predict(X_pred)[0]
prediksi = max(1, round(prediksi_raw))
# Return result
return jsonify({
'status': 'success',
'input': data,
'prediksi': {
'jumlah_unit': prediksi,
'nilai_raw': round(prediksi_raw, 2)
},
'model_accuracy': {
'r2_score': round(metadata['r2_score'], 4),
'mae': round(metadata['mae'], 4),
'rmse': round(metadata['rmse'], 4)
}
}), 200
except Exception as e:
logger.error(f"Prediction error: {str(e)}")
return jsonify({
'status': 'error',
'message': f'Prediction failed: {str(e)}'
}), 500
@app.route('/batch-prediksi', methods=['POST'])
def batch_prediksi():
"""
Batch prediction for multiple items
Body: {
"items": [
{"tahun": 2024, "bulan": 4, ...},
{"tahun": 2024, "bulan": 5, ...}
]
}
"""
try:
data = request.json
if 'items' not in data or not isinstance(data['items'], list):
return jsonify({
'status': 'error',
'message': 'Body harus berisi "items" array'
}), 400
results = []
for i, item in enumerate(data['items']):
try:
# Check required fields
missing_fields = [f for f in feature_columns if f not in item]
if missing_fields:
results.append({
'index': i,
'status': 'error',
'message': f'Missing fields: {", ".join(missing_fields)}'
})
continue
# Create feature array
X_pred = np.array([[item[f] for f in feature_columns]])
# Predict
prediksi_raw = model.predict(X_pred)[0]
prediksi = max(1, round(prediksi_raw))
results.append({
'index': i,
'status': 'success',
'prediksi': prediksi,
'nilai_raw': round(prediksi_raw, 2)
})
except Exception as e:
results.append({
'index': i,
'status': 'error',
'message': str(e)
})
return jsonify({
'status': 'success',
'total_items': len(data['items']),
'results': results
}), 200
except Exception as e:
logger.error(f"Batch prediction error: {str(e)}")
return jsonify({
'status': 'error',
'message': f'Batch prediction failed: {str(e)}'
}), 500
@app.route('/info', methods=['GET'])
def info():
"""Get API information"""
return jsonify({
'api_name': 'Prediksi Permintaan Stok Bahan',
'version': '2.0',
'model': metadata['model_type'],
'endpoints': {
'GET /health': 'API health check',
'GET /metadata': 'Get model metadata',
'GET /info': 'Get API info',
'POST /prediksi': 'Single prediction',
'POST /batch-prediksi': 'Batch prediction',
'GET /products': 'Get all products',
'POST /transactions': 'Save transaction (legacy)',
'GET /transactions': 'Get transaction history',
'POST /transaksi': 'Save transaction and update stock automatically',
'POST /stock/consume': 'Consume stock after production'
},
'required_features': feature_columns
}), 200
# ============================================================================
# DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS
# ============================================================================
@app.route('/products', methods=['GET'])
def get_products():
"""Get all products from database"""
try:
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
backfill_default_min_stock(connection)
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM products ORDER BY name")
products = cursor.fetchall()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'total': len(products),
'products': products
}), 200
except Exception as e:
logger.error(f"Get products error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/products/<int:product_id>', methods=['GET'])
def get_product(product_id):
"""Get specific product by ID"""
try:
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
product = cursor.fetchone()
cursor.close()
connection.close()
if not product:
return jsonify({'status': 'error', 'message': 'Product not found'}), 404
return jsonify({
'status': 'success',
'product': product
}), 200
except Exception as e:
logger.error(f"Get product error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/products', methods=['POST'])
def create_product():
"""
Create new product in database
Body: {
"name": "Tepung Terigu 1kg",
"category": "Tepung",
"price": 15000,
"current_stock": 50,
"unit": "kg",
"product_type": "Bahan"
}
"""
try:
data = request.json
# Validate required fields
required_fields = ['name', 'category', 'price', 'current_stock']
missing_fields = [f for f in required_fields if f not in data]
if missing_fields:
return jsonify({
'status': 'error',
'message': f'Missing fields: {", ".join(missing_fields)}',
'required_fields': required_fields
}), 400
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
cursor = connection.cursor()
# Optional columns compatibility (works for old/new schemas)
cursor.execute("SHOW COLUMNS FROM products LIKE 'unit'")
has_unit_column = cursor.fetchone() is not None
cursor.execute("SHOW COLUMNS FROM products LIKE 'product_type'")
has_product_type_column = cursor.fetchone() is not None
cursor.execute("SHOW COLUMNS FROM products LIKE 'min_stock'")
has_min_stock_column = cursor.fetchone() is not None
unit_value = data.get('unit')
product_type_value = data.get('product_type')
min_stock_value = data.get('min_stock', 0)
# Check for duplicate product name
cursor.execute("SELECT id FROM products WHERE name = %s", (data['name'],))
if cursor.fetchone():
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': f'Product "{data["name"]}" already exists'
}), 409
columns = ['name', 'category', 'price', 'current_stock']
values = [data['name'], data['category'], data['price'], data['current_stock']]
if has_unit_column:
columns.append('unit')
values.append(unit_value)
if has_product_type_column:
columns.append('product_type')
values.append(product_type_value)
if has_min_stock_column:
columns.append('min_stock')
values.append(min_stock_value)
column_sql = ', '.join(columns)
placeholders = ', '.join(['%s'] * len(columns))
cursor.execute(
f"INSERT INTO products ({column_sql}) VALUES ({placeholders})",
tuple(values)
)
connection.commit()
product_id = cursor.lastrowid
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'product_id': product_id,
'message': 'Product created successfully'
}), 201
except Exception as e:
logger.error(f"Create product error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/stock/consume', methods=['POST'])
def consume_stock():
"""
Consume stock after production.
Body: {
"recipe_name": "Donat",
"production_quantity": 10,
"items": [
{"product_id": 1, "product_name": "Tepung Terigu 1kg", "quantity": 1, "unit": "kg"}
],
"allow_partial": false
}
"""
print("FLASK TERBARU AKTIF")
print("REQUEST RECEIVED")
connection = None
cursor = None
started_transaction = False
try:
data = request.json or {}
print("REQUEST DATA:", data)
items = data.get('items', [])
allow_partial = bool(data.get('allow_partial', False))
if not isinstance(items, list) or not items:
return jsonify({
'status': 'error',
'message': 'Items wajib diisi'
}), 400
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_product_stock_precision(connection)
ensure_stock_usage_tables(connection)
name_column = get_product_name_column(connection)
unit_column_exists = has_product_unit_column(connection)
history_enabled = table_exists(connection, 'stock_usage_history')
stock_out_enabled = table_exists(connection, 'stok_keluar')
stock_out_product_col = None
stock_out_qty_col = None
stock_out_unit_col = None
stock_out_date_col = None
if stock_out_enabled:
stock_out_product_col = get_existing_column(connection, 'stok_keluar', ['bahan_id', 'product_id'])
stock_out_qty_col = get_existing_column(connection, 'stok_keluar', ['jumlah_keluar'])
stock_out_unit_col = get_existing_column(connection, 'stok_keluar', ['satuan', 'unit'])
stock_out_date_col = get_existing_column(connection, 'stok_keluar', ['tanggal_keluar'])
stock_out_enabled = bool(stock_out_product_col and stock_out_qty_col)
cursor = connection.cursor(dictionary=True)
started_transaction = True
errors = []
valid_items = []
for item in items:
product_id = item.get('product_id')
product_name = item.get('product_name')
quantity = item.get('quantity')
unit = (item.get('unit') or '').strip().lower()
if not isinstance(quantity, (int, float)) or quantity <= 0:
errors.append({
'item': item,
'message': 'Quantity harus lebih dari 0'
})
continue
if unit_column_exists:
select_fields = f"id, {name_column} as name, current_stock, unit"
else:
select_fields = f"id, {name_column} as name, current_stock"
if product_id:
cursor.execute(
f"SELECT {select_fields} FROM products WHERE id = %s",
(product_id,)
)
else:
cursor.execute(
f"SELECT {select_fields} FROM products WHERE {name_column} = %s",
(product_name,)
)
product = cursor.fetchone()
if not product:
errors.append({
'item': item,
'message': 'Produk tidak ditemukan'
})
continue
product_unit = (product.get('unit') or '').strip().lower()
effective_unit = normalize_stock_unit(product_unit or unit)
effective_quantity = convert_stock_quantity(
float(quantity),
unit or effective_unit,
effective_unit
)
logger.info(
f"[stock/consume] Using quantity: {quantity} {unit or effective_unit} -> {effective_quantity} {effective_unit} (product_id={product.get('id')})"
)
if product['current_stock'] < effective_quantity:
errors.append({
'item': item,
'message': 'Stok tidak cukup',
'available': product['current_stock']
})
continue
valid_items.append({
'product': product,
'quantity': effective_quantity,
'unit': effective_unit or product.get('unit'),
'input_quantity': quantity,
'input_unit': unit or product.get('unit')
})
if errors and not allow_partial:
connection.rollback()
return jsonify({
'status': 'error',
'message': 'Ada item gagal diproses',
'errors': errors
}), 400
print("UPDATING STOCK")
results = []
for entry in valid_items:
product = entry['product']
quantity = entry['quantity']
cursor.execute(
"UPDATE products SET current_stock = current_stock - %s WHERE id = %s",
(quantity, product['id'])
)
if history_enabled:
cursor.execute(
"""
INSERT INTO stock_usage_history
(recipe_name, production_quantity, product_id, product_name, quantity_used, unit)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
data.get('recipe_name'),
data.get('production_quantity'),
product['id'],
product['name'],
quantity,
entry['unit']
)
)
if stock_out_enabled:
columns = [stock_out_product_col, stock_out_qty_col]
values = [product['id'], quantity]
if stock_out_unit_col:
columns.append(stock_out_unit_col)
values.append(entry['unit'])
if stock_out_date_col:
columns.append(stock_out_date_col)
values.append(datetime.now().date())
column_sql = ', '.join(columns)
placeholders = ', '.join(['%s'] * len(columns))
cursor.execute(
f"INSERT INTO stok_keluar ({column_sql}) VALUES ({placeholders})",
tuple(values)
)
results.append({
'product_id': product['id'],
'product_name': product['name'],
'quantity_used': quantity,
'deducted_amount': quantity,
'unit': entry['unit'],
'quantity_input': entry.get('input_quantity'),
'unit_input': entry.get('input_unit')
})
connection.commit()
return jsonify({
'status': 'success',
'processed': len(results),
'results': results,
'errors': errors if allow_partial else []
}), 200
except Exception as e:
if connection and started_transaction:
try:
connection.rollback()
except Exception:
logger.exception("[stock/consume] Failed to rollback transaction")
logger.error(f"Consume stock error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
if cursor is not None:
try:
cursor.close()
except Exception:
logger.exception("[stock/consume] Failed to close cursor")
if connection is not None:
try:
connection.close()
except Exception:
logger.exception("[stock/consume] Failed to close connection")
print("REQUEST FINISHED")
@app.route('/transactions', methods=['POST'])
def save_transaction():
"""
Save transaction to database
Body: {
"product_name": "Tepung Terigu 1kg",
"category": "Tepung",
"quantity": 5,
"unit_price": 15000,
"total_price": 75000,
"transaction_date": "2024-04-05"
}
"""
try:
data = request.json
# Validate required fields
required_fields = ['product_name', 'category', 'quantity', 'unit_price', 'total_price', 'transaction_date']
missing_fields = [f for f in required_fields if f not in data]
if missing_fields:
return jsonify({
'status': 'error',
'message': f'Missing fields: {", ".join(missing_fields)}',
'required_fields': required_fields
}), 400
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
cursor = connection.cursor()
cursor.execute(f"""
INSERT INTO {TRANSACTIONS_TABLE}
(product_name, category, quantity, unit_price, total_price, transaction_date)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
data['product_name'],
data['category'],
data['quantity'],
data['unit_price'],
data['total_price'],
data['transaction_date']
))
connection.commit()
transaction_id = cursor.lastrowid
cursor.close()
connection.close()
logger.info(f"Transaction saved: ID={transaction_id}")
return jsonify({
'status': 'success',
'message': 'Transaction saved successfully',
'transaction_id': transaction_id
}), 201
except Exception as e:
logger.error(f"Save transaction error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/transaksi', methods=['POST'])
def add_transaction_with_stock_update():
"""
Add transaction AND update stock automatically
Body: {
"produk_id": 1,
"jumlah": 5,
"unit_price": 15000,
"total_price": 75000,
"transaction_date": "2024-04-05"
}
"""
try:
data = request.json
# Validate required fields
required_fields = ['produk_id', 'jumlah', 'unit_price', 'total_price', 'transaction_date']
missing_fields = [f for f in required_fields if f not in data]
if missing_fields:
return jsonify({
'status': 'error',
'message': f'Missing fields: {", ".join(missing_fields)}',
'required_fields': required_fields
}), 400
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
cursor = connection.cursor(dictionary=True)
# 1. Get product info
cursor.execute("SELECT id, name, category FROM products WHERE id = %s", (data['produk_id'],))
product = cursor.fetchone()
if not product:
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': f'Product ID {data["produk_id"]} not found'
}), 404
# 2. Save transaction
cursor.execute(f"""
INSERT INTO {TRANSACTIONS_TABLE}
(product_name, category, quantity, unit_price, total_price, transaction_date)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
product['name'],
product['category'],
data['jumlah'],
data['unit_price'],
data['total_price'],
data['transaction_date']
))
connection.commit()
transaction_id = cursor.lastrowid
# 3. Update product stock (add quantity)
cursor.execute("""
UPDATE products
SET current_stock = current_stock + %s
WHERE id = %s
""", (data['jumlah'], data['produk_id']))
connection.commit()
cursor.close()
connection.close()
logger.info(f"Transaction saved: ID={transaction_id}, Stock updated for product ID={data['produk_id']} (+{data['jumlah']})")
return jsonify({
'status': 'success',
'message': 'Stok berhasil ditambahkan',
'transaction_id': transaction_id,
'product_id': data['produk_id'],
'product_name': product['name'],
'quantity_added': data['jumlah']
}), 201
except Exception as e:
logger.error(f"Add transaction with stock update error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/transactions', methods=['GET'])
def get_transactions():
"""Get transaction history"""
try:
# Get optional query parameters
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
product_name = request.args.get('product_name', None)
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
cursor = connection.cursor(dictionary=True)
# Build query
query = f"SELECT * FROM {TRANSACTIONS_TABLE} WHERE 1=1"
params = []
if product_name:
query += " AND product_name LIKE %s"
params.append(f"%{product_name}%")
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
cursor.execute(query, params)
transactions = cursor.fetchall()
# Get total count
cursor.execute(f"SELECT COUNT(*) as total FROM {TRANSACTIONS_TABLE}" +
(" WHERE product_name LIKE %s" if product_name else ""),
([f"%{product_name}%"] if product_name else []))
total = cursor.fetchone()['total']
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'total': total,
'limit': limit,
'offset': offset,
'transactions': transactions
}), 200
except Exception as e:
logger.error(f"Get transactions error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/predictions', methods=['POST'])
def save_prediction():
"""
Save prediction result to database
Body: {
"product_name": "Tepung Terigu 1kg",
"category": "Tepung",
"unit_price": 15000,
"prediction_date": "2024-04-05",
"predicted_quantity": 45,
"raw_value": 44.8,
"estimated_total_price": 672000,
"accuracy_r2": 0.9964,
"error_mae": 2.51
}
"""
try:
data = request.json
required_fields = ['product_name', 'category', 'unit_price', 'prediction_date', 'predicted_quantity']
missing_fields = [f for f in required_fields if f not in data]
if missing_fields:
return jsonify({
'status': 'error',
'message': f'Missing fields: {", ".join(missing_fields)}'
}), 400
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
table_name = 'predictions'
needs_col = ensure_prediction_needs_column(connection, table_name)
columns = [
'product_name',
'category',
'unit_price',
'prediction_date',
'predicted_quantity',
'raw_value',
'estimated_total_price',
'accuracy_r2',
'error_mae'
]
values = [
data['product_name'],
data['category'],
data['unit_price'],
data['prediction_date'],
data['predicted_quantity'],
data.get('raw_value'),
data.get('estimated_total_price'),
data.get('accuracy_r2'),
data.get('error_mae')
]
if needs_col:
columns.append(needs_col)
values.append(data.get('estimated_needs') or data.get('estimasi_kebutuhan_bahan'))
cursor = connection.cursor()
placeholders = ', '.join(['%s'] * len(columns))
column_sql = ', '.join(columns)
cursor.execute(
f"INSERT INTO predictions ({column_sql}) VALUES ({placeholders})",
tuple(values)
)
connection.commit()
prediction_id = cursor.lastrowid
cursor.close()
connection.close()
logger.info(f"Prediction saved: ID={prediction_id}")
return jsonify({
'status': 'success',
'message': 'Prediction saved successfully',
'prediction_id': prediction_id
}), 201
except Exception as e:
logger.error(f"Save prediction error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
# ============================================================================
# REPORT ENDPOINTS - LAPORAN
# ============================================================================
@app.route('/laporan/stok', methods=['GET'])
def laporan_stok():
"""Laporan stok bahan dari tabel bahan/products."""
try:
connection = get_db_connection()
if not connection:
return build_report_response(False, 'Database connection failed', [], 500)
data, error_message = fetch_stock_report(connection)
connection.close()
if error_message:
return build_report_response(False, error_message, [], 404)
return build_report_response(True, 'Data berhasil diambil', data, 200)
except Exception as e:
logger.error(f"Laporan stok error: {str(e)}")
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
@app.route('/laporan/bahan-kritis', methods=['GET'])
def laporan_bahan_kritis():
"""Laporan bahan kritis/ rendah berdasarkan stok minimum."""
try:
connection = get_db_connection()
if not connection:
return build_report_response(False, 'Database connection failed', [], 500)
data, error_message = fetch_stock_report(connection)
connection.close()
if error_message:
return build_report_response(False, error_message, [], 404)
critical_items = [
item for item in data
if item['status'] in ['Rendah', 'Kritis']
]
return build_report_response(True, 'Data berhasil diambil', critical_items, 200)
except Exception as e:
logger.error(f"Laporan bahan kritis error: {str(e)}")
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
@app.route('/laporan/stok-masuk', methods=['GET'])
def laporan_stok_masuk():
"""Laporan riwayat stok masuk dari tabel stok_masuk/Stock In/transactions."""
try:
connection = get_db_connection()
if not connection:
return build_report_response(False, 'Database connection failed', [], 500)
table_name = get_existing_table(connection, ['stok_masuk', 'Stock In', 'transactions'])
if not table_name:
connection.close()
return build_report_response(False, 'Tabel stok_masuk/transactions tidak ditemukan', [], 404)
name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name'])
qty_col = get_existing_column(connection, table_name, ['jumlah', 'quantity'])
date_col = get_existing_column(connection, table_name, ['tanggal', 'transaction_date', 'created_at'])
unit_col = get_existing_column(connection, table_name, ['unit'])
product_id_col = get_existing_column(connection, table_name, ['product_id', 'produk_id', 'bahan_id'])
if not qty_col or not date_col:
connection.close()
return build_report_response(False, 'Kolom stok masuk tidak lengkap', [], 500)
cursor = connection.cursor(dictionary=True)
table_sql = escape_table_name(table_name)
if not name_col and product_id_col:
product_table = get_existing_table(connection, ['products', 'bahan'])
if product_table:
product_name_col = get_existing_column(connection, product_table, ['nama_bahan', 'product_name', 'name'])
product_unit_col = get_existing_column(connection, product_table, ['unit'])
if product_name_col:
cursor.execute(
f"""
SELECT p.{product_name_col} AS nama_bahan,
sm.{qty_col} AS jumlah,
sm.{date_col} AS tanggal,
p.{product_unit_col} AS unit
FROM {table_sql} sm
JOIN {escape_table_name(product_table)} p ON p.id = sm.{product_id_col}
ORDER BY sm.{date_col} DESC
"""
)
else:
cursor.execute(
f"""
SELECT sm.{qty_col} AS jumlah,
sm.{date_col} AS tanggal
FROM {table_sql} sm
ORDER BY sm.{date_col} DESC
"""
)
else:
cursor.execute(
f"""
SELECT sm.{qty_col} AS jumlah,
sm.{date_col} AS tanggal
FROM {table_sql} sm
ORDER BY sm.{date_col} DESC
"""
)
else:
unit_select = unit_col if unit_col else "''"
cursor.execute(
f"""
SELECT {name_col} AS nama_bahan,
{qty_col} AS jumlah,
{date_col} AS tanggal,
{unit_select} AS unit
FROM {table_sql}
ORDER BY {date_col} DESC
"""
)
rows = cursor.fetchall()
cursor.close()
connection.close()
data = []
for row in rows:
tanggal = row.get('tanggal')
data.append({
'nama_bahan': row.get('nama_bahan'),
'jumlah': float(row.get('jumlah') or 0),
'tanggal': tanggal.isoformat() if hasattr(tanggal, 'isoformat') else tanggal,
'unit': row.get('unit') or 'kg'
})
return build_report_response(True, 'Data berhasil diambil', data, 200)
except Exception as e:
logger.error(f"Laporan stok masuk error: {str(e)}")
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
@app.route('/laporan/prediksi', methods=['GET'])
def laporan_prediksi():
"""Laporan prediksi permintaan dari tabel prediksi/predictions."""
try:
connection = get_db_connection()
if not connection:
return build_report_response(False, 'Database connection failed', [], 500)
table_name = get_existing_table(connection, ['prediksi', 'predictions'])
if not table_name:
connection.close()
return build_report_response(False, 'Tabel prediksi/predictions tidak ditemukan', [], 404)
name_col = get_existing_column(connection, table_name, ['nama_produk', 'product_name', 'name'])
result_col = get_existing_column(connection, table_name, ['hasil_prediksi', 'predicted_quantity'])
estimate_col = get_existing_column(connection, table_name, ['estimasi_kebutuhan_bahan', 'estimated_needs', 'raw_value'])
date_col = get_existing_column(connection, table_name, ['tanggal_prediksi', 'prediction_date', 'created_at'])
if not result_col or not date_col:
connection.close()
return build_report_response(False, 'Kolom prediksi tidak lengkap', [], 500)
cursor = connection.cursor(dictionary=True)
table_sql = escape_table_name(table_name)
estimate_select = estimate_col if estimate_col else 'NULL'
name_select = name_col if name_col else "''"
cursor.execute(
f"""
SELECT {name_select} AS nama_produk,
{result_col} AS hasil_prediksi,
{estimate_select} AS estimasi_kebutuhan_bahan,
{date_col} AS tanggal_prediksi
FROM {table_sql}
ORDER BY {date_col} DESC
"""
)
rows = cursor.fetchall()
cursor.close()
connection.close()
data = []
for row in rows:
tanggal_prediksi = row.get('tanggal_prediksi')
data.append({
'nama_produk': row.get('nama_produk'),
'hasil_prediksi': float(row.get('hasil_prediksi') or 0),
'estimasi_kebutuhan_bahan': row.get('estimasi_kebutuhan_bahan'),
'tanggal_prediksi': tanggal_prediksi.isoformat() if hasattr(tanggal_prediksi, 'isoformat') else tanggal_prediksi
})
return build_report_response(True, 'Data berhasil diambil', data, 200)
except Exception as e:
logger.error(f"Laporan prediksi error: {str(e)}")
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
# ============================================================================
# DASHBOARD ENDPOINTS
# ============================================================================
@app.route('/api/dashboard/bahan-digunakan-hari-ini', methods=['GET'])
def bahanDigunakanHariIni():
"""Total bahan keluar hari ini dari stok_keluar."""
connection = None
try:
connection = get_db_connection()
if not connection:
return jsonify({
'status': False,
'message': 'Database connection failed',
'total': 0,
'satuan': 'kg',
'keterangan': 'total penggunaan hari ini'
}), 500
data = getBahanDigunakanHariIni(connection)
return jsonify(data), 200
except Exception as e:
logger.error(f"Bahan digunakan hari ini error: {str(e)}")
return jsonify({
'status': False,
'message': f'Gagal mengambil data: {str(e)}',
'total': 0,
'satuan': 'kg',
'keterangan': 'total penggunaan hari ini'
}), 500
finally:
if connection:
connection.close()
@app.route('/api/dashboard/summary', methods=['GET'])
def dashboard_summary():
"""Ringkasan dashboard untuk grafik penggunaan bahan."""
try:
connection = get_db_connection()
if not connection:
return jsonify({
'status': False,
'message': 'Database connection failed',
'penggunaan_bahan': []
}), 500
penggunaan = fetch_penggunaan_bahan(connection)
connection.close()
return jsonify({
'status': True,
'penggunaan_bahan': penggunaan
}), 200
except Exception as e:
logger.error(f"Dashboard summary error: {str(e)}")
return jsonify({
'status': False,
'message': f'Gagal mengambil data: {str(e)}',
'penggunaan_bahan': []
}), 500
# ============================================================================
# RECIPES ENDPOINTS
# ============================================================================
@app.route('/recipes', methods=['GET'])
def get_recipes():
"""Get all recipes with their ingredients"""
try:
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
cursor = connection.cursor(dictionary=True)
# Get all recipes
cursor.execute("SELECT id, recipe_name, description FROM recipes ORDER BY recipe_name")
recipes = cursor.fetchall()
# Get ingredients for each recipe
for recipe in recipes:
cursor.execute("""
SELECT product_name, quantity_needed, unit
FROM recipe_ingredients
WHERE recipe_id = %s
ORDER BY product_name
""", (recipe['id'],))
recipe['ingredients'] = cursor.fetchall()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'total': len(recipes),
'recipes': recipes
}), 200
except Exception as e:
logger.error(f"Get recipes error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/recipes/<int:recipe_id>', methods=['GET'])
def get_recipe(recipe_id):
"""Get specific recipe with ingredients"""
try:
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
cursor = connection.cursor(dictionary=True)
# Get recipe
cursor.execute("SELECT id, recipe_name, description FROM recipes WHERE id = %s", (recipe_id,))
recipe = cursor.fetchone()
if not recipe:
cursor.close()
connection.close()
return jsonify({'status': 'error', 'message': 'Recipe not found'}), 404
# Get ingredients
cursor.execute("""
SELECT product_name, quantity_needed, unit
FROM recipe_ingredients
WHERE recipe_id = %s
ORDER BY product_name
""", (recipe_id,))
recipe['ingredients'] = cursor.fetchall()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'recipe': recipe
}), 200
except Exception as e:
logger.error(f"Get recipe error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.errorhandler(404)
def not_found(error):
return jsonify({'status': 'error', 'message': 'Endpoint tidak ditemukan'}), 404
@app.errorhandler(500)
def internal_error(error):
return jsonify({'status': 'error', 'message': 'Internal server error'}), 500
@app.route('/test')
def test():
return {"message": "FLASK BARU AKTIF"}
# ============================================================================
# MAIN
# ============================================================================
if __name__ == '__main__':
logger.info("=" * 80)
logger.info("Starting Prediksi Stok API")
logger.info("=" * 80)
logger.info(f"Model: {metadata['model_type']}")
logger.info(f"Accuracy (R²): {metadata['r2_score']:.4f}")
logger.info(f"Features: {len(feature_columns)}")
logger.info("Endpoints: /health, /metadata, /info, /prediksi, /batch-prediksi, /products, /transactions, /predictions, /recipes")
logger.info("Access API at: http://localhost:5000")
logger.info("=" * 80)
app.run(
debug=False,
host='0.0.0.0',
port=5000,
threaded=True
)