This commit is contained in:
Suherdy Yacob 2025-08-19 19:06:26 +07:00
parent 80a514f1de
commit 01f8d2114e
35 changed files with 1347 additions and 36 deletions

Binary file not shown.

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.5 on 2025-08-19 08:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('inventory', '0002_customer_supplier'),
]
operations = [
migrations.RemoveField(
model_name='supplier',
name='rating',
),
]

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import ManufacturingOrder, ManufacturingLine, ManufacturingOrderLine
from .models import ManufacturingOrder, ManufacturingLine, ManufacturingOrderLine, BillOfMaterials, BillOfMaterialsTotal
@admin.register(ManufacturingOrder)
class ManufacturingOrderAdmin(admin.ModelAdmin):
@ -20,6 +20,7 @@ class ManufacturingOrderAdmin(admin.ModelAdmin):
def get_queryset(self, request):
return super().get_queryset(request).select_related('product', 'created_by')
@admin.register(ManufacturingLine)
class ManufacturingLineAdmin(admin.ModelAdmin):
list_display = ('name', 'capacity_per_hour', 'is_active', 'created_at')
@ -32,6 +33,7 @@ class ManufacturingLineAdmin(admin.ModelAdmin):
('Status', {'fields': ('is_active',)}),
)
@admin.register(ManufacturingOrderLine)
class ManufacturingOrderLineAdmin(admin.ModelAdmin):
list_display = ('manufacturing_order', 'manufacturing_line', 'actual_quantity',
@ -48,3 +50,35 @@ class ManufacturingOrderLineAdmin(admin.ModelAdmin):
def get_queryset(self, request):
return super().get_queryset(request).select_related('manufacturing_order', 'manufacturing_line')
@admin.register(BillOfMaterials)
class BillOfMaterialsAdmin(admin.ModelAdmin):
list_display = ('manufactured_product', 'component', 'quantity', 'unit', 'created_at')
list_filter = ('manufactured_product__category', 'created_at')
search_fields = ('manufactured_product__name', 'manufactured_product__code',
'component__name', 'component__code')
ordering = ('manufactured_product__name', 'component__name')
fieldsets = (
('BOM Information', {'fields': ('manufactured_product', 'component', 'quantity', 'unit')}),
)
def get_queryset(self, request):
return super().get_queryset(request).select_related('manufactured_product', 'component')
@admin.register(BillOfMaterialsTotal)
class BillOfMaterialsTotalAdmin(admin.ModelAdmin):
list_display = ('bom', 'total_cost', 'total_weight', 'last_calculated')
list_filter = ('last_calculated',)
search_fields = ('bom__manufactured_product__name', 'bom__component__name')
ordering = ('-last_calculated',)
fieldsets = (
('BOM Total Information', {'fields': ('bom', 'total_cost', 'total_weight')}),
)
readonly_fields = ('last_calculated',)
def get_queryset(self, request):
return super().get_queryset(request).select_related('bom__manufactured_product', 'bom__component')

View File

@ -1,5 +1,7 @@
from django import forms
from .models import ManufacturingOrder
from django.forms import formset_factory
from django.forms.widgets import NumberInput
from .models import ManufacturingOrder, BillOfMaterials
from inventory.models import Product
class ManufacturingOrderForm(forms.ModelForm):
@ -10,6 +12,7 @@ class ManufacturingOrderForm(forms.ModelForm):
fields = ['product', 'quantity', 'date', 'notes']
widgets = {
'date': forms.DateInput(attrs={'type': 'date'}),
'quantity': forms.NumberInput(attrs={'step': '0.01'}),
'notes': forms.Textarea(attrs={'rows': 3}),
}
@ -31,4 +34,145 @@ class ManufacturingOrderForm(forms.ModelForm):
quantity = self.cleaned_data.get('quantity')
if quantity <= 0:
raise forms.ValidationError("Quantity must be greater than zero.")
return quantity
return quantity
class BillOfMaterialsForm(forms.ModelForm):
"""Form for creating and editing Bill of Materials entries"""
class Meta:
model = BillOfMaterials
fields = ['manufactured_product', 'component', 'quantity', 'unit']
widgets = {
'quantity': forms.NumberInput(attrs={'step': '0.0001'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add Bootstrap classes
for field in self.fields.values():
if isinstance(field.widget, (forms.CheckboxInput, forms.RadioSelect)):
field.widget.attrs.update({'class': 'form-check-input'})
elif isinstance(field.widget, forms.Textarea):
field.widget.attrs.update({'class': 'form-control'})
elif isinstance(field.widget, forms.DateInput):
field.widget.attrs.update({'class': 'form-control'})
else:
field.widget.attrs.update({'class': 'form-control'})
# Filter manufactured products to only show those marked as manufactured
self.fields['manufactured_product'].queryset = Product.objects.filter(is_manufactured=True)
def clean_quantity(self):
"""Ensure quantity is positive"""
quantity = self.cleaned_data.get('quantity')
if quantity <= 0:
raise forms.ValidationError("Quantity must be greater than zero.")
return quantity
def clean(self):
"""Validate that manufactured product and component are not the same"""
cleaned_data = super().clean()
manufactured_product = cleaned_data.get('manufactured_product')
component = cleaned_data.get('component')
if manufactured_product and component and manufactured_product == component:
raise forms.ValidationError("A product cannot be a component of itself.")
return cleaned_data
class MultipleBillOfMaterialsForm(forms.Form):
"""Form for creating multiple BOM entries at once"""
manufactured_product = forms.ModelChoiceField(
queryset=Product.objects.filter(is_manufactured=True),
label="Manufactured Product",
help_text="The product that is manufactured using these components"
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add Bootstrap classes
for field in self.fields.values():
if isinstance(field.widget, (forms.CheckboxInput, forms.RadioSelect)):
field.widget.attrs.update({'class': 'form-check-input'})
elif isinstance(field.widget, forms.Textarea):
field.widget.attrs.update({'class': 'form-control'})
elif isinstance(field.widget, forms.DateInput):
field.widget.attrs.update({'class': 'form-control'})
else:
field.widget.attrs.update({'class': 'form-control'})
def clean_manufactured_product(self):
"""Ensure the selected product is marked as manufactured"""
manufactured_product = self.cleaned_data.get('manufactured_product')
if manufactured_product and not manufactured_product.is_manufactured:
raise forms.ValidationError("Selected product is not marked as manufactured.")
return manufactured_product
class BOMComponentForm(forms.Form):
"""Form for individual BOM component entry"""
component = forms.ModelChoiceField(
queryset=Product.objects.all(),
label="Component",
help_text="A component used in manufacturing"
)
quantity = forms.DecimalField(
max_digits=10,
decimal_places=4,
min_value=0.0001,
label="Quantity",
help_text="Quantity of component needed per unit of manufactured product"
)
unit = forms.CharField(
max_length=20,
required=False,
label="Unit",
help_text="Unit of measurement for the component (e.g., kg, pieces, meters)",
widget=forms.TextInput(attrs={'readonly': 'readonly'})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add Bootstrap classes
for field in self.fields.values():
if isinstance(field.widget, (forms.CheckboxInput, forms.RadioSelect)):
field.widget.attrs.update({'class': 'form-check-input'})
elif isinstance(field.widget, forms.Textarea):
field.widget.attrs.update({'class': 'form-control'})
elif isinstance(field.widget, forms.DateInput):
field.widget.attrs.update({'class': 'form-control'})
else:
field.widget.attrs.update({'class': 'form-control'})
def clean_quantity(self):
"""Ensure quantity is positive"""
quantity = self.cleaned_data.get('quantity')
if quantity <= 0:
raise forms.ValidationError("Quantity must be greater than zero.")
return quantity
def clean(self):
"""Validate that component is provided"""
cleaned_data = super().clean()
component = cleaned_data.get('component')
# If no component is selected, this might be an empty form
if not component:
# Check if any other fields have data
quantity = cleaned_data.get('quantity')
unit = cleaned_data.get('unit')
# If other fields have data but no component, raise error
if quantity or unit:
raise forms.ValidationError("Component is required.")
# If all fields are empty, mark form as empty
raise forms.ValidationError("This form is empty.")
return cleaned_data
# Create a formset for BOM components
BOMComponentFormSet = formset_factory(BOMComponentForm, extra=1, min_num=1, validate_min=True)

View File

@ -0,0 +1,49 @@
# Generated by Django 5.2.5 on 2025-08-19 08:54
import django.core.validators
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0003_remove_supplier_rating'),
('manufacture', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='BillOfMaterials',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=4, help_text='Quantity of component needed per unit of manufactured product', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('unit', models.CharField(blank=True, help_text='Unit of measurement for the component (e.g., kg, pieces, meters)', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('component', models.ForeignKey(help_text='A component used in manufacturing', on_delete=django.db.models.deletion.CASCADE, related_name='bom_components', to='inventory.product')),
('manufactured_product', models.ForeignKey(help_text='The product that is manufactured using this BOM', on_delete=django.db.models.deletion.CASCADE, related_name='bom_manufactured', to='inventory.product')),
],
options={
'verbose_name': 'Bill of Materials',
'verbose_name_plural': 'Bills of Materials',
'ordering': ['manufactured_product__name', 'component__name'],
'unique_together': {('manufactured_product', 'component')},
},
),
migrations.CreateModel(
name='BillOfMaterialsTotal',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_cost', models.DecimalField(decimal_places=4, default=0, max_digits=12)),
('total_weight', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('last_calculated', models.DateTimeField(auto_now=True)),
('bom', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='totals', to='manufacture.billofmaterials')),
],
options={
'verbose_name': 'BOM Total',
'verbose_name_plural': 'BOM Totals',
},
),
]

View File

@ -60,11 +60,51 @@ class ManufacturingOrder(models.Model):
def save(self, *args, **kwargs):
"""Override save to calculate total cost and update product stock"""
# Calculate total cost
self.total_cost = self.labor_cost + self.overhead_cost
# Check if product has BOM entries
bom_entries = BillOfMaterials.objects.filter(manufactured_product=self.product)
# Calculate total cost based on BOM if available, otherwise use manual costs
if bom_entries.exists():
# Calculate total cost based on BOM
bom_total_cost = 0
for bom_entry in bom_entries:
component_cost = bom_entry.get_total_component_cost()
bom_total_cost += component_cost * self.quantity
# Add labor and overhead costs
self.total_cost = bom_total_cost + self.labor_cost + self.overhead_cost
else:
# Calculate total cost using manual costs
self.total_cost = self.labor_cost + self.overhead_cost
# If this is a new order and status is completed, add to inventory
if not self.pk and self.status == 'completed':
# Check if product has BOM entries and deduct components from stock
if bom_entries.exists():
# Check if there's enough stock for all components
insufficient_stock = []
for bom_entry in bom_entries:
required_quantity = bom_entry.quantity * self.quantity
if bom_entry.component.current_stock < required_quantity:
insufficient_stock.append({
'component': bom_entry.component.name,
'required': required_quantity,
'available': bom_entry.component.current_stock
})
# If there's insufficient stock, raise an error
if insufficient_stock:
error_msg = "Insufficient stock for components: "
for item in insufficient_stock:
error_msg += f"{item['component']} (required: {item['required']}, available: {item['available']}) "
raise ValueError(error_msg)
# Deduct components from stock
for bom_entry in bom_entries:
required_quantity = bom_entry.quantity * self.quantity
bom_entry.component.current_stock -= required_quantity
bom_entry.component.save()
# Update product stock
self.product.current_stock += self.quantity
@ -101,6 +141,7 @@ class ManufacturingOrder(models.Model):
return ((self.product.selling_price - unit_cost) / unit_cost) * 100
return Decimal('0')
class ManufacturingLine(models.Model):
"""Manufacturing line/workstation information"""
@ -125,6 +166,7 @@ class ManufacturingLine(models.Model):
def __str__(self):
return self.name
class ManufacturingOrderLine(models.Model):
"""Individual line items for manufacturing orders (optional for future expansion)"""
@ -151,3 +193,77 @@ class ManufacturingOrderLine(models.Model):
if self.start_time and self.end_time:
return self.end_time - self.start_time
return None
class BillOfMaterials(models.Model):
"""Bill of Materials - defines components needed to manufacture a product"""
manufactured_product = models.ForeignKey(
'inventory.Product',
on_delete=models.CASCADE,
related_name='bom_manufactured',
help_text='The product that is manufactured using this BOM'
)
component = models.ForeignKey(
'inventory.Product',
on_delete=models.CASCADE,
related_name='bom_components',
help_text='A component used in manufacturing'
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=4,
validators=[MinValueValidator(Decimal('0.0001'))],
help_text='Quantity of component needed per unit of manufactured product'
)
unit = models.CharField(
max_length=20,
blank=True,
help_text='Unit of measurement for the component (e.g., kg, pieces, meters)'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('manufactured_product', 'component')
ordering = ['manufactured_product__name', 'component__name']
verbose_name = _('Bill of Materials')
verbose_name_plural = _('Bills of Materials')
def __str__(self):
return f"{self.manufactured_product.name} - {self.component.name} ({self.quantity} {self.unit})"
def save(self, *args, **kwargs):
"""Override save to ensure the manufactured product is marked as manufactured"""
# Ensure the manufactured product is marked as manufactured
if not self.manufactured_product.is_manufactured:
self.manufactured_product.is_manufactured = True
self.manufactured_product.save()
# If unit is not provided, use the component's unit
if not self.unit and self.component.unit:
self.unit = self.component.unit
super().save(*args, **kwargs)
def get_total_component_cost(self):
"""Calculate total cost of this component for one unit of manufactured product"""
return self.quantity * self.component.cost_price
class BillOfMaterialsTotal(models.Model):
"""Pre-calculated totals for a BOM to improve performance"""
bom = models.OneToOneField(BillOfMaterials, on_delete=models.CASCADE, related_name='totals')
total_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
total_weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
last_calculated = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _('BOM Total')
verbose_name_plural = _('BOM Totals')
def __str__(self):
return f"{self.bom} - Total: {self.total_cost}"

View File

View File

@ -0,0 +1,79 @@
from django import template
register = template.Library()
@register.filter
def format_number(value, decimal_places=2):
"""
Format a decimal value to display with Indonesian number formatting.
Uses '.' as thousand separator and ',' as decimal separator.
Removes trailing zeros after decimal.
"""
if value is None:
return ''
try:
# Convert to float
num = float(value)
# Format with specified decimal places
formatted = f"{num:.{decimal_places}f}"
# Split into integer and decimal parts
if '.' in formatted:
integer_part, decimal_part = formatted.split('.')
else:
integer_part, decimal_part = formatted, ''
# Add thousand separators to integer part
# Reverse the string, add separators every 3 digits, then reverse back
integer_part = '{:,}'.format(int(integer_part)).replace(',', '.')
# Handle decimal part - remove trailing zeros
if decimal_part and int(decimal_part) != 0:
# Remove trailing zeros
decimal_part = decimal_part.rstrip('0')
return f"{integer_part},{decimal_part}"
else:
return integer_part
except (ValueError, TypeError):
return str(value)
@register.filter
def format_quantity(value):
"""
Format a decimal value to display with Indonesian number formatting for quantities.
Uses '.' as thousand separator and ',' as decimal separator.
Removes trailing zeros after decimal.
"""
return format_number(value, 4)
@register.filter
def format_currency(value):
"""
Format a decimal value to display with Indonesian number formatting for currency.
Uses '.' as thousand separator and ',' as decimal separator.
Always shows 2 decimal places for currency.
"""
if value is None:
return ''
try:
# Convert to float
num = float(value)
# Format with 2 decimal places for currency
formatted = f"{num:,.2f}"
# Split into integer and decimal parts
integer_part, decimal_part = formatted.split('.')
# Add thousand separators to integer part
integer_part = '{:,}'.format(int(integer_part)).replace(',', '.')
# Combine with decimal part
return f"{integer_part},{decimal_part}"
except (ValueError, TypeError):
return str(value)

View File

@ -10,4 +10,12 @@ urlpatterns = [
path('<int:pk>/', views.ManufactureDetailView.as_view(), name='manufacture_detail'),
path('<int:pk>/edit/', views.manufacture_edit, name='manufacture_edit'),
path('<int:pk>/delete/', views.manufacture_delete, name='manufacture_delete'),
# BOM URLs
path('bom/', views.BillOfMaterialsListView.as_view(), name='bom_list'),
path('bom/create/', views.bom_create, name='bom_create'),
path('bom/<int:pk>/', views.BillOfMaterialsDetailView.as_view(), name='bom_detail'),
path('bom/<int:pk>/edit/', views.bom_edit, name='bom_edit'),
path('bom/<int:pk>/delete/', views.bom_delete, name='bom_delete'),
path('bom/product/<int:product_id>/info/', views.get_product_info, name='get_product_info'),
]

View File

@ -3,8 +3,11 @@ from django.views.generic import ListView, DetailView
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.contrib import messages
from .models import ManufacturingOrder
from .forms import ManufacturingOrderForm
from django.http import JsonResponse
from .models import ManufacturingOrder, BillOfMaterials
from inventory.models import Product
from .forms import ManufacturingOrderForm, BillOfMaterialsForm, MultipleBillOfMaterialsForm, BOMComponentFormSet
from users.views import has_manufacturing_access
@method_decorator(login_required, name='dispatch')
class ManufactureListView(ListView):
@ -13,6 +16,23 @@ class ManufactureListView(ListView):
context_object_name = 'manufacturing_orders'
paginate_by = 20
@method_decorator(login_required, name='dispatch')
class BillOfMaterialsListView(ListView):
model = BillOfMaterials
template_name = 'manufacture/bom_list.html'
context_object_name = 'boms'
paginate_by = 20
def get_queryset(self):
queryset = super().get_queryset()
# Optionally filter by manufactured product
product_id = self.request.GET.get('product_id')
if product_id:
queryset = queryset.filter(manufactured_product_id=product_id)
return queryset
@login_required
def manufacture_create(request):
"""Create a new manufacturing order with simple input"""
@ -46,11 +66,11 @@ def manufacture_create(request):
return render(request, 'manufacture/manufacture_form.html', {'form': form, 'title': 'Create Manufacturing Order'})
@login_required
def manufacture_edit(request, pk):
"""Edit an existing manufacturing order"""
from users.views import is_administrator
if not is_administrator(request.user):
if not has_manufacturing_access(request.user):
messages.error(request, 'You do not have permission to edit manufacturing orders.')
return redirect('manufacture:manufacture_detail', pk=pk)
@ -71,11 +91,11 @@ def manufacture_edit(request, pk):
'manufacturing_order': manufacturing_order
})
@login_required
def manufacture_delete(request, pk):
"""Delete a manufacturing order"""
from users.views import is_administrator
if not is_administrator(request.user):
if not has_manufacturing_access(request.user):
messages.error(request, 'You do not have permission to delete manufacturing orders.')
return redirect('manufacture:manufacture_list')
@ -90,8 +110,171 @@ def manufacture_delete(request, pk):
return render(request, 'manufacture/manufacture_confirm_delete.html', {'manufacturing_order': manufacturing_order})
@login_required
def bom_create(request):
"""Create a new bill of materials entry"""
if not has_manufacturing_access(request.user):
messages.error(request, 'You do not have permission to create bill of materials entries.')
return redirect('manufacture:bom_list')
if request.method == 'POST':
main_form = MultipleBillOfMaterialsForm(request.POST)
component_formset = BOMComponentFormSet(request.POST)
if main_form.is_valid() and component_formset.is_valid():
manufactured_product = main_form.cleaned_data['manufactured_product']
# Save all component entries
saved_components = []
for component_form in component_formset:
# Skip empty forms
if not component_form.cleaned_data:
continue
component = component_form.cleaned_data['component']
quantity = component_form.cleaned_data['quantity']
unit = component_form.cleaned_data['unit']
# Create BOM entry
bom = BillOfMaterials(
manufactured_product=manufactured_product,
component=component,
quantity=quantity,
unit=unit
)
bom.save()
saved_components.append(bom)
messages.success(request, f'Bill of Materials for "{manufactured_product.name}" with {len(saved_components)} components created successfully!')
return redirect('manufacture:bom_list')
else:
main_form = MultipleBillOfMaterialsForm()
component_formset = BOMComponentFormSet()
return render(request, 'manufacture/bom_form.html', {
'main_form': main_form,
'component_formset': component_formset,
'title': 'Create Bill of Materials'
})
@login_required
def bom_edit(request, pk):
"""Edit an existing bill of materials entry"""
if not has_manufacturing_access(request.user):
messages.error(request, 'You do not have permission to edit bill of materials.')
return redirect('manufacture:bom_list')
# Get the BOM entry to edit
bom = get_object_or_404(BillOfMaterials, pk=pk)
if request.method == 'POST':
main_form = MultipleBillOfMaterialsForm(request.POST)
component_formset = BOMComponentFormSet(request.POST)
if main_form.is_valid() and component_formset.is_valid():
manufactured_product = main_form.cleaned_data['manufactured_product']
# Delete existing BOM entries for this manufactured product
BillOfMaterials.objects.filter(manufactured_product=manufactured_product).delete()
# Save all component entries
saved_components = []
for component_form in component_formset:
# Skip empty forms
if not component_form.cleaned_data:
continue
component = component_form.cleaned_data['component']
quantity = component_form.cleaned_data['quantity']
unit = component_form.cleaned_data['unit']
# Create BOM entry
bom_entry = BillOfMaterials(
manufactured_product=manufactured_product,
component=component,
quantity=quantity,
unit=unit
)
bom_entry.save()
saved_components.append(bom_entry)
messages.success(request, f'Bill of Materials for "{manufactured_product.name}" with {len(saved_components)} components updated successfully!')
return redirect('manufacture:bom_list')
else:
# Pre-populate the form with existing components
main_form = MultipleBillOfMaterialsForm(initial={'manufactured_product': bom.manufactured_product})
# Get all existing components for this manufactured product
existing_components = BillOfMaterials.objects.filter(manufactured_product=bom.manufactured_product)
# Create initial data for the formset
initial_data = []
for existing_bom in existing_components:
initial_data.append({
'component': existing_bom.component,
'quantity': existing_bom.quantity,
'unit': existing_bom.unit
})
# Add extra empty forms if needed
while len(initial_data) < 1:
initial_data.append({})
component_formset = BOMComponentFormSet(initial=initial_data)
return render(request, 'manufacture/bom_form.html', {
'main_form': main_form,
'component_formset': component_formset,
'title': 'Edit Bill of Materials',
'bom': bom
})
@login_required
def bom_delete(request, pk):
"""Delete a bill of materials entry"""
if not has_manufacturing_access(request.user):
messages.error(request, 'You do not have permission to delete bill of materials entries.')
return redirect('manufacture:bom_list')
bom = get_object_or_404(BillOfMaterials, pk=pk)
if request.method == 'POST':
manufactured_product_name = bom.manufactured_product.name
bom.delete()
messages.success(request, f'Bill of Materials entry for "{manufactured_product_name}" deleted successfully!')
return redirect('manufacture:bom_list')
return render(request, 'manufacture/bom_confirm_delete.html', {'bom': bom})
@method_decorator(login_required, name='dispatch')
class ManufactureDetailView(DetailView):
model = ManufacturingOrder
template_name = 'manufacture/manufacture_detail.html'
context_object_name = 'manufacturing_order'
@method_decorator(login_required, name='dispatch')
class BillOfMaterialsDetailView(DetailView):
model = BillOfMaterials
template_name = 'manufacture/bom_detail.html'
context_object_name = 'bom'
@login_required
def get_product_info(request, product_id):
"""Return product information including unit as JSON"""
if not has_manufacturing_access(request.user):
return JsonResponse({'error': 'Permission denied'}, status=403)
try:
product = Product.objects.get(id=product_id)
return JsonResponse({
'id': product.id,
'name': product.name,
'unit': product.unit,
'unit_display': product.get_unit_display()
})
except Product.DoesNotExist:
return JsonResponse({'error': 'Product not found'}, status=404)

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2025-08-19 09:11
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0003_remove_supplier_rating'),
('purchase', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='purchaseorder',
name='supplier',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='inventory.supplier'),
),
migrations.DeleteModel(
name='Supplier',
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2025-08-19 09:11
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0003_remove_supplier_rating'),
('sales', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='saleorder',
name='customer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sale_orders', to='inventory.customer'),
),
migrations.DeleteModel(
name='Customer',
),
]

View File

@ -129,6 +129,12 @@
Manufacturing
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'manufacture:bom_list' %}">
<i class="bi bi-list-check"></i>
Bill of Materials
</a>
</li>
{% endif %}
{% if user.has_inventory_permission %}

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Product - {{ product.name }}{% endblock %}
@ -110,11 +111,11 @@
<div class="card-body">
<div class="text-center mb-3">
<h6 class="text-muted">Cost Price</h6>
<h4 class="text-primary">Rp {{ product.cost_price|floatformat:0 }}</h4>
<h4 class="text-primary">Rp {{ product.cost_price|format_currency }}</h4>
</div>
<div class="text-center mb-3">
<h6 class="text-muted">Selling Price</h6>
<h4 class="text-success">Rp {{ product.selling_price|floatformat:0 }}</h4>
<h4 class="text-success">Rp {{ product.selling_price|format_currency }}</h4>
</div>
{% if product.selling_price > 0 and product.cost_price > 0 %}
<div class="text-center">

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Inventory - Products{% endblock %}
@ -46,8 +47,8 @@
</span>
</td>
<td>{{ product.min_stock_level }} {{ product.unit }}</td>
<td>Rp {{ product.cost_price|floatformat:0 }}</td>
<td>Rp {{ product.selling_price|floatformat:0 }}</td>
<td>Rp {{ product.cost_price|format_currency }}</td>
<td>Rp {{ product.selling_price|format_currency }}</td>
<td>
<span class="badge bg-{% if product.is_active %}success{% else %}secondary{% endif %}">
{{ product.is_active|yesno:"Active,Inactive" }}

View File

@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% block title %}Delete BOM Entry{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-trash me-2"></i>
Delete BOM Entry
</h1>
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>
Back to BOM List
</a>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h5 class="card-title mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
Confirm Deletion
</h5>
</div>
<div class="card-body">
<p class="lead">Are you sure you want to delete the BOM entry for <strong>"{{ bom.manufactured_product.name }}"</strong> with component <strong>"{{ bom.component.name }}"</strong>?</p>
<p>This action cannot be undone.</p>
<form method="post">
{% csrf_token %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-secondary me-md-2">
<i class="bi bi-x-circle me-2"></i>
Cancel
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-2"></i>
Delete BOM Entry
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,130 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}BOM - {{ bom.manufactured_product.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-list-check me-2"></i>
Bill of Materials
</h1>
<div>
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary me-2">
<i class="bi bi-arrow-left me-2"></i>
Back to List
</a>
{% if user.is_superuser or user.is_staff %}
<a href="{% url 'manufacture:bom_edit' bom.pk %}" class="btn btn-primary">
<i class="bi bi-pencil me-2"></i>
Edit
</a>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">BOM Details</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Manufactured Product:</strong> {{ bom.manufactured_product.name }}</p>
<p><strong>Product Code:</strong> {{ bom.manufactured_product.code }}</p>
</div>
<div class="col-md-6">
<p><strong>Component:</strong> {{ bom.component.name }}</p>
<p><strong>Quantity:</strong> {{ bom.quantity|format_quantity }}</p>
<p><strong>Unit:</strong> {{ bom.unit|default:bom.component.unit }}</p>
</div>
</div>
</div>
<!-- Show all components for this manufactured product -->
{% with bom.manufactured_product.bom_manufactured.all as all_components %}
{% if all_components.count > 1 %}
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">All Components for {{ bom.manufactured_product.name }}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Component</th>
<th>Quantity</th>
<th>Unit</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for component_bom in all_components %}
<tr>
<td>{{ component_bom.component.name }}</td>
<td>{{ component_bom.quantity|format_quantity }}</td>
<td>{{ component_bom.unit|default:component_bom.component.unit }}</td>
<td>
{% if component_bom.pk != bom.pk %}
<a href="{% url 'manufacture:bom_detail' component_bom.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'manufacture:bom_edit' component_bom.pk %}" class="btn btn-sm btn-outline-warning ms-1">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endwith %}
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Cost Information</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Component Cost Price:</strong> Rp {{ bom.component.cost_price|format_currency }}</p>
</div>
<div class="col-md-6">
<p><strong>Total Component Cost:</strong> Rp {{ bom.get_total_component_cost|format_currency }}</p>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Product Information</h5>
</div>
<div class="card-body">
<p><strong>Component Code:</strong> {{ bom.component.code }}</p>
<p><strong>Component Category:</strong> {{ bom.component.category.name|default:"N/A" }}</p>
<p><strong>Component Current Stock:</strong> {{ bom.component.current_stock }}</p>
<p><strong>Component Unit:</strong> {{ bom.component.unit }}</p>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="card-title mb-0">Timestamps</h5>
</div>
<div class="card-body">
<p><strong>Created:</strong> {{ bom.created_at|date:"d/m/Y H:i" }}</p>
<p><strong>Updated:</strong> {{ bom.updated_at|date:"d/m/Y H:i" }}</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,338 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-list-check me-2"></i>
{{ title }}
</h1>
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>
Back to BOM List
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Bill of Materials Information</h5>
</div>
<div class="card-body">
<form method="post" id="bom-form">
{% csrf_token %}
{# Use dynamic form for both creation and editing #}
<div class="row">
<div class="col-md-12">
{% if main_form %}
{{ main_form.manufactured_product|as_crispy_field }}
{% else %}
{{ form.manufactured_product|as_crispy_field }}
{% endif %}
</div>
</div>
<h5 class="mt-4 mb-3">Components</h5>
{% if component_formset %}
{{ component_formset.management_form }}
<div id="component-forms-container">
{% for component_form in component_formset %}
<div class="card mb-3 component-form">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0">Component #{{ forloop.counter }}</h6>
{% if forloop.counter > 1 %}
<button type="button" class="btn btn-sm btn-outline-danger remove-component">
<i class="bi bi-trash"></i> Remove
</button>
{% endif %}
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
{{ component_form.component|as_crispy_field }}
</div>
<div class="col-md-6">
{{ component_form.quantity|as_crispy_field }}
</div>
<div class="row">
<div class="col-md-6">
{{ component_form.unit|as_crispy_field }}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="mb-3">
<button type="button" id="add-component" class="btn btn-outline-primary">
<i class="bi bi-plus-circle me-2"></i>
Add Another Component
</button>
</div>
{% else %}
{# Single BOM form for backward compatibility #}
<div class="row">
<div class="col-md-6">
{{ form.component|as_crispy_field }}
</div>
<div class="col-md-6">
{{ form.quantity|as_crispy_field }}
</div>
<div class="row">
<div class="col-md-6">
{{ form.unit|as_crispy_field }}
</div>
</div>
{% endif %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-2"></i>
Save BOM Entry
</button>
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary ms-2">
Cancel
</a>
</div>
</form>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">BOM Tips</h5>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-info-circle text-primary me-2"></i>
Select the manufactured product and component
</li>
<li class="mb-2">
<i class="bi bi-calculator text-success me-2"></i>
Enter the exact quantity needed
</li>
<li class="mb-2">
<i class="bi bi-rulers text-info me-2"></i>
Unit of measurement is automatically populated
</li>
{% if component_formset %}
<li class="mb-2">
<i class="bi bi-plus-circle text-warning me-2"></i>
Add multiple components dynamically
</li>
<li class="mb-2">
<i class="bi bi-dash-circle text-danger me-2"></i>
Remove components you don't need
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get CSRF token
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Function to update unit field based on selected component
function updateUnitField(componentSelect, unitInput) {
const productId = componentSelect.value;
if (!productId) {
unitInput.value = '';
return;
}
// Make AJAX call to get product info
fetch(`/manufacture/bom/product/${productId}/info/`, {
method: 'GET',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json',
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (data.unit) {
unitInput.value = data.unit;
} else {
unitInput.value = '';
}
})
.catch(error => {
console.error('Error fetching product info:', error);
unitInput.value = '';
});
}
// Check if we have the dynamic form
if (document.getElementById('component-forms-container')) {
// Add component button
document.getElementById('add-component').addEventListener('click', function() {
var container = document.getElementById('component-forms-container');
var formCount = container.querySelectorAll('.component-form').length;
var totalForms = document.getElementById('id_component-TOTAL_FORMS');
// Clone the first form as a template
var firstForm = container.querySelector('.component-form');
var newForm = firstForm.cloneNode(true);
// Update form indices
var newFormIndex = formCount;
var formRegex = /component-\d+/g;
// Update all input names and IDs
newForm.innerHTML = newForm.innerHTML.replace(formRegex, 'component-' + newFormIndex);
// Clear input values
var inputs = newForm.querySelectorAll('input, select, textarea');
inputs.forEach(function(input) {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = false;
} else if (input.type !== 'hidden') {
input.value = '';
}
});
// Update header
var header = newForm.querySelector('.card-header h6');
header.textContent = 'Component #' + (newFormIndex + 1);
// Add remove button if it doesn't exist
var removeButtonContainer = newForm.querySelector('.card-header');
if (!removeButtonContainer.querySelector('.remove-component')) {
var removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'btn btn-sm btn-outline-danger remove-component';
removeButton.innerHTML = '<i class="bi bi-trash"></i> Remove';
removeButtonContainer.appendChild(removeButton);
}
// Add event listener to the new remove button
var newRemoveButton = newForm.querySelector('.remove-component');
newRemoveButton.addEventListener('click', function() {
newForm.remove();
updateFormIndices();
});
// Add event listener to the new component select
var newComponentSelect = newForm.querySelector('select[id^="id_component-"][id$="-component"]');
var newUnitInput = newForm.querySelector('input[id^="id_component-"][id$="-unit"]');
if (newComponentSelect && newUnitInput) {
newComponentSelect.addEventListener('change', function() {
updateUnitField(this, newUnitInput);
});
}
// Append new form
container.appendChild(newForm);
// Update total forms count
totalForms.value = formCount + 1;
});
// Remove component buttons
document.querySelectorAll('.remove-component').forEach(function(button) {
button.addEventListener('click', function() {
var form = this.closest('.component-form');
form.remove();
updateFormIndices();
});
});
// Add event listeners to existing component selects
document.querySelectorAll('.component-form select[id$="-component"]').forEach(function(select) {
var unitInput = select.closest('.component-form').querySelector('input[id$="-unit"]');
if (unitInput) {
select.addEventListener('change', function() {
updateUnitField(this, unitInput);
});
// Initialize unit field if component is already selected
if (select.value) {
updateUnitField(select, unitInput);
}
}
});
// Update form indices function
function updateFormIndices() {
var forms = document.querySelectorAll('.component-form');
var totalForms = document.getElementById('id_component-TOTAL_FORMS');
forms.forEach(function(form, index) {
// Update header
var header = form.querySelector('.card-header h6');
header.textContent = 'Component #' + (index + 1);
// Update form indices in names and IDs
var formRegex = /component-\d+/g;
form.innerHTML = form.innerHTML.replace(formRegex, 'component-' + index);
// Update event listeners for component selects
var componentSelect = form.querySelector('select[id$="-component"]');
var unitInput = form.querySelector('input[id$="-unit"]');
if (componentSelect && unitInput) {
// Remove existing event listeners by cloning
var newSelect = componentSelect.cloneNode(true);
componentSelect.parentNode.replaceChild(newSelect, componentSelect);
// Add new event listener
newSelect.addEventListener('change', function() {
updateUnitField(this, unitInput);
});
}
// Update remove button event listener
var removeButton = form.querySelector('.remove-component');
if (removeButton) {
// Remove existing event listener by cloning
var newRemoveButton = removeButton.cloneNode(true);
removeButton.parentNode.replaceChild(newRemoveButton, removeButton);
// Add new event listener
newRemoveButton.addEventListener('click', function() {
form.remove();
updateFormIndices();
});
}
});
// Update total forms count
totalForms.value = forms.length;
}
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Bill of Materials{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-list-check me-2"></i>
Bill of Materials
</h1>
<a href="{% url 'manufacture:bom_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>
New BOM Entry (Multiple Components)
</a>
</div>
<div class="card">
<div class="card-body">
{% if boms %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Manufactured Product</th>
<th>Component</th>
<th>Quantity</th>
<th>Unit</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for bom in boms %}
<tr>
<td>
<strong>{{ bom.manufactured_product.name }}</strong>
<div class="small text-muted">{{ bom.manufactured_product.code }}</div>
</td>
<td>{{ bom.component.name }}</td>
<td>{{ bom.quantity|format_quantity }}</td>
<td>{{ bom.unit|default:bom.component.unit }}</td>
<td>{{ bom.created_at|date:"d/m/Y" }}</td>
<td>
<a href="{% url 'manufacture:bom_detail' bom.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'manufacture:bom_edit' bom.pk %}" class="btn btn-sm btn-outline-warning ms-1">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'manufacture:bom_delete' bom.pk %}" class="btn btn-sm btn-outline-danger ms-1">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_paginated %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-list-check display-1 text-muted"></i>
<h4 class="mt-3 text-muted">No Bill of Materials Entries</h4>
<p class="text-muted">Start by creating your first BOM entry.</p>
<a href="{% url 'manufacture:bom_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>
Create BOM Entry
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Manufacturing Order - {{ manufacturing_order.order_number }}{% endblock %}
@ -64,26 +65,26 @@
<div class="col-md-4">
<div class="text-center">
<h6 class="text-muted">Labor Cost</h6>
<h4 class="text-primary">Rp {{ manufacturing_order.labor_cost|floatformat:0 }}</h4>
<h4 class="text-primary">Rp {{ manufacturing_order.labor_cost|format_currency }}</h4>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h6 class="text-muted">Overhead Cost</h6>
<h4 class="text-warning">Rp {{ manufacturing_order.overhead_cost|floatformat:0 }}</h4>
<h4 class="text-warning">Rp {{ manufacturing_order.overhead_cost|format_currency }}</h4>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h6 class="text-muted">Total Cost</h6>
<h4 class="text-success">Rp {{ manufacturing_order.total_cost|floatformat:0 }}</h4>
<h4 class="text-success">Rp {{ manufacturing_order.total_cost|format_currency }}</h4>
</div>
</div>
</div>
<div class="text-center mt-3">
<h6 class="text-muted">Unit Cost</h6>
<h5 class="text-info">Rp {{ manufacturing_order.get_unit_cost|floatformat:0 }}</h5>
<h5 class="text-info">Rp {{ manufacturing_order.get_unit_cost|format_currency }}</h5>
</div>
</div>
</div>
@ -98,8 +99,8 @@
<p><strong>Product Code:</strong> {{ manufacturing_order.product.code }}</p>
<p><strong>Category:</strong> {{ manufacturing_order.product.category.name|default:"N/A" }}</p>
<p><strong>Current Stock:</strong> {{ manufacturing_order.product.current_stock }}</p>
<p><strong>Cost Price:</strong> Rp {{ manufacturing_order.product.cost_price|floatformat:0 }}</p>
<p><strong>Selling Price:</strong> Rp {{ manufacturing_order.product.selling_price|floatformat:0 }}</p>
<p><strong>Cost Price:</strong> Rp {{ manufacturing_order.product.cost_price|format_currency }}</p>
<p><strong>Selling Price:</strong> Rp {{ manufacturing_order.product.selling_price|format_currency }}</p>
</div>
</div>

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Manufacturing Orders{% endblock %}
@ -44,7 +45,7 @@
{{ order.get_status_display }}
</span>
</td>
<td>Rp {{ order.total_cost|floatformat:0 }}</td>
<td>Rp {{ order.total_cost|format_currency }}</td>
<td>
<a href="{% url 'manufacture:manufacture_detail' order.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Purchase Order - {{ purchase_order.order_number }}{% endblock %}
@ -62,25 +63,25 @@
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Subtotal</h6>
<h4 class="text-primary">Rp {{ purchase_order.subtotal|floatformat:0 }}</h4>
<h4 class="text-primary">Rp {{ purchase_order.subtotal|format_currency }}</h4>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Tax</h6>
<h4 class="text-warning">Rp {{ purchase_order.tax_amount|floatformat:0 }}</h4>
<h4 class="text-warning">Rp {{ purchase_order.tax_amount|format_currency }}</h4>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Shipping</h6>
<h4 class="text-info">Rp {{ purchase_order.shipping_cost|floatformat:0 }}</h4>
<h4 class="text-info">Rp {{ purchase_order.shipping_cost|format_currency }}</h4>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Total</h6>
<h4 class="text-success">Rp {{ purchase_order.total_amount|floatformat:0 }}</h4>
<h4 class="text-success">Rp {{ purchase_order.total_amount|format_currency }}</h4>
</div>
</div>
</div>
@ -103,7 +104,7 @@
{{ purchase_order.supplier.rating }}/5
</span>
</p>
<p><strong>Credit Limit:</strong> Rp {{ purchase_order.supplier.credit_limit|floatformat:0 }}</p>
<p><strong>Credit Limit:</strong> Rp {{ purchase_order.supplier.credit_limit|format_currency }}</p>
</div>
</div>

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Purchase Orders{% endblock %}
@ -43,8 +44,8 @@
{{ order.get_status_display }}
</span>
</td>
<td>Rp {{ order.subtotal|floatformat:0 }}</td>
<td>Rp {{ order.total_amount|floatformat:0 }}</td>
<td>Rp {{ order.subtotal|format_currency }}</td>
<td>Rp {{ order.total_amount|format_currency }}</td>
<td>
<a href="{% url 'purchase:purchase_detail' order.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Sales Order - {{ sale_order.order_number }}{% endblock %}
@ -62,25 +63,25 @@
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Subtotal</h6>
<h4 class="text-primary">Rp {{ sale_order.subtotal|floatformat:0 }}</h4>
<h4 class="text-primary">Rp {{ sale_order.subtotal|format_currency }}</h4>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Tax</h6>
<h4 class="text-warning">Rp {{ sale_order.tax_amount|floatformat:0 }}</h4>
<h4 class="text-warning">Rp {{ sale_order.tax_amount|format_currency }}</h4>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Discount</h6>
<h4 class="text-info">Rp {{ sale_order.discount_amount|floatformat:0 }}</h4>
<h4 class="text-info">Rp {{ sale_order.discount_amount|format_currency }}</h4>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Total</h6>
<h4 class="text-success">Rp {{ sale_order.total_amount|floatformat:0 }}</h4>
<h4 class="text-success">Rp {{ sale_order.total_amount|format_currency }}</h4>
</div>
</div>
</div>
@ -99,7 +100,7 @@
<p><strong>Contact:</strong> {{ sale_order.customer.contact_person|default:"N/A" }}</p>
<p><strong>Email:</strong> {{ sale_order.customer.email|default:"N/A" }}</p>
<p><strong>Phone:</strong> {{ sale_order.customer.phone|default:"N/A" }}</p>
<p><strong>Credit Limit:</strong> Rp {{ sale_order.customer.credit_limit|floatformat:0 }}</p>
<p><strong>Credit Limit:</strong> Rp {{ sale_order.customer.credit_limit|format_currency }}</p>
</div>
</div>

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load manufacture_extras %}
{% block title %}Sales Orders{% endblock %}
@ -43,8 +44,8 @@
{{ order.get_status_display }}
</span>
</td>
<td>Rp {{ order.subtotal|floatformat:0 }}</td>
<td>Rp {{ order.total_amount|floatformat:0 }}</td>
<td>Rp {{ order.subtotal|format_currency }}</td>
<td>Rp {{ order.total_amount|format_currency }}</td>
<td>
<a href="{% url 'sales:sales_detail' order.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>

View File

@ -176,3 +176,14 @@ def group_delete(request, pk):
return redirect('users:group_list')
return render(request, 'users/group_confirm_delete.html', {'group': group})
def has_manufacturing_access(user):
"""Check if user has manufacturing access (either admin or manufacturing permission)"""
if user.is_superuser:
return True
if user.group and user.group.name == 'Administrators':
return True
if hasattr(user, 'has_manufacturing_permission') and user.has_manufacturing_permission():
return True
return False