569 lines
20 KiB
Python
569 lines
20 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 import forms
|
|
from django.db.models import Sum, Q, F
|
|
from decimal import Decimal
|
|
from django.utils import timezone
|
|
from .models import Customer, SalesOrder, Delivery, DeliveryItem, SalesOrderItem
|
|
from apps.inventory.models import Product, Warehouse, Inventory, StockMovement
|
|
|
|
|
|
class CustomerForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Customer
|
|
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}),
|
|
}
|
|
|
|
|
|
@login_required
|
|
def sales_dashboard(request):
|
|
"""Sales dashboard view"""
|
|
from django.db.models import Count, Sum, Q
|
|
from django.utils import timezone
|
|
from datetime import timedelta
|
|
|
|
# Get dashboard data
|
|
customers = Customer.objects.filter(is_active=True)
|
|
pending_orders = SalesOrder.objects.filter(
|
|
Q(status='ordered') | Q(status='processing')
|
|
)
|
|
recent_orders = SalesOrder.objects.select_related('customer').order_by('-order_date')[:10]
|
|
|
|
# Calculate total value of recent orders (last 30 days)
|
|
thirty_days_ago = timezone.now().date() - timedelta(days=30)
|
|
total_value = SalesOrder.objects.filter(
|
|
order_date__gte=thirty_days_ago
|
|
).aggregate(
|
|
total=Sum('total_amount')
|
|
)['total'] or 0
|
|
|
|
# Get top customers by order count
|
|
top_customers = Customer.objects.annotate(
|
|
order_count=Count('salesorder')
|
|
).order_by('-order_count')[:5]
|
|
|
|
context = {
|
|
'module_title': 'Sales Management',
|
|
'customers': customers,
|
|
'pending_orders': pending_orders,
|
|
'total_value': total_value,
|
|
'recent_orders': recent_orders,
|
|
'top_customers': top_customers,
|
|
}
|
|
return render(request, 'sales/dashboard.html', context)
|
|
|
|
|
|
# Customer Views
|
|
@login_required
|
|
@permission_required('sales.view_customer', raise_exception=True)
|
|
def customer_list_view(request):
|
|
"""List all customers"""
|
|
customers = Customer.objects.all()
|
|
context = {
|
|
'module_title': 'Customer List',
|
|
'customers': customers,
|
|
}
|
|
return render(request, 'sales/customer_list.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.add_customer', raise_exception=True)
|
|
def create_customer_view(request):
|
|
"""Create a new customer"""
|
|
if request.method == 'POST':
|
|
form = CustomerForm(request.POST)
|
|
if form.is_valid():
|
|
customer = form.save()
|
|
messages.success(request, f'Customer "{customer.name}" created successfully!')
|
|
return redirect('sales:customer_detail', customer_id=customer.id)
|
|
else:
|
|
form = CustomerForm()
|
|
|
|
context = {
|
|
'form': form,
|
|
'module_title': 'Create Customer',
|
|
'is_create': True,
|
|
}
|
|
return render(request, 'sales/customer_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.view_customer', raise_exception=True)
|
|
def customer_detail_view(request, customer_id):
|
|
"""View customer details"""
|
|
try:
|
|
customer = Customer.objects.get(id=customer_id)
|
|
|
|
# Get customer's sales orders
|
|
sales_orders = SalesOrder.objects.filter(customer=customer).order_by('-order_date')[:10]
|
|
|
|
# Calculate statistics
|
|
total_orders = SalesOrder.objects.filter(customer=customer).count()
|
|
total_value = SalesOrder.objects.filter(customer=customer).aggregate(
|
|
total=Sum('total_amount')
|
|
)['total'] or 0
|
|
pending_orders = SalesOrder.objects.filter(
|
|
customer=customer,
|
|
status__in=['processing']
|
|
).count()
|
|
|
|
# For delete confirmation
|
|
customer_order_count = total_orders
|
|
customer_total_value = total_value
|
|
|
|
context = {
|
|
'module_title': 'Customer Details',
|
|
'customer': customer,
|
|
'sales_orders': sales_orders,
|
|
'total_orders': total_orders,
|
|
'total_value': total_value,
|
|
'pending_orders': pending_orders,
|
|
'customer_order_count': customer_order_count,
|
|
'customer_total_value': customer_total_value,
|
|
}
|
|
return render(request, 'sales/customer_detail.html', context)
|
|
except Customer.DoesNotExist:
|
|
messages.error(request, 'Customer not found')
|
|
return redirect('sales:customer_list')
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.change_customer', raise_exception=True)
|
|
def edit_customer_view(request, customer_id):
|
|
"""Edit customer details"""
|
|
customer = get_object_or_404(Customer, id=customer_id)
|
|
|
|
if request.method == 'POST':
|
|
form = CustomerForm(request.POST, instance=customer)
|
|
if form.is_valid():
|
|
customer = form.save()
|
|
messages.success(request, f'Customer "{customer.name}" updated successfully!')
|
|
return redirect('sales:customer_detail', customer_id=customer.id)
|
|
else:
|
|
form = CustomerForm(instance=customer)
|
|
|
|
context = {
|
|
'form': form,
|
|
'customer': customer,
|
|
'module_title': 'Edit Customer',
|
|
'is_create': False,
|
|
}
|
|
return render(request, 'sales/customer_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.delete_customer', raise_exception=True)
|
|
def delete_customer_view(request, customer_id):
|
|
"""Delete a customer"""
|
|
customer = get_object_or_404(Customer, id=customer_id)
|
|
|
|
if request.method == 'POST':
|
|
customer_name = customer.name
|
|
customer.delete()
|
|
messages.success(request, f'Customer "{customer_name}" deleted successfully!')
|
|
return redirect('sales:customer_list')
|
|
|
|
context = {
|
|
'customer': customer,
|
|
'module_title': 'Delete Customer',
|
|
}
|
|
return render(request, 'sales/customer_confirm_delete.html', context)
|
|
|
|
|
|
# Sales Order Views
|
|
@login_required
|
|
@permission_required('sales.view_salesorder', raise_exception=True)
|
|
def so_list_view(request):
|
|
"""List all sales orders"""
|
|
sos = SalesOrder.objects.all().select_related('customer')
|
|
|
|
# Calculate summary statistics
|
|
total_value = SalesOrder.objects.aggregate(
|
|
total=Sum('total_amount')
|
|
)['total'] or 0
|
|
|
|
pending_orders = SalesOrder.objects.filter(
|
|
status__in=['processing']
|
|
).count()
|
|
|
|
context = {
|
|
'module_title': 'Sales Order List',
|
|
'sos': sos,
|
|
'total_value': total_value,
|
|
'pending_orders': pending_orders,
|
|
}
|
|
return render(request, 'sales/so_list.html', context)
|
|
|
|
|
|
from django.forms import inlineformset_factory
|
|
from .models import SalesOrder, SalesOrderItem, Delivery, DeliveryItem
|
|
|
|
class SalesOrderItemForm(forms.ModelForm):
|
|
class Meta:
|
|
model = SalesOrderItem
|
|
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 SalesOrderForm(forms.ModelForm):
|
|
class Meta:
|
|
model = SalesOrder
|
|
fields = ['customer', '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}),
|
|
}
|
|
|
|
|
|
class DeliveryItemForm(forms.ModelForm):
|
|
class Meta:
|
|
model = DeliveryItem
|
|
fields = ['so_item', 'delivered_quantity', 'notes']
|
|
widgets = {
|
|
'delivered_quantity': forms.NumberInput(attrs={'step': '0.01', 'min': '0'}),
|
|
'notes': forms.Textarea(attrs={'rows': 2}),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
if hasattr(self, 'so') and self.so:
|
|
self.fields['so_item'].queryset = SalesOrderItem.objects.filter(so=self.so)
|
|
|
|
|
|
class DeliveryForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Delivery
|
|
fields = ['delivery_number', 'delivery_date', 'notes']
|
|
widgets = {
|
|
'delivery_date': forms.DateInput(attrs={'type': 'date'}),
|
|
'notes': forms.Textarea(attrs={'rows': 3}),
|
|
}
|
|
|
|
SalesOrderItemFormSet = inlineformset_factory(
|
|
SalesOrder,
|
|
SalesOrderItem,
|
|
form=SalesOrderItemForm,
|
|
extra=0,
|
|
can_delete=True,
|
|
min_num=1,
|
|
validate_min=True
|
|
)
|
|
|
|
@login_required
|
|
@permission_required('sales.add_salesorder', raise_exception=True)
|
|
def create_so_view(request):
|
|
"""Create a new sales order"""
|
|
if request.method == 'POST':
|
|
form = SalesOrderForm(request.POST)
|
|
formset = SalesOrderItemFormSet(request.POST)
|
|
|
|
if form.is_valid() and formset.is_valid():
|
|
# Save the sales order
|
|
so = form.save(commit=False)
|
|
so.created_by = request.user
|
|
so.save()
|
|
|
|
# Save the items
|
|
items = formset.save(commit=False)
|
|
for item in items:
|
|
item.so = so
|
|
item.save()
|
|
|
|
# Calculate totals
|
|
so.subtotal = sum(item.total_price for item in so.items.all())
|
|
so.tax_amount = so.subtotal * Decimal('0.11') # 11% tax
|
|
so.total_amount = so.subtotal + so.tax_amount
|
|
so.save()
|
|
|
|
messages.success(request, f'Sales Order "{so.so_number}" created successfully!')
|
|
return redirect('sales:so_detail', so_number=so.so_number)
|
|
else:
|
|
form = SalesOrderForm()
|
|
formset = SalesOrderItemFormSet()
|
|
|
|
context = {
|
|
'form': form,
|
|
'formset': formset,
|
|
'module_title': 'Create Sales Order',
|
|
'is_create': True,
|
|
}
|
|
return render(request, 'sales/so_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.view_salesorder', raise_exception=True)
|
|
def so_detail_view(request, so_number):
|
|
"""View sales order details"""
|
|
try:
|
|
so = SalesOrder.objects.select_related('customer').prefetch_related('items').get(so_number=so_number)
|
|
context = {
|
|
'module_title': 'Sales Order Details',
|
|
'so': so,
|
|
}
|
|
return render(request, 'sales/so_detail.html', context)
|
|
except SalesOrder.DoesNotExist:
|
|
messages.error(request, 'Sales order not found')
|
|
return redirect('sales:so_list')
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.change_salesorder', raise_exception=True)
|
|
def edit_so_view(request, so_number):
|
|
"""Edit sales order details"""
|
|
so = get_object_or_404(SalesOrder, so_number=so_number)
|
|
|
|
if request.method == 'POST':
|
|
form = SalesOrderForm(request.POST, instance=so)
|
|
formset = SalesOrderItemFormSet(request.POST, instance=so)
|
|
|
|
# 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 sales order
|
|
so = form.save(commit=False)
|
|
so.save()
|
|
|
|
# Save the items (excluding deleted ones)
|
|
items = formset.save(commit=False)
|
|
for item in items:
|
|
item.so = so
|
|
item.save()
|
|
|
|
# Delete items marked for deletion
|
|
for deleted_item in formset.deleted_objects:
|
|
deleted_item.delete()
|
|
|
|
# Recalculate totals
|
|
so.subtotal = sum(item.total_price for item in so.items.all())
|
|
so.tax_amount = so.subtotal * Decimal('0.11') # 11% tax
|
|
so.total_amount = so.subtotal + so.tax_amount
|
|
so.save()
|
|
|
|
messages.success(request, f'Sales Order "{so.so_number}" updated successfully!')
|
|
return redirect('sales:so_detail', so_number=so.so_number)
|
|
else:
|
|
form = SalesOrderForm(instance=so)
|
|
formset = SalesOrderItemFormSet(instance=so)
|
|
|
|
context = {
|
|
'form': form,
|
|
'formset': formset,
|
|
'so': so,
|
|
'module_title': 'Edit Sales Order',
|
|
'is_create': False,
|
|
}
|
|
return render(request, 'sales/so_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.change_salesorder', raise_exception=True)
|
|
def approve_so_view(request, so_number):
|
|
"""Approve a sales order"""
|
|
# In a real app, this would contain SO approval logic
|
|
return HttpResponse("Approve sales order page")
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.change_salesorder', raise_exception=True)
|
|
def cancel_so_view(request, so_number):
|
|
"""Cancel a sales order"""
|
|
# In a real app, this would contain SO cancellation logic
|
|
return HttpResponse("Cancel sales order page")
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.delete_salesorder', raise_exception=True)
|
|
def delete_so_view(request, so_number):
|
|
"""Delete a sales order"""
|
|
so = get_object_or_404(SalesOrder, so_number=so_number)
|
|
|
|
if request.method == 'POST':
|
|
so_number = so.so_number
|
|
so.delete()
|
|
messages.success(request, f'Sales Order "{so_number}" deleted successfully!')
|
|
return redirect('sales:so_list')
|
|
|
|
context = {
|
|
'so': so,
|
|
'module_title': 'Delete Sales Order',
|
|
}
|
|
return render(request, 'sales/so_confirm_delete.html', context)
|
|
|
|
|
|
# Delivery Views
|
|
@login_required
|
|
@permission_required('sales.view_delivery', raise_exception=True)
|
|
def delivery_list_view(request):
|
|
"""List all deliveries"""
|
|
deliveries = Delivery.objects.select_related(
|
|
'so', 'so__customer', 'delivered_by'
|
|
).prefetch_related('items').order_by('-created_at')
|
|
|
|
# Calculate statistics
|
|
total_deliveries = deliveries.count()
|
|
total_value = sum(
|
|
sum(item.delivered_quantity * item.so_item.unit_price for item in delivery.items.all())
|
|
for delivery in deliveries
|
|
)
|
|
|
|
context = {
|
|
'module_title': 'Delivery List',
|
|
'deliveries': deliveries,
|
|
'total_deliveries': total_deliveries,
|
|
'total_value': total_value,
|
|
}
|
|
return render(request, 'sales/delivery_list.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.add_delivery', raise_exception=True)
|
|
def create_delivery_view(request, so_number):
|
|
"""Create a new delivery"""
|
|
so = get_object_or_404(SalesOrder, so_number=so_number)
|
|
|
|
# Check if SO can have delivery
|
|
if so.status not in ['processing', 'completed']:
|
|
messages.error(request, f'Cannot create delivery for Sales Order in "{so.get_status_display()}" status.')
|
|
return redirect('sales:so_detail', so_number=so.so_number)
|
|
|
|
# Get undelivered items
|
|
undelivered_items = so.items.filter(
|
|
quantity__gt=F('shipped_quantity')
|
|
)
|
|
|
|
if not undelivered_items:
|
|
messages.warning(request, 'All items in this sales order have been fully delivered.')
|
|
return redirect('sales:so_detail', so_number=so.so_number)
|
|
|
|
if request.method == 'POST':
|
|
# Generate delivery number (simple auto-increment)
|
|
last_delivery = Delivery.objects.order_by('-id').first()
|
|
delivery_number = f"DEL{str(last_delivery.id + 1).zfill(4)}" if last_delivery else "DEL0001"
|
|
|
|
# Create delivery
|
|
delivery = Delivery.objects.create(
|
|
delivery_number=delivery_number,
|
|
so=so,
|
|
delivery_date=request.POST.get('delivery_date', timezone.now().date()),
|
|
delivered_by=request.user,
|
|
notes=request.POST.get('notes', '')
|
|
)
|
|
|
|
# Create delivery items and update inventory
|
|
for so_item in undelivered_items:
|
|
# Convert Indonesian format (comma) to standard format (dot) for Decimal
|
|
quantity_str = request.POST.get(f'quantity_{so_item.id}', '0').replace(',', '.')
|
|
delivered_qty = Decimal(quantity_str) if quantity_str else Decimal('0')
|
|
|
|
if delivered_qty > 0:
|
|
# Check if delivery quantity doesn't exceed remaining quantity
|
|
remaining_qty = so_item.quantity - so_item.shipped_quantity
|
|
if delivered_qty > remaining_qty:
|
|
delivered_qty = remaining_qty
|
|
|
|
# Create delivery item
|
|
DeliveryItem.objects.create(
|
|
delivery=delivery,
|
|
so_item=so_item,
|
|
delivered_quantity=delivered_qty,
|
|
notes=request.POST.get(f'notes_{so_item.id}', '')
|
|
)
|
|
|
|
# Update SO item shipped quantity
|
|
so_item.shipped_quantity += delivered_qty
|
|
so_item.save()
|
|
|
|
# Update inventory (decrease stock)
|
|
warehouse = Warehouse.objects.first() # Default to first warehouse if not specified
|
|
inventory, created = Inventory.objects.get_or_create(
|
|
product=so_item.product,
|
|
warehouse=warehouse,
|
|
defaults={'quantity': 0}
|
|
)
|
|
inventory.quantity -= delivered_qty
|
|
inventory.save()
|
|
|
|
# Create stock movement record
|
|
StockMovement.objects.create(
|
|
product=so_item.product,
|
|
warehouse=warehouse,
|
|
movement_type='out',
|
|
quantity=delivered_qty,
|
|
reference_number=f"DEL{delivery.delivery_number}",
|
|
notes=f"Delivery from SO {so.so_number}",
|
|
created_by=request.user
|
|
)
|
|
|
|
# Check if SO is fully delivered
|
|
total_shipped = sum(item.shipped_quantity for item in so.items.all())
|
|
total_ordered = sum(item.quantity for item in so.items.all())
|
|
|
|
if total_shipped >= total_ordered:
|
|
so.status = 'completed'
|
|
so.save()
|
|
messages.success(request, f'Delivery "{delivery.delivery_number}" created successfully! Sales Order is now completed.')
|
|
else:
|
|
messages.success(request, f'Delivery "{delivery.delivery_number}" created successfully! Partial delivery recorded.')
|
|
|
|
return redirect('sales:delivery_detail', delivery_number=delivery.delivery_number)
|
|
|
|
context = {
|
|
'so': so,
|
|
'undelivered_items': undelivered_items,
|
|
'module_title': f'Create Delivery for {so.so_number}',
|
|
'today': timezone.now().date(),
|
|
}
|
|
return render(request, 'sales/delivery_form.html', context)
|
|
|
|
|
|
@login_required
|
|
@permission_required('sales.view_delivery', raise_exception=True)
|
|
def delivery_detail_view(request, delivery_number):
|
|
"""View delivery details"""
|
|
try:
|
|
delivery = Delivery.objects.select_related('so', 'so__customer').prefetch_related('items', 'items__so_item', 'items__so_item__product').get(delivery_number=delivery_number)
|
|
|
|
# Calculate totals
|
|
total_quantity = sum(item.delivered_quantity for item in delivery.items.all())
|
|
total_value = sum(item.so_item.unit_price * item.delivered_quantity for item in delivery.items.all())
|
|
|
|
# Calculate previously delivered quantities and item totals for each item
|
|
for item in delivery.items.all():
|
|
# Previously delivered = current total shipped - this delivery quantity
|
|
item.previously_delivered = item.so_item.shipped_quantity - item.delivered_quantity
|
|
# Calculate item total value
|
|
item.item_total = item.so_item.unit_price * item.delivered_quantity
|
|
|
|
context = {
|
|
'module_title': 'Delivery Details',
|
|
'delivery': delivery,
|
|
'total_quantity': total_quantity,
|
|
'total_value': total_value,
|
|
}
|
|
return render(request, 'sales/delivery_detail.html', context)
|
|
except Delivery.DoesNotExist:
|
|
messages.error(request, 'Delivery not found')
|
|
return redirect('sales:delivery_list')
|