528 lines
18 KiB
Python
528 lines
18 KiB
Python
from django.shortcuts import render, redirect, get_object_or_404
|
|
from django.contrib.auth.decorators import login_required, permission_required
|
|
from django.contrib import messages
|
|
from django.http import HttpResponse
|
|
from django.db.models import Sum, Q, Count, F
|
|
from datetime import timedelta
|
|
from django.utils import timezone
|
|
from django import forms
|
|
from django.forms import inlineformset_factory
|
|
from decimal import Decimal
|
|
from .models import Supplier, PurchaseOrder, PurchaseOrderItem, GoodsReceipt, GoodsReceiptItem
|
|
from apps.inventory.models import Product, Warehouse, Inventory, StockMovement
|
|
|
|
|
|
class SupplierForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Supplier
|
|
fields = ['code', 'name', 'contact_person', 'email', 'phone', 'address', 'tax_id', 'payment_terms', 'is_active']
|
|
widgets = {
|
|
'address': forms.Textarea(attrs={'rows': 3}),
|
|
'payment_terms': forms.Textarea(attrs={'rows': 2}),
|
|
}
|
|
|
|
|
|
class PurchaseOrderItemForm(forms.ModelForm):
|
|
class Meta:
|
|
model = PurchaseOrderItem
|
|
fields = ['product', 'quantity', 'unit_price']
|
|
widgets = {
|
|
'quantity': forms.NumberInput(attrs={'step': '0.01', 'min': '0'}),
|
|
'unit_price': forms.NumberInput(attrs={'step': '0.01', 'min': '0'}),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields['product'].queryset = Product.objects.filter(is_active=True)
|
|
|
|
|
|
class PurchaseOrderForm(forms.ModelForm):
|
|
class Meta:
|
|
model = PurchaseOrder
|
|
fields = ['supplier', 'order_date', 'expected_delivery_date', 'notes']
|
|
widgets = {
|
|
'order_date': forms.DateInput(attrs={'type': 'date'}),
|
|
'expected_delivery_date': forms.DateInput(attrs={'type': 'date'}),
|
|
'notes': forms.Textarea(attrs={'rows': 3}),
|
|
}
|
|
|
|
|
|
PurchaseOrderItemFormSet = inlineformset_factory(
|
|
PurchaseOrder,
|
|
PurchaseOrderItem,
|
|
form=PurchaseOrderItemForm,
|
|
extra=0,
|
|
can_delete=True,
|
|
min_num=1,
|
|
validate_min=True
|
|
)
|
|
|
|
|
|
@login_required
|
|
def purchasing_dashboard(request):
|
|
"""Purchasing dashboard view"""
|
|
suppliers = Supplier.objects.filter(is_active=True)
|
|
pending_orders = PurchaseOrder.objects.filter(
|
|
Q(status='ordered') | Q(status='processing')
|
|
)
|
|
recent_orders = PurchaseOrder.objects.select_related('supplier').order_by('-created_at')[:5]
|
|
|
|
# Calculate total value of recent orders (last 30 days)
|
|
thirty_days_ago = timezone.now() - timedelta(days=30)
|
|
total_value = PurchaseOrder.objects.filter(
|
|
created_at__gte=thirty_days_ago
|
|
).aggregate(total=Sum('total_amount'))['total'] or 0
|
|
|
|
# Get top suppliers by order count
|
|
top_suppliers = Supplier.objects.annotate(
|
|
order_count=Count('purchaseorder')
|
|
).order_by('-order_count')[:5]
|
|
|
|
context = {
|
|
'module_title': 'Purchasing Management',
|
|
'suppliers': suppliers,
|
|
'pending_orders': pending_orders,
|
|
'recent_orders': recent_orders,
|
|
'total_value': total_value,
|
|
'top_suppliers': top_suppliers,
|
|
}
|
|
return render(request, 'purchasing/dashboard.html', context)
|
|
|
|
|
|
# Supplier Views
|
|
@login_required
|
|
@permission_required('purchasing.view_supplier', raise_exception=True)
|
|
def supplier_list_view(request):
|
|
"""List all suppliers"""
|
|
suppliers = Supplier.objects.all()
|
|
context = {
|
|
'module_title': 'Supplier List',
|
|
'suppliers': suppliers,
|
|
}
|
|
return render(request, 'purchasing/supplier_list.html', context)
|
|
|
|
|
|
@login_required
|
|
def create_supplier_view(request):
|
|
"""Create a new supplier"""
|
|
if request.method == 'POST':
|
|
form = SupplierForm(request.POST)
|
|
if form.is_valid():
|
|
supplier = form.save()
|
|
messages.success(request, f'Supplier "{supplier.name}" created successfully!')
|
|
return redirect('purchasing:supplier_detail', supplier_id=supplier.id)
|
|
else:
|
|
form = SupplierForm()
|
|
|
|
context = {
|
|
'form': form,
|
|
'module_title': 'Create Supplier',
|
|
'is_create': True,
|
|
}
|
|
return render(request, 'purchasing/supplier_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('purchasing.view_supplier', raise_exception=True)
|
|
def supplier_detail_view(request, supplier_id):
|
|
"""View supplier details"""
|
|
try:
|
|
supplier = Supplier.objects.get(id=supplier_id)
|
|
context = {
|
|
'module_title': 'Supplier Details',
|
|
'supplier': supplier,
|
|
}
|
|
return render(request, 'purchasing/supplier_detail.html', context)
|
|
except Supplier.DoesNotExist:
|
|
messages.error(request, 'Supplier not found')
|
|
return redirect('purchasing:supplier_list')
|
|
|
|
|
|
@login_required
|
|
def edit_supplier_view(request, supplier_id):
|
|
"""Edit supplier details"""
|
|
supplier = get_object_or_404(Supplier, id=supplier_id)
|
|
|
|
if request.method == 'POST':
|
|
form = SupplierForm(request.POST, instance=supplier)
|
|
if form.is_valid():
|
|
supplier = form.save()
|
|
messages.success(request, f'Supplier "{supplier.name}" updated successfully!')
|
|
return redirect('purchasing:supplier_detail', supplier_id=supplier.id)
|
|
else:
|
|
form = SupplierForm(instance=supplier)
|
|
|
|
context = {
|
|
'form': form,
|
|
'supplier': supplier,
|
|
'module_title': 'Edit Supplier',
|
|
'is_create': False,
|
|
}
|
|
return render(request, 'purchasing/supplier_form.html', context)
|
|
|
|
|
|
@login_required
|
|
def delete_supplier_view(request, supplier_id):
|
|
"""Delete a supplier"""
|
|
supplier = get_object_or_404(Supplier, id=supplier_id)
|
|
|
|
if request.method == 'POST':
|
|
supplier_name = supplier.name
|
|
supplier.delete()
|
|
messages.success(request, f'Supplier "{supplier_name}" deleted successfully!')
|
|
return redirect('purchasing:supplier_list')
|
|
|
|
# Get supplier statistics for confirmation
|
|
supplier_order_count = PurchaseOrder.objects.filter(supplier=supplier).count()
|
|
supplier_total_value = PurchaseOrder.objects.filter(supplier=supplier).aggregate(
|
|
total=Sum('total_amount')
|
|
)['total'] or 0
|
|
|
|
context = {
|
|
'supplier': supplier,
|
|
'module_title': 'Delete Supplier',
|
|
'supplier_order_count': supplier_order_count,
|
|
'supplier_total_value': supplier_total_value,
|
|
}
|
|
return render(request, 'purchasing/supplier_confirm_delete.html', context)
|
|
|
|
|
|
# Purchase Order Views
|
|
@login_required
|
|
@permission_required('purchasing.view_purchaseorder', raise_exception=True)
|
|
def po_list_view(request):
|
|
"""List all purchase orders"""
|
|
pos = PurchaseOrder.objects.select_related('supplier').all()
|
|
|
|
# Calculate statistics
|
|
total_value = pos.aggregate(total=Sum('total_amount'))['total'] or 0
|
|
pending_orders = pos.filter(status__in=['ordered', 'processing']).count()
|
|
|
|
context = {
|
|
'module_title': 'Purchase Order List',
|
|
'pos': pos,
|
|
'total_value': total_value,
|
|
'pending_orders': pending_orders,
|
|
}
|
|
return render(request, 'purchasing/po_list.html', context)
|
|
|
|
|
|
@login_required
|
|
def create_po_view(request):
|
|
"""Create a new purchase order"""
|
|
if request.method == 'POST':
|
|
form = PurchaseOrderForm(request.POST)
|
|
formset = PurchaseOrderItemFormSet(request.POST)
|
|
|
|
if form.is_valid() and formset.is_valid():
|
|
# Save the purchase order
|
|
po = form.save(commit=False)
|
|
po.created_by = request.user
|
|
po.save()
|
|
|
|
# Save the items
|
|
items = formset.save(commit=False)
|
|
for item in items:
|
|
item.po = po
|
|
item.save()
|
|
|
|
# Calculate totals
|
|
po.subtotal = sum(item.total_price for item in po.items.all())
|
|
po.tax_amount = po.subtotal * Decimal('0.11') # 11% tax
|
|
po.total_amount = po.subtotal + po.tax_amount
|
|
po.save()
|
|
|
|
messages.success(request, f'Purchase Order "{po.po_number}" created successfully!')
|
|
return redirect('purchasing:po_detail', po_number=po.po_number)
|
|
else:
|
|
form = PurchaseOrderForm()
|
|
formset = PurchaseOrderItemFormSet()
|
|
|
|
context = {
|
|
'form': form,
|
|
'formset': formset,
|
|
'module_title': 'Create Purchase Order',
|
|
'is_create': True,
|
|
}
|
|
return render(request, 'purchasing/po_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('purchasing.view_purchaseorder', raise_exception=True)
|
|
def po_detail_view(request, po_number):
|
|
"""View purchase order details"""
|
|
try:
|
|
po = PurchaseOrder.objects.get(po_number=po_number)
|
|
context = {
|
|
'module_title': 'Purchase Order Details',
|
|
'po': po,
|
|
}
|
|
return render(request, 'purchasing/po_detail.html', context)
|
|
except PurchaseOrder.DoesNotExist:
|
|
messages.error(request, 'Purchase order not found')
|
|
return redirect('purchasing:po_list')
|
|
|
|
|
|
@login_required
|
|
def edit_po_view(request, po_number):
|
|
"""Edit purchase order details"""
|
|
po = get_object_or_404(PurchaseOrder, po_number=po_number)
|
|
|
|
if request.method == 'POST':
|
|
form = PurchaseOrderForm(request.POST, instance=po)
|
|
formset = PurchaseOrderItemFormSet(request.POST, instance=po)
|
|
|
|
# Custom validation to skip deleted items
|
|
formset_valid = True
|
|
for i, form_item in enumerate(formset):
|
|
# Check if this item is marked for deletion in POST data
|
|
delete_field_name = f'form-{i}-DELETE'
|
|
is_deleted = request.POST.get(delete_field_name, 'off') == 'on'
|
|
|
|
if not is_deleted:
|
|
# Only validate non-deleted items
|
|
if not form_item.is_valid():
|
|
formset_valid = False
|
|
|
|
if form.is_valid() and formset_valid:
|
|
# Save the purchase order
|
|
po = form.save(commit=False)
|
|
po.save()
|
|
|
|
# Save the items (excluding deleted ones)
|
|
items = formset.save(commit=False)
|
|
for item in items:
|
|
item.po = po
|
|
item.save()
|
|
|
|
# Delete items marked for deletion
|
|
for deleted_item in formset.deleted_objects:
|
|
deleted_item.delete()
|
|
|
|
# Recalculate totals
|
|
po.subtotal = sum(item.total_price for item in po.items.all())
|
|
po.tax_amount = po.subtotal * Decimal('0.11') # 11% tax
|
|
po.total_amount = po.subtotal + po.tax_amount
|
|
po.save()
|
|
|
|
messages.success(request, f'Purchase Order "{po.po_number}" updated successfully!')
|
|
return redirect('purchasing:po_detail', po_number=po.po_number)
|
|
else:
|
|
form = PurchaseOrderForm(instance=po)
|
|
formset = PurchaseOrderItemFormSet(instance=po)
|
|
|
|
context = {
|
|
'form': form,
|
|
'formset': formset,
|
|
'po': po,
|
|
'module_title': 'Edit Purchase Order',
|
|
'is_create': False,
|
|
}
|
|
return render(request, 'purchasing/po_form.html', context)
|
|
|
|
|
|
@login_required
|
|
def approve_po_view(request, po_number):
|
|
"""Approve a purchase order"""
|
|
po = get_object_or_404(PurchaseOrder, po_number=po_number)
|
|
|
|
if request.method == 'POST':
|
|
if po.status == 'ordered':
|
|
po.status = 'approved'
|
|
po.save()
|
|
messages.success(request, f'Purchase Order "{po.po_number}" has been approved successfully!')
|
|
|
|
# Log the approval
|
|
# You could add audit logging here
|
|
|
|
else:
|
|
messages.warning(request, f'Purchase Order "{po.po_number}" cannot be approved in its current status.')
|
|
|
|
return redirect('purchasing:po_detail', po_number=po.po_number)
|
|
|
|
context = {
|
|
'po': po,
|
|
'action': 'approve',
|
|
'action_title': 'Approve Purchase Order',
|
|
'action_message': f'Are you sure you want to approve Purchase Order "{po.po_number}"?',
|
|
'action_button': 'Approve Order',
|
|
'action_class': 'success',
|
|
}
|
|
return render(request, 'purchasing/po_action_confirm.html', context)
|
|
|
|
|
|
@login_required
|
|
def cancel_po_view(request, po_number):
|
|
"""Cancel a purchase order"""
|
|
po = get_object_or_404(PurchaseOrder, po_number=po_number)
|
|
|
|
if request.method == 'POST':
|
|
if po.status in ['ordered', 'approved']:
|
|
po.status = 'cancelled'
|
|
po.save()
|
|
messages.success(request, f'Purchase Order "{po.po_number}" has been cancelled successfully!')
|
|
|
|
# Log the cancellation
|
|
# You could add audit logging here
|
|
|
|
else:
|
|
messages.warning(request, f'Purchase Order "{po.po_number}" cannot be cancelled in its current status.')
|
|
|
|
return redirect('purchasing:po_detail', po_number=po.po_number)
|
|
|
|
context = {
|
|
'po': po,
|
|
'action': 'cancel',
|
|
'action_title': 'Cancel Purchase Order',
|
|
'action_message': f'Are you sure you want to cancel Purchase Order "{po.po_number}"? This action cannot be undone.',
|
|
'action_button': 'Cancel Order',
|
|
'action_class': 'danger',
|
|
}
|
|
return render(request, 'purchasing/po_action_confirm.html', context)
|
|
|
|
|
|
@login_required
|
|
def delete_po_view(request, po_number):
|
|
"""Delete a purchase order"""
|
|
po = get_object_or_404(PurchaseOrder, po_number=po_number)
|
|
|
|
if request.method == 'POST':
|
|
po_number = po.po_number
|
|
po.delete()
|
|
messages.success(request, f'Purchase Order "{po_number}" deleted successfully!')
|
|
return redirect('purchasing:po_list')
|
|
|
|
context = {
|
|
'po': po,
|
|
'module_title': 'Delete Purchase Order',
|
|
}
|
|
return render(request, 'purchasing/po_confirm_delete.html', context)
|
|
|
|
|
|
# Goods Receipt Views
|
|
@login_required
|
|
@permission_required('purchasing.view_goodsreceipt', raise_exception=True)
|
|
def gr_list_view(request):
|
|
"""List all goods receipts"""
|
|
grs = GoodsReceipt.objects.all()
|
|
context = {
|
|
'module_title': 'Goods Receipt List',
|
|
'grs': grs,
|
|
}
|
|
return render(request, 'purchasing/gr_list.html', context)
|
|
|
|
|
|
@login_required
|
|
def create_gr_view(request, po_number):
|
|
"""Create a new goods receipt"""
|
|
po = get_object_or_404(PurchaseOrder, po_number=po_number)
|
|
|
|
# Check if PO can have goods receipt
|
|
if po.status not in ['ordered', 'approved']:
|
|
messages.error(request, f'Cannot create goods receipt for Purchase Order in "{po.get_status_display()}" status.')
|
|
return redirect('purchasing:po_detail', po_number=po.po_number)
|
|
|
|
if request.method == 'POST':
|
|
# Generate GR number (simple auto-increment)
|
|
last_gr = GoodsReceipt.objects.order_by('-id').first()
|
|
gr_number = f"GR{str(last_gr.id + 1).zfill(4)}" if last_gr else "GR0001"
|
|
|
|
# Create goods receipt
|
|
gr = GoodsReceipt.objects.create(
|
|
gr_number=gr_number,
|
|
po=po,
|
|
receipt_date=request.POST.get('receipt_date', timezone.now().date()),
|
|
received_by=request.user,
|
|
notes=request.POST.get('notes', '')
|
|
)
|
|
|
|
# Create goods receipt items and update inventory
|
|
for po_item in po.items.all():
|
|
# Convert Indonesian format (comma) to standard format (dot) for Decimal
|
|
quantity_str = request.POST.get(f'quantity_{po_item.id}', '0').replace(',', '.')
|
|
received_qty = Decimal(quantity_str) if quantity_str else Decimal('0')
|
|
|
|
if received_qty > 0:
|
|
# Create GR item
|
|
GoodsReceiptItem.objects.create(
|
|
receipt=gr,
|
|
po_item=po_item,
|
|
received_quantity=received_qty,
|
|
notes=request.POST.get(f'notes_{po_item.id}', '')
|
|
)
|
|
|
|
# Update PO item received quantity
|
|
po_item.received_quantity += received_qty
|
|
po_item.save()
|
|
|
|
# Update inventory
|
|
warehouse = Warehouse.objects.first() # Default to first warehouse if not specified
|
|
inventory, created = Inventory.objects.get_or_create(
|
|
product=po_item.product,
|
|
warehouse=warehouse,
|
|
defaults={'quantity': 0}
|
|
)
|
|
inventory.quantity += received_qty
|
|
inventory.save()
|
|
|
|
# Create stock movement record
|
|
StockMovement.objects.create(
|
|
product=po_item.product,
|
|
warehouse=warehouse,
|
|
movement_type='in',
|
|
quantity=received_qty,
|
|
reference_number=f"GR{gr.gr_number}",
|
|
notes=f"Goods receipt from PO {po.po_number}",
|
|
created_by=request.user
|
|
)
|
|
|
|
# Check if PO is fully received
|
|
total_received = sum(item.received_quantity for item in po.items.all())
|
|
total_ordered = sum(item.quantity for item in po.items.all())
|
|
|
|
if total_received >= total_ordered:
|
|
po.status = 'completed'
|
|
po.save()
|
|
messages.success(request, f'Goods Receipt "{gr.gr_number}" created successfully! Purchase Order is now completed.')
|
|
else:
|
|
messages.success(request, f'Goods Receipt "{gr.gr_number}" created successfully! Partial receipt recorded.')
|
|
|
|
return redirect('purchasing:gr_detail', gr_number=gr.gr_number)
|
|
|
|
context = {
|
|
'po': po,
|
|
'module_title': f'Create Goods Receipt for {po.po_number}',
|
|
'today': timezone.now().date(),
|
|
}
|
|
return render(request, 'purchasing/gr_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('purchasing.view_goodsreceipt', raise_exception=True)
|
|
def gr_detail_view(request, gr_number):
|
|
"""View goods receipt details"""
|
|
try:
|
|
gr = GoodsReceipt.objects.get(gr_number=gr_number)
|
|
|
|
# Calculate totals
|
|
total_quantity = sum(item.received_quantity for item in gr.items.all())
|
|
total_value = sum(item.po_item.unit_price * item.received_quantity for item in gr.items.all())
|
|
|
|
# Calculate previously received quantities and item totals for each item
|
|
for item in gr.items.all():
|
|
# Previously received = current total received - this receipt quantity
|
|
item.previously_received = item.po_item.received_quantity - item.received_quantity
|
|
# Calculate item total value
|
|
item.item_total = item.po_item.unit_price * item.received_quantity
|
|
|
|
context = {
|
|
'module_title': 'Goods Receipt Details',
|
|
'gr': gr,
|
|
'total_quantity': total_quantity,
|
|
'total_value': total_value,
|
|
}
|
|
return render(request, 'purchasing/gr_detail.html', context)
|
|
except GoodsReceipt.DoesNotExist:
|
|
messages.error(request, 'Goods receipt not found')
|
|
return redirect('purchasing:gr_list')
|