diff --git a/db.sqlite3 b/db.sqlite3 index 587f20c..d7db162 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/inventory/__pycache__/forms.cpython-311.pyc b/inventory/__pycache__/forms.cpython-311.pyc index a200f8e..dd0549c 100644 Binary files a/inventory/__pycache__/forms.cpython-311.pyc and b/inventory/__pycache__/forms.cpython-311.pyc differ diff --git a/inventory/migrations/0003_remove_supplier_rating.py b/inventory/migrations/0003_remove_supplier_rating.py new file mode 100644 index 0000000..e4156bc --- /dev/null +++ b/inventory/migrations/0003_remove_supplier_rating.py @@ -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', + ), + ] diff --git a/manufacture/__pycache__/admin.cpython-311.pyc b/manufacture/__pycache__/admin.cpython-311.pyc index c0343d2..c203870 100644 Binary files a/manufacture/__pycache__/admin.cpython-311.pyc and b/manufacture/__pycache__/admin.cpython-311.pyc differ diff --git a/manufacture/__pycache__/forms.cpython-311.pyc b/manufacture/__pycache__/forms.cpython-311.pyc index 0f34f68..2728378 100644 Binary files a/manufacture/__pycache__/forms.cpython-311.pyc and b/manufacture/__pycache__/forms.cpython-311.pyc differ diff --git a/manufacture/__pycache__/models.cpython-311.pyc b/manufacture/__pycache__/models.cpython-311.pyc index bb98a7b..9d0e76e 100644 Binary files a/manufacture/__pycache__/models.cpython-311.pyc and b/manufacture/__pycache__/models.cpython-311.pyc differ diff --git a/manufacture/__pycache__/urls.cpython-311.pyc b/manufacture/__pycache__/urls.cpython-311.pyc index 06147f9..9a9b74d 100644 Binary files a/manufacture/__pycache__/urls.cpython-311.pyc and b/manufacture/__pycache__/urls.cpython-311.pyc differ diff --git a/manufacture/__pycache__/views.cpython-311.pyc b/manufacture/__pycache__/views.cpython-311.pyc index 7868207..1042058 100644 Binary files a/manufacture/__pycache__/views.cpython-311.pyc and b/manufacture/__pycache__/views.cpython-311.pyc differ diff --git a/manufacture/admin.py b/manufacture/admin.py index e97f979..c68ee47 100644 --- a/manufacture/admin.py +++ b/manufacture/admin.py @@ -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') diff --git a/manufacture/forms.py b/manufacture/forms.py index 0ea3d05..602815a 100644 --- a/manufacture/forms.py +++ b/manufacture/forms.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/manufacture/migrations/0002_billofmaterials_billofmaterialstotal.py b/manufacture/migrations/0002_billofmaterials_billofmaterialstotal.py new file mode 100644 index 0000000..474e932 --- /dev/null +++ b/manufacture/migrations/0002_billofmaterials_billofmaterialstotal.py @@ -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', + }, + ), + ] diff --git a/manufacture/models.py b/manufacture/models.py index 546ece0..02b288d 100644 --- a/manufacture/models.py +++ b/manufacture/models.py @@ -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}" diff --git a/manufacture/templatetags/__init__.py b/manufacture/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manufacture/templatetags/manufacture_extras.py b/manufacture/templatetags/manufacture_extras.py new file mode 100644 index 0000000..bed17c6 --- /dev/null +++ b/manufacture/templatetags/manufacture_extras.py @@ -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) \ No newline at end of file diff --git a/manufacture/urls.py b/manufacture/urls.py index 59021f5..d3ac8bb 100644 --- a/manufacture/urls.py +++ b/manufacture/urls.py @@ -10,4 +10,12 @@ urlpatterns = [ path('/', views.ManufactureDetailView.as_view(), name='manufacture_detail'), path('/edit/', views.manufacture_edit, name='manufacture_edit'), path('/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//', views.BillOfMaterialsDetailView.as_view(), name='bom_detail'), + path('bom//edit/', views.bom_edit, name='bom_edit'), + path('bom//delete/', views.bom_delete, name='bom_delete'), + path('bom/product//info/', views.get_product_info, name='get_product_info'), ] diff --git a/manufacture/views.py b/manufacture/views.py index 1074b79..0ff26e4 100644 --- a/manufacture/views.py +++ b/manufacture/views.py @@ -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) diff --git a/purchase/__pycache__/forms.cpython-311.pyc b/purchase/__pycache__/forms.cpython-311.pyc index bbd7400..490f560 100644 Binary files a/purchase/__pycache__/forms.cpython-311.pyc and b/purchase/__pycache__/forms.cpython-311.pyc differ diff --git a/purchase/migrations/0002_alter_purchaseorder_supplier_delete_supplier.py b/purchase/migrations/0002_alter_purchaseorder_supplier_delete_supplier.py new file mode 100644 index 0000000..4810c05 --- /dev/null +++ b/purchase/migrations/0002_alter_purchaseorder_supplier_delete_supplier.py @@ -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', + ), + ] diff --git a/sales/__pycache__/forms.cpython-311.pyc b/sales/__pycache__/forms.cpython-311.pyc index 6dde320..137ec5d 100644 Binary files a/sales/__pycache__/forms.cpython-311.pyc and b/sales/__pycache__/forms.cpython-311.pyc differ diff --git a/sales/migrations/0002_alter_saleorder_customer_delete_customer.py b/sales/migrations/0002_alter_saleorder_customer_delete_customer.py new file mode 100644 index 0000000..3e4e841 --- /dev/null +++ b/sales/migrations/0002_alter_saleorder_customer_delete_customer.py @@ -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', + ), + ] diff --git a/templates/base.html b/templates/base.html index 6d20e26..20dd643 100644 --- a/templates/base.html +++ b/templates/base.html @@ -129,6 +129,12 @@ Manufacturing + {% endif %} {% if user.has_inventory_permission %} diff --git a/templates/inventory/product_detail.html b/templates/inventory/product_detail.html index 19757ab..b1117f7 100644 --- a/templates/inventory/product_detail.html +++ b/templates/inventory/product_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Product - {{ product.name }}{% endblock %} @@ -110,11 +111,11 @@
Cost Price
-

Rp {{ product.cost_price|floatformat:0 }}

+

Rp {{ product.cost_price|format_currency }}

Selling Price
-

Rp {{ product.selling_price|floatformat:0 }}

+

Rp {{ product.selling_price|format_currency }}

{% if product.selling_price > 0 and product.cost_price > 0 %}
diff --git a/templates/inventory/product_list.html b/templates/inventory/product_list.html index b5be6b1..852f3dc 100644 --- a/templates/inventory/product_list.html +++ b/templates/inventory/product_list.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Inventory - Products{% endblock %} @@ -46,8 +47,8 @@ {{ product.min_stock_level }} {{ product.unit }} - Rp {{ product.cost_price|floatformat:0 }} - Rp {{ product.selling_price|floatformat:0 }} + Rp {{ product.cost_price|format_currency }} + Rp {{ product.selling_price|format_currency }} {{ product.is_active|yesno:"Active,Inactive" }} diff --git a/templates/manufacture/bom_confirm_delete.html b/templates/manufacture/bom_confirm_delete.html new file mode 100644 index 0000000..366192b --- /dev/null +++ b/templates/manufacture/bom_confirm_delete.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} + +{% block title %}Delete BOM Entry{% endblock %} + +{% block content %} +
+

+ + Delete BOM Entry +

+ + + Back to BOM List + +
+ +
+
+
+
+
+ + Confirm Deletion +
+
+
+

Are you sure you want to delete the BOM entry for "{{ bom.manufactured_product.name }}" with component "{{ bom.component.name }}"?

+

This action cannot be undone.

+ +
+ {% csrf_token %} +
+ + + Cancel + + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/manufacture/bom_detail.html b/templates/manufacture/bom_detail.html new file mode 100644 index 0000000..7faa73b --- /dev/null +++ b/templates/manufacture/bom_detail.html @@ -0,0 +1,130 @@ +{% extends 'base.html' %} +{% load manufacture_extras %} + +{% block title %}BOM - {{ bom.manufactured_product.name }}{% endblock %} + +{% block content %} +
+

+ + Bill of Materials +

+
+ + + Back to List + + {% if user.is_superuser or user.is_staff %} + + + Edit + + {% endif %} +
+
+ +
+
+
+
+
BOM Details
+
+
+
+
+

Manufactured Product: {{ bom.manufactured_product.name }}

+

Product Code: {{ bom.manufactured_product.code }}

+
+
+

Component: {{ bom.component.name }}

+

Quantity: {{ bom.quantity|format_quantity }}

+

Unit: {{ bom.unit|default:bom.component.unit }}

+
+
+
+ + + {% with bom.manufactured_product.bom_manufactured.all as all_components %} + {% if all_components.count > 1 %} +
+
+
All Components for {{ bom.manufactured_product.name }}
+
+
+
+ + + + + + + + + + + {% for component_bom in all_components %} + + + + + + + {% endfor %} + +
ComponentQuantityUnitActions
{{ component_bom.component.name }}{{ component_bom.quantity|format_quantity }}{{ component_bom.unit|default:component_bom.component.unit }} + {% if component_bom.pk != bom.pk %} + + + + + + + {% endif %} +
+
+
+
+ {% endif %} + {% endwith %} + +
+
+
Cost Information
+
+
+
+
+

Component Cost Price: Rp {{ bom.component.cost_price|format_currency }}

+
+
+

Total Component Cost: Rp {{ bom.get_total_component_cost|format_currency }}

+
+
+
+
+ +
+
+
+
Product Information
+
+
+

Component Code: {{ bom.component.code }}

+

Component Category: {{ bom.component.category.name|default:"N/A" }}

+

Component Current Stock: {{ bom.component.current_stock }}

+

Component Unit: {{ bom.component.unit }}

+
+
+ +
+
+
Timestamps
+
+
+

Created: {{ bom.created_at|date:"d/m/Y H:i" }}

+

Updated: {{ bom.updated_at|date:"d/m/Y H:i" }}

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/manufacture/bom_form.html b/templates/manufacture/bom_form.html new file mode 100644 index 0000000..87e768b --- /dev/null +++ b/templates/manufacture/bom_form.html @@ -0,0 +1,338 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+

+ + {{ title }} +

+ + + Back to BOM List + +
+ +
+
+
+
+
Bill of Materials Information
+
+
+
+ {% csrf_token %} + + {# Use dynamic form for both creation and editing #} +
+
+ {% if main_form %} + {{ main_form.manufactured_product|as_crispy_field }} + {% else %} + {{ form.manufactured_product|as_crispy_field }} + {% endif %} +
+
+ +
Components
+ {% if component_formset %} + {{ component_formset.management_form }} + +
+ {% for component_form in component_formset %} +
+
+
Component #{{ forloop.counter }}
+ {% if forloop.counter > 1 %} + + {% endif %} +
+
+
+
+ {{ component_form.component|as_crispy_field }} +
+
+ {{ component_form.quantity|as_crispy_field }} +
+
+
+ {{ component_form.unit|as_crispy_field }} +
+
+
+
+ {% endfor %} +
+ +
+ +
+ {% else %} + {# Single BOM form for backward compatibility #} +
+
+ {{ form.component|as_crispy_field }} +
+
+ {{ form.quantity|as_crispy_field }} +
+
+
+ {{ form.unit|as_crispy_field }} +
+
+ {% endif %} + +
+ + + Cancel + +
+ +
+
+ +
+
+
+
BOM Tips
+
+
+
    +
  • + + Select the manufactured product and component +
  • +
  • + + Enter the exact quantity needed +
  • +
  • + + Unit of measurement is automatically populated +
  • + {% if component_formset %} +
  • + + Add multiple components dynamically +
  • +
  • + + Remove components you don't need +
  • + {% endif %} +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/manufacture/bom_list.html b/templates/manufacture/bom_list.html new file mode 100644 index 0000000..43f8b8f --- /dev/null +++ b/templates/manufacture/bom_list.html @@ -0,0 +1,95 @@ +{% extends 'base.html' %} +{% load manufacture_extras %} + +{% block title %}Bill of Materials{% endblock %} + +{% block content %} +
+

+ + Bill of Materials +

+ + + New BOM Entry (Multiple Components) + +
+ +
+
+ {% if boms %} +
+ + + + + + + + + + + + + {% for bom in boms %} + + + + + + + + + {% endfor %} + +
Manufactured ProductComponentQuantityUnitCreatedActions
+ {{ bom.manufactured_product.name }} +
{{ bom.manufactured_product.code }}
+
{{ bom.component.name }}{{ bom.quantity|format_quantity }}{{ bom.unit|default:bom.component.unit }}{{ bom.created_at|date:"d/m/Y" }} + + + + + + + + + +
+
+ + {% if is_paginated %} + + {% endif %} + {% else %} +
+ +

No Bill of Materials Entries

+

Start by creating your first BOM entry.

+ + + Create BOM Entry + +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/manufacture/manufacture_detail.html b/templates/manufacture/manufacture_detail.html index 66ad3e7..db6a2e0 100644 --- a/templates/manufacture/manufacture_detail.html +++ b/templates/manufacture/manufacture_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Manufacturing Order - {{ manufacturing_order.order_number }}{% endblock %} @@ -64,26 +65,26 @@
Labor Cost
-

Rp {{ manufacturing_order.labor_cost|floatformat:0 }}

+

Rp {{ manufacturing_order.labor_cost|format_currency }}

Overhead Cost
-

Rp {{ manufacturing_order.overhead_cost|floatformat:0 }}

+

Rp {{ manufacturing_order.overhead_cost|format_currency }}

Total Cost
-

Rp {{ manufacturing_order.total_cost|floatformat:0 }}

+

Rp {{ manufacturing_order.total_cost|format_currency }}

Unit Cost
-
Rp {{ manufacturing_order.get_unit_cost|floatformat:0 }}
+
Rp {{ manufacturing_order.get_unit_cost|format_currency }}
@@ -98,8 +99,8 @@

Product Code: {{ manufacturing_order.product.code }}

Category: {{ manufacturing_order.product.category.name|default:"N/A" }}

Current Stock: {{ manufacturing_order.product.current_stock }}

-

Cost Price: Rp {{ manufacturing_order.product.cost_price|floatformat:0 }}

-

Selling Price: Rp {{ manufacturing_order.product.selling_price|floatformat:0 }}

+

Cost Price: Rp {{ manufacturing_order.product.cost_price|format_currency }}

+

Selling Price: Rp {{ manufacturing_order.product.selling_price|format_currency }}

diff --git a/templates/manufacture/manufacture_list.html b/templates/manufacture/manufacture_list.html index dcb2362..5cb6d46 100644 --- a/templates/manufacture/manufacture_list.html +++ b/templates/manufacture/manufacture_list.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Manufacturing Orders{% endblock %} @@ -44,7 +45,7 @@ {{ order.get_status_display }}
- Rp {{ order.total_cost|floatformat:0 }} + Rp {{ order.total_cost|format_currency }} diff --git a/templates/purchase/purchase_detail.html b/templates/purchase/purchase_detail.html index 24783fd..5f1a4e7 100644 --- a/templates/purchase/purchase_detail.html +++ b/templates/purchase/purchase_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Purchase Order - {{ purchase_order.order_number }}{% endblock %} @@ -62,25 +63,25 @@
Subtotal
-

Rp {{ purchase_order.subtotal|floatformat:0 }}

+

Rp {{ purchase_order.subtotal|format_currency }}

Tax
-

Rp {{ purchase_order.tax_amount|floatformat:0 }}

+

Rp {{ purchase_order.tax_amount|format_currency }}

Shipping
-

Rp {{ purchase_order.shipping_cost|floatformat:0 }}

+

Rp {{ purchase_order.shipping_cost|format_currency }}

Total
-

Rp {{ purchase_order.total_amount|floatformat:0 }}

+

Rp {{ purchase_order.total_amount|format_currency }}

@@ -103,7 +104,7 @@ {{ purchase_order.supplier.rating }}/5

-

Credit Limit: Rp {{ purchase_order.supplier.credit_limit|floatformat:0 }}

+

Credit Limit: Rp {{ purchase_order.supplier.credit_limit|format_currency }}

diff --git a/templates/purchase/purchase_list.html b/templates/purchase/purchase_list.html index cfe370e..a19001d 100644 --- a/templates/purchase/purchase_list.html +++ b/templates/purchase/purchase_list.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Purchase Orders{% endblock %} @@ -43,8 +44,8 @@ {{ order.get_status_display }} - Rp {{ order.subtotal|floatformat:0 }} - Rp {{ order.total_amount|floatformat:0 }} + Rp {{ order.subtotal|format_currency }} + Rp {{ order.total_amount|format_currency }} diff --git a/templates/sales/sales_detail.html b/templates/sales/sales_detail.html index 2964c7c..e1320fc 100644 --- a/templates/sales/sales_detail.html +++ b/templates/sales/sales_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Sales Order - {{ sale_order.order_number }}{% endblock %} @@ -62,25 +63,25 @@
Subtotal
-

Rp {{ sale_order.subtotal|floatformat:0 }}

+

Rp {{ sale_order.subtotal|format_currency }}

Tax
-

Rp {{ sale_order.tax_amount|floatformat:0 }}

+

Rp {{ sale_order.tax_amount|format_currency }}

Discount
-

Rp {{ sale_order.discount_amount|floatformat:0 }}

+

Rp {{ sale_order.discount_amount|format_currency }}

Total
-

Rp {{ sale_order.total_amount|floatformat:0 }}

+

Rp {{ sale_order.total_amount|format_currency }}

@@ -99,7 +100,7 @@

Contact: {{ sale_order.customer.contact_person|default:"N/A" }}

Email: {{ sale_order.customer.email|default:"N/A" }}

Phone: {{ sale_order.customer.phone|default:"N/A" }}

-

Credit Limit: Rp {{ sale_order.customer.credit_limit|floatformat:0 }}

+

Credit Limit: Rp {{ sale_order.customer.credit_limit|format_currency }}

diff --git a/templates/sales/sales_list.html b/templates/sales/sales_list.html index 9ee8e27..5b6d24d 100644 --- a/templates/sales/sales_list.html +++ b/templates/sales/sales_list.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load manufacture_extras %} {% block title %}Sales Orders{% endblock %} @@ -43,8 +44,8 @@ {{ order.get_status_display }} - Rp {{ order.subtotal|floatformat:0 }} - Rp {{ order.total_amount|floatformat:0 }} + Rp {{ order.subtotal|format_currency }} + Rp {{ order.total_amount|format_currency }}
diff --git a/users/__pycache__/views.cpython-311.pyc b/users/__pycache__/views.cpython-311.pyc index 6a28a04..5580f1b 100644 Binary files a/users/__pycache__/views.cpython-311.pyc and b/users/__pycache__/views.cpython-311.pyc differ diff --git a/users/views.py b/users/views.py index 075ad49..353f965 100644 --- a/users/views.py +++ b/users/views.py @@ -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