mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-12 23:28:48 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
154
tests/test_api_v1_inventory_movements.py
Normal file
154
tests/test_api_v1_inventory_movements.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user