247 lines
8.8 KiB
Python
247 lines
8.8 KiB
Python
from django.db import models
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
from django.utils.translation import gettext_lazy as _
|
|
from decimal import Decimal
|
|
|
|
User = get_user_model()
|
|
|
|
class Category(models.Model):
|
|
"""Product categories"""
|
|
name = models.CharField(max_length=100, unique=True)
|
|
description = models.TextField(blank=True)
|
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True, related_name='children')
|
|
is_active = models.BooleanField(default=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = _('Category')
|
|
verbose_name_plural = _('Categories')
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def get_full_path(self):
|
|
"""Get full category path including parent categories"""
|
|
if self.parent:
|
|
return f"{self.parent.get_full_path()} > {self.name}"
|
|
return self.name
|
|
|
|
class Product(models.Model):
|
|
"""Product model for inventory management"""
|
|
|
|
UNIT_CHOICES = [
|
|
('pcs', 'Pieces'),
|
|
('kg', 'Kilograms'),
|
|
('m', 'Meters'),
|
|
('l', 'Liters'),
|
|
('box', 'Boxes'),
|
|
('set', 'Sets'),
|
|
]
|
|
|
|
name = models.CharField(max_length=200)
|
|
code = models.CharField(max_length=50, unique=True, help_text='Unique product code/SKU')
|
|
description = models.TextField(blank=True)
|
|
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
|
|
|
|
# Stock information
|
|
current_stock = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(Decimal('0'))]
|
|
)
|
|
min_stock_level = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(Decimal('0'))]
|
|
)
|
|
max_stock_level = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
default=1000,
|
|
validators=[MinValueValidator(Decimal('0'))]
|
|
)
|
|
|
|
# Pricing
|
|
cost_price = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(Decimal('0'))]
|
|
)
|
|
selling_price = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(Decimal('0'))]
|
|
)
|
|
|
|
# Product details
|
|
unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default='pcs')
|
|
weight = models.DecimalField(max_digits=8, decimal_places=2, blank=True, null=True)
|
|
dimensions = models.CharField(max_length=100, blank=True, help_text='Format: LxWxH in cm')
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
is_manufactured = models.BooleanField(default=False, help_text='Is this a manufactured product?')
|
|
|
|
# Timestamps
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ['name']
|
|
verbose_name = _('Product')
|
|
verbose_name_plural = _('Products')
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.code})"
|
|
|
|
def get_stock_status(self):
|
|
"""Get stock status for the product"""
|
|
if self.current_stock <= self.min_stock_level:
|
|
return 'low'
|
|
elif self.current_stock >= self.max_stock_level:
|
|
return 'high'
|
|
else:
|
|
return 'normal'
|
|
|
|
def get_stock_value(self):
|
|
"""Calculate total stock value"""
|
|
return self.current_stock * self.cost_price
|
|
|
|
def is_low_stock(self):
|
|
"""Check if product has low stock"""
|
|
return self.current_stock <= self.min_stock_level
|
|
|
|
def is_overstocked(self):
|
|
"""Check if product is overstocked"""
|
|
return self.current_stock >= self.max_stock_level
|
|
|
|
class StockMovement(models.Model):
|
|
"""Track all stock movements (in/out)"""
|
|
|
|
MOVEMENT_TYPES = [
|
|
('in', 'Stock In'),
|
|
('out', 'Stock Out'),
|
|
('adjustment', 'Stock Adjustment'),
|
|
('manufacturing', 'Manufacturing Output'),
|
|
('sale', 'Sale'),
|
|
('purchase', 'Purchase'),
|
|
]
|
|
|
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='stock_movements')
|
|
movement_type = models.CharField(max_length=20, choices=MOVEMENT_TYPES)
|
|
quantity = models.DecimalField(max_digits=10, decimal_places=2)
|
|
unit_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)
|
|
total_value = models.DecimalField(max_digits=12, decimal_places=2, blank=True, null=True)
|
|
|
|
# Reference fields for different movement types
|
|
reference_type = models.CharField(max_length=50, blank=True, help_text='Type of reference document')
|
|
reference_id = models.IntegerField(blank=True, null=True, help_text='ID of reference document')
|
|
reference_note = models.CharField(max_length=200, blank=True)
|
|
|
|
# Movement details
|
|
date = models.DateField()
|
|
notes = models.TextField(blank=True)
|
|
|
|
# User tracking
|
|
created_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['-date', '-created_at']
|
|
verbose_name = _('Stock Movement')
|
|
verbose_name_plural = _('Stock Movements')
|
|
|
|
def __str__(self):
|
|
return f"{self.product.name} - {self.get_movement_type_display()} ({self.quantity})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Override save to calculate total value and update product stock"""
|
|
if self.unit_price and self.quantity:
|
|
self.total_value = self.unit_price * self.quantity
|
|
|
|
# Update product stock
|
|
if self.pk: # Existing movement - revert old quantity
|
|
old_movement = StockMovement.objects.get(pk=self.pk)
|
|
if old_movement.movement_type == 'in':
|
|
self.product.current_stock -= old_movement.quantity
|
|
elif old_movement.movement_type == 'out':
|
|
self.product.current_stock += old_movement.quantity
|
|
|
|
# Apply new movement
|
|
if self.movement_type == 'in':
|
|
self.product.current_stock += self.quantity
|
|
elif self.movement_type == 'out':
|
|
self.product.current_stock -= self.quantity
|
|
elif self.movement_type == 'adjustment':
|
|
# For adjustments, quantity is the new stock level
|
|
self.product.current_stock = self.quantity
|
|
|
|
# Ensure stock doesn't go negative
|
|
if self.product.current_stock < 0:
|
|
self.product.current_stock = 0
|
|
|
|
self.product.save()
|
|
super().save(*args, **kwargs)
|
|
|
|
def delete(self, *args, **kwargs):
|
|
"""Override delete to revert stock changes"""
|
|
if self.movement_type == 'in':
|
|
self.product.current_stock -= self.quantity
|
|
elif self.movement_type == 'out':
|
|
self.product.current_stock += self.quantity
|
|
|
|
self.product.save()
|
|
super().delete(*args, **kwargs)
|
|
|
|
class Customer(models.Model):
|
|
"""Customer model"""
|
|
CUSTOMER_TYPE_CHOICES = [
|
|
('retail', 'Retail'),
|
|
('wholesale', 'Wholesale'),
|
|
('corporate', 'Corporate'),
|
|
]
|
|
|
|
code = models.CharField(max_length=50, unique=True)
|
|
name = models.CharField(max_length=200)
|
|
customer_type = models.CharField(max_length=20, choices=CUSTOMER_TYPE_CHOICES, default='retail')
|
|
contact_person = models.CharField(max_length=100, blank=True)
|
|
email = models.EmailField(blank=True)
|
|
phone = models.CharField(max_length=20, blank=True)
|
|
address = models.TextField(blank=True)
|
|
credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
|
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']
|
|
|
|
def __str__(self):
|
|
return f"{self.code} - {self.name}"
|
|
|
|
class Supplier(models.Model):
|
|
"""Supplier model"""
|
|
code = models.CharField(max_length=50, unique=True)
|
|
name = models.CharField(max_length=200)
|
|
contact_person = models.CharField(max_length=100, blank=True)
|
|
email = models.EmailField(blank=True)
|
|
phone = models.CharField(max_length=20, blank=True)
|
|
address = models.TextField(blank=True)
|
|
credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
|
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']
|
|
|
|
def __str__(self):
|
|
return f"{self.code} - {self.name}"
|