inventory: add tests, UX hint, and docs for return/waste devaluation (fixes #385)

Stock devaluation for return and waste movements was already implemented via valuation layers (stock lots). This change hardens and documents it:

- Add route tests: return with devaluation creates devalued lot; waste with devaluation consumes from devalued lot.

- Add API tests for POST /api/v1/inventory/movements with devalue_enabled (return and waste).

- Add discoverability hint on Record Movement form when type is Return or Waste.

- Document stock devaluation in INVENTORY_MANAGEMENT_PLAN.md (subsection 5.3) and list devaluation in movement_type.
This commit is contained in:
Dries Peeters
2026-01-30 16:50:20 +01:00
parent fe0a31c583
commit 1f75754879
4 changed files with 271 additions and 4 deletions

View File

@@ -37,6 +37,7 @@
<option value="waste">{{ _('Waste') }}</option>
<option value="devaluation">{{ _('Devaluation') }}</option>
</select>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 hidden" id="devaluation_discovery_hint">{{ _('You can apply devaluation below to record returned or wasted items at a reduced cost.') }}</p>
</div>
<div>
<label for="stock_item_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Stock Item') }} *</label>
@@ -131,12 +132,16 @@
const quantityError = document.getElementById('quantity_error');
const devaluePercent = document.getElementById('devalue_percent');
const devalueUnitCost = document.getElementById('devalue_unit_cost');
const discoveryHint = document.getElementById('devaluation_discovery_hint');
function refreshVisibility() {
const type = (movementType.value || '').toLowerCase();
const showSection = (type === 'return' || type === 'waste' || type === 'devaluation');
section.classList.toggle('hidden', !showSection);
const showDiscoveryHint = (type === 'return' || type === 'waste');
if (discoveryHint) discoveryHint.classList.toggle('hidden', !showDiscoveryHint);
if (type === 'devaluation') {
enabled.checked = true;
enabled.disabled = true;

View File

@@ -101,7 +101,7 @@ This document outlines the complete implementation plan for adding a comprehensi
**Fields**:
- `id` (Integer, Primary Key)
- `movement_type` (String(20), Required) - 'adjustment', 'transfer', 'sale', 'purchase', 'return', 'waste'
- `movement_type` (String(20), Required) - 'adjustment', 'transfer', 'sale', 'purchase', 'return', 'waste', 'devaluation'
- `stock_item_id` (Integer, ForeignKey -> stock_items.id, Required, Indexed)
- `warehouse_id` (Integer, ForeignKey -> warehouses.id, Required, Indexed) - Source/target warehouse
- `quantity` (Numeric(10, 2), Required) - Positive for additions, negative for removals
@@ -446,6 +446,22 @@ Add to `app/templates/base.html` after "Finance & Expenses" section:
- Link ExtraGood records to StockItems
- Convert ExtraGood to StockItem (migration path)
### 5.3 Stock Devaluation (Return and Waste)
Stock can be devalued when recording **return** or **waste** movements so that items are valued at a reduced cost without creating new stock items. Valuation is handled via **stock lots** (valuation layers), not by creating separate items.
1. **Return with devaluation**
- When recording a **return** (positive quantity, items coming back), you can enable **Apply devaluation** and set a new unit cost (percent off default cost or a fixed amount).
- The returned quantity is booked into a new lot with `lot_type="devalued"` at that cost.
- Use this when items return after a period (e.g. from rent or repair) and should be carried at a lower value.
2. **Waste with devaluation**
- When recording **waste** (negative quantity, items written off), you can enable **Apply devaluation** so the write-off is valued at a reduced cost.
- The system first revalues that quantity into a devalued lot (FIFO consume from existing lots, create a devalued lot at the new cost), then records the waste movement consuming from that devalued lot.
- Use this when writing off damaged or obsolete stock at a lower value for accounting.
**Requirements**: The stock item must be **trackable** and have a **default cost** set. Devaluation options are available on the Record Movement form when movement type is Return, Waste, or Devaluation (standalone revaluation of quantity in place).
---
## 6. Permissions

View File

@@ -0,0 +1,154 @@
"""Tests for API v1 inventory movements (including return/waste with devaluation)."""
import json
import pytest
from decimal import Decimal
from flask import url_for
from app import db
from app.models import (
User,
ApiToken,
Warehouse,
StockItem,
WarehouseStock,
StockMovement,
StockLot,
)
@pytest.fixture
def api_token(db_session, test_user):
"""Create an API token with write:projects (used for inventory movements)."""
token, plain_token = ApiToken.create_token(
user_id=test_user.id,
name="Inventory Test Token",
description="For inventory API tests",
scopes="read:projects,write:projects",
)
db.session.add(token)
db.session.commit()
return plain_token
@pytest.fixture
def test_warehouse(db_session, test_user):
"""Create a test warehouse."""
warehouse = Warehouse(name="API Test Warehouse", code="WH-API", created_by=test_user.id)
db.session.add(warehouse)
db.session.commit()
return warehouse
@pytest.fixture
def test_stock_item_trackable(db_session, test_user):
"""Create a trackable stock item with default_cost for devaluation tests."""
item = StockItem(
sku="API-TEST-001",
name="API Test Product",
created_by=test_user.id,
default_price=Decimal("10.00"),
default_cost=Decimal("5.00"),
is_trackable=True,
)
db.session.add(item)
db.session.commit()
return item
class TestInventoryMovementsAPI:
"""Test POST /api/v1/inventory/movements with return/waste devaluation."""
def test_create_return_with_devaluation(
self, client, test_user, api_token, test_stock_item_trackable, test_warehouse
):
"""POST return movement with devalue_enabled creates a devalued lot."""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
payload = {
"movement_type": "return",
"stock_item_id": test_stock_item_trackable.id,
"warehouse_id": test_warehouse.id,
"quantity": "5.00",
"devalue_enabled": True,
"devalue_method": "fixed",
"devalue_unit_cost": "2.50",
"reason": "Returned with damage (API)",
}
response = client.post("/api/v1/inventory/movements", data=json.dumps(payload), headers=headers)
assert response.status_code == 201
data = response.get_json()
assert "message" in data
assert "movement" in data
assert data["movement"]["movement_type"] == "return"
assert float(data["movement"]["quantity"]) == 5.0
stock = WarehouseStock.query.filter_by(
warehouse_id=test_warehouse.id, stock_item_id=test_stock_item_trackable.id
).first()
assert stock is not None
assert stock.quantity_on_hand == Decimal("5.00")
lots = StockLot.query.filter_by(
stock_item_id=test_stock_item_trackable.id, warehouse_id=test_warehouse.id
).all()
assert len(lots) >= 1
devalued = [l for l in lots if l.lot_type == "devalued"]
assert len(devalued) >= 1
assert any(Decimal(str(l.quantity_on_hand)) == Decimal("5.00") for l in devalued)
assert any(Decimal(str(l.unit_cost)) == Decimal("2.50") for l in devalued)
def test_create_waste_with_devaluation(
self, client, test_user, api_token, test_stock_item_trackable, test_warehouse
):
"""POST waste movement with devalue_enabled consumes from a devalued lot."""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
# Create stock first via model (no API for simple purchase in this test)
StockMovement.record_movement(
movement_type="purchase",
stock_item_id=test_stock_item_trackable.id,
warehouse_id=test_warehouse.id,
quantity=Decimal("10.00"),
moved_by=test_user.id,
unit_cost=Decimal("5.00"),
update_stock=True,
)
db.session.commit()
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
payload = {
"movement_type": "waste",
"stock_item_id": test_stock_item_trackable.id,
"warehouse_id": test_warehouse.id,
"quantity": "-4.00",
"devalue_enabled": True,
"devalue_method": "fixed",
"devalue_unit_cost": "1.00",
"reason": "Wasted impaired items (API)",
}
response = client.post("/api/v1/inventory/movements", data=json.dumps(payload), headers=headers)
assert response.status_code == 201
data = response.get_json()
assert "message" in data
assert "movement" in data
assert data["movement"]["movement_type"] == "waste"
assert float(data["movement"]["quantity"]) == -4.0
stock = WarehouseStock.query.filter_by(
warehouse_id=test_warehouse.id, stock_item_id=test_stock_item_trackable.id
).first()
assert stock is not None
assert stock.quantity_on_hand == Decimal("6.00")
lots = StockLot.query.filter_by(
stock_item_id=test_stock_item_trackable.id, warehouse_id=test_warehouse.id
).all()
devalued = [l for l in lots if l.lot_type == "devalued"]
assert any(Decimal(str(l.quantity_on_hand)) == Decimal("0.00") for l in devalued)

View File

@@ -4,7 +4,7 @@ import pytest
from decimal import Decimal
from flask import url_for
from app import db
from app.models import Warehouse, StockItem, WarehouseStock, User
from app.models import Warehouse, StockItem, WarehouseStock, StockMovement, StockLot, User
@pytest.fixture
@@ -28,8 +28,15 @@ def test_warehouse(db_session, test_user):
@pytest.fixture
def test_stock_item(db_session, test_user):
"""Create a test stock item"""
item = StockItem(sku="TEST-001", name="Test Product", created_by=test_user.id, default_price=Decimal("10.00"))
"""Create a test stock item (trackable with default_cost for devaluation tests)."""
item = StockItem(
sku="TEST-001",
name="Test Product",
created_by=test_user.id,
default_price=Decimal("10.00"),
default_cost=Decimal("5.00"),
is_trackable=True,
)
db_session.add(item)
db_session.commit()
return item
@@ -199,3 +206,88 @@ class TestStockMovementsRoutes:
stock = WarehouseStock.query.filter_by(warehouse_id=test_warehouse.id, stock_item_id=test_stock_item.id).first()
assert stock is not None
assert stock.quantity_on_hand == Decimal("50.00")
def test_create_return_with_devaluation(self, client, test_user, test_stock_item, test_warehouse):
"""Test recording a return with devaluation creates a devalued lot."""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
response = client.post(
url_for("inventory.new_movement"),
data={
"movement_type": "return",
"stock_item_id": test_stock_item.id,
"warehouse_id": test_warehouse.id,
"quantity": "5.00",
"devalue_enabled": "on",
"devalue_method": "fixed",
"devalue_unit_cost": "2.50",
"reason": "Returned with damage",
},
follow_redirects=True,
)
assert response.status_code == 200
assert b"devaluation" in response.data.lower() or b"success" in response.data.lower()
stock = WarehouseStock.query.filter_by(
warehouse_id=test_warehouse.id, stock_item_id=test_stock_item.id
).first()
assert stock is not None
assert stock.quantity_on_hand == Decimal("5.00")
lots = StockLot.query.filter_by(
stock_item_id=test_stock_item.id, warehouse_id=test_warehouse.id
).all()
assert len(lots) >= 1
devalued = [l for l in lots if l.lot_type == "devalued"]
assert len(devalued) >= 1
assert any(Decimal(str(l.quantity_on_hand)) == Decimal("5.00") for l in devalued)
assert any(Decimal(str(l.unit_cost)) == Decimal("2.50") for l in devalued)
def test_create_waste_with_devaluation(self, client, test_user, test_stock_item, test_warehouse):
"""Test recording waste with devaluation consumes from a devalued lot."""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
# Create stock first via purchase
StockMovement.record_movement(
movement_type="purchase",
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal("10.00"),
moved_by=test_user.id,
unit_cost=Decimal("5.00"),
update_stock=True,
)
db.session.commit()
response = client.post(
url_for("inventory.new_movement"),
data={
"movement_type": "waste",
"stock_item_id": test_stock_item.id,
"warehouse_id": test_warehouse.id,
"quantity": "-4.00",
"devalue_enabled": "on",
"devalue_method": "fixed",
"devalue_unit_cost": "1.00",
"reason": "Wasted impaired items",
},
follow_redirects=True,
)
assert response.status_code == 200
assert b"devaluation" in response.data.lower() or b"success" in response.data.lower()
stock = WarehouseStock.query.filter_by(
warehouse_id=test_warehouse.id, stock_item_id=test_stock_item.id
).first()
assert stock is not None
assert stock.quantity_on_hand == Decimal("6.00")
lots = StockLot.query.filter_by(
stock_item_id=test_stock_item.id, warehouse_id=test_warehouse.id
).all()
devalued = [l for l in lots if l.lot_type == "devalued"]
assert any(Decimal(str(l.quantity_on_hand)) == Decimal("0.00") for l in devalued)