621 lines
22 KiB
Python
621 lines
22 KiB
Python
import os
|
|
import platform
|
|
import io
|
|
import base64
|
|
import json
|
|
import pandas as pd
|
|
from datetime import timedelta, datetime
|
|
from django import get_version as django_version
|
|
from django.shortcuts import render, redirect, get_object_or_404
|
|
from django.utils import timezone
|
|
from django.db.models import Sum, Q
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.contrib import messages
|
|
from django.http import JsonResponse, HttpResponse
|
|
from .models import Medicine, Batch, Category, StockTransaction, Supplier, Faktur, AppSetting
|
|
from .forms import SupplierForm, FakturForm, StockInForm, StockOutForm, CategoryForm, MedicineForm, AppSettingForm
|
|
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import letter, A4
|
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
|
|
from ai.local_ai_api import LocalAIApi
|
|
|
|
@login_required
|
|
def home(request):
|
|
"""Render the medicine warehouse dashboard."""
|
|
now = timezone.now()
|
|
today = now.date()
|
|
|
|
settings = AppSetting.objects.first()
|
|
project_name = settings.app_name if settings else "DN-WRS"
|
|
|
|
# Stats
|
|
total_medicines = Medicine.objects.count()
|
|
|
|
# Total stock across all batches
|
|
total_stock = Batch.objects.aggregate(total=Sum('quantity'))['total'] or 0
|
|
|
|
# Expired batches
|
|
expired_batches_count = Batch.objects.filter(expiry_date__lte=today).count()
|
|
|
|
# Near expiry (next 90 days)
|
|
near_expiry_batches_count = Batch.objects.filter(
|
|
expiry_date__gt=today,
|
|
expiry_date__lte=today + timezone.timedelta(days=90)
|
|
).count()
|
|
|
|
# Low stock medicines
|
|
all_medicines = Medicine.objects.all()
|
|
low_stock_count = 0
|
|
for med in all_medicines:
|
|
if med.total_stock <= med.min_stock:
|
|
low_stock_count += 1
|
|
|
|
# Latest medicines for the table
|
|
recent_medicines = all_medicines.order_by('-created_at')[:5]
|
|
|
|
# Chart Data: Last 7 days movement
|
|
chart_labels = []
|
|
chart_data_in = []
|
|
chart_data_out = []
|
|
|
|
for i in range(6, -1, -1):
|
|
day = today - timedelta(days=i)
|
|
chart_labels.append(day.strftime('%d %b'))
|
|
|
|
in_sum = StockTransaction.objects.filter(
|
|
transaction_type='IN',
|
|
created_at__date=day
|
|
).aggregate(total=Sum('quantity'))['total'] or 0
|
|
|
|
out_sum = StockTransaction.objects.filter(
|
|
transaction_type='OUT',
|
|
created_at__date=day
|
|
).aggregate(total=Sum('quantity'))['total'] or 0
|
|
|
|
chart_data_in.append(in_sum)
|
|
chart_data_out.append(out_sum)
|
|
|
|
context = {
|
|
"project_name": project_name,
|
|
"total_medicines": total_medicines,
|
|
"total_stock": total_stock,
|
|
"expired_count": expired_batches_count,
|
|
"near_expiry_count": near_expiry_batches_count,
|
|
"low_stock_count": low_stock_count,
|
|
"recent_medicines": recent_medicines,
|
|
"chart_labels": chart_labels,
|
|
"chart_data_in": chart_data_in,
|
|
"chart_data_out": chart_data_out,
|
|
"django_version": django_version(),
|
|
"python_version": platform.python_version(),
|
|
"current_time": now,
|
|
}
|
|
return render(request, "core/index.html", context)
|
|
|
|
@login_required
|
|
def app_settings(request):
|
|
settings = AppSetting.objects.first()
|
|
if not settings:
|
|
settings = AppSetting.objects.create(app_name="DN-WRS")
|
|
|
|
if request.method == 'POST':
|
|
form = AppSettingForm(request.POST, instance=settings)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Pengaturan aplikasi berhasil diperbarui.")
|
|
return redirect('app_settings')
|
|
else:
|
|
form = AppSettingForm(instance=settings)
|
|
|
|
return render(request, 'core/settings.html', {'form': form, 'settings': settings})
|
|
|
|
# --- MASTER DATA MANAGEMENT ---
|
|
|
|
@login_required
|
|
def category_list(request):
|
|
if request.method == 'POST':
|
|
form = CategoryForm(request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Kategori berhasil ditambahkan.")
|
|
return redirect('category_list')
|
|
else:
|
|
form = CategoryForm()
|
|
|
|
categories = Category.objects.all().order_by('name')
|
|
return render(request, 'core/categories.html', {
|
|
'categories': categories,
|
|
'form': form
|
|
})
|
|
|
|
@login_required
|
|
def supplier_list(request):
|
|
if request.method == 'POST':
|
|
form = SupplierForm(request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Supplier berhasil ditambahkan.")
|
|
return redirect('supplier_list')
|
|
else:
|
|
form = SupplierForm()
|
|
|
|
suppliers = Supplier.objects.all().order_by('name')
|
|
return render(request, 'core/suppliers.html', {
|
|
'suppliers': suppliers,
|
|
'form': form
|
|
})
|
|
|
|
@login_required
|
|
def medicine_list(request):
|
|
query = request.GET.get('q')
|
|
show_low_stock = request.GET.get('low_stock') == '1'
|
|
|
|
if request.method == 'POST':
|
|
form = MedicineForm(request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Data barang berhasil ditambahkan.")
|
|
return redirect('medicine_list')
|
|
else:
|
|
form = MedicineForm()
|
|
|
|
medicines_all = Medicine.objects.all().order_by('name')
|
|
|
|
# Calculate low stock count for the summary
|
|
low_stock_count = 0
|
|
for med in medicines_all:
|
|
if med.total_stock <= med.min_stock:
|
|
low_stock_count += 1
|
|
|
|
medicines = medicines_all
|
|
|
|
if query:
|
|
medicines = medicines.filter(
|
|
Q(name__icontains=query) |
|
|
Q(sku__icontains=query) |
|
|
Q(category__name__icontains=query)
|
|
)
|
|
|
|
if show_low_stock:
|
|
# Filter for low stock
|
|
low_stock_ids = [m.id for m in medicines if m.total_stock <= m.min_stock]
|
|
medicines = medicines.filter(id__in=low_stock_ids)
|
|
|
|
return render(request, 'core/medicines.html', {
|
|
'medicines': medicines,
|
|
'form': form,
|
|
'query': query,
|
|
'show_low_stock': show_low_stock,
|
|
'low_stock_count': low_stock_count,
|
|
'total_count': medicines_all.count()
|
|
})
|
|
|
|
@login_required
|
|
def export_low_stock_pdf(request):
|
|
# Get low stock medicines
|
|
all_medicines = Medicine.objects.all().order_by('main_supplier__name', 'name')
|
|
low_stock_medicines = [m for m in all_medicines if m.total_stock <= m.min_stock]
|
|
|
|
# Group by main supplier
|
|
grouped = {}
|
|
for m in low_stock_medicines:
|
|
supplier_name = m.main_supplier.name if m.main_supplier else "Tanpa Supplier Utama"
|
|
if supplier_name not in grouped:
|
|
grouped[supplier_name] = []
|
|
grouped[supplier_name].append(m)
|
|
|
|
# Generate PDF
|
|
buffer = io.BytesIO()
|
|
doc = SimpleDocTemplate(buffer, pagesize=A4)
|
|
elements = []
|
|
styles = getSampleStyleSheet()
|
|
|
|
title_style = ParagraphStyle(
|
|
'Title',
|
|
parent=styles['Heading1'],
|
|
fontSize=18,
|
|
alignment=1,
|
|
spaceAfter=20,
|
|
textColor=colors.HexColor("#0d6efd")
|
|
)
|
|
|
|
elements.append(Paragraph("Laporan Daftar Stok Menipis", title_style))
|
|
elements.append(Paragraph(f"Tanggal Cetak: {timezone.now().strftime('%d %B %Y %H:%M')}", styles["Normal"]))
|
|
elements.append(Spacer(1, 20))
|
|
|
|
if not low_stock_medicines:
|
|
elements.append(Paragraph("Tidak ada stok yang menipis saat ini.", styles["Normal"]))
|
|
else:
|
|
for supplier, items in grouped.items():
|
|
elements.append(Paragraph(f"Supplier Utama: {supplier}", styles["Heading2"]))
|
|
elements.append(Spacer(1, 5))
|
|
|
|
data = [["Nama Barang", "SKU", "Stok", "Min.", "Satuan", "Supplier Alternatif"]]
|
|
for item in items:
|
|
alt_supplier = item.alternative_supplier.name if item.alternative_supplier else "-"
|
|
data.append([
|
|
item.name,
|
|
item.sku,
|
|
str(item.total_stock),
|
|
str(item.min_stock),
|
|
item.unit,
|
|
alt_supplier
|
|
])
|
|
|
|
table = Table(data, colWidths=[160, 60, 40, 40, 50, 110])
|
|
table.setStyle(TableStyle([
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#343a40")),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, 0), 10),
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
|
|
('TOPPADDING', (0, 0), (-1, 0), 10),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
|
|
('FONTSIZE', (0, 1), (-1, -1), 9),
|
|
]))
|
|
elements.append(table)
|
|
elements.append(Spacer(1, 15))
|
|
|
|
doc.build(elements)
|
|
|
|
buffer.seek(0)
|
|
response = HttpResponse(buffer, content_type='application/pdf')
|
|
response['Content-Disposition'] = 'attachment; filename="laporan_stok_menipis.pdf"'
|
|
return response
|
|
|
|
# --- TRANSAKSI ---
|
|
|
|
@login_required
|
|
def input_faktur(request):
|
|
if request.method == 'POST':
|
|
faktur_form = FakturForm(request.POST)
|
|
if faktur_form.is_valid():
|
|
faktur = faktur_form.save()
|
|
return redirect('faktur_detail', pk=faktur.pk)
|
|
else:
|
|
faktur_form = FakturForm(initial={'faktur_type': 'MASUK', 'date': timezone.now().date()})
|
|
|
|
fakturs = Faktur.objects.all().order_by('-created_at')
|
|
return render(request, 'core/input_faktur.html', {
|
|
'faktur_form': faktur_form,
|
|
'fakturs': fakturs
|
|
})
|
|
|
|
@login_required
|
|
def faktur_detail(request, pk):
|
|
faktur = get_object_or_404(Faktur, pk=pk)
|
|
if request.method == 'POST':
|
|
if 'add_item' in request.POST:
|
|
form = StockInForm(request.POST)
|
|
if form.is_valid():
|
|
# Create Batch
|
|
batch = Batch.objects.create(
|
|
medicine=form.cleaned_data['medicine'],
|
|
faktur=faktur,
|
|
batch_number=form.cleaned_data['batch_number'],
|
|
expiry_date=form.cleaned_data['expiry_date'],
|
|
quantity=form.cleaned_data['quantity'],
|
|
buying_price=form.cleaned_data['buying_price'],
|
|
selling_price=form.cleaned_data['selling_price']
|
|
)
|
|
# Create Transaction
|
|
StockTransaction.objects.create(
|
|
medicine=batch.medicine,
|
|
batch=batch,
|
|
faktur=faktur,
|
|
transaction_type='IN',
|
|
quantity=batch.quantity,
|
|
note=f"Input dari Faktur {faktur.faktur_number}"
|
|
)
|
|
messages.success(request, f"Barang {batch.medicine.name} berhasil ditambahkan.")
|
|
return redirect('faktur_detail', pk=pk)
|
|
else:
|
|
form = StockInForm()
|
|
|
|
items = Batch.objects.filter(faktur=faktur)
|
|
cart = request.session.get('import_cart', [])
|
|
cart_faktur_id = request.session.get('cart_faktur_id')
|
|
|
|
# Only show cart if it belongs to this faktur
|
|
show_cart = cart and cart_faktur_id == pk
|
|
|
|
return render(request, 'core/faktur_detail.html', {
|
|
'faktur': faktur,
|
|
'form': form,
|
|
'items': items,
|
|
'cart': cart if show_cart else []
|
|
})
|
|
|
|
@login_required
|
|
def barang_keluar(request):
|
|
if request.method == 'POST':
|
|
form = StockOutForm(request.POST)
|
|
if form.is_valid():
|
|
medicine = form.cleaned_data['medicine']
|
|
batch = form.cleaned_data['batch']
|
|
qty = form.cleaned_data['quantity']
|
|
|
|
if batch.quantity < qty:
|
|
messages.error(request, f"Stok tidak mencukupi. Stok saat ini: {batch.quantity}")
|
|
else:
|
|
# Update Batch
|
|
batch.quantity -= qty
|
|
batch.save()
|
|
|
|
# Create Transaction
|
|
StockTransaction.objects.create(
|
|
medicine=medicine,
|
|
batch=batch,
|
|
transaction_type='OUT',
|
|
quantity=qty,
|
|
note=form.cleaned_data['note']
|
|
)
|
|
messages.success(request, f"Barang keluar berhasil dicatat.")
|
|
return redirect('barang_keluar')
|
|
else:
|
|
form = StockOutForm()
|
|
|
|
transactions = StockTransaction.objects.filter(transaction_type='OUT').order_by('-created_at')[:10]
|
|
cart = request.session.get('export_cart', [])
|
|
|
|
return render(request, 'core/barang_keluar.html', {
|
|
'form': form,
|
|
'transactions': transactions,
|
|
'cart': cart
|
|
})
|
|
|
|
@login_required
|
|
def get_batches(request):
|
|
medicine_id = request.GET.get('medicine_id')
|
|
batches = Batch.objects.filter(medicine_id=medicine_id, quantity__gt=0).values('id', 'batch_number', 'quantity')
|
|
return JsonResponse(list(batches), safe=False)
|
|
|
|
@login_required
|
|
def laporan_transaksi(request):
|
|
transactions = StockTransaction.objects.all().order_by('-created_at')
|
|
return render(request, 'core/laporan_transaksi.html', {
|
|
'transactions': transactions
|
|
})
|
|
|
|
@login_required
|
|
def delete_transaksi(request, pk):
|
|
transaction = get_object_or_404(StockTransaction, pk=pk)
|
|
batch = transaction.batch
|
|
|
|
if batch:
|
|
if transaction.transaction_type == 'IN':
|
|
batch.quantity -= transaction.quantity
|
|
elif transaction.transaction_type == 'OUT':
|
|
batch.quantity += transaction.quantity
|
|
batch.save()
|
|
|
|
transaction.delete()
|
|
messages.success(request, "Transaksi berhasil dihapus dan stok telah diperbarui.")
|
|
return redirect('laporan_transaksi')
|
|
|
|
@login_required
|
|
def edit_transaksi(request, pk):
|
|
transaction = get_object_or_404(StockTransaction, pk=pk)
|
|
if request.method == 'POST':
|
|
new_qty = int(request.POST.get('quantity'))
|
|
old_qty = transaction.quantity
|
|
batch = transaction.batch
|
|
|
|
if batch:
|
|
if transaction.transaction_type == 'IN':
|
|
# Revert old, apply new
|
|
batch.quantity = batch.quantity - old_qty + new_qty
|
|
elif transaction.transaction_type == 'OUT':
|
|
# Revert old, apply new
|
|
batch.quantity = batch.quantity + old_qty - new_qty
|
|
|
|
if batch.quantity < 0:
|
|
messages.error(request, "Error: Stok tidak boleh negatif setelah perubahan.")
|
|
return redirect('laporan_transaksi')
|
|
|
|
batch.save()
|
|
|
|
transaction.quantity = new_qty
|
|
transaction.note = request.POST.get('note', transaction.note)
|
|
transaction.save()
|
|
messages.success(request, "Transaksi berhasil diperbarui.")
|
|
return redirect('laporan_transaksi')
|
|
|
|
return render(request, 'core/edit_transaksi.html', {
|
|
'transaction': transaction
|
|
})
|
|
|
|
# --- IMPORT / EXCEL / OCR ---
|
|
|
|
@login_required
|
|
def download_template(request, type):
|
|
if type == 'masuk':
|
|
cols = ['SKU', 'Nama Barang', 'Batch', 'Expiry Date (YYYY-MM-DD)', 'Qty', 'Harga Beli', 'Harga Jual']
|
|
filename = 'template_barang_masuk.xlsx'
|
|
else:
|
|
cols = ['SKU', 'Batch Number', 'Qty', 'Catatan']
|
|
filename = 'template_barang_keluar.xlsx'
|
|
|
|
df = pd.DataFrame(columns=cols)
|
|
buffer = io.BytesIO()
|
|
with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
|
|
df.to_excel(writer, index=False)
|
|
|
|
buffer.seek(0)
|
|
response = HttpResponse(buffer.read(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
response['Content-Disposition'] = f'attachment; filename={filename}'
|
|
return response
|
|
|
|
@login_required
|
|
def import_excel(request, type, faktur_id=None):
|
|
if request.method == 'POST' and request.FILES.get('file'):
|
|
file = request.FILES['file']
|
|
try:
|
|
df = pd.read_excel(file)
|
|
items = []
|
|
|
|
for _, row in df.iterrows():
|
|
if type == 'masuk':
|
|
# Validate SKU
|
|
sku = str(row.get('SKU', '')).strip()
|
|
medicine = Medicine.objects.filter(sku=sku).first()
|
|
items.append({
|
|
'sku': sku,
|
|
'name': row.get('Nama Barang', medicine.name if medicine else 'Unknown'),
|
|
'batch': str(row.get('Batch', '')),
|
|
'expiry': str(row.get('Expiry Date (YYYY-MM-DD)', '')),
|
|
'qty': int(row.get('Qty', 0)),
|
|
'buy': float(row.get('Harga Beli', 0)),
|
|
'sell': float(row.get('Harga Jual', 0)),
|
|
'valid': medicine is not None
|
|
})
|
|
else:
|
|
sku = str(row.get('SKU', '')).strip()
|
|
batch_num = str(row.get('Batch Number', '')).strip()
|
|
medicine = Medicine.objects.filter(sku=sku).first()
|
|
batch = Batch.objects.filter(medicine=medicine, batch_number=batch_num).first() if medicine else None
|
|
items.append({
|
|
'sku': sku,
|
|
'batch': batch_num,
|
|
'qty': int(row.get('Qty', 0)),
|
|
'note': str(row.get('Catatan', '')),
|
|
'valid': batch is not None and batch.quantity >= int(row.get('Qty', 0))
|
|
})
|
|
|
|
if type == 'masuk':
|
|
request.session['import_cart'] = items
|
|
request.session['cart_faktur_id'] = faktur_id
|
|
return redirect('faktur_detail', pk=faktur_id)
|
|
else:
|
|
request.session['export_cart'] = items
|
|
return redirect('barang_keluar')
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Gagal mengimpor file: {str(e)}")
|
|
|
|
return redirect('home')
|
|
|
|
@login_required
|
|
def ocr_faktur(request, faktur_id):
|
|
if request.method == 'POST' and request.FILES.get('image'):
|
|
image_file = request.FILES['image']
|
|
try:
|
|
# Read image and encode to base64
|
|
img_data = base64.b64encode(image_file.read()).decode('utf-8')
|
|
|
|
prompt = """
|
|
Extract items from this invoice image. Return a JSON object with a list 'items'.
|
|
Each item should have: 'sku', 'name', 'batch', 'expiry' (YYYY-MM-DD), 'qty', 'buy', 'sell'.
|
|
If SKU is not visible, leave it empty.
|
|
Format the output strictly as JSON.
|
|
"""
|
|
|
|
response = LocalAIApi.create_response({
|
|
"model": "gpt-4o",
|
|
"input": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": prompt},
|
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_data}"}}
|
|
]
|
|
}
|
|
],
|
|
"text": {"format": {"type": "json_object"}}
|
|
})
|
|
|
|
if response.get("success"):
|
|
data = LocalAIApi.decode_json_from_response(response)
|
|
items = data.get('items', [])
|
|
|
|
# Enrich with validation
|
|
for item in items:
|
|
sku = item.get('sku', '')
|
|
medicine = Medicine.objects.filter(sku=sku).first() if sku else None
|
|
item['valid'] = medicine is not None
|
|
|
|
request.session['import_cart'] = items
|
|
request.session['cart_faktur_id'] = faktur_id
|
|
messages.success(request, "OCR Berhasil. Silakan periksa data di keranjang.")
|
|
else:
|
|
messages.error(request, f"Gagal OCR: {response.get('error')}")
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error OCR: {str(e)}")
|
|
|
|
return redirect('faktur_detail', pk=faktur_id)
|
|
|
|
@login_required
|
|
def process_cart(request, type, faktur_id=None):
|
|
if type == 'masuk':
|
|
cart = request.session.get('import_cart', [])
|
|
faktur = get_object_or_404(Faktur, pk=faktur_id)
|
|
|
|
count = 0
|
|
for item in cart:
|
|
if item.get('valid'):
|
|
medicine = Medicine.objects.get(sku=item['sku'])
|
|
batch = Batch.objects.create(
|
|
medicine=medicine,
|
|
faktur=faktur,
|
|
batch_number=item['batch'],
|
|
expiry_date=item['expiry'],
|
|
quantity=item['qty'],
|
|
buying_price=item['buy'],
|
|
selling_price=item['sell']
|
|
)
|
|
StockTransaction.objects.create(
|
|
medicine=medicine,
|
|
batch=batch,
|
|
faktur=faktur,
|
|
transaction_type='IN',
|
|
quantity=item['qty'],
|
|
note=f"Import Excel/OCR Faktur {faktur.faktur_number}"
|
|
)
|
|
count += 1
|
|
|
|
del request.session['import_cart']
|
|
del request.session['cart_faktur_id']
|
|
messages.success(request, f"{count} barang berhasil diimpor.")
|
|
return redirect('faktur_detail', pk=faktur_id)
|
|
|
|
else:
|
|
cart = request.session.get('export_cart', [])
|
|
count = 0
|
|
for item in cart:
|
|
if item.get('valid'):
|
|
medicine = Medicine.objects.get(sku=item['sku'])
|
|
batch = Batch.objects.get(medicine=medicine, batch_number=item['batch'])
|
|
qty = item['qty']
|
|
|
|
batch.quantity -= qty
|
|
batch.save()
|
|
|
|
StockTransaction.objects.create(
|
|
medicine=medicine,
|
|
batch=batch,
|
|
transaction_type='OUT',
|
|
quantity=qty,
|
|
note=item.get('note', 'Import Excel')
|
|
)
|
|
count += 1
|
|
|
|
del request.session['export_cart']
|
|
messages.success(request, f"{count} barang keluar berhasil diproses.")
|
|
return redirect('barang_keluar')
|
|
|
|
@login_required
|
|
def clear_cart(request, type, faktur_id=None):
|
|
if type == 'masuk':
|
|
if 'import_cart' in request.session:
|
|
del request.session['import_cart']
|
|
return redirect('faktur_detail', pk=faktur_id)
|
|
else:
|
|
if 'export_cart' in request.session:
|
|
del request.session['export_cart']
|
|
return redirect('barang_keluar') |