1669 lines
65 KiB
Python
1669 lines
65 KiB
Python
from django.test import TestCase
|
|
from django.urls import reverse
|
|
from .models import (
|
|
Inventory,
|
|
InventoryItem,
|
|
Product,
|
|
PurchaseOrder,
|
|
PurchaseOrderItem,
|
|
Stock,
|
|
StockAlert,
|
|
StockMovement,
|
|
Supplier,
|
|
Warehouse,
|
|
WarehouseLocation,
|
|
SerializedItem,
|
|
)
|
|
|
|
|
|
class StockFeaturesTests(TestCase):
|
|
def setUp(self):
|
|
self.warehouse = Warehouse.objects.create(
|
|
code="WH-1",
|
|
name="Entrepôt Central",
|
|
manager="Alice",
|
|
)
|
|
self.location = WarehouseLocation.objects.create(
|
|
warehouse=self.warehouse,
|
|
name="Allée A",
|
|
code="LOC-A",
|
|
)
|
|
self.supplier = Supplier.objects.create(name="Fournisseur 1")
|
|
self.product = Product.objects.create(
|
|
code="PROD-1",
|
|
name="Produit A",
|
|
sku="SKU-1",
|
|
threshold=10,
|
|
)
|
|
self.stock = Stock.objects.create(
|
|
product=self.product,
|
|
location=self.location,
|
|
quantity=5,
|
|
)
|
|
|
|
def test_inventory_validation_updates_stock(self):
|
|
inventory = Inventory.objects.create(
|
|
warehouse_location=self.location,
|
|
performed_by="Bob",
|
|
status=Inventory.STATUS_DRAFT,
|
|
)
|
|
InventoryItem.objects.create(
|
|
inventory=inventory,
|
|
product=self.product,
|
|
expected_quantity=None,
|
|
counted_quantity=8,
|
|
)
|
|
|
|
inventory.validate()
|
|
|
|
self.stock.refresh_from_db()
|
|
movement = StockMovement.objects.get(product=self.product)
|
|
|
|
self.assertEqual(inventory.status, Inventory.STATUS_VALIDATED)
|
|
self.assertEqual(self.stock.quantity, 8)
|
|
self.assertEqual(movement.quantity, 3)
|
|
self.assertTrue(
|
|
"Ajustement inventaire" in movement.reason or
|
|
"Inventory adjustment" in movement.reason
|
|
)
|
|
|
|
def test_purchase_order_receive_updates_stock(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig
|
|
user = User.objects.create_user(username="buyer", password="password")
|
|
UserConfig.objects.create(user=user, is_intern=True)
|
|
user.is_superuser = True
|
|
user.save()
|
|
self.client.force_login(user)
|
|
|
|
purchase_order = PurchaseOrder.objects.create(
|
|
supplier=self.supplier,
|
|
warehouse=self.warehouse,
|
|
status="pending",
|
|
)
|
|
PurchaseOrderItem.objects.create(
|
|
purchase_order=purchase_order,
|
|
product=self.product,
|
|
location=self.location,
|
|
quantity=7,
|
|
)
|
|
|
|
response = self.client.post(
|
|
reverse("stock:purchase_order_receive", args=[purchase_order.pk])
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
purchase_order.refresh_from_db()
|
|
self.stock.refresh_from_db()
|
|
|
|
self.assertEqual(purchase_order.status, "received")
|
|
self.assertIsNotNone(purchase_order.received_at)
|
|
self.assertEqual(self.stock.quantity, 12)
|
|
self.assertTrue(
|
|
StockMovement.objects.filter(
|
|
product=self.product,
|
|
reason__icontains="order",
|
|
).exists() or
|
|
StockMovement.objects.filter(
|
|
product=self.product,
|
|
reason__icontains="commande",
|
|
).exists()
|
|
)
|
|
|
|
def test_stock_alerts_trigger_and_resolve(self):
|
|
alert = StockAlert.objects.filter(product=self.product, warehouse=self.warehouse).first()
|
|
self.assertIsNotNone(alert)
|
|
self.assertTrue(alert.is_active)
|
|
self.assertEqual(alert.current_quantity, 5)
|
|
|
|
self.stock.quantity = 15
|
|
self.stock.save()
|
|
|
|
alert.refresh_from_db()
|
|
self.assertFalse(alert.is_active)
|
|
self.assertIsNotNone(alert.resolved_at)
|
|
self.assertEqual(alert.current_quantity, 15)
|
|
|
|
def test_products_api_with_inactive_filter(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig
|
|
user = User.objects.create_user(username="buyer_api", password="password")
|
|
UserConfig.objects.create(user=user, is_intern=True)
|
|
user.is_superuser = True
|
|
user.save()
|
|
self.client.force_login(user)
|
|
|
|
# Create an inactive product
|
|
inactive_prod = Product.objects.create(
|
|
code="PROD-INACTIVE",
|
|
name="Produit Inactif",
|
|
sku="SKU-INACTIVE",
|
|
threshold=5,
|
|
is_active=False,
|
|
)
|
|
Stock.objects.create(
|
|
product=inactive_prod,
|
|
location=self.location,
|
|
quantity=0,
|
|
)
|
|
|
|
# 1. Fetch products API by default (should exclude inactive)
|
|
response = self.client.get(reverse("stock:products_api"))
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.json()
|
|
active_ids = [item["id"] for item in data]
|
|
self.assertIn(self.product.id, active_ids)
|
|
self.assertNotIn(inactive_prod.id, active_ids)
|
|
|
|
# 2. Fetch products API with include_inactive=true (should include inactive)
|
|
response = self.client.get(reverse("stock:products_api") + "?include_inactive=true")
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.json()
|
|
all_ids = [item["id"] for item in data]
|
|
self.assertIn(self.product.id, all_ids)
|
|
self.assertIn(inactive_prod.id, all_ids)
|
|
|
|
# Check if is_active is returned correctly in data
|
|
inactive_item = next(item for item in data if item["id"] == inactive_prod.id)
|
|
self.assertFalse(inactive_item["is_active"])
|
|
|
|
active_item = next(item for item in data if item["id"] == self.product.id)
|
|
self.assertTrue(active_item["is_active"])
|
|
|
|
def test_inventory_add_item_api(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig, UserWarehouseAccess
|
|
|
|
user = User.objects.create_user(username="validator", password="password")
|
|
config = UserConfig.objects.create(user=user, is_intern=True)
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=config,
|
|
warehouse=self.warehouse,
|
|
can_validate_inventory=True,
|
|
)
|
|
self.client.force_login(user)
|
|
|
|
inventory = Inventory.objects.create(
|
|
warehouse_location=self.location,
|
|
performed_by="Bob",
|
|
status=Inventory.STATUS_DRAFT,
|
|
)
|
|
new_prod = Product.objects.create(
|
|
code="PROD-ADD",
|
|
name="New Product A",
|
|
sku="SKU-ADD",
|
|
threshold=5,
|
|
)
|
|
|
|
# 1. Success case: Adding a new product
|
|
response = self.client.post(
|
|
reverse("stock:inventory_add_item_api", args=[inventory.pk]),
|
|
{"product_id": new_prod.pk, "counted_quantity": 12, "comment": "Added via API"}
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["counted_quantity"], 12)
|
|
self.assertEqual(data["comment"], "Added via API")
|
|
|
|
# Verify in DB
|
|
item = InventoryItem.objects.get(inventory=inventory, product=new_prod)
|
|
self.assertEqual(item.counted_quantity, 12)
|
|
self.assertEqual(item.comment, "Added via API")
|
|
|
|
# 2. Failure case: negative quantity
|
|
response = self.client.post(
|
|
reverse("stock:inventory_add_item_api", args=[inventory.pk]),
|
|
{"product_id": new_prod.pk, "counted_quantity": -5}
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertFalse(response.json()["success"])
|
|
|
|
def test_inventory_item_delete_api(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig, UserWarehouseAccess
|
|
|
|
# Validator user
|
|
validator = User.objects.create_user(username="val_user", password="password")
|
|
val_config = UserConfig.objects.create(user=validator, is_intern=True)
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=val_config,
|
|
warehouse=self.warehouse,
|
|
can_validate_inventory=True,
|
|
)
|
|
|
|
# Worker user
|
|
worker = User.objects.create_user(username="work_user", password="password")
|
|
work_config = UserConfig.objects.create(user=worker, is_intern=True)
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=work_config,
|
|
warehouse=self.warehouse,
|
|
can_validate_inventory=False,
|
|
)
|
|
|
|
inventory = Inventory.objects.create(
|
|
warehouse_location=self.location,
|
|
performed_by="Bob",
|
|
status=Inventory.STATUS_DRAFT,
|
|
)
|
|
item = InventoryItem.objects.create(
|
|
inventory=inventory,
|
|
product=self.product,
|
|
expected_quantity=5,
|
|
counted_quantity=5,
|
|
)
|
|
|
|
# 1. Non-validator tries to delete item (should fail with 403)
|
|
self.client.force_login(worker)
|
|
response = self.client.post(
|
|
reverse("stock:inventory_item_delete_api", args=[inventory.pk, item.pk])
|
|
)
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertTrue(InventoryItem.objects.filter(pk=item.pk).exists())
|
|
|
|
# 2. Validator tries to delete item (should succeed)
|
|
self.client.force_login(validator)
|
|
response = self.client.post(
|
|
reverse("stock:inventory_item_delete_api", args=[inventory.pk, item.pk])
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertFalse(InventoryItem.objects.filter(pk=item.pk).exists())
|
|
|
|
def test_inventory_change_status_restriction(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig, UserWarehouseAccess
|
|
|
|
# Worker user (can manage but NOT validate)
|
|
worker = User.objects.create_user(username="worker_status", password="password")
|
|
work_config = UserConfig.objects.create(user=worker, is_intern=True)
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=work_config,
|
|
warehouse=self.warehouse,
|
|
can_manage=True,
|
|
can_validate_inventory=False,
|
|
)
|
|
|
|
# Validator user (can manage and validate)
|
|
validator = User.objects.create_user(username="val_status", password="password")
|
|
val_config = UserConfig.objects.create(user=validator, is_intern=True)
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=val_config,
|
|
warehouse=self.warehouse,
|
|
can_manage=True,
|
|
can_validate_inventory=True,
|
|
)
|
|
|
|
inventory = Inventory.objects.create(
|
|
warehouse_location=self.location,
|
|
performed_by="Bob",
|
|
status=Inventory.STATUS_DRAFT,
|
|
)
|
|
|
|
# 1. Worker can change status from draft to in_progress
|
|
self.client.force_login(worker)
|
|
response = self.client.post(
|
|
reverse("stock:inventory_change_status", args=[inventory.pk]),
|
|
{"status": "in_progress"}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
inventory.refresh_from_db()
|
|
self.assertEqual(inventory.status, Inventory.STATUS_IN_PROGRESS)
|
|
|
|
# 2. Worker can change status from in_progress to done
|
|
response = self.client.post(
|
|
reverse("stock:inventory_change_status", args=[inventory.pk]),
|
|
{"status": "done"}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
inventory.refresh_from_db()
|
|
self.assertEqual(inventory.status, Inventory.STATUS_DONE)
|
|
|
|
# 3. Worker CANNOT change status from done to in_progress (demotion)
|
|
response = self.client.post(
|
|
reverse("stock:inventory_change_status", args=[inventory.pk]),
|
|
{"status": "in_progress"}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
inventory.refresh_from_db()
|
|
self.assertEqual(inventory.status, Inventory.STATUS_DONE) # Remains done
|
|
|
|
# 4. Validator CAN change status from done to in_progress (demotion)
|
|
self.client.force_login(validator)
|
|
response = self.client.post(
|
|
reverse("stock:inventory_change_status", args=[inventory.pk]),
|
|
{"status": "in_progress"}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
inventory.refresh_from_db()
|
|
self.assertEqual(inventory.status, Inventory.STATUS_IN_PROGRESS)
|
|
|
|
def test_inventory_pdf_export_options(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig, UserWarehouseAccess
|
|
|
|
# Set up a manager/user who can view/manage
|
|
user = User.objects.create_user(username="pdf_exporter", password="password")
|
|
config = UserConfig.objects.create(user=user, is_intern=True)
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=config,
|
|
warehouse=self.warehouse,
|
|
can_manage=True,
|
|
)
|
|
self.client.force_login(user)
|
|
|
|
inventory = Inventory.objects.create(
|
|
warehouse_location=self.location,
|
|
performed_by="Bob",
|
|
status=Inventory.STATUS_DRAFT,
|
|
)
|
|
|
|
# 1. Blank export
|
|
response = self.client.get(
|
|
reverse("stock:inventory_pdf", args=[inventory.pk]) + "?type=blank"
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response["Content-Type"], "application/pdf")
|
|
|
|
# 2. Complete export
|
|
response = self.client.get(
|
|
reverse("stock:inventory_pdf", args=[inventory.pk]) + "?type=complete"
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response["Content-Type"], "application/pdf")
|
|
|
|
|
|
|
|
|
|
class PreparationOrderPriorityTests(TestCase):
|
|
def setUp(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig
|
|
self.user = User.objects.create_superuser(username="testuser", password="password", email="test@example.com")
|
|
UserConfig.objects.create(user=self.user)
|
|
self.client.login(username="testuser", password="password")
|
|
|
|
def test_default_priority_is_normal(self):
|
|
from .models import PreparationOrder
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-TEST-001"
|
|
)
|
|
self.assertEqual(po.priority, '3') # Normal
|
|
self.assertEqual(po.priority_badge_class, 'bg-primary')
|
|
|
|
def test_priority_form_choices(self):
|
|
from .forms import PreparationOrderForm
|
|
form = PreparationOrderForm()
|
|
self.assertIn('priority', form.fields)
|
|
choices = form.fields['priority'].choices
|
|
choices_dict = dict(choices)
|
|
self.assertIn('1', choices_dict)
|
|
self.assertIn('2', choices_dict)
|
|
self.assertIn('3', choices_dict)
|
|
self.assertIn('4', choices_dict)
|
|
|
|
def test_priority_inherited_from_intervention(self):
|
|
from interventions.models import Intervention
|
|
from .forms import PreparationOrderForm
|
|
intervention = Intervention.objects.create(
|
|
title="Intervention Test",
|
|
priority='1', # Urgent
|
|
)
|
|
initial = {
|
|
'order_number': "BP-TEST-002",
|
|
'status': 'to_process',
|
|
'priority': intervention.priority
|
|
}
|
|
form = PreparationOrderForm(initial=initial)
|
|
self.assertEqual(form.initial['priority'], '1')
|
|
|
|
def test_stock_movements_filters(self):
|
|
from .models import PreparationOrder
|
|
po1 = PreparationOrder.objects.create(order_number="BP-TEST-F1", status="to_process", priority="1")
|
|
po2 = PreparationOrder.objects.create(order_number="BP-TEST-F2", status="processed", priority="3")
|
|
|
|
# Test status filter
|
|
response = self.client.get(reverse("stock:stock_movements") + "?status=to_process")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(po1, list(response.context["preparation_orders"]))
|
|
self.assertNotIn(po2, list(response.context["preparation_orders"]))
|
|
|
|
# Test priority filter
|
|
response = self.client.get(reverse("stock:stock_movements") + "?priority=3")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(po2, list(response.context["preparation_orders"]))
|
|
self.assertNotIn(po1, list(response.context["preparation_orders"]))
|
|
|
|
def test_mobile_preparation_orders_filters(self):
|
|
from .models import PreparationOrder
|
|
po1 = PreparationOrder.objects.create(order_number="BP-TEST-M1", status="to_process", priority="1")
|
|
po2 = PreparationOrder.objects.create(order_number="BP-TEST-M2", status="processed", priority="3")
|
|
|
|
# Test status filter
|
|
response = self.client.get(reverse("mobile:stock_preparation_orders_mobile") + "?status=to_process")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(po1, list(response.context["preparation_orders"]))
|
|
self.assertNotIn(po2, list(response.context["preparation_orders"]))
|
|
|
|
# Test priority filter
|
|
response = self.client.get(reverse("mobile:stock_preparation_orders_mobile") + "?priority=3")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(po2, list(response.context["preparation_orders"]))
|
|
self.assertNotIn(po1, list(response.context["preparation_orders"]))
|
|
|
|
def test_preparation_order_detail_shows_intervention_manager(self):
|
|
from django.contrib.auth.models import User
|
|
from interventions.models import Intervention
|
|
from .models import PreparationOrder
|
|
|
|
# 1. Test with first/last name (using short name)
|
|
manager1 = User.objects.create_user(
|
|
username="manager_test1",
|
|
password="password",
|
|
first_name="Jean-Pierre",
|
|
last_name="Dupont"
|
|
)
|
|
intervention1 = Intervention.objects.create(
|
|
title="Intervention avec manager 1",
|
|
intervention_manager=manager1
|
|
)
|
|
po1 = PreparationOrder.objects.create(
|
|
order_number="BP-TEST-MGR1",
|
|
intervention=intervention1
|
|
)
|
|
|
|
# Desktop detail view
|
|
response = self.client.get(reverse("stock:preparation_order_detail", args=[po1.pk]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "Jean-Pierre D.")
|
|
|
|
# Mobile detail view
|
|
response_mobile = self.client.get(reverse("mobile:stock_preparation_order_detail_mobile", args=[po1.pk]))
|
|
self.assertEqual(response_mobile.status_code, 200)
|
|
self.assertContains(response_mobile, "Jean-Pierre D.")
|
|
|
|
# 2. Test with fallback to username (no first/last name)
|
|
manager2 = User.objects.create_user(
|
|
username="manager_test2_username",
|
|
password="password"
|
|
)
|
|
intervention2 = Intervention.objects.create(
|
|
title="Intervention avec manager 2",
|
|
intervention_manager=manager2
|
|
)
|
|
po2 = PreparationOrder.objects.create(
|
|
order_number="BP-TEST-MGR2",
|
|
intervention=intervention2
|
|
)
|
|
|
|
# Desktop detail view
|
|
response2 = self.client.get(reverse("stock:preparation_order_detail", args=[po2.pk]))
|
|
self.assertEqual(response2.status_code, 200)
|
|
self.assertContains(response2, "manager_test2_username")
|
|
|
|
# Mobile detail view
|
|
response_mobile2 = self.client.get(reverse("mobile:stock_preparation_order_detail_mobile", args=[po2.pk]))
|
|
self.assertEqual(response_mobile2.status_code, 200)
|
|
self.assertContains(response_mobile2, "manager_test2_username")
|
|
|
|
|
|
class ProductLocationsTests(TestCase):
|
|
def setUp(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig
|
|
|
|
self.warehouse = Warehouse.objects.create(code="WH-2", name="Entrepôt B", manager="Bob")
|
|
self.location_ok = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Emplacement OK", code="LOC-OK")
|
|
self.location_fail = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Emplacement Interdit", code="LOC-FAIL")
|
|
|
|
self.product = Product.objects.create(
|
|
code="PROD-LOC",
|
|
name="Produit Restreint",
|
|
sku="SKU-LOC",
|
|
)
|
|
self.product.authorized_locations.add(self.location_ok)
|
|
|
|
self.user = User.objects.create_user(username="adminuser", password="password")
|
|
self.user.is_superuser = True
|
|
self.user.save()
|
|
UserConfig.objects.create(user=self.user)
|
|
self.client.force_login(self.user)
|
|
|
|
def test_purchase_order_item_authorized_location_validation(self):
|
|
from .forms import PurchaseOrderItemForm
|
|
purchase_order = PurchaseOrder.objects.create(
|
|
supplier=Supplier.objects.create(name="Supplier B"),
|
|
warehouse=self.warehouse,
|
|
status="pending",
|
|
)
|
|
|
|
# Authorized location should be valid
|
|
form_ok = PurchaseOrderItemForm(
|
|
data={
|
|
"product": self.product.id,
|
|
"location": self.location_ok.id,
|
|
"quantity": 10,
|
|
},
|
|
warehouse=self.warehouse,
|
|
)
|
|
self.assertTrue(form_ok.is_valid())
|
|
|
|
# Unauthorized location should be invalid
|
|
form_fail = PurchaseOrderItemForm(
|
|
data={
|
|
"product": self.product.id,
|
|
"location": self.location_fail.id,
|
|
"quantity": 10,
|
|
},
|
|
warehouse=self.warehouse,
|
|
)
|
|
self.assertFalse(form_fail.is_valid())
|
|
self.assertIn("location", form_fail.errors.get("__all__", [str(form_fail.errors)])[0])
|
|
|
|
def test_product_locations_api_returns_only_authorized(self):
|
|
response = self.client.get(reverse("stock:product_locations_api", args=[self.product.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.json()
|
|
loc_ids = [loc["id"] for loc in data["locations"]]
|
|
self.assertIn(self.location_ok.id, loc_ids)
|
|
self.assertNotIn(self.location_fail.id, loc_ids)
|
|
|
|
def test_product_create_at_location_view(self):
|
|
response = self.client.get(reverse("stock:product_create_at_location", args=[self.location_ok.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
response_post = self.client.post(
|
|
reverse("stock:product_create_at_location", args=[self.location_ok.id]),
|
|
data={
|
|
"code": "PROD-NEW-LOC",
|
|
"name": "Nouveau Produit Localisé",
|
|
"sku": "SKU-NEW-LOC",
|
|
"unit": "pc",
|
|
"threshold": 5,
|
|
"quantity": 25,
|
|
}
|
|
)
|
|
self.assertEqual(response_post.status_code, 302)
|
|
|
|
new_product = Product.objects.get(code="PROD-NEW-LOC")
|
|
stock = Stock.objects.get(product=new_product, location=self.location_ok)
|
|
self.assertEqual(stock.quantity, 25)
|
|
|
|
# Check if StockMovement was created
|
|
movement = StockMovement.objects.get(product=new_product)
|
|
self.assertEqual(movement.quantity, 25)
|
|
self.assertEqual(movement.destination_location, self.location_ok)
|
|
|
|
def test_product_without_stock_visible_in_permission_filter(self):
|
|
from stock.permissions import filter_products_by_permission
|
|
# Create a product with no stock
|
|
new_prod = Product.objects.create(
|
|
code="PROD-NO-STOCK",
|
|
name="Produit sans stock",
|
|
sku="SKU-NO-STOCK",
|
|
)
|
|
products = Product.objects.all()
|
|
filtered = filter_products_by_permission(products, self.user)
|
|
self.assertIn(new_prod, filtered)
|
|
|
|
def test_product_create_with_initial_quantities(self):
|
|
# Create product with initial quantities for location_ok
|
|
response = self.client.post(
|
|
reverse("stock:product_create"),
|
|
data={
|
|
"code": "PROD-GEN-INIT",
|
|
"name": "Produit Initialise",
|
|
"sku": "SKU-GEN-INIT",
|
|
"unit": "pc",
|
|
"threshold": 5,
|
|
"authorized_locations": [self.location_ok.id],
|
|
f"initial_qty_{self.location_ok.id}": 15,
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
new_product = Product.objects.get(code="PROD-GEN-INIT")
|
|
stock = Stock.objects.get(product=new_product, location=self.location_ok)
|
|
self.assertEqual(stock.quantity, 15)
|
|
|
|
# Check if StockMovement was created
|
|
movement = StockMovement.objects.get(product=new_product)
|
|
self.assertEqual(movement.quantity, 15)
|
|
self.assertEqual(movement.destination_location, self.location_ok)
|
|
|
|
def test_product_detail_lists_authorized_locations_without_stock_virtually(self):
|
|
# self.product has location_ok authorized and has stock there.
|
|
# Let's add location_fail as authorized too, but with NO physical stock record.
|
|
self.product.authorized_locations.add(self.location_fail)
|
|
|
|
response = self.client.get(reverse("stock:product_detail", args=[self.product.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
stocks_context = list(response.context["stocks"])
|
|
# There should be 2 stocks shown in the context (one real and one virtual)
|
|
self.assertEqual(len(stocks_context), 2)
|
|
|
|
# Check that the virtual stock has quantity 0
|
|
virtual_stock = next(s for s in stocks_context if s.location == self.location_fail)
|
|
self.assertEqual(virtual_stock.quantity, 0)
|
|
self.assertEqual(virtual_stock.product, self.product)
|
|
|
|
def test_serialized_item_initial_location_validation(self):
|
|
# Make the product serialized so we can register items for it
|
|
self.product.is_serialized = True
|
|
self.product.save()
|
|
|
|
# Try to register a serialized item in the authorized location LOC-OK
|
|
response = self.client.post(
|
|
reverse("stock:serialized_item_create"),
|
|
data={
|
|
"product": self.product.id,
|
|
"serial_number": "SN-OK-123",
|
|
"current_location": self.location_ok.id,
|
|
"notes": "Some notes"
|
|
}
|
|
)
|
|
# Should redirect to detail view on success
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(SerializedItem.objects.filter(serial_number="SN-OK-123").exists())
|
|
|
|
# Try to register a serialized item in an unauthorized location LOC-FAIL
|
|
response_fail = self.client.post(
|
|
reverse("stock:serialized_item_create"),
|
|
data={
|
|
"product": self.product.id,
|
|
"serial_number": "SN-FAIL-123",
|
|
"current_location": self.location_fail.id,
|
|
"notes": "Some notes"
|
|
}
|
|
)
|
|
# Should return 200 (form re-rendered with error)
|
|
self.assertEqual(response_fail.status_code, 200)
|
|
# Item should not be created
|
|
self.assertFalse(SerializedItem.objects.filter(serial_number="SN-FAIL-123").exists())
|
|
|
|
def test_serialized_item_event_location_validation(self):
|
|
self.product.is_serialized = True
|
|
self.product.save()
|
|
|
|
# Create a valid item initially in LOC-OK
|
|
item = SerializedItem.objects.create(
|
|
product=self.product,
|
|
serial_number="SN-EVENT-TEST",
|
|
current_location=self.location_ok,
|
|
status=SerializedItem.STATUS_AVAILABLE
|
|
)
|
|
|
|
# 1. Try to record an event with a destination location that is authorized (LOC-OK)
|
|
# Event type can be 'transferred'
|
|
response_ok = self.client.post(
|
|
reverse("stock:serialized_item_event_create", args=[item.pk]),
|
|
data={
|
|
"event_type": "transferred",
|
|
"to_location": self.location_ok.id,
|
|
"notes": "Transfer to OK"
|
|
}
|
|
)
|
|
self.assertEqual(response_ok.status_code, 302)
|
|
|
|
# 2. Try to record an event with a destination location that is NOT authorized (LOC-FAIL)
|
|
response_fail = self.client.post(
|
|
reverse("stock:serialized_item_event_create", args=[item.pk]),
|
|
data={
|
|
"event_type": "transferred",
|
|
"to_location": self.location_fail.id,
|
|
"notes": "Transfer to fail"
|
|
}
|
|
)
|
|
# Should redirect back to detail with an error message
|
|
self.assertEqual(response_fail.status_code, 302)
|
|
# Check that the last event in history is NOT the failed one
|
|
last_event = item.events.order_by('-date').first()
|
|
if last_event:
|
|
self.assertNotEqual(last_event.notes, "Transfer to fail")
|
|
|
|
|
|
class AssociateStockLocationsCommandTests(TestCase):
|
|
def setUp(self):
|
|
self.warehouse = Warehouse.objects.create(code="WH-TEST-CMD", name="Entrepôt Test Commande", manager="Charlie")
|
|
self.location_a = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Emplacement A", code="LOC-A")
|
|
self.location_b = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Emplacement B", code="LOC-B")
|
|
|
|
# Product 1: stock quantity > 0 in LOC-A, 0 in LOC-B
|
|
self.product_1 = Product.objects.create(code="PROD-TEST-1", name="Product Test 1", sku="SKU-T1")
|
|
Stock.objects.create(product=self.product_1, location=self.location_a, quantity=5)
|
|
Stock.objects.create(product=self.product_1, location=self.location_b, quantity=0)
|
|
|
|
# Product 2: stock quantity > 0 in LOC-B. Already has LOC-A as authorized location.
|
|
self.product_2 = Product.objects.create(code="PROD-TEST-2", name="Product Test 2", sku="SKU-T2")
|
|
self.product_2.authorized_locations.add(self.location_a)
|
|
Stock.objects.create(product=self.product_2, location=self.location_b, quantity=10)
|
|
|
|
def test_dry_run_does_not_modify_database(self):
|
|
from django.core.management import call_command
|
|
call_command('associate_stock_locations', '--dry-run')
|
|
|
|
# Check that no changes were saved
|
|
self.product_1.refresh_from_db()
|
|
self.product_2.refresh_from_db()
|
|
|
|
self.assertEqual(self.product_1.authorized_locations.count(), 0)
|
|
self.assertEqual(list(self.product_2.authorized_locations.all()), [self.location_a])
|
|
|
|
def test_associate_all_stocks_by_default(self):
|
|
from django.core.management import call_command
|
|
call_command('associate_stock_locations')
|
|
|
|
self.product_1.refresh_from_db()
|
|
self.product_2.refresh_from_db()
|
|
|
|
# Product 1 should have LOC-A and LOC-B (since it has Stock records for both, even with quantity 0)
|
|
p1_locs = list(self.product_1.authorized_locations.order_by('code'))
|
|
self.assertEqual(p1_locs, [self.location_a, self.location_b])
|
|
|
|
# Product 2 should have both LOC-A (pre-existing) and LOC-B (from active stock)
|
|
p2_locs = list(self.product_2.authorized_locations.order_by('code'))
|
|
self.assertEqual(p2_locs, [self.location_a, self.location_b])
|
|
|
|
def test_associate_positive_only(self):
|
|
from django.core.management import call_command
|
|
call_command('associate_stock_locations', '--positive-only')
|
|
|
|
self.product_1.refresh_from_db()
|
|
self.product_2.refresh_from_db()
|
|
|
|
# Product 1 should only have LOC-A, because quantity in LOC-B is 0
|
|
p1_locs = list(self.product_1.authorized_locations.all())
|
|
self.assertEqual(p1_locs, [self.location_a])
|
|
|
|
# Product 2 should have LOC-A (pre-existing) and LOC-B (from positive stock)
|
|
p2_locs = list(self.product_2.authorized_locations.order_by('code'))
|
|
self.assertEqual(p2_locs, [self.location_a, self.location_b])
|
|
|
|
|
|
class LocationLevelInventoryPermissionsTests(TestCase):
|
|
def setUp(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig, UserWarehouseLocationAccess
|
|
|
|
# Base database records
|
|
self.warehouse = Warehouse.objects.create(
|
|
code="WH-PERM",
|
|
name="Entrepôt Permissions",
|
|
manager="Denis",
|
|
)
|
|
self.camionnette_1 = WarehouseLocation.objects.create(
|
|
warehouse=self.warehouse,
|
|
name="Camionnette 1",
|
|
code="CAMIONNETTE-1",
|
|
)
|
|
self.camionnette_2 = WarehouseLocation.objects.create(
|
|
warehouse=self.warehouse,
|
|
name="Camionnette 2",
|
|
code="CAMIONNETTE-2",
|
|
)
|
|
self.product = Product.objects.create(
|
|
code="PROD-PERM",
|
|
name="Produit Permissions",
|
|
sku="SKU-PERM",
|
|
)
|
|
self.stock_1 = Stock.objects.create(
|
|
product=self.product,
|
|
location=self.camionnette_1,
|
|
quantity=10,
|
|
)
|
|
self.stock_2 = Stock.objects.create(
|
|
product=self.product,
|
|
location=self.camionnette_2,
|
|
quantity=10,
|
|
)
|
|
|
|
# Users
|
|
# 1. Tech 1 has permission to perform inventory on Camionnette 1
|
|
self.tech_user = User.objects.create_user(username="tech_1", password="password")
|
|
self.tech_config = UserConfig.objects.create(user=self.tech_user, is_intern=True)
|
|
UserWarehouseLocationAccess.objects.create(
|
|
user_config=self.tech_config,
|
|
warehouse_location=self.camionnette_1,
|
|
can_perform_inventory=True,
|
|
can_validate_inventory=False,
|
|
)
|
|
|
|
# 2. Validator 1 has permission to validate inventory on Camionnette 1
|
|
self.val_user = User.objects.create_user(username="validator_1", password="password")
|
|
self.val_config = UserConfig.objects.create(user=self.val_user, is_intern=True)
|
|
UserWarehouseLocationAccess.objects.create(
|
|
user_config=self.val_config,
|
|
warehouse_location=self.camionnette_1,
|
|
can_perform_inventory=False,
|
|
can_validate_inventory=True,
|
|
)
|
|
|
|
# 3. Restrictive user with no permissions on any warehouse or location
|
|
self.other_user = User.objects.create_user(username="other_guy", password="password")
|
|
self.other_config = UserConfig.objects.create(user=self.other_user, is_intern=True)
|
|
# Note: self.other_config.userwarehouses exists is False, meaning by default they would see warehouses unless they have some userwarehouses.
|
|
# Let's assign them access to a dummy warehouse to restrict their access.
|
|
from common.models import UserWarehouseAccess
|
|
self.dummy_warehouse = Warehouse.objects.create(code="WH-DUMMY", name="Entrepôt Dummy", manager="Dummy")
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=self.other_config,
|
|
warehouse=self.dummy_warehouse,
|
|
can_view=True,
|
|
)
|
|
|
|
def test_tech_can_perform_inventory_on_permitted_location(self):
|
|
self.client.force_login(self.tech_user)
|
|
|
|
# Tech can list inventories
|
|
response = self.client.get(reverse("stock:inventory_list"))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
# Tech can create a draft inventory on Camionnette 1
|
|
# In Django Form, we send warehouse_location and status
|
|
response = self.client.post(
|
|
reverse("stock:inventory_create"),
|
|
{
|
|
"warehouse_location": self.camionnette_1.id,
|
|
"status": "draft",
|
|
"items-TOTAL_FORMS": "0",
|
|
"items-INITIAL_FORMS": "0",
|
|
"items-MIN_NUM_FORMS": "0",
|
|
"items-MAX_NUM_FORMS": "1000",
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
inv = Inventory.objects.filter(warehouse_location=self.camionnette_1).first()
|
|
self.assertIsNotNone(inv)
|
|
self.assertEqual(inv.status, "draft")
|
|
|
|
# Tech CANNOT create a draft inventory on Camionnette 2
|
|
response_fail = self.client.post(
|
|
reverse("stock:inventory_create"),
|
|
{
|
|
"warehouse_location": self.camionnette_2.id,
|
|
"status": "draft",
|
|
"items-TOTAL_FORMS": "0",
|
|
"items-INITIAL_FORMS": "0",
|
|
"items-MIN_NUM_FORMS": "0",
|
|
"items-MAX_NUM_FORMS": "1000",
|
|
}
|
|
)
|
|
self.assertEqual(response_fail.status_code, 302) # redirects to stock_movements with error
|
|
self.assertEqual(Inventory.objects.filter(warehouse_location=self.camionnette_2).count(), 0)
|
|
|
|
# Tech CANNOT validate their inventory
|
|
response_validate = self.client.post(
|
|
reverse("stock:inventory_validate", args=[inv.pk]),
|
|
{"validator_comment": "Try to validate"}
|
|
)
|
|
self.assertEqual(response_validate.status_code, 302)
|
|
inv.refresh_from_db()
|
|
self.assertNotEqual(inv.status, "validated")
|
|
|
|
def test_validator_can_validate_permitted_location(self):
|
|
self.client.force_login(self.val_user)
|
|
|
|
# Create an inventory on Camionnette 1
|
|
inv = Inventory.objects.create(
|
|
warehouse_location=self.camionnette_1,
|
|
status="draft",
|
|
)
|
|
InventoryItem.objects.create(
|
|
inventory=inv,
|
|
product=self.product,
|
|
expected_quantity=10,
|
|
counted_quantity=12,
|
|
)
|
|
|
|
# Validator can validate it
|
|
response = self.client.post(
|
|
reverse("stock:inventory_validate", args=[inv.pk]),
|
|
{"validator_comment": "Validated by tests"}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
inv.refresh_from_db()
|
|
self.assertEqual(inv.status, "validated")
|
|
|
|
# Verify stock updated
|
|
self.stock_1.refresh_from_db()
|
|
self.assertEqual(self.stock_1.quantity, 12)
|
|
|
|
# Validator cannot validate or change status of inventory on Camionnette 2
|
|
inv_2 = Inventory.objects.create(
|
|
warehouse_location=self.camionnette_2,
|
|
status="draft",
|
|
)
|
|
response_fail = self.client.post(
|
|
reverse("stock:inventory_validate", args=[inv_2.pk])
|
|
)
|
|
self.assertEqual(response_fail.status_code, 302)
|
|
inv_2.refresh_from_db()
|
|
self.assertNotEqual(inv_2.status, "validated")
|
|
|
|
def test_unauthorized_user_cannot_view_detail(self):
|
|
self.client.force_login(self.other_user)
|
|
|
|
inv = Inventory.objects.create(
|
|
warehouse_location=self.camionnette_1,
|
|
status="draft",
|
|
)
|
|
|
|
# Try to view detail
|
|
response = self.client.get(reverse("stock:inventory_detail", args=[inv.pk]))
|
|
self.assertEqual(response.status_code, 302) # Redirects to inventory_list or warehouses with error
|
|
|
|
|
|
class RequestForQuoteAndPurchaseOrderTests(TestCase):
|
|
def setUp(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig
|
|
from .models import Product, Supplier, Warehouse, WarehouseLocation
|
|
|
|
# Setup standard testing entities
|
|
self.warehouse = Warehouse.objects.create(
|
|
code="WH-RFQ-TEST",
|
|
name="Entrepôt RFQ Test",
|
|
manager="Denis",
|
|
location="123 Rue de la Station\n1000 Bruxelles",
|
|
)
|
|
self.location = WarehouseLocation.objects.create(
|
|
warehouse=self.warehouse,
|
|
name="Emplacement RFQ A",
|
|
code="LOC-RFQ-A",
|
|
is_default=True,
|
|
)
|
|
self.supplier = Supplier.objects.create(name="Fournisseur RFQ Test")
|
|
self.product_1 = Product.objects.create(
|
|
code="PROD-RFQ-1",
|
|
name="Produit RFQ 1",
|
|
sku="SKU-RFQ-1",
|
|
)
|
|
self.product_2 = Product.objects.create(
|
|
code="PROD-RFQ-2",
|
|
name="Produit RFQ 2",
|
|
sku="SKU-RFQ-2",
|
|
)
|
|
|
|
# Users
|
|
self.superuser = User.objects.create_superuser(username="admin_rfq", password="password", email="admin_rfq@test.com")
|
|
self.superuser_config = UserConfig.objects.create(user=self.superuser, is_intern=True)
|
|
|
|
self.normal_user = User.objects.create_user(username="normal_rfq", password="password")
|
|
self.normal_config = UserConfig.objects.create(user=self.normal_user, is_intern=True)
|
|
# Note: normal_user has no permissions to manage purchase orders or RFQs
|
|
from common.models import UserWarehouseAccess
|
|
self.dummy_warehouse = Warehouse.objects.create(code="WH-DUMMY-RFQ", name="Entrepôt Dummy RFQ", manager="Dummy")
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=self.normal_config,
|
|
warehouse=self.dummy_warehouse,
|
|
can_view=True,
|
|
can_manage_purchase_orders=False,
|
|
)
|
|
|
|
def test_rfq_creation_and_items(self):
|
|
from .models import RequestForQuote, RequestForQuoteItem
|
|
|
|
self.client.force_login(self.superuser)
|
|
|
|
# Check GET new RFQ form works
|
|
response = self.client.get(reverse("stock:request_for_quote_new"))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
# Create RFQ with two items
|
|
post_data = {
|
|
"warehouse": self.warehouse.pk,
|
|
"suppliers": [self.supplier.pk],
|
|
"notes": "Test RFQ Notes",
|
|
"status": "draft",
|
|
"items-TOTAL_FORMS": "2",
|
|
"items-INITIAL_FORMS": "0",
|
|
"items-MIN_NUM_FORMS": "0",
|
|
"items-MAX_NUM_FORMS": "1000",
|
|
"items-0-product": self.product_1.pk,
|
|
"items-0-num_articles": "10",
|
|
"items-0-quantity_per_article": "5",
|
|
"items-0-quantity": "", # should auto-compute to 50
|
|
"items-1-product": self.product_2.pk,
|
|
"items-1-num_articles": "",
|
|
"items-1-quantity_per_article": "",
|
|
"items-1-quantity": "15", # direct quantity
|
|
}
|
|
response = self.client.post(reverse("stock:request_for_quote_new"), post_data)
|
|
self.assertEqual(response.status_code, 302) # Redirect to detail page
|
|
|
|
# Verify DB records
|
|
rfq = RequestForQuote.objects.last()
|
|
self.assertIsNotNone(rfq)
|
|
self.assertEqual(rfq.warehouse, self.warehouse)
|
|
self.assertEqual(rfq.status, "draft")
|
|
self.assertEqual(rfq.notes, "Test RFQ Notes")
|
|
self.assertIn(self.supplier, rfq.suppliers.all())
|
|
self.assertTrue(rfq.code.startswith("RFQ-"))
|
|
|
|
items = rfq.items.all().order_by("product__code")
|
|
self.assertEqual(items.count(), 2)
|
|
self.assertEqual(items[0].product, self.product_1)
|
|
self.assertEqual(items[0].quantity, 50)
|
|
self.assertEqual(items[1].product, self.product_2)
|
|
self.assertEqual(items[1].quantity, 15)
|
|
|
|
def test_rfq_status_transitions(self):
|
|
from .models import RequestForQuote
|
|
|
|
rfq = RequestForQuote.objects.create(
|
|
warehouse=self.warehouse,
|
|
status="draft",
|
|
)
|
|
|
|
self.client.force_login(self.superuser)
|
|
|
|
# Transition Brouillon -> Envoyée
|
|
response = self.client.post(reverse("stock:request_for_quote_ordered", args=[rfq.pk]))
|
|
self.assertEqual(response.status_code, 302)
|
|
rfq.refresh_from_db()
|
|
self.assertEqual(rfq.status, "sent")
|
|
|
|
# Transition Envoyée -> Annulée
|
|
response = self.client.post(reverse("stock:request_for_quote_cancel", args=[rfq.pk]))
|
|
self.assertEqual(response.status_code, 302)
|
|
rfq.refresh_from_db()
|
|
self.assertEqual(rfq.status, "cancelled")
|
|
|
|
# Attempt to mark cancelled RFQ as sent (should fail/show error)
|
|
response = self.client.post(reverse("stock:request_for_quote_ordered", args=[rfq.pk]))
|
|
self.assertEqual(response.status_code, 302)
|
|
rfq.refresh_from_db()
|
|
self.assertEqual(rfq.status, "cancelled") # Stays cancelled
|
|
|
|
def test_po_prefill_and_convert(self):
|
|
from .models import RequestForQuote, RequestForQuoteItem, PurchaseOrder
|
|
from decimal import Decimal
|
|
|
|
rfq = RequestForQuote.objects.create(
|
|
warehouse=self.warehouse,
|
|
status="sent",
|
|
)
|
|
RequestForQuoteItem.objects.create(
|
|
request_for_quote=rfq,
|
|
product=self.product_1,
|
|
num_articles=5,
|
|
quantity_per_article=10,
|
|
quantity=50,
|
|
)
|
|
|
|
self.client.force_login(self.superuser)
|
|
|
|
# Get PO form pre-filled with RFQ parameter
|
|
response = self.client.get(reverse("stock:purchase_order_new") + f"?rfq={rfq.pk}")
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
# Submit PO form to convert RFQ to PO
|
|
post_data = {
|
|
"supplier": self.supplier.pk,
|
|
"warehouse": self.warehouse.pk,
|
|
"status": "pending",
|
|
"request_for_quote": rfq.pk,
|
|
"items-TOTAL_FORMS": "1",
|
|
"items-INITIAL_FORMS": "0",
|
|
"items-MIN_NUM_FORMS": "0",
|
|
"items-MAX_NUM_FORMS": "1000",
|
|
"items-0-product": self.product_1.pk,
|
|
"items-0-location": self.location.pk,
|
|
"items-0-num_articles": "5",
|
|
"items-0-quantity_per_article": "10",
|
|
"items-0-quantity": "50",
|
|
"items-0-unit_price": "12.50",
|
|
"items-0-vat_rate": "21.00",
|
|
}
|
|
response = self.client.post(reverse("stock:purchase_order_new"), post_data)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
# Verify Purchase Order was created, linked, and RFQ is completed
|
|
po = PurchaseOrder.objects.last()
|
|
self.assertIsNotNone(po)
|
|
self.assertEqual(po.request_for_quote, rfq)
|
|
self.assertEqual(po.supplier, self.supplier)
|
|
self.assertEqual(po.warehouse, self.warehouse)
|
|
|
|
rfq.refresh_from_db()
|
|
self.assertEqual(rfq.status, "completed")
|
|
|
|
po_item = po.items.first()
|
|
self.assertIsNotNone(po_item)
|
|
self.assertEqual(po_item.product, self.product_1)
|
|
self.assertEqual(po_item.unit_price, Decimal("12.50"))
|
|
self.assertEqual(po_item.vat_rate, Decimal("21.00"))
|
|
|
|
def test_pricing_computations(self):
|
|
from decimal import Decimal
|
|
from .models import PurchaseOrder, PurchaseOrderItem
|
|
|
|
po = PurchaseOrder.objects.create(
|
|
supplier=self.supplier,
|
|
warehouse=self.warehouse,
|
|
status="pending",
|
|
)
|
|
# Add two items with different pricing/VAT
|
|
item_1 = PurchaseOrderItem.objects.create(
|
|
purchase_order=po,
|
|
product=self.product_1,
|
|
location=self.location,
|
|
quantity=10,
|
|
unit_price=Decimal("15.00"), # Net amount = 150.00, VAT (21%) = 31.50, Total = 181.50
|
|
vat_rate=Decimal("21.00"),
|
|
)
|
|
item_2 = PurchaseOrderItem.objects.create(
|
|
purchase_order=po,
|
|
product=self.product_2,
|
|
location=self.location,
|
|
quantity=5,
|
|
unit_price=Decimal("20.00"), # Net amount = 100.00, VAT (6%) = 6.00, Total = 106.00
|
|
vat_rate=Decimal("6.00"),
|
|
)
|
|
|
|
# Test item pricing properties
|
|
self.assertEqual(item_1.net_amount, Decimal("150.00"))
|
|
self.assertEqual(item_1.vat_amount, Decimal("31.50"))
|
|
self.assertEqual(item_1.total_amount, Decimal("181.50"))
|
|
|
|
self.assertEqual(item_2.net_amount, Decimal("100.00"))
|
|
self.assertEqual(item_2.vat_amount, Decimal("6.00"))
|
|
self.assertEqual(item_2.total_amount, Decimal("106.00"))
|
|
|
|
# Test PO pricing properties
|
|
self.assertEqual(po.total_net_amount, Decimal("250.00"))
|
|
self.assertEqual(po.total_vat_amount, Decimal("37.50"))
|
|
self.assertEqual(po.total_amount, Decimal("287.50"))
|
|
|
|
def test_pdf_and_xlsx_exports(self):
|
|
from .models import RequestForQuote, RequestForQuoteItem, PurchaseOrder, PurchaseOrderItem
|
|
from decimal import Decimal
|
|
|
|
# RFQ Export
|
|
rfq = RequestForQuote.objects.create(
|
|
warehouse=self.warehouse,
|
|
status="sent",
|
|
)
|
|
RequestForQuoteItem.objects.create(
|
|
request_for_quote=rfq,
|
|
product=self.product_1,
|
|
quantity=50,
|
|
)
|
|
|
|
# PO Export
|
|
po = PurchaseOrder.objects.create(
|
|
supplier=self.supplier,
|
|
warehouse=self.warehouse,
|
|
status="ordered",
|
|
)
|
|
PurchaseOrderItem.objects.create(
|
|
purchase_order=po,
|
|
product=self.product_1,
|
|
location=self.location,
|
|
quantity=50,
|
|
unit_price=Decimal("10.00"),
|
|
vat_rate=Decimal("21.00"),
|
|
)
|
|
|
|
self.client.force_login(self.superuser)
|
|
|
|
# RFQ PDF
|
|
response = self.client.get(reverse("stock:request_for_quote_pdf", args=[rfq.pk]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response["Content-Type"], "application/pdf")
|
|
|
|
# RFQ XLSX
|
|
response = self.client.get(reverse("stock:request_for_quote_xlsx", args=[rfq.pk]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response["Content-Type"], "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
|
|
# PO PDF
|
|
response = self.client.get(reverse("stock:purchase_order_pdf", args=[po.pk]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response["Content-Type"], "application/pdf")
|
|
|
|
# PO XLSX
|
|
response = self.client.get(reverse("stock:purchase_order_xlsx", args=[po.pk]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response["Content-Type"], "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
|
|
def test_unauthorized_user_access(self):
|
|
from .models import RequestForQuote
|
|
|
|
rfq = RequestForQuote.objects.create(
|
|
warehouse=self.warehouse,
|
|
status="draft",
|
|
)
|
|
|
|
self.client.force_login(self.normal_user)
|
|
|
|
# Blocked from creating new RFQ
|
|
response = self.client.get(reverse("stock:request_for_quote_new"))
|
|
self.assertEqual(response.status_code, 302) # Redirect to stock_purchases with error
|
|
|
|
# Blocked from viewing RFQ detail (returns 404 because get_object_or_404 filters by accessible warehouses)
|
|
response = self.client.get(reverse("stock:request_for_quote_detail", args=[rfq.pk]))
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
|
|
class StockMovementAutocompleteTests(TestCase):
|
|
def setUp(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig
|
|
from .models import Warehouse, WarehouseLocation, Product
|
|
|
|
self.user = User.objects.create_superuser(username="admin_auto", password="password")
|
|
UserConfig.objects.create(user=self.user)
|
|
self.client.force_login(self.user)
|
|
|
|
self.warehouse = Warehouse.objects.create(code="WH-AUTO", name="Entrepôt Auto")
|
|
self.location = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Emplacement Auto", code="LOC-AUTO")
|
|
self.product = Product.objects.create(code="PROD-AUTO", name="Product Auto", sku="SKU-AUTO")
|
|
|
|
def test_new_stock_batch_with_custom_company_name(self):
|
|
from contracts.models import Company
|
|
from .models import StockMovementBatch
|
|
|
|
self.assertFalse(Company.objects.filter(name="Nouvelle Entreprise Test").exists())
|
|
|
|
response = self.client.post(
|
|
reverse("stock:new_stock_entry"),
|
|
{
|
|
"movement_subtype": "purchase",
|
|
"global_destination_location_id": self.location.id,
|
|
"company_name": "Nouvelle Entreprise Test",
|
|
"product_id": [self.product.id],
|
|
"quantity": [5]
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
# Verify the company was created
|
|
company = Company.objects.filter(name="Nouvelle Entreprise Test").first()
|
|
self.assertIsNotNone(company)
|
|
|
|
# Verify the batch has the correct company linked
|
|
batch = StockMovementBatch.objects.first()
|
|
self.assertEqual(batch.company, company)
|
|
|
|
def test_stock_movement_batch_detail_views(self):
|
|
from .models import StockMovementBatch
|
|
|
|
# Create a batch
|
|
self.client.post(
|
|
reverse("stock:new_stock_entry"),
|
|
{
|
|
"movement_subtype": "purchase",
|
|
"global_destination_location_id": self.location.id,
|
|
"company_name": "Nouvelle Entreprise Test",
|
|
"product_id": [self.product.id],
|
|
"quantity": [5]
|
|
}
|
|
)
|
|
batch = StockMovementBatch.objects.first()
|
|
self.assertIsNotNone(batch)
|
|
|
|
# Test desktop batch detail view
|
|
response = self.client.get(reverse("stock:stock_movement_batch_detail", args=[batch.pk]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "Nouvelle Entreprise Test")
|
|
|
|
# Test mobile batch detail view
|
|
response = self.client.get(reverse("mobile:stock_movement_batch_detail_mobile", kwargs={"batch_id": batch.pk}))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "Nouvelle Entreprise Test")
|
|
|
|
|
|
class MaterialLoanTests(TestCase):
|
|
def setUp(self):
|
|
from django.contrib.auth.models import User
|
|
from common.models import UserConfig, UserWarehouseAccess
|
|
from .models import Warehouse, WarehouseLocation, Product, Stock
|
|
|
|
self.warehouse = Warehouse.objects.create(code="WH-LOAN", name="Entrepôt Prêt", manager="Alice")
|
|
self.location = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Emplacement Prêt", code="LOC-LOAN")
|
|
self.product = Product.objects.create(code="PROD-LOAN", name="Produit Prêté", sku="SKU-LOAN")
|
|
self.stock = Stock.objects.create(product=self.product, location=self.location, quantity=20)
|
|
|
|
self.user = User.objects.create_superuser(username="admin_loan", password="password")
|
|
self.config = UserConfig.objects.create(user=self.user, is_intern=True)
|
|
UserWarehouseAccess.objects.create(
|
|
user_config=self.config,
|
|
warehouse=self.warehouse,
|
|
can_manage=True,
|
|
)
|
|
self.client.force_login(self.user)
|
|
|
|
def test_create_preparation_order_with_loan_info(self):
|
|
from .models import PreparationOrder
|
|
from django.utils import timezone
|
|
import datetime
|
|
|
|
estimated_date = timezone.now().date() + datetime.timedelta(days=7)
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-LOAN-01",
|
|
is_loan=True,
|
|
estimated_return_date=estimated_date,
|
|
status="to_process",
|
|
)
|
|
self.assertTrue(po.is_loan)
|
|
self.assertEqual(po.estimated_return_date, estimated_date)
|
|
self.assertFalse(po.loan_returned)
|
|
|
|
def test_process_preparation_order_copies_loan_info(self):
|
|
from .models import PreparationOrder, PreparationOrderItem, StockMovementBatch
|
|
from django.utils import timezone
|
|
import datetime
|
|
|
|
estimated_date = timezone.now().date() + datetime.timedelta(days=7)
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-LOAN-02",
|
|
is_loan=True,
|
|
estimated_return_date=estimated_date,
|
|
status="to_process",
|
|
)
|
|
item = PreparationOrderItem.objects.create(
|
|
preparation_order=po,
|
|
product=self.product,
|
|
quantity=5,
|
|
)
|
|
|
|
# Process the preparation order (GET on the process page)
|
|
response = self.client.get(reverse("stock:preparation_order_process", args=[po.pk]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
# Submit the process post form
|
|
post_data = {
|
|
"mark_as_processed": "true",
|
|
f"quantity_{item.id}": "5",
|
|
f"location_{item.id}": self.location.id,
|
|
}
|
|
response = self.client.post(reverse("stock:preparation_order_process", args=[po.pk]), post_data)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
po.refresh_from_db()
|
|
self.assertEqual(po.status, "processed")
|
|
|
|
# Verify the created batch
|
|
batch = StockMovementBatch.objects.filter(preparation_order=po).first()
|
|
self.assertIsNotNone(batch)
|
|
self.assertTrue(batch.is_loan)
|
|
self.assertEqual(batch.estimated_return_date, estimated_date)
|
|
self.assertFalse(batch.loan_returned)
|
|
self.assertEqual(batch.movement_type, StockMovementBatch.TYPE_OUT)
|
|
|
|
def test_direct_exit_with_loan_subtype(self):
|
|
from .models import StockMovementBatch
|
|
from django.utils import timezone
|
|
import datetime
|
|
|
|
return_date = timezone.now().date() + datetime.timedelta(days=5)
|
|
response = self.client.post(
|
|
reverse("stock:new_stock_exit"),
|
|
{
|
|
"movement_subtype": "loan",
|
|
"global_source_location_id": self.location.id,
|
|
"estimated_return_date": return_date.strftime("%Y-%m-%d"),
|
|
"product_id": [self.product.id],
|
|
"quantity": [3]
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
batch = StockMovementBatch.objects.filter(movement_subtype="loan").first()
|
|
self.assertIsNotNone(batch)
|
|
self.assertTrue(batch.is_loan)
|
|
self.assertEqual(batch.estimated_return_date, return_date)
|
|
self.assertFalse(batch.loan_returned)
|
|
|
|
def test_toggle_loan_return_views(self):
|
|
from .models import PreparationOrder, StockMovementBatch
|
|
from django.utils import timezone
|
|
import datetime
|
|
|
|
estimated_date = timezone.now().date() + datetime.timedelta(days=7)
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-LOAN-03",
|
|
is_loan=True,
|
|
estimated_return_date=estimated_date,
|
|
status="to_process",
|
|
)
|
|
batch = StockMovementBatch.objects.create(
|
|
preparation_order=po,
|
|
movement_type=StockMovementBatch.TYPE_OUT,
|
|
is_loan=True,
|
|
estimated_return_date=estimated_date,
|
|
loan_returned=False,
|
|
)
|
|
|
|
# 1. Toggle batch loan return to True
|
|
response = self.client.post(
|
|
reverse("stock:stock_movement_batch_toggle_loan_return", args=[batch.pk]),
|
|
{"returned": "true"}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
batch.refresh_from_db()
|
|
po.refresh_from_db()
|
|
self.assertTrue(batch.loan_returned)
|
|
self.assertTrue(po.loan_returned)
|
|
|
|
# 2. Toggle preparation order loan return to False
|
|
response = self.client.post(
|
|
reverse("stock:preparation_order_toggle_loan_return", args=[po.pk]),
|
|
{"returned": "false"}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
batch.refresh_from_db()
|
|
po.refresh_from_db()
|
|
self.assertFalse(batch.loan_returned)
|
|
self.assertFalse(po.loan_returned)
|
|
|
|
def test_update_loan_info_views(self):
|
|
from .models import PreparationOrder, StockMovementBatch
|
|
from django.utils import timezone
|
|
import datetime
|
|
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-LOAN-04",
|
|
is_loan=False,
|
|
status="to_process",
|
|
)
|
|
batch = StockMovementBatch.objects.create(
|
|
preparation_order=po,
|
|
movement_type=StockMovementBatch.TYPE_OUT,
|
|
is_loan=False,
|
|
loan_returned=False,
|
|
)
|
|
|
|
new_return_date = timezone.now().date() + datetime.timedelta(days=10)
|
|
|
|
# 1. Update preparation order loan info
|
|
response = self.client.post(
|
|
reverse("stock:preparation_order_update_loan_info", args=[po.pk]),
|
|
{
|
|
"is_loan": "on",
|
|
"estimated_return_date": new_return_date.strftime("%Y-%m-%d")
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
po.refresh_from_db()
|
|
batch.refresh_from_db()
|
|
self.assertTrue(po.is_loan)
|
|
self.assertEqual(po.estimated_return_date, new_return_date)
|
|
self.assertTrue(batch.is_loan)
|
|
self.assertEqual(batch.estimated_return_date, new_return_date)
|
|
|
|
def test_overdue_loans_alerts(self):
|
|
from .models import StockMovementBatch, StockMovement
|
|
from django.utils import timezone
|
|
import datetime
|
|
|
|
past_date = timezone.now().date() - datetime.timedelta(days=2)
|
|
|
|
# Overdue batch
|
|
overdue_batch = StockMovementBatch.objects.create(
|
|
movement_type=StockMovementBatch.TYPE_OUT,
|
|
movement_subtype="loan",
|
|
is_loan=True,
|
|
estimated_return_date=past_date,
|
|
loan_returned=False,
|
|
)
|
|
# Add a movement to make the batch active on a location
|
|
StockMovement.objects.create(
|
|
batch=overdue_batch,
|
|
product=self.product,
|
|
source_location=self.location,
|
|
quantity=5,
|
|
)
|
|
|
|
response = self.client.get(reverse("stock:stock_alerts"))
|
|
self.assertEqual(response.status_code, 200)
|
|
overdue_batches = list(response.context["overdue_batches"])
|
|
self.assertIn(overdue_batch, overdue_batches)
|
|
|
|
def test_reopen_processed_preparation_order(self):
|
|
from .models import PreparationOrder, PreparationOrderItem, Stock, StockMovementBatch
|
|
|
|
# 1. Créer un bon de préparation avec 10 unités et le traiter
|
|
self.stock.quantity = 20
|
|
self.stock.save()
|
|
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-REOPEN-01",
|
|
status="to_process",
|
|
)
|
|
item = PreparationOrderItem.objects.create(
|
|
preparation_order=po,
|
|
product=self.product,
|
|
quantity=10,
|
|
)
|
|
|
|
# Traiter le bon
|
|
response = self.client.post(
|
|
reverse("stock:preparation_order_process", args=[po.pk]),
|
|
{
|
|
"mark_as_processed": "true",
|
|
f"quantity_{item.id}": "10",
|
|
f"location_{item.id}": str(self.location.id),
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
po.refresh_from_db()
|
|
self.assertEqual(po.status, "processed")
|
|
self.stock.refresh_from_db()
|
|
self.assertEqual(self.stock.quantity, 10) # 20 - 10 = 10
|
|
|
|
# 2. Réouvrir le bon
|
|
response = self.client.post(reverse("stock:preparation_order_reopen", args=[po.pk]))
|
|
self.assertEqual(response.status_code, 302)
|
|
po.refresh_from_db()
|
|
self.assertEqual(po.status, "to_process")
|
|
|
|
# 3. Réduire la quantité de 10 à 4 (Delta = -6 -> restitution de 6 en stock)
|
|
item.quantity = 4
|
|
item.save()
|
|
|
|
# 4. Traiter à nouveau
|
|
response = self.client.post(
|
|
reverse("stock:preparation_order_process", args=[po.pk]),
|
|
{
|
|
"mark_as_processed": "true",
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
po.refresh_from_db()
|
|
self.assertEqual(po.status, "processed")
|
|
self.stock.refresh_from_db()
|
|
self.assertEqual(self.stock.quantity, 16) # 10 + 6 = 16
|
|
|
|
def test_delete_item_on_reopened_order_restores_stock(self):
|
|
from .models import PreparationOrder, PreparationOrderItem, Stock, StockMovementBatch
|
|
|
|
self.stock.quantity = 30
|
|
self.stock.save()
|
|
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-REOPEN-DEL-01",
|
|
status="to_process",
|
|
)
|
|
item = PreparationOrderItem.objects.create(
|
|
preparation_order=po,
|
|
product=self.product,
|
|
quantity=8,
|
|
)
|
|
|
|
# Traiter le bon
|
|
self.client.post(
|
|
reverse("stock:preparation_order_process", args=[po.pk]),
|
|
{
|
|
"mark_as_processed": "true",
|
|
f"quantity_{item.id}": "8",
|
|
f"location_{item.id}": str(self.location.id),
|
|
}
|
|
)
|
|
self.stock.refresh_from_db()
|
|
self.assertEqual(self.stock.quantity, 22) # 30 - 8 = 22
|
|
|
|
# Réouvrir le bon
|
|
self.client.post(reverse("stock:preparation_order_reopen", args=[po.pk]))
|
|
po.refresh_from_db()
|
|
self.assertEqual(po.status, "to_process")
|
|
|
|
# Supprimer l'article du bon
|
|
item.delete()
|
|
|
|
# Magasinier retraite le bon
|
|
response = self.client.post(
|
|
reverse("stock:preparation_order_process", args=[po.pk]),
|
|
{
|
|
"mark_as_processed": "true",
|
|
}
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
po.refresh_from_db()
|
|
self.assertEqual(po.status, "processed")
|
|
self.stock.refresh_from_db()
|
|
self.assertEqual(self.stock.quantity, 30) # Restitution intégrale des 8 unités
|
|
|
|
def test_cancel_processed_order_restores_stock(self):
|
|
from .models import PreparationOrder, PreparationOrderItem
|
|
|
|
self.stock.quantity = 50
|
|
self.stock.save()
|
|
|
|
po = PreparationOrder.objects.create(
|
|
order_number="BP-CANCEL-PROCESSED-01",
|
|
status="to_process",
|
|
)
|
|
item = PreparationOrderItem.objects.create(
|
|
preparation_order=po,
|
|
product=self.product,
|
|
quantity=15,
|
|
)
|
|
|
|
# Traiter le bon
|
|
self.client.post(
|
|
reverse("stock:preparation_order_process", args=[po.pk]),
|
|
{
|
|
"mark_as_processed": "true",
|
|
f"quantity_{item.id}": "15",
|
|
f"location_{item.id}": str(self.location.id),
|
|
}
|
|
)
|
|
self.stock.refresh_from_db()
|
|
self.assertEqual(self.stock.quantity, 35) # 50 - 15 = 35
|
|
|
|
# Annuler le bon traité
|
|
response = self.client.post(reverse("stock:preparation_order_cancel", args=[po.pk]))
|
|
self.assertEqual(response.status_code, 302)
|
|
po.refresh_from_db()
|
|
self.assertEqual(po.status, "cancelled")
|
|
self.stock.refresh_from_db()
|
|
self.assertEqual(self.stock.quantity, 50) # Restitution complète des 15 unités
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|