Files
TimeTracker/tests/test_integration/test_inventory_integration.py
T
Dries Peeters 974f5cdd50 feat(quotes): invoice-style line items, costs, and extra goods (#585)
Add quote_items.line_kind (item | expense | good) plus display_name,
category, line_date, and sku so one table still drives totals, PDFs,
and acceptance stock logic.

- Migration 147: new columns with backfill line_kind=item
- Quote create/edit: three sections; stock and warehouse only when a
  line item is sourced from inventory; shared JS partial
- POST parsing, positions, and duplicate-quote copying for all fields
- API v1 quote items accept the new fields with defaults for old clients
- View, client portal, and PDF/fallback rendering use display_name and
  line metadata where relevant
- Integration tests: stock POST shape; expense and good line creation

Docs: extend INVENTORY_MANAGEMENT_PLAN QuoteItem and migration notes.
2026-04-12 14:00:12 +02:00

421 lines
14 KiB
Python

"""Integration tests for inventory with quotes and invoices"""
import pytest
pytestmark = [pytest.mark.integration]
from datetime import datetime, timedelta
from decimal import Decimal
from flask import url_for
from app import db
from app.models import (
Warehouse,
StockItem,
WarehouseStock,
StockReservation,
StockMovement,
Quote,
QuoteItem,
Invoice,
InvoiceItem,
Project,
Client,
User,
)
@pytest.fixture
def test_user(db_session):
"""Create a test user"""
user = User(username="testuser", role="admin")
user.set_password("testpass")
db_session.add(user)
db_session.commit()
return user
@pytest.fixture
def test_client(db_session):
"""Create a test client"""
client = Client(name="Test Client", email="test@client.com")
db_session.add(client)
db_session.commit()
return client
@pytest.fixture
def test_warehouse(db_session, test_user):
"""Create a test warehouse"""
warehouse = Warehouse(name="Main Warehouse", code="WH-001", created_by=test_user.id)
db_session.add(warehouse)
db_session.commit()
return warehouse
@pytest.fixture
def test_stock_item(db_session, test_user):
"""Create a test stock item with stock"""
item = StockItem(
sku="PROD-001",
name="Test Product",
created_by=test_user.id,
default_price=Decimal("25.00"),
default_cost=Decimal("10.00"),
is_trackable=True,
)
db_session.add(item)
db_session.commit()
return item
@pytest.fixture
def test_stock_with_quantity(db_session, test_stock_item, test_warehouse):
"""Create stock with quantity"""
stock = WarehouseStock(
warehouse_id=test_warehouse.id, stock_item_id=test_stock_item.id, quantity_on_hand=Decimal("100.00")
)
db_session.add(stock)
db_session.commit()
return stock
class TestQuoteInventoryIntegration:
"""Test inventory integration with quotes"""
def test_quote_with_stock_item(
self, client, test_user, test_client, test_stock_item, test_warehouse, test_stock_with_quantity
):
"""Test creating a quote with a stock item"""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
# Create quote with stock item
response = client.post(
url_for("quotes.create_quote"),
data={
"client_id": test_client.id,
"title": "Test Quote",
"tax_rate": "0",
"currency_code": "EUR",
"item_description[]": ["Test Product"],
"item_quantity[]": ["5"],
"item_price[]": ["25.00"],
"item_unit[]": ["pcs"],
"item_line_source[]": ["stock"],
"item_stock_item_id[]": [str(test_stock_item.id)],
"item_warehouse_id[]": [str(test_warehouse.id)],
},
follow_redirects=True,
)
assert response.status_code == 200
# Check quote was created
quote = Quote.query.filter_by(title="Test Quote").first()
assert quote is not None
# Check quote item has stock_item_id
assert len(quote.items) >= 1
quote_item = quote.items[0]
assert quote_item is not None
assert quote_item.stock_item_id == test_stock_item.id
assert quote_item.warehouse_id == test_warehouse.id
assert quote_item.is_stock_item is True
def test_quote_create_expense_and_good_lines(self, client, test_user, test_client):
"""Quote form posts costs + extra goods as separate line_kind rows (#585)."""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
response = client.post(
url_for("quotes.create_quote"),
data={
"client_id": test_client.id,
"title": "Mixed quote lines",
"tax_rate": "0",
"currency_code": "EUR",
"qe_title[]": ["Trip"],
"qe_description[]": ["Client visit"],
"qe_category[]": ["travel"],
"qe_amount[]": ["99.50"],
"qe_date[]": ["2026-04-01"],
"qg_name[]": ["License pack"],
"qg_description[]": [""],
"qg_category[]": ["license"],
"qg_quantity[]": ["2"],
"qg_unit_price[]": ["25"],
"qg_sku[]": ["L-1"],
},
follow_redirects=True,
)
assert response.status_code == 200
quote = Quote.query.filter_by(title="Mixed quote lines").first()
assert quote is not None
kinds = {i.line_kind for i in quote.items}
assert "expense" in kinds
assert "good" in kinds
exp = next(i for i in quote.items if i.line_kind == "expense")
assert exp.display_name == "Trip"
assert exp.unit_price == Decimal("99.50")
assert exp.quantity == Decimal("1")
good = next(i for i in quote.items if i.line_kind == "good")
assert good.display_name == "License pack"
assert good.sku == "L-1"
assert good.total_amount == Decimal("50.00")
def test_quote_send_reserves_stock(
self, client, test_user, test_client, test_stock_item, test_warehouse, test_stock_with_quantity
):
"""Test that sending a quote reserves stock (if enabled)"""
import os
os.environ["INVENTORY_AUTO_RESERVE_ON_QUOTE_SENT"] = "true"
# Create quote with stock item
quote = Quote(
quote_number="QUO-TEST-001", client_id=test_client.id, title="Test Quote", created_by=test_user.id
)
db.session.add(quote)
db.session.flush()
quote_item = QuoteItem(
quote_id=quote.id,
description="Test Product",
quantity=Decimal("10.00"),
unit_price=Decimal("25.00"),
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
)
db.session.add(quote_item)
db.session.commit()
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
# Send quote
response = client.post(url_for("quotes.send_quote", quote_id=quote.id), follow_redirects=True)
# Check if reservation was created
reservation = StockReservation.query.filter_by(reservation_type="quote", reservation_id=quote.id).first()
# Note: Reservation only created if INVENTORY_AUTO_RESERVE_ON_QUOTE_SENT is true
# This test verifies the integration point exists
assert quote.status == "sent"
class TestInvoiceInventoryIntegration:
"""Test inventory integration with invoices"""
def test_invoice_with_stock_item(
self, client, test_user, test_client, test_stock_item, test_warehouse, test_stock_with_quantity
):
"""Test creating an invoice with a stock item"""
# Create project
project = Project(name="Test Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
# Create invoice
invoice = Invoice(
invoice_number="INV-TEST-001",
project_id=project.id,
client_name=test_client.name,
client_id=test_client.id,
due_date=datetime.utcnow().date() + timedelta(days=30),
created_by=test_user.id,
)
db.session.add(invoice)
db.session.flush()
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
# Edit invoice to add stock item
response = client.post(
url_for("invoices.edit_invoice", invoice_id=invoice.id),
data={
"client_name": test_client.name,
"due_date": (datetime.utcnow().date() + timedelta(days=30)).strftime("%Y-%m-%d"),
"tax_rate": "0",
"description[]": ["Test Product"],
"quantity[]": ["5"],
"unit_price[]": ["25.00"],
"item_stock_item_id[]": [str(test_stock_item.id)],
"item_warehouse_id[]": [str(test_warehouse.id)],
},
follow_redirects=True,
)
assert response.status_code == 200
# Check invoice item has stock_item_id
invoice_item = invoice.items.first()
if invoice_item:
assert invoice_item.stock_item_id == test_stock_item.id
assert invoice_item.is_stock_item is True
def test_invoice_sent_reduces_stock(
self, client, test_user, test_client, test_stock_item, test_warehouse, test_stock_with_quantity
):
"""Test that marking invoice as sent reduces stock (if configured)"""
import os
os.environ["INVENTORY_REDUCE_ON_INVOICE_SENT"] = "true"
# Create project and invoice
project = Project(name="Test Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
invoice = Invoice(
invoice_number="INV-TEST-002",
project_id=project.id,
client_name=test_client.name,
client_id=test_client.id,
due_date=datetime.utcnow().date() + timedelta(days=30),
created_by=test_user.id,
status="draft",
)
db.session.add(invoice)
db.session.flush()
invoice_item = InvoiceItem(
invoice_id=invoice.id,
description="Test Product",
quantity=Decimal("10.00"),
unit_price=Decimal("25.00"),
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
)
db.session.add(invoice_item)
db.session.commit()
initial_stock = test_stock_with_quantity.quantity_on_hand
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
# Mark invoice as sent
response = client.post(
url_for("invoices.update_invoice_status", invoice_id=invoice.id),
data={"new_status": "sent"},
follow_redirects=False,
)
# Check if stock was reduced
db.session.refresh(test_stock_with_quantity)
# Stock should be reduced if INVENTORY_REDUCE_ON_INVOICE_SENT is true
# This test verifies the integration point exists
assert invoice.status == "sent" or response.status_code in [200, 302]
def test_invoice_sent_twice_does_not_double_reduce_stock(
self, client, test_user, test_client, test_stock_item, test_warehouse, test_stock_with_quantity
):
"""Sending an already-sent invoice should not create extra sale movement."""
import os
os.environ["INVENTORY_REDUCE_ON_INVOICE_SENT"] = "true"
project = Project(name="Idempotency Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
invoice = Invoice(
invoice_number="INV-TEST-IDEMPOTENT",
project_id=project.id,
client_name=test_client.name,
client_id=test_client.id,
due_date=datetime.utcnow().date() + timedelta(days=30),
created_by=test_user.id,
status="draft",
)
db.session.add(invoice)
db.session.flush()
db.session.add(
InvoiceItem(
invoice_id=invoice.id,
description="Test Product",
quantity=Decimal("2.00"),
unit_price=Decimal("25.00"),
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
)
)
db.session.commit()
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
response_first = client.post(
url_for("invoices.update_invoice_status", invoice_id=invoice.id),
data={"new_status": "sent"},
follow_redirects=False,
)
assert response_first.status_code == 200
first_count = StockMovement.query.filter_by(reference_type="invoice", reference_id=invoice.id).count()
response_second = client.post(
url_for("invoices.update_invoice_status", invoice_id=invoice.id),
data={"new_status": "sent"},
follow_redirects=False,
)
assert response_second.status_code == 200
second_count = StockMovement.query.filter_by(reference_type="invoice", reference_id=invoice.id).count()
assert second_count == first_count
class TestStockReservationLifecycle:
"""Test stock reservation lifecycle"""
def test_reservation_fulfillment(
self, db_session, test_user, test_stock_item, test_warehouse, test_stock_with_quantity
):
"""Test reservation fulfillment flow"""
# Create reservation
reservation, updated_stock = StockReservation.create_reservation(
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal("20.00"),
reservation_type="invoice",
reservation_id=1,
reserved_by=test_user.id,
)
db_session.commit()
initial_reserved = updated_stock.quantity_reserved
# Fulfill reservation
reservation.fulfill()
db_session.commit()
db_session.refresh(updated_stock)
assert updated_stock.quantity_reserved < initial_reserved
assert reservation.status == "fulfilled"
def test_reservation_cancellation(
self, db_session, test_user, test_stock_item, test_warehouse, test_stock_with_quantity
):
"""Test reservation cancellation flow"""
# Create reservation
reservation, updated_stock = StockReservation.create_reservation(
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal("15.00"),
reservation_type="quote",
reservation_id=1,
reserved_by=test_user.id,
)
db_session.commit()
initial_reserved = updated_stock.quantity_reserved
# Cancel reservation
reservation.cancel()
db_session.commit()
db_session.refresh(updated_stock)
assert updated_stock.quantity_reserved < initial_reserved
assert reservation.status == "cancelled"