from django.db import models from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ from decimal import Decimal class ManufacturingOrder(models.Model): """Simple manufacturing order without BOM - just input end product result""" ORDER_STATUS = [ ('completed', 'Completed'), ('in_progress', 'In Progress'), ('cancelled', 'Cancelled'), ] order_number = models.CharField(max_length=50, unique=True, help_text='Unique manufacturing order number') date = models.DateField() product = models.ForeignKey('inventory.Product', on_delete=models.CASCADE, related_name='manufacturing_orders') quantity = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal('0.01'))] ) # Manufacturing details notes = models.TextField(blank=True, help_text='Manufacturing notes and instructions') status = models.CharField(max_length=20, choices=ORDER_STATUS, default='completed') # Cost tracking (optional) labor_cost = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))] ) overhead_cost = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))] ) total_cost = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))] ) # User tracking created_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-date', '-created_at'] verbose_name = _('Manufacturing Order') verbose_name_plural = _('Manufacturing Orders') def __str__(self): return f"MO-{self.order_number} - {self.product.name} ({self.quantity})" def save(self, *args, **kwargs): """Override save to calculate total cost and update product stock""" # 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 # Update product cost price if this is the first manufacturing order if self.product.manufacturing_orders.count() == 0: self.product.cost_price = self.total_cost / self.quantity else: # Calculate weighted average cost total_quantity = self.product.manufacturing_orders.aggregate( total=models.Sum('quantity') )['total'] or 0 total_cost = self.product.manufacturing_orders.aggregate( total=models.Sum('total_cost') )['total'] or 0 if total_quantity > 0: self.product.cost_price = total_cost / total_quantity self.product.save() super().save(*args, **kwargs) def get_unit_cost(self): """Calculate unit cost for this manufacturing order""" if self.quantity > 0: return self.total_cost / self.quantity return Decimal('0') def get_profit_margin(self): """Calculate potential profit margin if product has selling price""" if self.product.selling_price > 0: unit_cost = self.get_unit_cost() if unit_cost > 0: return ((self.product.selling_price - unit_cost) / unit_cost) * 100 return Decimal('0') class ManufacturingLine(models.Model): """Manufacturing line/workstation information""" name = models.CharField(max_length=100) description = models.TextField(blank=True) capacity_per_hour = models.DecimalField( max_digits=8, decimal_places=2, blank=True, null=True, help_text='Production capacity per hour' ) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['name'] verbose_name = _('Manufacturing Line') verbose_name_plural = _('Manufacturing Lines') def __str__(self): return self.name class ManufacturingOrderLine(models.Model): """Individual line items for manufacturing orders (optional for future expansion)""" manufacturing_order = models.ForeignKey(ManufacturingOrder, on_delete=models.CASCADE, related_name='lines') manufacturing_line = models.ForeignKey(ManufacturingLine, on_delete=models.SET_NULL, null=True, blank=True) start_time = models.DateTimeField(blank=True, null=True) end_time = models.DateTimeField(blank=True, null=True) actual_quantity = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal('0'))] ) notes = models.TextField(blank=True) class Meta: verbose_name = _('Manufacturing Order Line') verbose_name_plural = _('Manufacturing Order Lines') def __str__(self): return f"{self.manufacturing_order.order_number} - Line {self.id}" def get_duration(self): """Calculate duration of manufacturing line operation""" 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}"