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)