2222 lines
78 KiB
Python
2222 lines
78 KiB
Python
from django.contrib.gis.db import models
|
||
from django.db import transaction
|
||
from django.db.models import Sum, F
|
||
from django.utils import timezone
|
||
from django.utils.translation import gettext_lazy as _
|
||
import uuid
|
||
import qrcode
|
||
from io import BytesIO
|
||
import base64
|
||
|
||
from common.storages import PrivateMediaStorage
|
||
|
||
private_storage = PrivateMediaStorage()
|
||
|
||
|
||
class Warehouse(models.Model):
|
||
code = models.CharField(max_length=100, unique=True)
|
||
name = models.CharField(max_length=100, unique=True)
|
||
location = models.TextField(blank=True, null=True)
|
||
description = models.TextField(blank=True, null=True)
|
||
manager = models.CharField(max_length=200)
|
||
lon = models.FloatField(null=True, blank=True)
|
||
lat = models.FloatField(null=True, blank=True)
|
||
geom = models.PointField(srid=3812, null=True, blank=True)
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
class Meta:
|
||
ordering = ("name",)
|
||
verbose_name = _("Entrepôt")
|
||
verbose_name_plural = _("Entrepôts")
|
||
|
||
def total_products(self):
|
||
return (
|
||
Stock.objects.filter(location__warehouse=self)
|
||
.aggregate(total=Sum("quantity"))["total"]
|
||
or 0
|
||
)
|
||
|
||
def total_by_category(self):
|
||
return (
|
||
Stock.objects.filter(location__warehouse=self)
|
||
.values(category_code=F("product__category__code"))
|
||
.annotate(total=Sum("quantity"))
|
||
.order_by("-total")
|
||
)
|
||
|
||
def total_by_product(self):
|
||
return (
|
||
Stock.objects.filter(location__warehouse=self)
|
||
.values(product_name=F("product__name"))
|
||
.annotate(total=Sum("quantity"))
|
||
.order_by("-total")
|
||
)
|
||
|
||
def stock_by_product(self, product):
|
||
return (
|
||
Stock.objects.filter(location__warehouse=self, product=product)
|
||
.aggregate(total=Sum("quantity"))["total"]
|
||
or 0
|
||
)
|
||
|
||
def is_below_threshold(self, product):
|
||
total = self.stock_by_product(product)
|
||
return total < product.threshold
|
||
|
||
def product_status(self, product):
|
||
stock = self.stock_by_product(product)
|
||
is_below = stock < product.threshold
|
||
pending_order = product.has_pending_order()
|
||
|
||
if is_below and pending_order:
|
||
return _("Sous seuil (Commande en cours)")
|
||
if is_below:
|
||
return _("Sous seuil (Pas de commande)")
|
||
return _("Stock suffisant")
|
||
|
||
|
||
class WarehouseLocation(models.Model):
|
||
warehouse = models.ForeignKey(
|
||
Warehouse, on_delete=models.CASCADE, related_name="locations"
|
||
)
|
||
code = models.CharField(max_length=100, unique=True)
|
||
name = models.CharField(max_length=100)
|
||
description = models.TextField(blank=True, null=True)
|
||
is_default = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Emplacement par défaut"),
|
||
help_text=_("Si activé, cet emplacement sera proposé par défaut dans les formulaires pour cet entrepôt."),
|
||
)
|
||
qr_code = models.ImageField(
|
||
upload_to='stock/qrcodes/locations/',
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_('QR Code'),
|
||
)
|
||
|
||
class Meta:
|
||
unique_together = ("warehouse", "name")
|
||
|
||
def __str__(self):
|
||
return f"{self.name} - {self.warehouse.name}"
|
||
|
||
def generate_qr_code(self):
|
||
"""Génère un QR code basé sur le code d'emplacement et le sauvegarde."""
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(f"STREETUP:stocklocation:{self.code}")
|
||
qr.make(fit=True)
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
from django.core.files.base import ContentFile
|
||
filename = f"stocklocation_{self.code}.png"
|
||
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.is_default:
|
||
# Ensure only one default location per warehouse
|
||
WarehouseLocation.objects.filter(
|
||
warehouse=self.warehouse, is_default=True
|
||
).exclude(pk=self.pk).update(is_default=False)
|
||
generate = False
|
||
if self.pk:
|
||
try:
|
||
old = WarehouseLocation.objects.get(pk=self.pk)
|
||
if old.code != self.code or not self.qr_code:
|
||
generate = True
|
||
except WarehouseLocation.DoesNotExist:
|
||
generate = True
|
||
else:
|
||
generate = True
|
||
if generate:
|
||
self.generate_qr_code()
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class ProductCategory(models.Model):
|
||
code = models.CharField(max_length=100, unique=True)
|
||
name_fr = models.CharField(max_length=100, unique=True)
|
||
name_en = models.CharField(max_length=100, unique=True, blank=True, null=True)
|
||
name_nl = models.CharField(max_length=100, unique=True, blank=True, null=True)
|
||
description = models.TextField(blank=True, null=True)
|
||
|
||
def __str__(self):
|
||
return self.name_fr
|
||
|
||
def name(self, lang="fr"):
|
||
if lang == "en" and self.name_en:
|
||
return self.name_en
|
||
if lang == "nl" and self.name_nl:
|
||
return self.name_nl
|
||
return self.name_fr
|
||
|
||
|
||
class Product(models.Model):
|
||
UNIT_PC = 'pc'
|
||
UNIT_KG = 'kg'
|
||
UNIT_G = 'g'
|
||
UNIT_T = 't'
|
||
UNIT_L = 'L'
|
||
UNIT_M = 'm'
|
||
UNIT_M2 = 'm2'
|
||
UNIT_M3 = 'm3'
|
||
|
||
UNIT_CHOICES = [
|
||
(UNIT_PC, _('Pièce(s)')),
|
||
(UNIT_KG, _('Kilogramme(s)')),
|
||
(UNIT_G, _('Gramme(s)')),
|
||
(UNIT_T, _('Tonne(s)')),
|
||
(UNIT_L, _('Litre(s)')),
|
||
(UNIT_M, _('Mètre(s)')),
|
||
(UNIT_M2, _('Mètre(s) carré(s)')),
|
||
(UNIT_M3, _('Mètre(s) cube(s)')),
|
||
]
|
||
|
||
code = models.CharField(max_length=100, unique=True)
|
||
name = models.CharField(max_length=200)
|
||
category = models.ForeignKey(
|
||
ProductCategory, on_delete=models.SET_NULL, null=True, related_name="products"
|
||
)
|
||
sku = models.CharField(max_length=100, unique=True)
|
||
description = models.TextField(blank=True, null=True)
|
||
price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)
|
||
unit = models.CharField(
|
||
max_length=10,
|
||
choices=UNIT_CHOICES,
|
||
default=UNIT_PC,
|
||
verbose_name=_('Unité'),
|
||
)
|
||
threshold = models.IntegerField(default=10)
|
||
total_stock = models.IntegerField(default=0)
|
||
reserved_quantity = models.IntegerField(default=0)
|
||
qr_code = models.ImageField(upload_to='stock/qrcodes/products/', blank=True, null=True, verbose_name=_('QR Code'))
|
||
is_serialized = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Articles tracés (numéro de série)"),
|
||
help_text=_("Si activé, chaque article de ce produit devra avoir un numéro de série unique."),
|
||
)
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name=_("Actif"),
|
||
help_text=_("Si désactivé, le produit ne sera plus disponible pour les opérations (nouvelles entrées, sorties, transferts, interventions)."),
|
||
)
|
||
authorized_locations = models.ManyToManyField(
|
||
"WarehouseLocation",
|
||
blank=True,
|
||
related_name="authorized_products",
|
||
verbose_name=_("Emplacements autorisés"),
|
||
help_text=_("Emplacements dans lesquels ce produit est autorisé à être stocké (vide = tous autorisés)."),
|
||
)
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def generate_qr_code(self):
|
||
"""Génère un QR code basé sur le code produit et le sauvegarde dans le champ qr_code."""
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(f"STREETUP:product:{self.code}")
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
|
||
from django.core.files.base import ContentFile
|
||
filename = f"product_{self.code}.png"
|
||
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Ne pas re-générer le QR code lors des saves partiels (update_fields)
|
||
update_fields = kwargs.get('update_fields')
|
||
if update_fields is not None:
|
||
super().save(*args, **kwargs)
|
||
return
|
||
# Générer le QR code si absent ou si le code produit a changé
|
||
generate = False
|
||
if self.pk:
|
||
try:
|
||
old = Product.objects.get(pk=self.pk)
|
||
if old.code != self.code or not self.qr_code:
|
||
generate = True
|
||
except Product.DoesNotExist:
|
||
generate = True
|
||
else:
|
||
generate = True
|
||
if generate:
|
||
self.generate_qr_code()
|
||
super().save(*args, **kwargs)
|
||
|
||
def update_total_stock(self):
|
||
"""Recalcule et met à jour la quantité totale disponible"""
|
||
total = (
|
||
Stock.objects.filter(product=self)
|
||
.aggregate(total=Sum("quantity"))["total"]
|
||
or 0
|
||
)
|
||
self.total_stock = total
|
||
self.save(update_fields=["total_stock"])
|
||
|
||
def get_available_stock(self):
|
||
"""Retourne le stock disponible (stock total - quantité réservée)"""
|
||
return max(0, self.total_stock - self.reserved_quantity)
|
||
|
||
def update_reserved_quantity(self):
|
||
"""Recalcule et met à jour la quantité réservée depuis les bons de préparation non traités"""
|
||
reserved = (
|
||
PreparationOrderItem.objects.filter(
|
||
product=self,
|
||
preparation_order__status__in=["to_process", "pending"]
|
||
)
|
||
.aggregate(total=Sum("quantity"))["total"]
|
||
or 0
|
||
)
|
||
self.reserved_quantity = reserved
|
||
self.save(update_fields=["reserved_quantity"])
|
||
|
||
def has_pending_order(self):
|
||
return PurchaseOrderItem.objects.filter(
|
||
product=self, purchase_order__status__in=["pending", "ordered"]
|
||
).exists()
|
||
|
||
|
||
class Stock(models.Model):
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stocks")
|
||
location = models.ForeignKey(
|
||
WarehouseLocation, on_delete=models.CASCADE, related_name="stocks"
|
||
)
|
||
quantity = models.IntegerField(default=0)
|
||
|
||
class Meta:
|
||
unique_together = ("product", "location")
|
||
|
||
def __str__(self):
|
||
return _("{quantity} de {product} à {location} ({warehouse})").format(
|
||
quantity=self.quantity,
|
||
product=self.product.name,
|
||
location=self.location.name,
|
||
warehouse=self.location.warehouse.name,
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
super().save(*args, **kwargs)
|
||
update_stock_alerts(self.product, self.location.warehouse)
|
||
# Mettre à jour le stock total du produit
|
||
self.product.update_total_stock()
|
||
|
||
|
||
class StockMovementBatch(models.Model):
|
||
"""Regroupe un ensemble de mouvements de stock créés simultanément."""
|
||
TYPE_IN = "in"
|
||
TYPE_OUT = "out"
|
||
TYPE_TRANSFER = "transfer"
|
||
TYPE_CHOICES = [
|
||
(TYPE_IN, _("Entrée")),
|
||
(TYPE_OUT, _("Sortie")),
|
||
(TYPE_TRANSFER, _("Transfert")),
|
||
]
|
||
|
||
SUBTYPE_CHOICES = [
|
||
("purchase", _("Achat")),
|
||
("return", _("Retour")),
|
||
("inventory", _("Inventaire")),
|
||
("consumption", _("Consommation")),
|
||
("loss", _("Perte")),
|
||
("donation", _("Don")),
|
||
("internal", _("Interne")),
|
||
("external", _("Externe")),
|
||
("correction", _("Correction")),
|
||
("confection", _("Confection")),
|
||
("preparation_order", _("Bon de préparation")),
|
||
("epi", _("EPI")),
|
||
("loan", _("Emprunt")),
|
||
("worksite", _("Chantier")),
|
||
("other", _("Autre")),
|
||
]
|
||
|
||
movement_type = models.CharField(max_length=20, choices=TYPE_CHOICES, verbose_name=_("Type"))
|
||
movement_subtype = models.CharField(
|
||
max_length=30,
|
||
choices=SUBTYPE_CHOICES,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Sous-type"),
|
||
)
|
||
date = models.DateTimeField(auto_now_add=True, verbose_name=_("Date"))
|
||
reason = models.TextField(blank=True, null=True, verbose_name=_("Raison"))
|
||
person = models.ForeignKey(
|
||
"contracts.CompanyMember",
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="stock_batches",
|
||
verbose_name=_("Personne"),
|
||
help_text=_("Personne à qui le matériel est attribué ou qui le restitue"),
|
||
)
|
||
company = models.ForeignKey(
|
||
"contracts.Company",
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="stock_batches",
|
||
verbose_name=_("Entreprise / Organisation"),
|
||
)
|
||
contract = models.ForeignKey(
|
||
"contracts.Contract",
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="stock_batches",
|
||
verbose_name=_("Contrat"),
|
||
)
|
||
created_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="created_stock_batches",
|
||
verbose_name=_("Créé par"),
|
||
)
|
||
preparation_order = models.ForeignKey(
|
||
"PreparationOrder",
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="stock_batches",
|
||
verbose_name=_("Bon de préparation"),
|
||
)
|
||
purchase_order = models.ForeignKey(
|
||
"PurchaseOrder",
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="stock_batches",
|
||
verbose_name=_("Commande d'achat"),
|
||
)
|
||
requester = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Demandeur"),
|
||
help_text=_("Personne ou service à l'origine de la demande"),
|
||
)
|
||
signature = models.ImageField(
|
||
upload_to="stock_movements/signatures/",
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Signature"),
|
||
help_text=_("Signature du réceptionnaire (image depuis tablette)"),
|
||
)
|
||
signature_name = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Nom du signataire"),
|
||
)
|
||
is_loan = models.BooleanField(default=False, verbose_name=_("Prêt de matériel"))
|
||
estimated_return_date = models.DateField(null=True, blank=True, verbose_name=_("Date de retour attendue"))
|
||
loan_returned = models.BooleanField(default=False, verbose_name=_("Prêt retourné"))
|
||
|
||
class Meta:
|
||
ordering = ("-date",)
|
||
verbose_name = _("Lot de mouvements de stock")
|
||
verbose_name_plural = _("Lots de mouvements de stock")
|
||
|
||
def __str__(self):
|
||
return _("Lot #{id} — {type} — {date}").format(
|
||
id=self.pk,
|
||
type=self.get_movement_type_display(),
|
||
date=self.date.strftime("%d/%m/%Y %H:%M") if self.date else "",
|
||
)
|
||
|
||
@property
|
||
def is_overdue(self):
|
||
from django.utils import timezone
|
||
if self.is_loan and not self.loan_returned and self.estimated_return_date and self.movement_type == 'out':
|
||
return self.estimated_return_date < timezone.now().date()
|
||
return False
|
||
|
||
def movement_count(self):
|
||
return self.movements.count()
|
||
|
||
|
||
def stock_movement_document_upload_path(instance, filename):
|
||
return f"stock_movements/batch_{instance.batch_id}/{filename}"
|
||
|
||
|
||
class StockMovementDocument(models.Model):
|
||
"""Document (photo, pièce justificative…) attaché à un lot de mouvements."""
|
||
batch = models.ForeignKey(
|
||
StockMovementBatch,
|
||
on_delete=models.CASCADE,
|
||
related_name="documents",
|
||
verbose_name=_("Lot"),
|
||
)
|
||
file = models.FileField(upload_to=stock_movement_document_upload_path, storage=private_storage, verbose_name=_("Fichier"))
|
||
name = models.CharField(max_length=255, blank=True, verbose_name=_("Nom"))
|
||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
def __str__(self):
|
||
return self.name or self.file.name
|
||
|
||
def filename(self):
|
||
import os
|
||
return os.path.basename(self.file.name)
|
||
|
||
def is_image(self):
|
||
name = self.file.name.lower()
|
||
return any(name.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"))
|
||
|
||
|
||
def stock_batch_signature_upload_path(instance, filename):
|
||
return f"stock_movements/signatures/batch_{instance.batch_id}/{filename}"
|
||
|
||
|
||
class StockBatchSignature(models.Model):
|
||
"""Signature immuable attachée à un lot de mouvements de stock."""
|
||
batch = models.ForeignKey(
|
||
StockMovementBatch,
|
||
on_delete=models.CASCADE,
|
||
related_name="signatures",
|
||
verbose_name=_("Lot"),
|
||
)
|
||
image = models.ImageField(
|
||
upload_to=stock_batch_signature_upload_path,
|
||
verbose_name=_("Image de signature"),
|
||
)
|
||
signed_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="batch_signatures",
|
||
verbose_name=_("Signé par"),
|
||
)
|
||
signed_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Signé le"))
|
||
signature_name = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Nom du signataire"),
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ("signed_at",)
|
||
verbose_name = _("Signature de lot")
|
||
verbose_name_plural = _("Signatures de lot")
|
||
|
||
def __str__(self):
|
||
return _("Signature lot #{batch} — {date}").format(
|
||
batch=self.batch_id,
|
||
date=self.signed_at.strftime("%d/%m/%Y %H:%M") if self.signed_at else "",
|
||
)
|
||
|
||
|
||
class StockMovement(models.Model):
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stock_movements")
|
||
source_location = models.ForeignKey(
|
||
WarehouseLocation,
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="outgoing_movements",
|
||
)
|
||
destination_location = models.ForeignKey(
|
||
WarehouseLocation,
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="incoming_movements",
|
||
)
|
||
quantity = models.IntegerField()
|
||
date = models.DateTimeField(auto_now_add=True)
|
||
reason = models.TextField(blank=True, null=True)
|
||
batch = models.ForeignKey(
|
||
StockMovementBatch,
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="movements",
|
||
verbose_name=_("Lot"),
|
||
)
|
||
serialized_items = models.ManyToManyField(
|
||
'SerializedItem',
|
||
blank=True,
|
||
related_name='stock_movements',
|
||
verbose_name=_('Articles tracés'),
|
||
)
|
||
|
||
def movement_type(self):
|
||
if self.source_location and self.destination_location:
|
||
return _("Transfert")
|
||
elif self.destination_location:
|
||
return _("Entrée")
|
||
else:
|
||
return _("Sortie")
|
||
|
||
def __str__(self):
|
||
movement_type = self.movement_type()
|
||
return _("{movement} de {quantity} {product}").format(
|
||
movement=movement_type,
|
||
quantity=abs(self.quantity),
|
||
product=self.product.name,
|
||
)
|
||
|
||
|
||
class Inventory(models.Model):
|
||
STATUS_DRAFT = "draft"
|
||
STATUS_IN_PROGRESS = "in_progress"
|
||
STATUS_DONE = "done"
|
||
STATUS_VALIDATED = "validated"
|
||
|
||
|
||
STATUS_CHOICES = (
|
||
(STATUS_DRAFT, _("Brouillon")),
|
||
(STATUS_IN_PROGRESS, _("En cours")),
|
||
(STATUS_DONE, _("Terminé")),
|
||
(STATUS_VALIDATED, _("Validé")),
|
||
)
|
||
|
||
warehouse_location = models.ForeignKey(WarehouseLocation, on_delete=models.CASCADE, related_name="inventories")
|
||
performed_by = models.CharField(max_length=255, blank=True)
|
||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
validated_at = models.DateTimeField(blank=True, null=True)
|
||
validated_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="validated_inventories",
|
||
verbose_name=_("Validé par"),
|
||
)
|
||
validator_comment = models.TextField(
|
||
blank=True,
|
||
default="",
|
||
verbose_name=_("Commentaire du validateur"),
|
||
)
|
||
qr_code = models.ImageField(
|
||
upload_to='stock/qrcodes/inventories/',
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_('QR Code'),
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ("-created_at", "-id")
|
||
|
||
def __str__(self):
|
||
return _("Inventaire #{id} - {warehouse_location}").format(
|
||
id=self.id,
|
||
warehouse_location=self.warehouse_location.name,
|
||
)
|
||
|
||
@property
|
||
def is_validated(self):
|
||
return self.status == self.STATUS_VALIDATED
|
||
|
||
@property
|
||
def is_editable(self):
|
||
"""L'inventaire est éditable tant qu'il n'est pas validé."""
|
||
return self.status != self.STATUS_VALIDATED
|
||
|
||
@property
|
||
def is_quantity_editable(self):
|
||
"""Les quantités comptées ne peuvent être modifiées qu'en cours d'inventaire (draft/in_progress)."""
|
||
return self.status in (self.STATUS_DRAFT, self.STATUS_IN_PROGRESS)
|
||
|
||
@property
|
||
def is_worker_comment_editable(self):
|
||
"""Le commentaire magasinier est modifiable jusqu'à la validation (donc aussi en statut 'terminé')."""
|
||
return self.status != self.STATUS_VALIDATED
|
||
|
||
def total_items(self):
|
||
return self.items.count()
|
||
|
||
def total_adjustment(self):
|
||
return sum(item.difference for item in self.items.all())
|
||
|
||
def generate_qr_code(self):
|
||
"""Génère un QR code basé sur l'ID de l'inventaire et le sauvegarde."""
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(f"STREETUP:inventory:{self.pk}")
|
||
qr.make(fit=True)
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
from django.core.files.base import ContentFile
|
||
filename = f"inventory_{self.pk}.png"
|
||
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
||
|
||
def save(self, *args, **kwargs):
|
||
update_fields = kwargs.get('update_fields')
|
||
if update_fields is not None:
|
||
super().save(*args, **kwargs)
|
||
return
|
||
needs_qr = self.pk is None or not self.qr_code
|
||
super().save(*args, **kwargs)
|
||
if needs_qr:
|
||
self.generate_qr_code()
|
||
super().save(update_fields=['qr_code'])
|
||
|
||
def validate(self, user=None):
|
||
if self.is_validated and self.validated_at:
|
||
return
|
||
self.status = self.STATUS_VALIDATED
|
||
if not self.validated_at:
|
||
self.validated_at = timezone.now()
|
||
if user and not self.validated_by:
|
||
self.validated_by = user
|
||
self.save(update_fields=["status", "validated_at", "validated_by", "updated_at"])
|
||
self.apply_adjustments()
|
||
|
||
def apply_adjustments(self):
|
||
for item in self.items.select_related("product"):
|
||
stock, created = Stock.objects.get_or_create(
|
||
product=item.product,
|
||
location=item.inventory.warehouse_location,
|
||
defaults={"quantity": 0},
|
||
)
|
||
difference = item.counted_quantity - stock.quantity
|
||
if difference == 0:
|
||
continue
|
||
StockMovement.objects.create(
|
||
product=item.product,
|
||
source_location=item.inventory.warehouse_location if difference < 0 else None,
|
||
destination_location=item.inventory.warehouse_location if difference > 0 else None,
|
||
quantity=difference,
|
||
reason=_("Ajustement inventaire #{inventory_id}").format(
|
||
inventory_id=self.id
|
||
),
|
||
)
|
||
stock.quantity = item.counted_quantity
|
||
stock.save()
|
||
|
||
|
||
class InventoryItem(models.Model):
|
||
inventory = models.ForeignKey(Inventory, on_delete=models.CASCADE, related_name="items")
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||
expected_quantity = models.IntegerField(blank=True, null=True)
|
||
counted_quantity = models.IntegerField()
|
||
is_validated = models.BooleanField(default=False, verbose_name=_("Validé"))
|
||
validated_at = models.DateTimeField(blank=True, null=True, verbose_name=_("Validé le"))
|
||
validated_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="validated_inventory_items",
|
||
verbose_name=_("Validé par"),
|
||
)
|
||
comment = models.TextField(blank=True, default="", verbose_name=_("Commentaire"))
|
||
comment_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="inventory_item_comments",
|
||
verbose_name=_("Commentaire par"),
|
||
)
|
||
validator_comment = models.TextField(blank=True, default="", verbose_name=_("Commentaire du validateur"))
|
||
validator_comment_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="inventory_item_validator_comments",
|
||
verbose_name=_("Commentaire validateur par"),
|
||
)
|
||
|
||
class Meta:
|
||
unique_together = ("inventory", "product")
|
||
|
||
def __str__(self):
|
||
return _("Inventaire {product} ({location})").format(
|
||
product=self.product.name,
|
||
location=self.inventory.warehouse_location.name,
|
||
)
|
||
|
||
@property
|
||
def difference(self):
|
||
return self.counted_quantity - (self.expected_quantity or 0)
|
||
|
||
def clean(self):
|
||
super().clean()
|
||
if self.product and hasattr(self, 'inventory') and self.inventory:
|
||
try:
|
||
location = self.inventory.warehouse_location
|
||
except AttributeError:
|
||
location = None
|
||
if location:
|
||
if self.product.authorized_locations.exists() and location not in self.product.authorized_locations.all():
|
||
from django.core.exceptions import ValidationError
|
||
raise ValidationError(
|
||
_("Le produit \"%(prod)s\" n'est pas autorisé dans l'emplacement de cet inventaire (\"%(loc)s\").") % {
|
||
'prod': self.product.name,
|
||
'loc': location.name
|
||
}
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
self.full_clean()
|
||
if self.expected_quantity is None:
|
||
try:
|
||
location = self.inventory.warehouse_location
|
||
except AttributeError:
|
||
location = None
|
||
if location:
|
||
expected = (
|
||
Stock.objects.filter(
|
||
product=self.product,
|
||
location=location,
|
||
)
|
||
.aggregate(total=Sum("quantity"))["total"]
|
||
)
|
||
self.expected_quantity = expected or 0
|
||
else:
|
||
self.expected_quantity = 0
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class Supplier(models.Model):
|
||
name = models.CharField(max_length=200)
|
||
contact_email = models.EmailField(blank=True, null=True)
|
||
phone_number = models.CharField(max_length=20, blank=True, null=True)
|
||
address = models.TextField(blank=True, null=True)
|
||
is_active = models.BooleanField(default=True)
|
||
enterprise_number = models.CharField(max_length=100, blank=True, null=True)
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
class Meta:
|
||
ordering = ("name",)
|
||
verbose_name = _("Fournisseur")
|
||
verbose_name_plural = _("Fournisseurs")
|
||
|
||
|
||
class RequestForQuote(models.Model):
|
||
STATUS_DRAFT = "draft"
|
||
STATUS_SENT = "sent"
|
||
STATUS_COMPLETED = "completed"
|
||
STATUS_CANCELLED = "cancelled"
|
||
|
||
STATUS_CHOICES = [
|
||
(STATUS_DRAFT, _("Brouillon")),
|
||
(STATUS_SENT, _("Envoyée")),
|
||
(STATUS_COMPLETED, _("Convertie en commande")),
|
||
(STATUS_CANCELLED, _("Annulée")),
|
||
]
|
||
|
||
code = models.CharField(max_length=50, unique=True, blank=True)
|
||
uuid = models.UUIDField(default=uuid.uuid4, editable=False)
|
||
warehouse = models.ForeignKey(
|
||
Warehouse, on_delete=models.CASCADE, related_name="request_for_quotes",
|
||
verbose_name=_("Entrepôt"),
|
||
)
|
||
date_created = models.DateField(auto_now_add=True)
|
||
status = models.CharField(
|
||
_("Statut"),
|
||
max_length=50,
|
||
choices=STATUS_CHOICES,
|
||
default=STATUS_DRAFT,
|
||
)
|
||
created_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
related_name="created_request_for_quotes",
|
||
verbose_name=_("Créé par"),
|
||
)
|
||
suppliers = models.ManyToManyField(
|
||
Supplier,
|
||
blank=True,
|
||
related_name="request_for_quotes",
|
||
verbose_name=_("Fournisseurs sollicités"),
|
||
)
|
||
notes = models.TextField(blank=True, null=True, verbose_name=_("Notes"))
|
||
|
||
class Meta:
|
||
ordering = ("-date_created", "-id")
|
||
verbose_name = _("Demande d'offre")
|
||
verbose_name_plural = _("Demandes d'offres")
|
||
|
||
def __str__(self):
|
||
return _("Demande d'offre {code}").format(
|
||
code=self.code or f"#{self.id}"
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
if not self.code:
|
||
if self.pk:
|
||
self.code = f"RFQ-{self.pk:06d}"
|
||
else:
|
||
super().save(*args, **kwargs)
|
||
self.code = f"RFQ-{self.pk:06d}"
|
||
kwargs['force_insert'] = False
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class RequestForQuoteItem(models.Model):
|
||
request_for_quote = models.ForeignKey(
|
||
RequestForQuote, on_delete=models.CASCADE, related_name="items"
|
||
)
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE, verbose_name=_("Produit"))
|
||
quantity = models.IntegerField(
|
||
verbose_name=_("Quantité totale"),
|
||
help_text=_("Quantité totale souhaitée"),
|
||
)
|
||
num_articles = models.PositiveIntegerField(
|
||
null=True, blank=True,
|
||
verbose_name=_("Nombre d'articles"),
|
||
)
|
||
quantity_per_article = models.DecimalField(
|
||
max_digits=10, decimal_places=3,
|
||
null=True, blank=True,
|
||
verbose_name=_("Quantité par article"),
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.num_articles and self.quantity_per_article:
|
||
self.quantity = int(self.num_articles * self.quantity_per_article)
|
||
super().save(*args, **kwargs)
|
||
|
||
@property
|
||
def total_quantity_label(self):
|
||
unit = self.product.get_unit_display()
|
||
if self.num_articles and self.quantity_per_article:
|
||
return _("{num} article(s) × {qpa} {unit} = {total} {unit}").format(
|
||
num=self.num_articles,
|
||
qpa=self.quantity_per_article,
|
||
total=self.quantity,
|
||
unit=unit,
|
||
)
|
||
return _("{total} {unit}").format(total=self.quantity, unit=unit)
|
||
|
||
def __str__(self):
|
||
return f"{self.quantity} x {self.product.name} (RFQ {self.request_for_quote.id})"
|
||
|
||
|
||
class PurchaseOrder(models.Model):
|
||
code = models.CharField(max_length=50, unique=True, blank=True)
|
||
uuid = models.UUIDField(default=uuid.uuid4, editable=False)
|
||
supplier = models.ForeignKey(
|
||
Supplier, on_delete=models.CASCADE, related_name="purchase_orders",
|
||
verbose_name=_("Fournisseur"),
|
||
)
|
||
warehouse = models.ForeignKey(
|
||
Warehouse, on_delete=models.CASCADE, related_name="purchase_orders",
|
||
verbose_name=_("Entrepôt"),
|
||
)
|
||
request_for_quote = models.ForeignKey(
|
||
RequestForQuote,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="purchase_orders",
|
||
verbose_name=_("Demande d'offre d'origine"),
|
||
)
|
||
date_ordered = models.DateField(auto_now_add=True)
|
||
status = models.CharField(
|
||
_("Statut"),
|
||
max_length=50,
|
||
choices=[
|
||
("pending", _("En attente")),
|
||
("ordered", _("Envoyée")),
|
||
("partial", _("Partiellement reçue")),
|
||
("received", _("Reçue")),
|
||
("cancelled", _("Annulée")),
|
||
],
|
||
default="pending",
|
||
)
|
||
received_at = models.DateTimeField(blank=True, null=True)
|
||
|
||
@property
|
||
def total_net_amount(self):
|
||
return sum((item.net_amount for item in self.items.all()), 0)
|
||
|
||
@property
|
||
def total_vat_amount(self):
|
||
return sum((item.vat_amount for item in self.items.all()), 0)
|
||
|
||
@property
|
||
def total_amount(self):
|
||
return sum((item.total_amount for item in self.items.all()), 0)
|
||
|
||
class Meta:
|
||
ordering = ("-date_ordered", "-id")
|
||
|
||
def __str__(self):
|
||
return _("Commande {code} - {supplier}").format(
|
||
code=self.code or f"#{self.id}",
|
||
supplier=self.supplier.name,
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Générer le code automatiquement si non défini
|
||
if not self.code:
|
||
# Si l'objet existe déjà (mise à jour), on peut utiliser l'ID
|
||
if self.pk:
|
||
self.code = f"PO-{self.pk:06d}"
|
||
else:
|
||
# Pour une nouvelle instance, sauvegarder d'abord pour obtenir l'ID
|
||
super().save(*args, **kwargs)
|
||
self.code = f"PO-{self.pk:06d}"
|
||
kwargs['force_insert'] = False # Empêcher une erreur lors du second save
|
||
super().save(*args, **kwargs)
|
||
|
||
def generate_qr_code(self):
|
||
"""Génère un QR code basé sur le code de commande et retourne l'image en base64"""
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(f"STREETUP:order:{self.uuid}")
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
|
||
# Convertir en base64 pour l'affichage HTML
|
||
img_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||
return f"data:image/png;base64,{img_base64}"
|
||
|
||
def receive(self):
|
||
"""Réceptionne tous les articles restants de la commande."""
|
||
if self.received_at:
|
||
return
|
||
with transaction.atomic():
|
||
now = timezone.now()
|
||
PurchaseOrder.objects.filter(pk=self.pk).update(
|
||
status="received", received_at=now
|
||
)
|
||
self.status = "received"
|
||
self.received_at = now
|
||
batch = StockMovementBatch.objects.create(
|
||
movement_type=StockMovementBatch.TYPE_IN,
|
||
movement_subtype='purchase',
|
||
reason=_("Réception commande #{order_id}").format(order_id=self.id),
|
||
purchase_order=self,
|
||
)
|
||
for item in self.items.select_related("product", "location"):
|
||
remaining = item.quantity - item.received_quantity
|
||
if remaining <= 0:
|
||
continue
|
||
stock, _created = Stock.objects.get_or_create(
|
||
product=item.product,
|
||
location=item.location,
|
||
defaults={"quantity": 0},
|
||
)
|
||
stock.quantity += remaining
|
||
stock.save()
|
||
StockMovement.objects.create(
|
||
product=item.product,
|
||
destination_location=item.location,
|
||
quantity=remaining,
|
||
reason=_("Réception commande #{order_id}").format(
|
||
order_id=self.id
|
||
),
|
||
batch=batch,
|
||
)
|
||
item.received_quantity = item.quantity
|
||
item.save(update_fields=["received_quantity"])
|
||
|
||
def receive_items(self, item_quantities):
|
||
"""
|
||
Réceptionne partiellement des articles.
|
||
item_quantities : dict {item_id: quantity_to_receive}
|
||
"""
|
||
with transaction.atomic():
|
||
now = timezone.now()
|
||
batch = StockMovementBatch.objects.create(
|
||
movement_type=StockMovementBatch.TYPE_IN,
|
||
movement_subtype='purchase',
|
||
reason=_("Réception partielle commande #{order_id}").format(order_id=self.id),
|
||
purchase_order=self,
|
||
)
|
||
for item in self.items.select_related("product", "location"):
|
||
qty = item_quantities.get(item.pk, 0)
|
||
if qty <= 0:
|
||
continue
|
||
# Ne pas dépasser le restant
|
||
remaining = item.quantity - item.received_quantity
|
||
qty = min(qty, remaining)
|
||
if qty <= 0:
|
||
continue
|
||
stock, _created = Stock.objects.get_or_create(
|
||
product=item.product,
|
||
location=item.location,
|
||
defaults={"quantity": 0},
|
||
)
|
||
stock.quantity += qty
|
||
stock.save()
|
||
StockMovement.objects.create(
|
||
product=item.product,
|
||
destination_location=item.location,
|
||
quantity=qty,
|
||
reason=_("Réception partielle commande #{order_id}").format(
|
||
order_id=self.id
|
||
),
|
||
batch=batch,
|
||
)
|
||
item.received_quantity += qty
|
||
item.save(update_fields=["received_quantity"])
|
||
|
||
# Mettre à jour le statut de la commande
|
||
all_items = list(self.items.all())
|
||
if all(i.received_quantity >= i.quantity for i in all_items):
|
||
PurchaseOrder.objects.filter(pk=self.pk).update(
|
||
status="received", received_at=now
|
||
)
|
||
self.status = "received"
|
||
self.received_at = now
|
||
else:
|
||
any_received = any(i.received_quantity > 0 for i in all_items)
|
||
if any_received and self.status not in ("partial", "received"):
|
||
PurchaseOrder.objects.filter(pk=self.pk).update(status="partial")
|
||
self.status = "partial"
|
||
elif any_received and self.status == "ordered":
|
||
PurchaseOrder.objects.filter(pk=self.pk).update(status="partial")
|
||
self.status = "partial"
|
||
|
||
|
||
class PurchaseOrderItem(models.Model):
|
||
purchase_order = models.ForeignKey(
|
||
PurchaseOrder, on_delete=models.CASCADE, related_name="items"
|
||
)
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||
location = models.ForeignKey(WarehouseLocation, on_delete=models.CASCADE)
|
||
quantity = models.IntegerField(
|
||
verbose_name=_('Quantité totale'),
|
||
help_text=_('Quantité totale dans l\'unité du produit à ajouter au stock'),
|
||
)
|
||
num_articles = models.PositiveIntegerField(
|
||
null=True, blank=True,
|
||
verbose_name=_('Nombre d\'articles'),
|
||
help_text=_('Nombre d\'articles commandés (ex: nombre de sacs)'),
|
||
)
|
||
quantity_per_article = models.DecimalField(
|
||
max_digits=10, decimal_places=3,
|
||
null=True, blank=True,
|
||
verbose_name=_('Quantité par article'),
|
||
help_text=_('Quantité dans l\'unité du produit par article (ex: 10 kg par sac)'),
|
||
)
|
||
received_quantity = models.IntegerField(
|
||
default=0,
|
||
verbose_name=_('Quantité reçue'),
|
||
help_text=_('Quantité déjà réceptionnée pour cet article'),
|
||
)
|
||
unit_price = models.DecimalField(
|
||
max_digits=10, decimal_places=2,
|
||
null=True, blank=True,
|
||
verbose_name=_("Prix unitaire (HTVA)"),
|
||
)
|
||
vat_rate = models.DecimalField(
|
||
max_digits=5, decimal_places=2,
|
||
default=21.00,
|
||
verbose_name=_("Taux de TVA (%)"),
|
||
)
|
||
|
||
@property
|
||
def net_amount(self):
|
||
if self.unit_price is not None:
|
||
from decimal import Decimal
|
||
return Decimal(self.quantity) * self.unit_price
|
||
return 0
|
||
|
||
@property
|
||
def vat_amount(self):
|
||
from decimal import Decimal
|
||
return self.net_amount * (self.vat_rate / Decimal('100.00'))
|
||
|
||
@property
|
||
def total_amount(self):
|
||
return self.net_amount + self.vat_amount
|
||
|
||
@property
|
||
def remaining_quantity(self):
|
||
"""Quantité restant à réceptionner."""
|
||
return max(0, self.quantity - self.received_quantity)
|
||
|
||
@property
|
||
def is_fully_received(self):
|
||
return self.received_quantity >= self.quantity
|
||
|
||
@property
|
||
def total_quantity_label(self):
|
||
"""Retourne un label lisible de la quantité totale avec l'unité."""
|
||
unit = self.product.get_unit_display()
|
||
if self.num_articles and self.quantity_per_article:
|
||
return _("{num} article(s) × {qpa} {unit} = {total} {unit}").format(
|
||
num=self.num_articles,
|
||
qpa=self.quantity_per_article,
|
||
total=self.quantity,
|
||
unit=unit,
|
||
)
|
||
return _("{total} {unit}").format(total=self.quantity, unit=unit)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Auto-compute quantity from articles if both fields are provided
|
||
if self.num_articles and self.quantity_per_article:
|
||
from decimal import Decimal
|
||
self.quantity = int(self.num_articles * self.quantity_per_article)
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return _("{quantity} x {product} dans la commande {order}").format(
|
||
quantity=self.quantity,
|
||
product=self.product.name,
|
||
order=self.purchase_order.id,
|
||
)
|
||
|
||
|
||
class StockAlert(models.Model):
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="alerts")
|
||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, related_name="alerts")
|
||
current_quantity = models.IntegerField(default=0)
|
||
triggered_at = models.DateTimeField(auto_now_add=True)
|
||
resolved_at = models.DateTimeField(blank=True, null=True)
|
||
is_active = models.BooleanField(default=True)
|
||
|
||
class Meta:
|
||
unique_together = ("product", "warehouse", "is_active")
|
||
|
||
def __str__(self):
|
||
return _("Alerte stock {product} ({warehouse})").format(
|
||
product=self.product.name,
|
||
warehouse=self.warehouse.name,
|
||
)
|
||
|
||
|
||
def update_stock_alerts(product, warehouse):
|
||
total = (
|
||
Stock.objects.filter(product=product, location__warehouse=warehouse)
|
||
.aggregate(total=Sum("quantity"))["total"]
|
||
or 0
|
||
)
|
||
alert = StockAlert.objects.filter(
|
||
product=product, warehouse=warehouse, is_active=True
|
||
).first()
|
||
if total < product.threshold:
|
||
if alert:
|
||
alert.current_quantity = total
|
||
alert.save(update_fields=["current_quantity"])
|
||
else:
|
||
StockAlert.objects.create(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
current_quantity=total,
|
||
)
|
||
elif alert:
|
||
alert.current_quantity = total
|
||
alert.is_active = False
|
||
alert.resolved_at = timezone.now()
|
||
alert.save(update_fields=["current_quantity", "is_active", "resolved_at"])
|
||
|
||
|
||
class PreparationOrder(models.Model):
|
||
order_number = models.CharField(max_length=100, unique=True)
|
||
date = models.DateTimeField(auto_now_add=True)
|
||
status = models.CharField(
|
||
max_length=50,
|
||
choices=[
|
||
("to_process", _("A traiter")),
|
||
("pending", _("En attente")),
|
||
("processed", _("Traité")),
|
||
("cancelled", _("Annulé")),
|
||
],
|
||
default="to_process",
|
||
)
|
||
priority = models.CharField(
|
||
max_length=50,
|
||
choices=[
|
||
('1', _('Urgent')),
|
||
('2', _('Elevé')),
|
||
('3', _('Normal')),
|
||
('4', _('Bas')),
|
||
],
|
||
default='3',
|
||
verbose_name=_("Priorité"),
|
||
)
|
||
intervention = models.ForeignKey(
|
||
"interventions.Intervention",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="preparation_orders",
|
||
verbose_name=_("Intervention")
|
||
)
|
||
requester = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Demandeur"),
|
||
help_text=_("Personne ou service à l'origine de la demande"),
|
||
)
|
||
is_loan = models.BooleanField(default=False, verbose_name=_("Prêt de matériel"))
|
||
estimated_return_date = models.DateField(null=True, blank=True, verbose_name=_("Date de retour attendue"))
|
||
loan_returned = models.BooleanField(default=False, verbose_name=_("Prêt retourné"))
|
||
items = models.ManyToManyField(Product, through="PreparationOrderItem")
|
||
qr_code = models.ImageField(upload_to='stock/qrcodes/preparation_orders/', blank=True, null=True, verbose_name=_('QR Code'))
|
||
|
||
STATUS_BADGE_CLASSES = {
|
||
'to_process': 'bg-danger',
|
||
'pending': 'bg-info',
|
||
'processed': 'bg-success',
|
||
'cancelled': 'bg-secondary',
|
||
}
|
||
STATUS_ICONS = {
|
||
'to_process': 'bi-hourglass',
|
||
'pending': 'bi-clock',
|
||
'processed': 'bi-check-circle',
|
||
'cancelled': 'bi-x-circle',
|
||
}
|
||
PRIORITY_BADGE_CLASSES = {
|
||
'1': 'bg-danger',
|
||
'2': 'bg-warning text-dark',
|
||
'3': 'bg-primary',
|
||
'4': 'bg-secondary',
|
||
}
|
||
|
||
def __str__(self):
|
||
return f"Preparation Order #{self.order_number}"
|
||
|
||
@property
|
||
def priority_badge_class(self):
|
||
return self.PRIORITY_BADGE_CLASSES.get(self.priority, 'bg-secondary')
|
||
|
||
@property
|
||
def status_badge_class(self):
|
||
return self.STATUS_BADGE_CLASSES.get(self.status, 'bg-secondary')
|
||
|
||
@property
|
||
def status_icon(self):
|
||
return self.STATUS_ICONS.get(self.status, 'bi-circle')
|
||
|
||
def generate_qr_code(self):
|
||
"""Génère un QR code basé sur le numéro de bon de préparation et le sauvegarde dans le champ qr_code."""
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(f"STREETUP:preparation:{self.order_number}")
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
|
||
from django.core.files.base import ContentFile
|
||
filename = f"preparation_{self.order_number}.png"
|
||
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
||
|
||
@property
|
||
def is_overdue(self):
|
||
from django.utils import timezone
|
||
if self.is_loan and not self.loan_returned and self.estimated_return_date and self.status == 'processed':
|
||
return self.estimated_return_date < timezone.now().date()
|
||
return False
|
||
|
||
def save(self, *args, **kwargs):
|
||
is_new = self.pk is None
|
||
old_status = None
|
||
if not is_new:
|
||
old_status = PreparationOrder.objects.filter(pk=self.pk).values_list('status', flat=True).first()
|
||
|
||
# Ne pas re-générer le QR code lors des saves partiels (update_fields)
|
||
update_fields = kwargs.get('update_fields')
|
||
if update_fields is None and self.order_number:
|
||
if is_new:
|
||
# Pour un nouvel objet, on sauvegarde d'abord pour obtenir le pk,
|
||
# puis on génère le QR code et on sauvegarde à nouveau.
|
||
super().save(*args, **kwargs)
|
||
self.generate_qr_code()
|
||
super().save(update_fields=['qr_code'])
|
||
else:
|
||
old = None
|
||
try:
|
||
old = PreparationOrder.objects.get(pk=self.pk)
|
||
except PreparationOrder.DoesNotExist:
|
||
pass
|
||
if old is None or old.order_number != self.order_number or not self.qr_code:
|
||
self.generate_qr_code()
|
||
super().save(*args, **kwargs)
|
||
else:
|
||
super().save(*args, **kwargs)
|
||
|
||
# Mettre à jour les quantités réservées si le statut change
|
||
if not is_new and old_status and old_status != self.status:
|
||
self.update_reserved_quantities()
|
||
|
||
def update_reserved_quantities(self):
|
||
"""Met à jour les quantités réservées pour tous les produits de ce bon"""
|
||
for item in self.items.all():
|
||
item.update_reserved_quantity()
|
||
|
||
def mark_as_processed(self):
|
||
"""Marque le bon comme traité et libère les quantités réservées"""
|
||
self.status = "processed"
|
||
self.save()
|
||
self.update_reserved_quantities()
|
||
|
||
def cancel(self):
|
||
"""Annule le bon et libère les quantités réservées"""
|
||
self.status = "cancelled"
|
||
self.save()
|
||
self.update_reserved_quantities()
|
||
|
||
|
||
class PreparationOrderItem(models.Model):
|
||
preparation_order = models.ForeignKey(PreparationOrder, on_delete=models.CASCADE)
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||
quantity = models.IntegerField() # Quantité demandée initialement (0 si ajouté pendant le traitement)
|
||
is_prepared = models.BooleanField(default=False)
|
||
quantity_modified = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Quantité modifiée"),
|
||
help_text=_("Indique que la quantité a été modifiée après que l'article ait été marqué comme préparé."),
|
||
)
|
||
previous_quantity = models.IntegerField(
|
||
null=True, blank=True,
|
||
verbose_name=_("Quantité précédente"),
|
||
help_text=_("Ancienne quantité avant modification (pour information du magasinier)."),
|
||
)
|
||
|
||
def __str__(self):
|
||
return _("{quantity} x {product} dans la commande {order}").format(
|
||
quantity=self.quantity,
|
||
product=self.product.name,
|
||
order=self.preparation_order.order_number,
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
super().save(*args, **kwargs)
|
||
# Mettre à jour la quantité réservée du produit
|
||
self.product.update_reserved_quantity()
|
||
|
||
def delete(self, *args, **kwargs):
|
||
product = self.product
|
||
super().delete(*args, **kwargs)
|
||
# Mettre à jour la quantité réservée du produit après suppression
|
||
product.update_reserved_quantity()
|
||
|
||
|
||
# class PreparationOrderDesign(models.Model):
|
||
# """
|
||
# Lie une conception (ProductDesign) à un bon de préparation.
|
||
# Permet d'associer plusieurs conceptions avec quantité à un même bon.
|
||
# Un bon de production est créé automatiquement pour chaque entrée.
|
||
# """
|
||
# preparation_order = models.ForeignKey(
|
||
# PreparationOrder, on_delete=models.CASCADE, related_name="design_orders"
|
||
# )
|
||
# product_design = models.ForeignKey(
|
||
# "ProductDesign", on_delete=models.CASCADE,
|
||
# verbose_name=_("Conception")
|
||
# )
|
||
# quantity = models.IntegerField(default=1, verbose_name=_("Quantité"))
|
||
#
|
||
# def __str__(self):
|
||
# return _("{quantity} x {design} dans le bon {order}").format(
|
||
# quantity=self.quantity,
|
||
# design=self.product_design.name,
|
||
# order=self.preparation_order.order_number,
|
||
# )
|
||
|
||
|
||
def purchase_order_document_upload_path(instance, filename):
|
||
return f"purchase_orders/order_{instance.purchase_order_id}/{filename}"
|
||
|
||
|
||
class PurchaseOrderDocument(models.Model):
|
||
"""Document (photo, pièce justificative…) attaché à une commande fournisseur."""
|
||
purchase_order = models.ForeignKey(
|
||
PurchaseOrder,
|
||
on_delete=models.CASCADE,
|
||
related_name="documents",
|
||
verbose_name=_("Commande fournisseur"),
|
||
)
|
||
file = models.FileField(upload_to=purchase_order_document_upload_path, storage=private_storage, verbose_name=_("Fichier"))
|
||
name = models.CharField(max_length=255, blank=True, verbose_name=_("Nom"))
|
||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
def __str__(self):
|
||
return self.name or self.file.name
|
||
|
||
def filename(self):
|
||
import os
|
||
return os.path.basename(self.file.name)
|
||
|
||
def is_image(self):
|
||
name = self.file.name.lower()
|
||
return any(name.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"))
|
||
|
||
|
||
def preparation_order_document_upload_path(instance, filename):
|
||
return f"preparation_orders/order_{instance.preparation_order_id}/{filename}"
|
||
|
||
|
||
class PreparationOrderDocument(models.Model):
|
||
"""Document (photo, pièce justificative…) attaché à un bon de préparation."""
|
||
preparation_order = models.ForeignKey(
|
||
PreparationOrder,
|
||
on_delete=models.CASCADE,
|
||
related_name="documents",
|
||
verbose_name=_("Bon de préparation"),
|
||
)
|
||
file = models.FileField(upload_to=preparation_order_document_upload_path, storage=private_storage, verbose_name=_("Fichier"))
|
||
name = models.CharField(max_length=255, blank=True, verbose_name=_("Nom"))
|
||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
def __str__(self):
|
||
return self.name or self.file.name
|
||
|
||
def filename(self):
|
||
import os
|
||
return os.path.basename(self.file.name)
|
||
|
||
def is_image(self):
|
||
name = self.file.name.lower()
|
||
return any(name.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"))
|
||
|
||
|
||
def production_order_document_upload_path(instance, filename):
|
||
return f"production_orders/order_{instance.production_order_id}/{filename}"
|
||
|
||
|
||
class ProductionOrderDocument(models.Model):
|
||
"""Document (photo, pièce justificative…) attaché à un bon de confection."""
|
||
production_order = models.ForeignKey(
|
||
'ProductionOrder',
|
||
on_delete=models.CASCADE,
|
||
related_name="documents",
|
||
verbose_name=_("Bon de confection"),
|
||
)
|
||
file = models.FileField(upload_to=production_order_document_upload_path, storage=private_storage, verbose_name=_("Fichier"))
|
||
name = models.CharField(max_length=255, blank=True, verbose_name=_("Nom"))
|
||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
def __str__(self):
|
||
return self.name or self.file.name
|
||
|
||
def filename(self):
|
||
import os
|
||
return os.path.basename(self.file.name)
|
||
|
||
def is_image(self):
|
||
name = self.file.name.lower()
|
||
return any(name.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"))
|
||
|
||
|
||
class PreparationOrderOtherItem(models.Model):
|
||
"""
|
||
Ligne "autre produit" dans un bon de préparation.
|
||
L'utilisateur décrit librement ce qu'il souhaite, choisit une catégorie
|
||
et, si la catégorie est Signalisation, peut préciser un type de panneau.
|
||
"""
|
||
preparation_order = models.ForeignKey(
|
||
PreparationOrder, on_delete=models.CASCADE, related_name="other_items"
|
||
)
|
||
description = models.TextField(verbose_name=_("Description"))
|
||
quantity = models.IntegerField(default=1, verbose_name=_("Quantité"))
|
||
category = models.ForeignKey(
|
||
ProductCategory,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name=_("Catégorie"),
|
||
)
|
||
sign_panel_type = models.ForeignKey(
|
||
"sign.SignPanelType",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name=_("Type de panneau"),
|
||
)
|
||
is_prepared = models.BooleanField(default=False, verbose_name=_("Préparé"))
|
||
|
||
def __str__(self):
|
||
return _("{quantity} x {description} dans le bon {order}").format(
|
||
quantity=self.quantity,
|
||
description=self.description[:50],
|
||
order=self.preparation_order.order_number,
|
||
)
|
||
|
||
|
||
# class ProductDesign(models.Model):
|
||
# """
|
||
# Conception de produit : une fiche/recette décrivant comment assembler
|
||
# un produit fini à partir de pièces du stock.
|
||
# Ex: un panneau de signalisation spécifique composé de plusieurs pièces.
|
||
# """
|
||
# STATUS_DRAFT = "draft"
|
||
# STATUS_ACTIVE = "active"
|
||
# STATUS_ARCHIVED = "archived"
|
||
|
||
# STATUS_CHOICES = [
|
||
# (STATUS_DRAFT, _("Brouillon")),
|
||
# (STATUS_ACTIVE, _("Actif")),
|
||
# (STATUS_ARCHIVED, _("Archivé")),
|
||
# ]
|
||
|
||
# code = models.CharField(max_length=100, unique=True)
|
||
# name = models.CharField(max_length=300)
|
||
# description = models.TextField(blank=True, null=True)
|
||
# image = models.ImageField(upload_to="product_designs/", blank=True, null=True)
|
||
# status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT)
|
||
# category = models.ForeignKey(
|
||
# ProductCategory, on_delete=models.SET_NULL, null=True, blank=True,
|
||
# related_name="product_designs"
|
||
# )
|
||
# warehouse = models.ForeignKey(
|
||
# Warehouse, on_delete=models.SET_NULL, null=True, blank=True,
|
||
# related_name="product_designs",
|
||
# verbose_name=_("Entrepôt de production")
|
||
# )
|
||
# default_location = models.ForeignKey(
|
||
# WarehouseLocation, on_delete=models.SET_NULL, null=True, blank=True,
|
||
# related_name="product_designs",
|
||
# verbose_name=_("Emplacement par défaut")
|
||
# )
|
||
# created_at = models.DateTimeField(auto_now_add=True)
|
||
# updated_at = models.DateTimeField(auto_now=True)
|
||
# output_product = models.ForeignKey(
|
||
# "Product",
|
||
# on_delete=models.SET_NULL,
|
||
# null=True, blank=True,
|
||
# related_name="design_output_for",
|
||
# verbose_name=_("Produit fini par défaut"),
|
||
# help_text=_("Produit existant à augmenter en stock quand un bon de confection est terminé. "
|
||
# "Peut être surchargé par le bon de confection."),
|
||
# )
|
||
# components = models.ManyToManyField(
|
||
# Product, through="ProductDesignComponent", related_name="used_in_designs"
|
||
# )
|
||
|
||
# class Meta:
|
||
# ordering = ("-created_at",)
|
||
|
||
# def __str__(self):
|
||
# return f"{self.code} - {self.name}"
|
||
|
||
# def total_components(self):
|
||
# """Nombre total de pièces nécessaires"""
|
||
# return (
|
||
# self.design_components.aggregate(total=Sum("quantity"))["total"] or 0
|
||
# )
|
||
|
||
# def estimated_cost(self):
|
||
# """Coût estimé basé sur le prix des composants"""
|
||
# total = 0
|
||
# for comp in self.design_components.select_related("product"):
|
||
# if comp.product.price:
|
||
# total += comp.product.price * comp.quantity
|
||
# return total
|
||
|
||
# def can_produce(self, warehouse=None):
|
||
# """
|
||
# Vérifie si le stock est suffisant pour produire une unité.
|
||
# Si un entrepôt est spécifié, ne vérifie que dans celui-ci.
|
||
# """
|
||
# for comp in self.design_components.select_related("product"):
|
||
# if warehouse:
|
||
# available = (
|
||
# Stock.objects.filter(
|
||
# product=comp.product, location__warehouse=warehouse
|
||
# ).aggregate(total=Sum("quantity"))["total"] or 0
|
||
# )
|
||
# else:
|
||
# available = comp.product.total_stock
|
||
# if available < comp.quantity:
|
||
# return False
|
||
# return True
|
||
|
||
|
||
# class ProductDesignComponent(models.Model):
|
||
# """
|
||
# Un composant (pièce) faisant partie d'une conception de produit.
|
||
# """
|
||
# product_design = models.ForeignKey(
|
||
# ProductDesign, on_delete=models.CASCADE, related_name="design_components"
|
||
# )
|
||
# product = models.ForeignKey(
|
||
# Product, on_delete=models.CASCADE, related_name="design_usages"
|
||
# )
|
||
# quantity = models.IntegerField(default=1)
|
||
# notes = models.CharField(max_length=500, blank=True, null=True)
|
||
|
||
# class Meta:
|
||
# unique_together = ("product_design", "product")
|
||
|
||
# def __str__(self):
|
||
# return _("{quantity} x {product} pour {design}").format(
|
||
# quantity=self.quantity,
|
||
# product=self.product.name,
|
||
# design=self.product_design.name,
|
||
# )
|
||
|
||
|
||
class ProductionOrder(models.Model):
|
||
"""
|
||
Bon de confection : assemblage d'un produit conçu à partir des composants du stock.
|
||
Peut être lié à un bon de préparation.
|
||
Quand il est terminé, le produit fini est automatiquement ajouté au stock.
|
||
"""
|
||
STATUS_DRAFT = "draft"
|
||
STATUS_TO_PROCESS = "to_process"
|
||
STATUS_IN_PRODUCTION = "in_production"
|
||
STATUS_COMPLETED = "completed"
|
||
STATUS_CANCELLED = "cancelled"
|
||
|
||
STATUS_CHOICES = [
|
||
(STATUS_DRAFT, _("Brouillon")),
|
||
(STATUS_TO_PROCESS, _("À traiter")),
|
||
(STATUS_IN_PRODUCTION, _("En confection")),
|
||
(STATUS_COMPLETED, _("Terminé")),
|
||
(STATUS_CANCELLED, _("Annulé")),
|
||
]
|
||
|
||
DESTINATION_INTERNAL = "internal"
|
||
DESTINATION_EXTERNAL = "external"
|
||
DESTINATION_CHOICES = [
|
||
(DESTINATION_INTERNAL, _("Interne")),
|
||
(DESTINATION_EXTERNAL, _("Externe")),
|
||
]
|
||
|
||
STATUS_BADGE_CLASSES = {
|
||
'draft': 'bg-secondary',
|
||
'to_process': 'bg-danger',
|
||
'in_production': 'bg-primary',
|
||
'completed': 'bg-success',
|
||
'cancelled': 'bg-secondary',
|
||
}
|
||
STATUS_ICONS = {
|
||
'draft': 'bi-pencil',
|
||
'to_process': 'bi-hourglass',
|
||
'in_production': 'bi-gear-wide-connected',
|
||
'completed': 'bi-check-circle',
|
||
'cancelled': 'bi-x-circle',
|
||
}
|
||
|
||
@property
|
||
def status_badge_class(self):
|
||
return self.STATUS_BADGE_CLASSES.get(self.status, 'bg-secondary')
|
||
|
||
@property
|
||
def status_icon(self):
|
||
return self.STATUS_ICONS.get(self.status, 'bi-circle')
|
||
|
||
code = models.CharField(max_length=100, unique=True, blank=True)
|
||
# product_design = models.ForeignKey(
|
||
# ProductDesign, on_delete=models.SET_NULL,
|
||
# null=True, blank=True,
|
||
# related_name="production_orders"
|
||
# )
|
||
preparation_order = models.ForeignKey(
|
||
PreparationOrder, on_delete=models.SET_NULL,
|
||
null=True, blank=True, related_name="production_orders"
|
||
)
|
||
preparation_order_item = models.ForeignKey(
|
||
PreparationOrderItem, on_delete=models.SET_NULL,
|
||
null=True, blank=True, related_name="production_orders",
|
||
verbose_name=_("Article du bon de préparation"),
|
||
)
|
||
preparation_order_other_item = models.ForeignKey(
|
||
PreparationOrderOtherItem, on_delete=models.SET_NULL,
|
||
null=True, blank=True, related_name="production_orders",
|
||
verbose_name=_("Autre produit du bon de préparation"),
|
||
)
|
||
warehouse = models.ForeignKey(
|
||
Warehouse, on_delete=models.CASCADE, related_name="production_orders"
|
||
)
|
||
quantity = models.IntegerField(default=1, help_text=_("Nombre d'unités à concevoir"))
|
||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_TO_PROCESS)
|
||
destination = models.CharField(
|
||
max_length=20, choices=DESTINATION_CHOICES, default=DESTINATION_INTERNAL,
|
||
verbose_name=_("Destination"),
|
||
help_text=_("Indiquer si la confection est destinée à l'usage interne ou externe."),
|
||
)
|
||
notes = models.TextField(blank=True, null=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
completed_at = models.DateTimeField(blank=True, null=True)
|
||
qr_code = models.ImageField(upload_to='stock/qrcodes/conception_orders/', blank=True, null=True, verbose_name=_('QR Code'))
|
||
|
||
# Produit fini à ajouter au stock lors de la complétion du bon de confection
|
||
output_product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="conception_orders_output",
|
||
verbose_name=_("Produit fini à stocker"),
|
||
help_text=_("Produit existant dont la quantité sera augmentée à la fin de la confection. "
|
||
"Si vide et qu'un nom est fourni, un nouveau produit sera créé."),
|
||
)
|
||
output_product_name = models.CharField(
|
||
max_length=300, blank=True, null=True,
|
||
verbose_name=_("Nom du produit fini (nouveau)"),
|
||
help_text=_("Nom du nouveau produit à créer si 'Produit fini à stocker' n'est pas sélectionné."),
|
||
)
|
||
output_location = models.ForeignKey(
|
||
WarehouseLocation,
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="conception_orders_output",
|
||
verbose_name=_("Emplacement de stockage du produit fini"),
|
||
help_text=_("Emplacement où le produit fini sera ajouté au stock. "
|
||
"Si vide, utilise l'emplacement par défaut de la conception."),
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ("-created_at",)
|
||
|
||
def __str__(self):
|
||
return _("Confection {code} x{qty}").format(
|
||
code=self.code or f"#{self.pk}",
|
||
qty=self.quantity,
|
||
)
|
||
|
||
def generate_qr_code(self):
|
||
"""Génère un QR code basé sur le code du bon de confection et le sauvegarde dans le champ qr_code."""
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(f"STREETUP:conception:{self.code}")
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
|
||
from django.core.files.base import ContentFile
|
||
filename = f"conception_{self.code}.png"
|
||
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Ne pas re-générer le QR code lors des saves partiels (update_fields)
|
||
update_fields = kwargs.get('update_fields')
|
||
|
||
if not self.code:
|
||
if self.pk:
|
||
self.code = f"CONC-{self.pk:06d}"
|
||
else:
|
||
super().save(*args, **kwargs)
|
||
self.code = f"CONC-{self.pk:06d}"
|
||
kwargs['force_insert'] = False
|
||
|
||
if update_fields is None:
|
||
generate = False
|
||
if self.pk:
|
||
try:
|
||
old = ProductionOrder.objects.get(pk=self.pk)
|
||
if old.code != self.code or not self.qr_code:
|
||
generate = True
|
||
except ProductionOrder.DoesNotExist:
|
||
generate = True
|
||
else:
|
||
generate = True
|
||
if generate:
|
||
self.generate_qr_code()
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def _get_active_components(self):
|
||
"""
|
||
Retourne les composants propres au bon de confection.
|
||
"""
|
||
if self.pk and self.order_components.exists():
|
||
return list(self.order_components.select_related("product"))
|
||
return []
|
||
|
||
def can_start(self):
|
||
"""Vérifie si tous les composants sont disponibles dans l'entrepôt"""
|
||
if self.status not in [self.STATUS_TO_PROCESS, self.STATUS_DRAFT, self.STATUS_IN_PRODUCTION]:
|
||
return False
|
||
for comp in self._get_active_components():
|
||
available = (
|
||
Stock.objects.filter(
|
||
product=comp.product, location__warehouse=self.warehouse
|
||
).aggregate(total=Sum("quantity"))["total"] or 0
|
||
)
|
||
if available < comp.quantity * self.quantity:
|
||
return False
|
||
return True
|
||
|
||
def get_components_status(self):
|
||
"""Retourne le statut de chaque composant par rapport au stock"""
|
||
components_status = []
|
||
for comp in self._get_active_components():
|
||
available = (
|
||
Stock.objects.filter(
|
||
product=comp.product, location__warehouse=self.warehouse
|
||
).aggregate(total=Sum("quantity"))["total"] or 0
|
||
)
|
||
required = comp.quantity * self.quantity
|
||
components_status.append({
|
||
'component': comp,
|
||
'required': required,
|
||
'available': available,
|
||
'sufficient': available >= required,
|
||
})
|
||
return components_status
|
||
|
||
def get_output_product(self):
|
||
"""
|
||
Retourne le produit fini à stocker, ou en crée un nouveau si nécessaire.
|
||
Retourne None si aucun produit de sortie n'est spécifié.
|
||
"""
|
||
# Produit explicitement lié au bon
|
||
if self.output_product_id:
|
||
return self.output_product
|
||
# Créer un nouveau produit si un nom est fourni
|
||
if self.output_product_name:
|
||
import uuid as _uuid
|
||
code = f"CUSTOM-{_uuid.uuid4().hex[:8].upper()}"
|
||
product = Product.objects.create(
|
||
code=code,
|
||
name=self.output_product_name,
|
||
sku=code,
|
||
unit=Product.UNIT_PC,
|
||
threshold=0,
|
||
total_stock=0,
|
||
)
|
||
# Lier le produit créé pour les prochaines fois
|
||
self.output_product = product
|
||
self.save(update_fields=["output_product", "updated_at"])
|
||
return product
|
||
return None
|
||
|
||
def get_output_location(self):
|
||
"""
|
||
Retourne l'emplacement de stockage du produit fini.
|
||
Priorité : output_location > première location de l'entrepôt
|
||
"""
|
||
if self.output_location_id:
|
||
return self.output_location
|
||
return self.warehouse.locations.first()
|
||
|
||
def complete(self):
|
||
"""
|
||
Marque le bon de confection comme terminé :
|
||
- Consomme le stock des composants
|
||
- Ajoute le produit fini au stock
|
||
"""
|
||
if self.status == self.STATUS_COMPLETED:
|
||
return
|
||
with transaction.atomic():
|
||
now = timezone.now()
|
||
|
||
# 1. Consommer les composants
|
||
active_components = self._get_active_components()
|
||
if active_components:
|
||
for comp in active_components:
|
||
needed = comp.quantity * self.quantity
|
||
# Prendre le stock depuis les emplacements de l'entrepôt
|
||
stocks = Stock.objects.filter(
|
||
product=comp.product,
|
||
location__warehouse=self.warehouse,
|
||
quantity__gt=0,
|
||
).order_by("-quantity")
|
||
remaining = needed
|
||
for stock_entry in stocks:
|
||
if remaining <= 0:
|
||
break
|
||
take = min(stock_entry.quantity, remaining)
|
||
stock_entry.quantity -= take
|
||
stock_entry.save()
|
||
remaining -= take
|
||
StockMovement.objects.create(
|
||
product=comp.product,
|
||
source_location=stock_entry.location,
|
||
quantity=-take,
|
||
reason=_("Bon de confection {code}").format(
|
||
code=self.code,
|
||
),
|
||
)
|
||
|
||
# 2. Ajouter le produit fini au stock
|
||
finished_product = self.get_output_product()
|
||
if finished_product:
|
||
output_loc = self.get_output_location()
|
||
if output_loc:
|
||
stock_entry, _created = Stock.objects.get_or_create(
|
||
product=finished_product,
|
||
location=output_loc,
|
||
defaults={"quantity": 0},
|
||
)
|
||
stock_entry.quantity += self.quantity
|
||
stock_entry.save()
|
||
StockMovement.objects.create(
|
||
product=finished_product,
|
||
destination_location=output_loc,
|
||
quantity=self.quantity,
|
||
reason=_("Bon de confection {code} terminé").format(
|
||
code=self.code,
|
||
),
|
||
)
|
||
|
||
self.status = self.STATUS_COMPLETED
|
||
self.completed_at = now
|
||
self.save(update_fields=["status", "completed_at", "updated_at"])
|
||
|
||
def cancel(self):
|
||
"""Annule le bon de confection"""
|
||
if self.status in [self.STATUS_TO_PROCESS, self.STATUS_DRAFT, self.STATUS_IN_PRODUCTION]:
|
||
self.status = self.STATUS_CANCELLED
|
||
self.save(update_fields=["status", "updated_at"])
|
||
|
||
|
||
class SerializedItem(models.Model):
|
||
"""Article tracé individuellement par numéro de série."""
|
||
|
||
STATUS_AVAILABLE = "available"
|
||
STATUS_ASSIGNED = "assigned"
|
||
STATUS_LOST = "lost"
|
||
STATUS_RETIRED = "retired"
|
||
|
||
STATUS_CHOICES = [
|
||
(STATUS_AVAILABLE, _("Disponible")),
|
||
(STATUS_ASSIGNED, _("Attribué")),
|
||
(STATUS_LOST, _("Perdu")),
|
||
(STATUS_RETIRED, _("Retiré du service")),
|
||
]
|
||
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.CASCADE,
|
||
related_name="serialized_items",
|
||
limit_choices_to={"is_serialized": True},
|
||
verbose_name=_("Produit"),
|
||
)
|
||
serial_number = models.CharField(
|
||
max_length=200,
|
||
unique=True,
|
||
verbose_name=_("Numéro de série"),
|
||
)
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default=STATUS_AVAILABLE,
|
||
verbose_name=_("Statut"),
|
||
)
|
||
current_location = models.ForeignKey(
|
||
WarehouseLocation,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="serialized_items",
|
||
verbose_name=_("Emplacement actuel"),
|
||
)
|
||
current_holder = models.ForeignKey(
|
||
"contracts.CompanyMember",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="held_serialized_items",
|
||
verbose_name=_("Détenteur actuel"),
|
||
)
|
||
notes = models.TextField(blank=True, default="", verbose_name=_("Notes"))
|
||
qr_code = models.ImageField(
|
||
upload_to='stock/qrcodes/serialized_items/',
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("QR Code"),
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
|
||
created_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="created_serialized_items",
|
||
verbose_name=_("Créé par"),
|
||
)
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name=_("Actif"),
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ("product__name", "serial_number")
|
||
verbose_name = _("Article tracé")
|
||
verbose_name_plural = _("Articles tracés")
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name} — {self.serial_number}"
|
||
|
||
def generate_qr_code(self):
|
||
"""Génère un QR code basé sur le numéro de série et le sauvegarde dans le champ qr_code."""
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(f"STREETUP:serializeditem:{self.serial_number}")
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
|
||
from django.core.files.base import ContentFile
|
||
safe_sn = self.serial_number.replace('/', '_').replace('\\', '_')
|
||
filename = f"serialized_{safe_sn}.png"
|
||
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
||
|
||
def save(self, *args, **kwargs):
|
||
update_fields = kwargs.get('update_fields')
|
||
if update_fields is not None:
|
||
super().save(*args, **kwargs)
|
||
return
|
||
generate = False
|
||
if self.pk:
|
||
try:
|
||
old = SerializedItem.objects.get(pk=self.pk)
|
||
if old.serial_number != self.serial_number or not self.qr_code:
|
||
generate = True
|
||
except SerializedItem.DoesNotExist:
|
||
generate = True
|
||
else:
|
||
generate = True
|
||
if generate:
|
||
self.generate_qr_code()
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class SerializedItemEvent(models.Model):
|
||
"""Evénement de l'historique d'un article tracé."""
|
||
|
||
TYPE_ENTRY = "entry"
|
||
TYPE_ASSIGNED = "assigned"
|
||
TYPE_RETURNED = "returned"
|
||
TYPE_TRANSFERRED = "transferred"
|
||
TYPE_LOST = "lost"
|
||
TYPE_RETIRED = "retired"
|
||
TYPE_NOTE = "note"
|
||
|
||
TYPE_CHOICES = [
|
||
(TYPE_ENTRY, _("Entrée en stock")),
|
||
(TYPE_ASSIGNED, _("Attribué")),
|
||
(TYPE_RETURNED, _("Rendu")),
|
||
(TYPE_TRANSFERRED, _("Transféré")),
|
||
(TYPE_LOST, _("Perdu")),
|
||
(TYPE_RETIRED, _("Retiré du service")),
|
||
(TYPE_NOTE, _("Note")),
|
||
]
|
||
|
||
item = models.ForeignKey(
|
||
SerializedItem,
|
||
on_delete=models.CASCADE,
|
||
related_name="events",
|
||
verbose_name=_("Article"),
|
||
)
|
||
event_type = models.CharField(
|
||
max_length=20,
|
||
choices=TYPE_CHOICES,
|
||
verbose_name=_("Type d'événement"),
|
||
)
|
||
date = models.DateTimeField(default=timezone.now, verbose_name=_("Date"))
|
||
person = models.ForeignKey(
|
||
"contracts.CompanyMember",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="serialized_item_events",
|
||
verbose_name=_("Personne"),
|
||
)
|
||
from_location = models.ForeignKey(
|
||
WarehouseLocation,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="serialized_events_from",
|
||
verbose_name=_("Depuis"),
|
||
)
|
||
to_location = models.ForeignKey(
|
||
WarehouseLocation,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="serialized_events_to",
|
||
verbose_name=_("Vers"),
|
||
)
|
||
notes = models.TextField(blank=True, default="", verbose_name=_("Remarques"))
|
||
created_by = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="serialized_item_events",
|
||
verbose_name=_("Enregistré par"),
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
ordering = ("-date", "-created_at")
|
||
verbose_name = _("Événement article tracé")
|
||
verbose_name_plural = _("Événements articles tracés")
|
||
|
||
def __str__(self):
|
||
return f"{self.get_event_type_display()} — {self.item} — {self.date:%d/%m/%Y}"
|
||
|
||
|
||
class ProductionOrderComponent(models.Model):
|
||
"""
|
||
Composant (article du stock) directement associé à un bon de confection.
|
||
Si des composants sont définis ici, ils ont priorité sur ceux du design lié.
|
||
"""
|
||
production_order = models.ForeignKey(
|
||
ProductionOrder, on_delete=models.CASCADE,
|
||
related_name="order_components"
|
||
)
|
||
product = models.ForeignKey(
|
||
Product, on_delete=models.CASCADE,
|
||
related_name="production_order_usages"
|
||
)
|
||
quantity = models.IntegerField(default=1)
|
||
notes = models.CharField(max_length=500, blank=True, null=True)
|
||
|
||
class Meta:
|
||
unique_together = ("production_order", "product")
|
||
ordering = ("product__name",)
|
||
|
||
def __str__(self):
|
||
return _("{quantity} x {product} pour {order}").format(
|
||
quantity=self.quantity,
|
||
product=self.product.name,
|
||
order=self.production_order.code,
|
||
)
|