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')