loko/streetup/stock/tests.py

1773 lines
71 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
class PreparationOrderAutocompleteTests(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
from interventions.models import Intervention
from stock.models import PreparationOrder
User = get_user_model()
self.user = User.objects.create_user(username="testuser_auto", password="password123")
self.client.login(username="testuser_auto", password="password123")
self.intervention = Intervention.objects.create(
code="INT-2026-9999",
title="Réparation Panneau Signalisation",
)
self.po = PreparationOrder.objects.create(
order_number="BP-AUTO-001",
intervention=self.intervention,
status="to_process",
)
def test_autocomplete_search_by_intervention_code(self):
url = reverse("stock:preparation_orders_autocomplete_api")
response = self.client.get(url, {"q": "INT-2026-9999"})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["results"]), 1)
self.assertEqual(data["results"][0]["id"], self.po.pk)
self.assertIn("INT-2026-9999", data["results"][0]["text"])
def test_autocomplete_search_by_bp_number(self):
url = reverse("stock:preparation_orders_autocomplete_api")
response = self.client.get(url, {"q": "BP-AUTO-001"})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["results"]), 1)
self.assertEqual(data["results"][0]["id"], self.po.pk)
self.assertIn("INT-2026-9999", data["results"][0]["text"])
def test_full_return_status_display_on_processed_order(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem, StockMovementBatch, StockMovement
prod = Product.objects.create(name="Panneau Restitué", code="PROD-RET-1", total_stock=10)
po = PreparationOrder.objects.create(order_number="BP-RET-100", status="processed")
PreparationOrderItem.objects.create(preparation_order=po, product=prod, quantity=2)
# Mouvement de sortie (out)
b_out = StockMovementBatch.objects.create(movement_type="out", preparation_order=po)
StockMovement.objects.create(batch=b_out, product=prod, quantity=2)
# Mouvement de retour complet (in)
b_in = StockMovementBatch.objects.create(movement_type="in", preparation_order=po)
StockMovement.objects.create(batch=b_in, product=prod, quantity=2)
response = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Restitué au stock")
self.assertNotContains(response, "à prélever")
def test_create_return_button_and_prefilled_entry_form(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem
from common.models import UserConfig, Role
self.user.is_superuser = True
self.user.save()
user_cfg, _ = UserConfig.objects.get_or_create(user=self.user)
admin_role, _ = Role.objects.get_or_create(name='admin')
user_cfg.roles.add(admin_role)
prod = Product.objects.create(name="Produit Retour Test", code="PROD-RET-2", total_stock=5)
po = PreparationOrder.objects.create(order_number="BP-RET-200", status="processed")
PreparationOrderItem.objects.create(preparation_order=po, product=prod, quantity=3)
# 1. Vérifier la présence du bouton 'Créer un retour' sur la page détail du BP
response_detail = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
self.assertEqual(response_detail.status_code, 200)
self.assertContains(response_detail, f"preparation_order={po.pk}")
self.assertContains(response_detail, "Créer un retour")
# 2. Accéder à la page de création d'entrée de stock avec la QueryParam preparation_order
url_entry = reverse("stock:new_stock_entry") + f"?preparation_order={po.pk}"
response_entry = self.client.get(url_entry)
self.assertEqual(response_entry.status_code, 200)
self.assertContains(response_entry, "BP-RET-200")
def test_unique_location_return_prefilled_in_api(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem, StockMovementBatch, StockMovement, Warehouse, WarehouseLocation
wh = Warehouse.objects.create(name="Entrepôt Unique", code="WH-U")
loc = WarehouseLocation.objects.create(warehouse=wh, name="Rayon A", code="LOC-RA")
prod = Product.objects.create(name="Produit Emplacement Unique", code="PROD-LOC-U", total_stock=10)
po = PreparationOrder.objects.create(order_number="BP-LOC-100", status="processed")
PreparationOrderItem.objects.create(preparation_order=po, product=prod, quantity=5)
# Mouvement de sortie depuis loc
b_out = StockMovementBatch.objects.create(movement_type="out", preparation_order=po)
StockMovement.objects.create(batch=b_out, product=prod, quantity=5, source_location=loc)
# Vérifier que get_default_return_location_id renvoie loc.id
self.assertEqual(po.get_default_return_location_id(), loc.id)
# Vérifier que l'API preparation_order_items_api renvoie default_location_id
url = reverse("stock:preparation_order_items_api", args=[po.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["default_location_id"], loc.id)