MIF_E31232435/ml_model/app.py

1764 lines
59 KiB
Python

"""
Flask API untuk Prediksi Permintaan Stok Bahan
Menggunakan Random Forest Model
"""
from flask import Flask, request, jsonify
from flask_cors import CORS
import joblib
import pandas as pd
import numpy as np
import logging
import math
import hashlib
from datetime import datetime
import mysql.connector
from mysql.connector import Error
# 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'
# 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 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 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'
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('/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
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
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
unit_value = data.get('unit')
product_type_value = data.get('product_type')
# 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
# Insert new product
if has_unit_column and has_product_type_column:
cursor.execute("""
INSERT INTO products
(name, category, price, current_stock, unit, product_type)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
data['name'],
data['category'],
data['price'],
data['current_stock'],
unit_value,
product_type_value
))
elif has_unit_column:
cursor.execute("""
INSERT INTO products
(name, category, price, current_stock, unit)
VALUES (%s, %s, %s, %s, %s)
""", (
data['name'],
data['category'],
data['price'],
data['current_stock'],
unit_value
))
elif has_product_type_column:
cursor.execute("""
INSERT INTO products
(name, category, price, current_stock, product_type)
VALUES (%s, %s, %s, %s, %s)
""", (
data['name'],
data['category'],
data['price'],
data['current_stock'],
product_type_value
))
else:
cursor.execute("""
INSERT INTO products
(name, category, price, current_stock)
VALUES (%s, %s, %s, %s)
""", (
data['name'],
data['category'],
data['price'],
data['current_stock']
))
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_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
)