2832 lines
95 KiB
Python
2832 lines
95 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 json
|
|
import secrets
|
|
import os
|
|
import smtplib
|
|
from datetime import datetime, timedelta
|
|
from base64 import urlsafe_b64encode
|
|
from urllib.parse import urlencode, urlparse
|
|
from urllib.error import HTTPError
|
|
from urllib.request import Request, urlopen
|
|
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)
|
|
|
|
# Initialize global variables for ML model components to satisfy IDE static analysis
|
|
model = None
|
|
encoders = {}
|
|
feature_columns = []
|
|
metadata = {}
|
|
|
|
|
|
# ============================================================================
|
|
# DATABASE CONFIGURATION
|
|
# ============================================================================
|
|
DATABASE_URL = os.getenv('MYSQL_URL') or os.getenv('DATABASE_URL', '')
|
|
parsed_database_url = urlparse(DATABASE_URL) if DATABASE_URL else None
|
|
|
|
DB_HOST = (
|
|
os.getenv('MYSQLHOST')
|
|
or os.getenv('DB_HOST')
|
|
or (parsed_database_url.hostname if parsed_database_url else None)
|
|
or 'localhost'
|
|
)
|
|
DB_PORT = int(
|
|
os.getenv('MYSQLPORT')
|
|
or os.getenv('DB_PORT')
|
|
or (parsed_database_url.port if parsed_database_url and parsed_database_url.port else 3306)
|
|
)
|
|
DB_USER = (
|
|
os.getenv('MYSQLUSER')
|
|
or os.getenv('DB_USER')
|
|
or (parsed_database_url.username if parsed_database_url else None)
|
|
or 'root'
|
|
)
|
|
DB_PASSWORD = (
|
|
os.getenv('MYSQLPASSWORD')
|
|
or os.getenv('DB_PASSWORD')
|
|
or (parsed_database_url.password if parsed_database_url else None)
|
|
or ''
|
|
)
|
|
DB_NAME = (
|
|
os.getenv('MYSQLDATABASE')
|
|
or os.getenv('DB_NAME')
|
|
or (parsed_database_url.path.lstrip('/') if parsed_database_url and parsed_database_url.path else None)
|
|
or '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_USE_SSL = os.getenv('SMTP_USE_SSL', '').strip().lower() in ['1', 'true', 'yes', 'on']
|
|
SMTP_EMAIL = os.getenv('SMTP_EMAIL', '')
|
|
SMTP_PASSWORD = os.getenv('SMTP_APP_PASSWORD', '').replace(' ', '')
|
|
SMTP_SENDER_NAME = os.getenv('SMTP_SENDER_NAME', 'Tobaku Sulastri')
|
|
GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID', '').strip()
|
|
GOOGLE_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET', '').strip()
|
|
GOOGLE_REFRESH_TOKEN = os.getenv('GOOGLE_REFRESH_TOKEN', '').strip()
|
|
GMAIL_SENDER_EMAIL = os.getenv('GMAIL_SENDER_EMAIL', SMTP_EMAIL).strip()
|
|
|
|
# Transaction table names seen across local dumps and deployed databases.
|
|
# The current Railway dump uses `stock in`.
|
|
TRANSACTION_TABLE_CANDIDATES = ['stock in', 'Stock in', 'Stock In', 'transactions']
|
|
|
|
def get_db_connection():
|
|
"""Get MySQL database connection"""
|
|
try:
|
|
connection = mysql.connector.connect(
|
|
host=DB_HOST,
|
|
port=DB_PORT,
|
|
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_unit_column(connection):
|
|
if not table_exists(connection, 'products') or has_product_unit_column(connection):
|
|
return
|
|
|
|
cursor = connection.cursor()
|
|
try:
|
|
cursor.execute(
|
|
"ALTER TABLE products ADD COLUMN unit VARCHAR(20) NOT NULL DEFAULT 'kg'"
|
|
)
|
|
connection.commit()
|
|
except Error as e:
|
|
logger.warning(f"Could not add unit column: {e}")
|
|
finally:
|
|
cursor.close()
|
|
|
|
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):
|
|
return
|
|
|
|
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_transactions_table_sql(connection) -> str | None:
|
|
table_name = get_existing_table(connection, TRANSACTION_TABLE_CANDIDATES)
|
|
return escape_table_name(table_name) if table_name else 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 GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and GOOGLE_REFRESH_TOKEN:
|
|
send_otp_email_gmail_api(recipient_email, otp_code)
|
|
return
|
|
|
|
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
|
|
"""
|
|
)
|
|
|
|
smtp_class = smtplib.SMTP_SSL if SMTP_USE_SSL or SMTP_PORT == 465 else smtplib.SMTP
|
|
with smtp_class(SMTP_HOST, SMTP_PORT, timeout=20) as server:
|
|
if smtp_class is smtplib.SMTP:
|
|
server.starttls()
|
|
server.login(SMTP_EMAIL, SMTP_PASSWORD)
|
|
server.send_message(message)
|
|
|
|
def get_google_access_token() -> str:
|
|
payload = {
|
|
'client_id': GOOGLE_CLIENT_ID,
|
|
'client_secret': GOOGLE_CLIENT_SECRET,
|
|
'refresh_token': GOOGLE_REFRESH_TOKEN,
|
|
'grant_type': 'refresh_token',
|
|
}
|
|
token_request = Request(
|
|
'https://oauth2.googleapis.com/token',
|
|
data=urlencode(payload).encode('utf-8'),
|
|
headers={
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
method='POST',
|
|
)
|
|
try:
|
|
with urlopen(token_request, timeout=20) as response:
|
|
data = json.loads(response.read().decode('utf-8'))
|
|
except HTTPError as e:
|
|
detail = e.read().decode('utf-8', errors='replace')
|
|
raise RuntimeError(f'Google token error: HTTP {e.code} {detail}') from e
|
|
|
|
access_token = data.get('access_token')
|
|
if not access_token:
|
|
raise RuntimeError('Gagal mengambil access token Gmail API')
|
|
return access_token
|
|
|
|
def send_otp_email_gmail_api(recipient_email: str, otp_code: str) -> None:
|
|
if not GMAIL_SENDER_EMAIL:
|
|
raise RuntimeError('GMAIL_SENDER_EMAIL atau SMTP_EMAIL belum dikonfigurasi')
|
|
|
|
message = EmailMessage()
|
|
message['Subject'] = 'Kode OTP Reset Password Tobaku Sulastri'
|
|
message['From'] = f'{SMTP_SENDER_NAME} <{GMAIL_SENDER_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
|
|
"""
|
|
)
|
|
|
|
raw_message = urlsafe_b64encode(message.as_bytes()).decode('utf-8').rstrip('=')
|
|
send_request = Request(
|
|
'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
|
|
data=json.dumps({'raw': raw_message}).encode('utf-8'),
|
|
headers={
|
|
'Authorization': f'Bearer {get_google_access_token()}',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
method='POST',
|
|
)
|
|
try:
|
|
with urlopen(send_request, timeout=20) as response:
|
|
if response.status >= 400:
|
|
raise RuntimeError(f'Gmail API send error: HTTP {response.status}')
|
|
except HTTPError as e:
|
|
detail = e.read().decode('utf-8', errors='replace')
|
|
raise RuntimeError(f'Gmail API send error: HTTP {e.code} {detail}') from e
|
|
|
|
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)
|
|
ensure_product_unit_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
|
|
|
|
|
|
PRODUCT_CODE_MAP = {
|
|
'baking powder 45gr': 0,
|
|
'cokelat bubuk 250gr': 1,
|
|
'gula pasir 1kg': 2,
|
|
'keju parut 250gr': 3,
|
|
'mentega 500gr': 4,
|
|
'susu bubuk 27gr': 5,
|
|
'telur 1kg': 6,
|
|
'tepung terigu 1kg': 7,
|
|
}
|
|
|
|
PRODUCT_CATEGORY_MAP = {
|
|
'baking powder': 'Bahan Tambahan',
|
|
'baking powder 45gr': 'Bahan Tambahan',
|
|
'cokelat bubuk 250gr': 'Cokelat',
|
|
'gula pasir 1kg': 'Gula',
|
|
'keju parut 250gr': 'Keju',
|
|
'mentega 500gr': 'Mentega',
|
|
'susu bubuk': 'Susu',
|
|
'susu bubuk 27gr': 'Susu',
|
|
'telur 1kg': 'Telur',
|
|
'tepung terigu 1kg': 'Tepung',
|
|
}
|
|
|
|
PRODUCT_PRICE_MAP = {
|
|
'baking powder': 8180,
|
|
'baking powder 45gr': 8180,
|
|
'cokelat bubuk 250gr': 21036,
|
|
'gula pasir 1kg': 14608,
|
|
'keju parut 250gr': 23373,
|
|
'mentega 500gr': 17529,
|
|
'susu bubuk': 17529,
|
|
'susu bubuk 27gr': 17529,
|
|
'telur 1kg': 26878,
|
|
'tepung terigu 1kg': 11687,
|
|
}
|
|
|
|
PRODUCT_UNIT_MAP = {
|
|
'baking powder 45gr': 'gr',
|
|
'cokelat bubuk 250gr': 'gr',
|
|
'gula pasir 1kg': 'kg',
|
|
'keju parut 250gr': 'gr',
|
|
'mentega 500gr': 'gr',
|
|
'susu bubuk 27gr': 'gr',
|
|
'susu bubuk': 'gr',
|
|
'telur 1kg': 'kg',
|
|
'tepung terigu 1kg': 'kg',
|
|
}
|
|
|
|
|
|
def normalize_product_for_model(product_name: str) -> str:
|
|
value = (product_name or '').lower()
|
|
if 'tepung' in value:
|
|
return 'Tepung Terigu 1kg'
|
|
if 'gula' in value:
|
|
return 'Gula Pasir 1kg'
|
|
if 'mentega' in value:
|
|
return 'Mentega 500gr'
|
|
if 'telur' in value:
|
|
return 'Telur 1kg'
|
|
if 'susu' in value:
|
|
return 'Susu Bubuk 27gr'
|
|
if 'cokelat' in value or 'coklat' in value:
|
|
return 'Cokelat Bubuk 250gr'
|
|
if 'keju' in value:
|
|
return 'Keju Parut 250gr'
|
|
if 'baking' in value or 'pengembang' in value:
|
|
return 'Baking Powder 45gr'
|
|
return product_name
|
|
|
|
|
|
def dataset_product_key(product_name: str) -> str:
|
|
return ' '.join((product_name or '').lower().split())
|
|
|
|
|
|
def dataset_product_unit(product_name: str, fallback: str | None = None) -> str:
|
|
return fallback or PRODUCT_UNIT_MAP.get(dataset_product_key(product_name), 'unit')
|
|
|
|
|
|
def apply_dataset_product_fields(product: dict) -> dict:
|
|
name = product.get('name') or product.get('product_name') or ''
|
|
key = dataset_product_key(name)
|
|
|
|
product['unit'] = product.get('unit') or PRODUCT_UNIT_MAP.get(key, 'unit')
|
|
if key in PRODUCT_CATEGORY_MAP:
|
|
product['category'] = PRODUCT_CATEGORY_MAP[key]
|
|
if key in PRODUCT_PRICE_MAP and not product.get('price'):
|
|
product['price'] = PRODUCT_PRICE_MAP[key]
|
|
|
|
return product
|
|
|
|
|
|
def encode_with_label_encoder(column_name: str, value: str, fallback: int = 0) -> int:
|
|
encoder = encoders.get(column_name)
|
|
if encoder is None:
|
|
return fallback
|
|
|
|
try:
|
|
return int(encoder.transform([value])[0])
|
|
except ValueError:
|
|
return fallback
|
|
|
|
|
|
def normalize_prediction_payload(data: dict) -> dict:
|
|
"""Accept full model features or simple UI payload and return model features."""
|
|
if all(feature in data for feature in feature_columns):
|
|
return data
|
|
|
|
normalized = dict(data)
|
|
prediction_date = data.get('prediction_date') or data.get('tanggal_prediksi')
|
|
if prediction_date:
|
|
date_value = datetime.fromisoformat(str(prediction_date).split('T')[0])
|
|
else:
|
|
date_value = datetime.now()
|
|
|
|
raw_product_name = data.get('product_name') or data.get('nama_produk') or ''
|
|
model_product_name = normalize_product_for_model(raw_product_name)
|
|
model_product_key = model_product_name.lower()
|
|
model_category = PRODUCT_CATEGORY_MAP.get(
|
|
model_product_key,
|
|
data.get('category') or data.get('kategori_produk') or 'Bahan Tambahan'
|
|
)
|
|
unit_price = int(float(
|
|
data.get('unit_price')
|
|
or data.get('harga_satuan_update')
|
|
or PRODUCT_PRICE_MAP.get(model_product_key, 1)
|
|
))
|
|
planned_quantity = max(1, int(float(data.get('planned_quantity') or data.get('jumlah_rencana') or 1)))
|
|
|
|
normalized.update({
|
|
'tahun': date_value.year,
|
|
'bulan': date_value.month,
|
|
'hari': date_value.day,
|
|
'hari_dalam_minggu': date_value.weekday(),
|
|
'hari_minggu': date_value.weekday(),
|
|
'harga_satuan_update': unit_price,
|
|
'total_harga_update': int(float(data.get('total_harga_update') or unit_price * planned_quantity)),
|
|
'produk_encoded': PRODUCT_CODE_MAP.get(model_product_key, 0),
|
|
'nama_produk_encoded': encode_with_label_encoder('nama_produk', model_product_name),
|
|
'kategori_produk_encoded': encode_with_label_encoder('kategori_produk', model_category),
|
|
})
|
|
|
|
return normalized
|
|
|
|
# ============================================================================
|
|
# LOAD MODELS AT STARTUP
|
|
# ============================================================================
|
|
try:
|
|
_model_dir = os.path.dirname(__file__)
|
|
model = joblib.load(os.path.join(_model_dir, 'model_prediksi.pkl'))
|
|
encoders = joblib.load(os.path.join(_model_dir, 'encoders.pkl'))
|
|
feature_columns = joblib.load(os.path.join(_model_dir, 'feature_columns.pkl'))
|
|
metadata = joblib.load(os.path.join(_model_dir, 'model_metadata.pkl'))
|
|
logger.info("Models loaded successfully")
|
|
logger.info(f"Model Type: {metadata['model_type']}")
|
|
logger.info(f"R2 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
|
|
|
|
data = normalize_prediction_payload(data)
|
|
|
|
# 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 = pd.DataFrame([[data[f] for f in feature_columns]], columns=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 = pd.DataFrame([[item[f] for f in feature_columns]], columns=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()
|
|
products = [apply_dataset_product_fields(product) for product in products]
|
|
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
|
|
|
|
product = apply_dataset_product_fields(product)
|
|
|
|
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)
|
|
ensure_product_unit_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 = dataset_product_unit(data['name'], data.get('unit'))
|
|
product_type_value = data.get('product_type')
|
|
min_stock_value = data.get('min_stock', 0)
|
|
category_value = PRODUCT_CATEGORY_MAP.get(dataset_product_key(data['name']), data['category'])
|
|
|
|
# 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'], category_value, 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('/products/<int:product_id>', methods=['PUT', 'PATCH'])
|
|
def update_product(product_id):
|
|
"""Update product data from Flutter product edit form."""
|
|
try:
|
|
data = request.json or {}
|
|
allowed_fields = {
|
|
'name': 'name',
|
|
'category': 'category',
|
|
'price': 'price',
|
|
'current_stock': 'current_stock',
|
|
'stock': 'current_stock',
|
|
'min_stock': 'min_stock',
|
|
'unit': 'unit',
|
|
}
|
|
|
|
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)
|
|
ensure_product_unit_column(connection)
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
cursor.execute("SELECT id FROM products WHERE id = %s", (product_id,))
|
|
if not cursor.fetchone():
|
|
cursor.close()
|
|
connection.close()
|
|
return jsonify({'status': 'error', 'message': 'Product not found'}), 404
|
|
|
|
if 'name' in data and str(data['name']).strip():
|
|
cursor.execute(
|
|
"SELECT id FROM products WHERE name = %s AND id <> %s",
|
|
(str(data['name']).strip(), product_id),
|
|
)
|
|
if cursor.fetchone():
|
|
cursor.close()
|
|
connection.close()
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Product "{data["name"]}" already exists'
|
|
}), 409
|
|
|
|
updates = []
|
|
values = []
|
|
for input_key, column_name in allowed_fields.items():
|
|
if input_key not in data:
|
|
continue
|
|
|
|
value = data[input_key]
|
|
if input_key == 'name':
|
|
value = str(value).strip()
|
|
if not value:
|
|
continue
|
|
if input_key == 'category':
|
|
value = str(value).strip()
|
|
if input_key == 'unit':
|
|
value = str(value).strip()
|
|
if input_key == 'price':
|
|
value = int(value)
|
|
if input_key in ['current_stock', 'stock', 'min_stock']:
|
|
value = float(value)
|
|
|
|
updates.append(f"{column_name} = %s")
|
|
values.append(value)
|
|
|
|
if not updates:
|
|
cursor.close()
|
|
connection.close()
|
|
return jsonify({'status': 'error', 'message': 'Tidak ada data yang diubah'}), 400
|
|
|
|
values.append(product_id)
|
|
cursor.execute(
|
|
f"UPDATE products SET {', '.join(updates)} WHERE id = %s",
|
|
tuple(values),
|
|
)
|
|
connection.commit()
|
|
|
|
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
|
|
product = cursor.fetchone()
|
|
cursor.close()
|
|
connection.close()
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Product updated successfully',
|
|
'product': apply_dataset_product_fields(product),
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Update 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
|
|
|
|
transactions_table_sql = get_transactions_table_sql(connection)
|
|
if not transactions_table_sql:
|
|
connection.close()
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Transaction table not found'
|
|
}), 500
|
|
|
|
cursor = connection.cursor()
|
|
cursor.execute(f"""
|
|
INSERT INTO {transactions_table_sql}
|
|
(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
|
|
|
|
transactions_table_sql = get_transactions_table_sql(connection)
|
|
if not transactions_table_sql:
|
|
connection.close()
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Transaction table not found'
|
|
}), 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_sql}
|
|
(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
|
|
|
|
transactions_table_sql = get_transactions_table_sql(connection)
|
|
if not transactions_table_sql:
|
|
connection.close()
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Transaction table not found'
|
|
}), 500
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
# Build query
|
|
query = f"SELECT * FROM {transactions_table_sql} 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_sql}" +
|
|
(" 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', 'Stock in', '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)
|
|
def normalize_prediction_date(value: str | None) -> datetime:
|
|
if not value:
|
|
return datetime.now()
|
|
try:
|
|
return datetime.fromisoformat(value.split('T')[0])
|
|
except Exception:
|
|
return datetime.now()
|
|
|
|
|
|
def forecast_day_multiplier(target_date: datetime) -> float:
|
|
weekday = target_date.weekday()
|
|
if weekday == 4: # Friday
|
|
return 1.1
|
|
if weekday == 5: # Saturday
|
|
return 1.2
|
|
if weekday == 6: # Sunday
|
|
return 1.15
|
|
return 1.0
|
|
|
|
|
|
def load_recipe_production_averages(connection) -> dict[str, float]:
|
|
averages: dict[str, float] = {}
|
|
if not table_exists(connection, 'stock_usage_history'):
|
|
return averages
|
|
cursor = connection.cursor(dictionary=True)
|
|
try:
|
|
cursor.execute(
|
|
"""
|
|
SELECT recipe_name, AVG(COALESCE(production_quantity, 0)) AS avg_production
|
|
FROM stock_usage_history
|
|
WHERE COALESCE(recipe_name, '') <> ''
|
|
AND COALESCE(production_quantity, 0) > 0
|
|
GROUP BY recipe_name
|
|
"""
|
|
)
|
|
rows = cursor.fetchall()
|
|
for row in rows:
|
|
recipe_name = (row.get('recipe_name') or '').strip()
|
|
if not recipe_name:
|
|
continue
|
|
averages[recipe_name] = float(row.get('avg_production') or 1.0)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
cursor.close()
|
|
return averages
|
|
|
|
|
|
def load_recipes_with_ingredients(connection, recipe_filter: str = '', limit: int | None = None) -> list[dict]:
|
|
if not table_exists(connection, 'recipes') or not table_exists(connection, 'recipe_ingredients'):
|
|
return []
|
|
cursor = connection.cursor(dictionary=True)
|
|
try:
|
|
query = """
|
|
SELECT r.id AS recipe_id,
|
|
r.recipe_name,
|
|
r.description,
|
|
ri.product_name,
|
|
ri.quantity_needed,
|
|
ri.unit
|
|
FROM recipes r
|
|
LEFT JOIN recipe_ingredients ri ON ri.recipe_id = r.id
|
|
"""
|
|
params = []
|
|
if recipe_filter:
|
|
query += " WHERE r.recipe_name LIKE %s"
|
|
params.append(f"%{recipe_filter}%")
|
|
query += " ORDER BY r.recipe_name, ri.product_name"
|
|
if limit:
|
|
query += " LIMIT %s"
|
|
params.append(limit)
|
|
|
|
cursor.execute(query, tuple(params))
|
|
return cursor.fetchall()
|
|
except Exception:
|
|
return []
|
|
finally:
|
|
cursor.close()
|
|
|
|
|
|
def build_forecast_items(connection, start_date, days, recipe_filter='', limit=8):
|
|
"""Predict ingredient needs for the next N days based on recipes and the ML model."""
|
|
averages = load_recipe_production_averages(connection)
|
|
recipes = load_recipes_with_ingredients(connection, recipe_filter=recipe_filter, limit=limit)
|
|
if not recipes:
|
|
return []
|
|
|
|
# Group ingredients by recipe
|
|
recipe_dict = {}
|
|
for r in recipes:
|
|
name = r['recipe_name']
|
|
if name not in recipe_dict:
|
|
recipe_dict[name] = {
|
|
'recipe_name': name,
|
|
'ingredients': []
|
|
}
|
|
if r['product_name']:
|
|
recipe_dict[name]['ingredients'].append({
|
|
'product_name': r['product_name'],
|
|
'quantity_needed': r['quantity_needed'],
|
|
'unit': r['unit']
|
|
})
|
|
|
|
report_items = []
|
|
for day_offset in range(days):
|
|
target_date = start_date + timedelta(days=day_offset)
|
|
multiplier = forecast_day_multiplier(target_date)
|
|
|
|
day_ingredients = {}
|
|
recipe_targets = []
|
|
|
|
for recipe_name, recipe_data in recipe_dict.items():
|
|
avg_production = averages.get(recipe_name, 1.0)
|
|
target_prod = max(1.0, avg_production * multiplier)
|
|
recipe_targets.append({
|
|
'recipe_name': recipe_name,
|
|
'target_produksi': round(target_prod, 2)
|
|
})
|
|
|
|
for ing in recipe_data['ingredients']:
|
|
ing_name = ing['product_name']
|
|
ing_unit = ing['unit'] or 'kg'
|
|
|
|
# Build model prediction features using standard normalization
|
|
features = normalize_prediction_payload({
|
|
'prediction_date': target_date.date().isoformat(),
|
|
'product_name': ing_name,
|
|
'planned_quantity': int(target_prod)
|
|
})
|
|
|
|
# Predict using the loaded model directly
|
|
X_pred = pd.DataFrame([[features[f] for f in feature_columns]], columns=feature_columns)
|
|
pred_raw = float(model.predict(X_pred)[0])
|
|
|
|
# Determine model package size mapping (in grams/kg)
|
|
package_size = 1.0
|
|
pkg_unit = 'kg'
|
|
ing_lower = ing_name.lower()
|
|
if 'baking' in ing_lower:
|
|
package_size = 45.0
|
|
pkg_unit = 'gr'
|
|
elif 'cokelat' in ing_lower or 'coklat' in ing_lower:
|
|
package_size = 250.0
|
|
pkg_unit = 'gr'
|
|
elif 'keju' in ing_lower:
|
|
package_size = 250.0
|
|
pkg_unit = 'gr'
|
|
elif 'mentega' in ing_lower:
|
|
package_size = 500.0
|
|
pkg_unit = 'gr'
|
|
elif 'susu' in ing_lower:
|
|
package_size = 27.0
|
|
pkg_unit = 'gr'
|
|
|
|
# Convert predicted package float quantity into the ingredient's recipe unit
|
|
pred_qty = convert_stock_quantity(pred_raw * package_size, pkg_unit, ing_unit)
|
|
|
|
if ing_name not in day_ingredients:
|
|
day_ingredients[ing_name] = {
|
|
'nama_bahan': ing_name,
|
|
'jumlah': 0.0,
|
|
'unit': ing_unit
|
|
}
|
|
day_ingredients[ing_name]['jumlah'] += pred_qty
|
|
|
|
ingredients_list = []
|
|
total_qty = 0.0
|
|
for ing_name, ing_info in day_ingredients.items():
|
|
ing_info['jumlah'] = round(ing_info['jumlah'], 2)
|
|
ingredients_list.append(ing_info)
|
|
total_qty += ing_info['jumlah']
|
|
|
|
report_items.append({
|
|
'tanggal_prediksi': target_date.date().isoformat(),
|
|
'multiplier': round(multiplier, 2),
|
|
'bahan': ingredients_list,
|
|
'target_produksi': recipe_targets,
|
|
'jumlah_bahan': float(len(ingredients_list)),
|
|
'total_bahan': round(total_qty, 2)
|
|
})
|
|
|
|
return report_items
|
|
|
|
|
|
@app.route('/laporan/prediksi-forecast', methods=['GET'])
|
|
def laporan_prediksi_forecast():
|
|
"""Laporan prediksi kebutuhan bahan beberapa hari ke depan dalam format JSON."""
|
|
try:
|
|
days = max(1, min(request.args.get('days', default=7, type=int), 90))
|
|
limit = max(1, min(request.args.get('limit', default=8, type=int), 50))
|
|
recipe_filter = request.args.get('product_name', default='', type=str).strip()
|
|
start_date = normalize_prediction_date(request.args.get('start_date'))
|
|
|
|
connection = get_db_connection()
|
|
if not connection:
|
|
return build_report_response(False, 'Database connection failed', [], 500)
|
|
|
|
try:
|
|
report_items = build_forecast_items(
|
|
connection,
|
|
start_date,
|
|
days,
|
|
recipe_filter=recipe_filter,
|
|
limit=limit,
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
return jsonify({
|
|
'status': True,
|
|
'message': 'Data prediksi kebutuhan bahan berhasil diambil',
|
|
'parameter': {
|
|
'start_date': start_date.date().isoformat(),
|
|
'days': days,
|
|
'limit': limit,
|
|
'recipe_name': recipe_filter or None,
|
|
},
|
|
'model_accuracy': {
|
|
'r2_score': round(metadata['r2_score'], 4),
|
|
'mae': round(metadata['mae'], 4),
|
|
'rmse': round(metadata['rmse'], 4),
|
|
},
|
|
'data': report_items,
|
|
}), 200
|
|
except Exception as e:
|
|
logger.error(f"Laporan prediksi forecast error: {str(e)}")
|
|
return build_report_response(False, f'Gagal membuat forecast: {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 (R2): {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
|
|
)
|