feat: Complete inventory management system implementation

Add comprehensive inventory management system with full feature set including
stock tracking, warehouse management, supplier management, purchase orders,
transfers, adjustments, and reporting.

Core Features:
- Stock Items: Full CRUD operations with categories, SKU, barcodes, pricing
- Warehouse Management: Multi-warehouse support with stock level tracking
- Supplier Management: Multi-supplier support with supplier-specific pricing
- Purchase Orders: Complete PO lifecycle (draft, sent, received, cancelled)
- Stock Transfers: Transfer stock between warehouses with audit trail
- Stock Adjustments: Dedicated interface for stock corrections
- Stock Reservations: Reserve stock for quotes/invoices/projects
- Movement History: Complete audit trail for all stock movements
- Low Stock Alerts: Automated alerts when items fall below reorder point

Reports & Analytics:
- Inventory Dashboard: Overview with key metrics and statistics
- Stock Valuation: Calculate total inventory value by warehouse/category
- Movement History Report: Detailed movement log with filters
- Turnover Analysis: Inventory turnover rates and sales analysis
- Low Stock Report: Comprehensive low stock items listing

Integration:
- Quote Integration: Stock reservation when quotes are created
- Invoice Integration: Automatic stock reduction on invoice payment
- Project Integration: Stock allocation for project requirements
- API Endpoints: RESTful API for suppliers, purchase orders, and inventory

Technical Implementation:
- 9 new database models with proper relationships
- 3 Alembic migrations for schema changes
- 60+ new routes for inventory management
- 20+ templates for all inventory features
- Comprehensive permission system integration
- CSRF protection on all forms
- Full menu navigation integration

Testing:
- Unit tests for inventory models
- Route tests for inventory endpoints
- Integration tests for quote/invoice stock integration

Documentation:
- Implementation plan document
- Missing features analysis
- Implementation status tracking
This commit is contained in:
Dries Peeters
2025-11-23 18:39:22 +01:00
parent f184020c1e
commit 73dfeecbaa
63 changed files with 10034 additions and 55 deletions

View File

@@ -872,6 +872,7 @@ def create_app(config=None):
from app.routes.webhooks import webhooks_bp
from app.routes.client_portal import client_portal_bp
from app.routes.quotes import quotes_bp
from app.routes.inventory import inventory_bp
try:
from app.routes.audit_logs import audit_logs_bp
app.register_blueprint(audit_logs_bp)
@@ -918,6 +919,7 @@ def create_app(config=None):
app.register_blueprint(import_export_bp)
app.register_blueprint(webhooks_bp)
app.register_blueprint(quotes_bp)
app.register_blueprint(inventory_bp)
# audit_logs_bp is registered above with error handling
# Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens)

View File

@@ -43,6 +43,15 @@ from .quote import Quote, QuoteItem, QuotePDFTemplate
from .quote_attachment import QuoteAttachment
from .quote_template import QuoteTemplate
from .quote_version import QuoteVersion
from .warehouse import Warehouse
from .stock_item import StockItem
from .warehouse_stock import WarehouseStock
from .stock_movement import StockMovement
from .stock_reservation import StockReservation
from .project_stock_allocation import ProjectStockAllocation
from .supplier import Supplier
from .supplier_stock_item import SupplierStockItem
from .purchase_order import PurchaseOrder, PurchaseOrderItem
__all__ = [
"User",
@@ -96,4 +105,14 @@ __all__ = [
"QuoteAttachment",
"QuoteTemplate",
"QuoteVersion",
"Warehouse",
"StockItem",
"WarehouseStock",
"StockMovement",
"StockReservation",
"ProjectStockAllocation",
"Supplier",
"SupplierStockItem",
"PurchaseOrder",
"PurchaseOrderItem",
]

View File

@@ -29,8 +29,14 @@ class ExtraGood(db.Model):
billable = db.Column(db.Boolean, default=True, nullable=False)
sku = db.Column(db.String(100), nullable=True) # Stock Keeping Unit / Product Code
# Inventory integration
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True)
# Metadata
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# Relationships
stock_item = db.relationship('StockItem', foreign_keys=[stock_item_id], lazy='joined')
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -40,7 +46,7 @@ class ExtraGood(db.Model):
def __init__(self, name, unit_price, quantity=1, created_by=None, project_id=None,
invoice_id=None, description=None, category='product', billable=True,
sku=None, currency_code='EUR'):
sku=None, currency_code='EUR', stock_item_id=None):
"""Initialize an ExtraGood instance.
Args:
@@ -65,6 +71,7 @@ class ExtraGood(db.Model):
self.currency_code = currency_code
self.billable = billable
self.sku = sku.strip() if sku else None
self.stock_item_id = stock_item_id
self.created_by = created_by
self.project_id = project_id
self.invoice_id = invoice_id
@@ -92,6 +99,7 @@ class ExtraGood(db.Model):
'currency_code': self.currency_code,
'billable': self.billable,
'sku': self.sku,
'stock_item_id': self.stock_item_id,
'created_by': self.created_by,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,

View File

@@ -313,16 +313,28 @@ class InvoiceItem(db.Model):
# Time entry reference (optional)
time_entry_ids = db.Column(db.String(500), nullable=True) # Comma-separated IDs
# Inventory integration
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=True)
is_stock_item = db.Column(db.Boolean, default=False, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
def __init__(self, invoice_id, description, quantity, unit_price, time_entry_ids=None):
# Relationships
stock_item = db.relationship('StockItem', foreign_keys=[stock_item_id], lazy='joined')
warehouse = db.relationship('Warehouse', foreign_keys=[warehouse_id], lazy='joined')
def __init__(self, invoice_id, description, quantity, unit_price, time_entry_ids=None, stock_item_id=None, warehouse_id=None):
self.invoice_id = invoice_id
self.description = description
self.quantity = Decimal(str(quantity))
self.unit_price = Decimal(str(unit_price))
self.total_amount = self.quantity * self.unit_price
self.time_entry_ids = time_entry_ids
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.is_stock_item = stock_item_id is not None
def __repr__(self):
return f'<InvoiceItem {self.description} ({self.quantity}h @ {self.unit_price})>'
@@ -337,5 +349,8 @@ class InvoiceItem(db.Model):
'unit_price': float(self.unit_price),
'total_amount': float(self.total_amount),
'time_entry_ids': self.time_entry_ids,
'stock_item_id': self.stock_item_id,
'warehouse_id': self.warehouse_id,
'is_stock_item': self.is_stock_item,
'created_at': self.created_at.isoformat() if self.created_at else None
}

View File

@@ -0,0 +1,66 @@
"""ProjectStockAllocation model for tracking stock allocated to projects"""
from datetime import datetime
from decimal import Decimal
from app import db
class ProjectStockAllocation(db.Model):
"""ProjectStockAllocation model - tracks stock items allocated to projects"""
__tablename__ = 'project_stock_allocations'
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False, index=True)
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id', ondelete='CASCADE'), nullable=False, index=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=False, index=True)
quantity_allocated = db.Column(db.Numeric(10, 2), nullable=False)
quantity_used = db.Column(db.Numeric(10, 2), nullable=False, default=0)
allocated_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
allocated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
notes = db.Column(db.Text, nullable=True)
# Relationships
project = db.relationship('Project', backref='stock_allocations')
stock_item = db.relationship('StockItem', backref='project_allocations')
warehouse = db.relationship('Warehouse', backref='project_allocations')
allocated_by_user = db.relationship('User', foreign_keys=[allocated_by])
def __init__(self, project_id, stock_item_id, warehouse_id, quantity_allocated, allocated_by, notes=None):
self.project_id = project_id
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.quantity_allocated = Decimal(str(quantity_allocated))
self.allocated_by = allocated_by
self.quantity_used = Decimal('0')
self.notes = notes.strip() if notes else None
def __repr__(self):
return f'<ProjectStockAllocation {self.project_id}/{self.stock_item_id}: {self.quantity_allocated}>'
@property
def quantity_remaining(self):
"""Calculate remaining allocated quantity"""
return self.quantity_allocated - self.quantity_used
def record_usage(self, quantity):
"""Record usage of allocated stock"""
qty = Decimal(str(quantity))
if qty > self.quantity_remaining:
raise ValueError(f"Cannot use more than allocated. Remaining: {self.quantity_remaining}, Requested: {qty}")
self.quantity_used += qty
def to_dict(self):
"""Convert project stock allocation to dictionary"""
return {
'id': self.id,
'project_id': self.project_id,
'stock_item_id': self.stock_item_id,
'warehouse_id': self.warehouse_id,
'quantity_allocated': float(self.quantity_allocated),
'quantity_used': float(self.quantity_used),
'quantity_remaining': float(self.quantity_remaining),
'allocated_by': self.allocated_by,
'allocated_at': self.allocated_at.isoformat() if self.allocated_at else None,
'notes': self.notes
}

View File

@@ -0,0 +1,198 @@
"""Purchase Order models for inventory management"""
from datetime import datetime
from decimal import Decimal
from app import db
class PurchaseOrder(db.Model):
"""PurchaseOrder model - represents a purchase order to a supplier"""
__tablename__ = 'purchase_orders'
id = db.Column(db.Integer, primary_key=True)
po_number = db.Column(db.String(50), unique=True, nullable=False, index=True)
supplier_id = db.Column(db.Integer, db.ForeignKey('suppliers.id'), nullable=False, index=True)
status = db.Column(db.String(20), default='draft', nullable=False, index=True) # draft, sent, confirmed, received, cancelled
order_date = db.Column(db.Date, nullable=False, index=True)
expected_delivery_date = db.Column(db.Date, nullable=True)
received_date = db.Column(db.Date, nullable=True)
# Financial
subtotal = db.Column(db.Numeric(10, 2), nullable=False, default=0)
tax_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
shipping_cost = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Metadata
notes = db.Column(db.Text, nullable=True)
internal_notes = db.Column(db.Text, nullable=True) # Not visible to supplier
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# Relationships
items = db.relationship('PurchaseOrderItem', backref='purchase_order', lazy='dynamic', cascade='all, delete-orphan')
def __init__(self, po_number, supplier_id, order_date, created_by, expected_delivery_date=None,
notes=None, internal_notes=None, currency_code='EUR'):
self.po_number = po_number.strip().upper()
self.supplier_id = supplier_id
self.order_date = order_date
self.expected_delivery_date = expected_delivery_date
self.created_by = created_by
self.notes = notes.strip() if notes else None
self.internal_notes = internal_notes.strip() if internal_notes else None
self.currency_code = currency_code.upper()
self.status = 'draft'
self.subtotal = Decimal('0')
self.tax_amount = Decimal('0')
self.shipping_cost = Decimal('0')
self.total_amount = Decimal('0')
def __repr__(self):
return f'<PurchaseOrder {self.po_number} ({self.status})>'
def calculate_totals(self):
"""Calculate subtotal, tax, and total from items"""
self.subtotal = sum(item.line_total for item in self.items)
# Tax calculation can be added later if needed
self.total_amount = self.subtotal + self.tax_amount + self.shipping_cost
self.updated_at = datetime.utcnow()
def mark_as_sent(self):
"""Mark purchase order as sent to supplier"""
if self.status == 'draft':
self.status = 'sent'
self.updated_at = datetime.utcnow()
def mark_as_received(self, received_date=None):
"""Mark purchase order as received"""
# Allow receiving from draft, sent, or confirmed status
if self.status not in ['received', 'cancelled']:
self.status = 'received'
self.received_date = received_date or datetime.utcnow().date()
self.updated_at = datetime.utcnow()
# Create stock movements for received items
for item in self.items:
if item.stock_item_id and item.quantity_received and item.quantity_received > 0:
from .stock_movement import StockMovement
# Use warehouse from item, or get first active warehouse
warehouse_id = item.warehouse_id
if not warehouse_id:
from .warehouse import Warehouse
first_warehouse = Warehouse.query.filter_by(is_active=True).first()
warehouse_id = first_warehouse.id if first_warehouse else None
if warehouse_id:
StockMovement.record_movement(
movement_type='purchase',
stock_item_id=item.stock_item_id,
warehouse_id=warehouse_id,
quantity=item.quantity_received,
moved_by=self.created_by,
reason=f'Purchase Order {self.po_number}',
reference_type='purchase_order',
reference_id=self.id,
unit_cost=item.unit_cost,
update_stock=True
)
def cancel(self):
"""Cancel purchase order"""
if self.status not in ['received', 'cancelled']:
self.status = 'cancelled'
self.updated_at = datetime.utcnow()
def to_dict(self):
"""Convert purchase order to dictionary"""
return {
'id': self.id,
'po_number': self.po_number,
'supplier_id': self.supplier_id,
'status': self.status,
'order_date': self.order_date.isoformat() if self.order_date else None,
'expected_delivery_date': self.expected_delivery_date.isoformat() if self.expected_delivery_date else None,
'received_date': self.received_date.isoformat() if self.received_date else None,
'subtotal': float(self.subtotal),
'tax_amount': float(self.tax_amount),
'shipping_cost': float(self.shipping_cost),
'total_amount': float(self.total_amount),
'currency_code': self.currency_code,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': self.created_by
}
class PurchaseOrderItem(db.Model):
"""PurchaseOrderItem model - items in a purchase order"""
__tablename__ = 'purchase_order_items'
id = db.Column(db.Integer, primary_key=True)
purchase_order_id = db.Column(db.Integer, db.ForeignKey('purchase_orders.id'), nullable=False, index=True)
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True)
supplier_stock_item_id = db.Column(db.Integer, db.ForeignKey('supplier_stock_items.id'), nullable=True, index=True)
# Item details
description = db.Column(db.String(500), nullable=False)
supplier_sku = db.Column(db.String(100), nullable=True)
quantity_ordered = db.Column(db.Numeric(10, 2), nullable=False)
quantity_received = db.Column(db.Numeric(10, 2), nullable=False, default=0)
unit_cost = db.Column(db.Numeric(10, 2), nullable=False)
line_total = db.Column(db.Numeric(10, 2), nullable=False)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Warehouse destination
warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=True, index=True)
# Notes
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __init__(self, purchase_order_id, description, quantity_ordered, unit_cost,
stock_item_id=None, supplier_stock_item_id=None, supplier_sku=None,
warehouse_id=None, notes=None, currency_code='EUR'):
self.purchase_order_id = purchase_order_id
self.stock_item_id = stock_item_id
self.supplier_stock_item_id = supplier_stock_item_id
self.description = description.strip()
self.supplier_sku = supplier_sku.strip() if supplier_sku else None
self.quantity_ordered = Decimal(str(quantity_ordered))
self.quantity_received = Decimal('0')
self.unit_cost = Decimal(str(unit_cost))
self.line_total = self.quantity_ordered * self.unit_cost
self.warehouse_id = warehouse_id
self.notes = notes.strip() if notes else None
self.currency_code = currency_code.upper()
def __repr__(self):
return f'<PurchaseOrderItem {self.description} ({self.quantity_ordered})>'
def update_line_total(self):
"""Recalculate line total"""
self.line_total = self.quantity_ordered * self.unit_cost
self.updated_at = datetime.utcnow()
def to_dict(self):
"""Convert purchase order item to dictionary"""
return {
'id': self.id,
'purchase_order_id': self.purchase_order_id,
'stock_item_id': self.stock_item_id,
'supplier_stock_item_id': self.supplier_stock_item_id,
'description': self.description,
'supplier_sku': self.supplier_sku,
'quantity_ordered': float(self.quantity_ordered),
'quantity_received': float(self.quantity_received),
'unit_cost': float(self.unit_cost),
'line_total': float(self.line_total),
'currency_code': self.currency_code,
'warehouse_id': self.warehouse_id,
'notes': self.notes
}

View File

@@ -387,16 +387,28 @@ class QuoteItem(db.Model):
# Optional fields
unit = db.Column(db.String(20), nullable=True) # 'hours', 'days', 'items', etc.
# Inventory integration
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=True)
is_stock_item = db.Column(db.Boolean, default=False, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
def __init__(self, quote_id, description, quantity, unit_price, unit=None):
# Relationships
stock_item = db.relationship('StockItem', foreign_keys=[stock_item_id], lazy='joined')
warehouse = db.relationship('Warehouse', foreign_keys=[warehouse_id], lazy='joined')
def __init__(self, quote_id, description, quantity, unit_price, unit=None, stock_item_id=None, warehouse_id=None):
self.quote_id = quote_id
self.description = description.strip()
self.quantity = Decimal(str(quantity))
self.unit_price = Decimal(str(unit_price))
self.total_amount = self.quantity * self.unit_price
self.unit = unit.strip() if unit else None
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.is_stock_item = stock_item_id is not None
def __repr__(self):
return f'<QuoteItem {self.description} ({self.quantity} @ {self.unit_price})>'
@@ -411,6 +423,9 @@ class QuoteItem(db.Model):
'unit_price': float(self.unit_price),
'total_amount': float(self.total_amount),
'unit': self.unit,
'stock_item_id': self.stock_item_id,
'warehouse_id': self.warehouse_id,
'is_stock_item': self.is_stock_item,
'created_at': self.created_at.isoformat() if self.created_at else None
}

162
app/models/stock_item.py Normal file
View File

@@ -0,0 +1,162 @@
"""StockItem model for inventory management"""
from datetime import datetime
from decimal import Decimal
from app import db
class StockItem(db.Model):
"""StockItem model - represents a product/item in the inventory catalog"""
__tablename__ = 'stock_items'
id = db.Column(db.Integer, primary_key=True)
sku = db.Column(db.String(100), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
category = db.Column(db.String(100), nullable=True, index=True)
unit = db.Column(db.String(20), nullable=False, default='pcs')
default_cost = db.Column(db.Numeric(10, 2), nullable=True)
default_price = db.Column(db.Numeric(10, 2), nullable=True)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
barcode = db.Column(db.String(100), nullable=True, index=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
is_trackable = db.Column(db.Boolean, default=True, nullable=False)
reorder_point = db.Column(db.Numeric(10, 2), nullable=True)
reorder_quantity = db.Column(db.Numeric(10, 2), nullable=True)
supplier = db.Column(db.String(200), nullable=True)
supplier_sku = db.Column(db.String(100), nullable=True)
image_url = db.Column(db.String(500), nullable=True)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# Relationships
warehouse_stock = db.relationship('WarehouseStock', backref='stock_item', lazy='dynamic', cascade='all, delete-orphan')
stock_movements = db.relationship('StockMovement', backref='stock_item', lazy='dynamic')
reservations = db.relationship('StockReservation', backref='stock_item', lazy='dynamic')
supplier_items = db.relationship('SupplierStockItem', backref='stock_item', lazy='dynamic', cascade='all, delete-orphan')
def __init__(self, sku, name, created_by, description=None, category=None, unit='pcs',
default_cost=None, default_price=None, currency_code='EUR', barcode=None,
is_active=True, is_trackable=True, reorder_point=None, reorder_quantity=None,
supplier=None, supplier_sku=None, image_url=None, notes=None):
self.sku = sku.strip().upper()
self.name = name.strip()
self.created_by = created_by
self.description = description.strip() if description else None
self.category = category.strip() if category else None
self.unit = unit.strip() if unit else 'pcs'
self.default_cost = Decimal(str(default_cost)) if default_cost else None
self.default_price = Decimal(str(default_price)) if default_price else None
self.currency_code = currency_code.upper()
self.barcode = barcode.strip() if barcode else None
self.is_active = is_active
self.is_trackable = is_trackable
self.reorder_point = Decimal(str(reorder_point)) if reorder_point else None
self.reorder_quantity = Decimal(str(reorder_quantity)) if reorder_quantity else None
self.supplier = supplier.strip() if supplier else None
self.supplier_sku = supplier_sku.strip() if supplier_sku else None
self.image_url = image_url.strip() if image_url else None
self.notes = notes.strip() if notes else None
def __repr__(self):
return f'<StockItem {self.sku} ({self.name})>'
@property
def total_quantity_on_hand(self):
"""Calculate total quantity across all warehouses"""
if not self.is_trackable:
return None
from .warehouse_stock import WarehouseStock
total = db.session.query(db.func.sum(WarehouseStock.quantity_on_hand)).filter_by(
stock_item_id=self.id
).scalar()
return Decimal(str(total)) if total else Decimal('0')
@property
def total_quantity_reserved(self):
"""Calculate total reserved quantity across all warehouses"""
if not self.is_trackable:
return None
from .warehouse_stock import WarehouseStock
total = db.session.query(db.func.sum(WarehouseStock.quantity_reserved)).filter_by(
stock_item_id=self.id
).scalar()
return Decimal(str(total)) if total else Decimal('0')
@property
def total_quantity_available(self):
"""Calculate total available quantity (on-hand minus reserved)"""
if not self.is_trackable:
return None
on_hand = self.total_quantity_on_hand or Decimal('0')
reserved = self.total_quantity_reserved or Decimal('0')
return on_hand - reserved
@property
def is_low_stock(self):
"""Check if any warehouse is below reorder point"""
if not self.is_trackable or not self.reorder_point:
return False
from .warehouse_stock import WarehouseStock
low_stock = WarehouseStock.query.filter(
WarehouseStock.stock_item_id == self.id,
WarehouseStock.quantity_on_hand < self.reorder_point
).first()
return low_stock is not None
def get_stock_level(self, warehouse_id):
"""Get stock level for a specific warehouse"""
if not self.is_trackable:
return None
from .warehouse_stock import WarehouseStock
stock = WarehouseStock.query.filter_by(
stock_item_id=self.id,
warehouse_id=warehouse_id
).first()
return stock.quantity_on_hand if stock else Decimal('0')
def get_available_quantity(self, warehouse_id):
"""Get available quantity for a specific warehouse"""
if not self.is_trackable:
return None
from .warehouse_stock import WarehouseStock
stock = WarehouseStock.query.filter_by(
stock_item_id=self.id,
warehouse_id=warehouse_id
).first()
if not stock:
return Decimal('0')
return stock.quantity_on_hand - stock.quantity_reserved
def to_dict(self):
"""Convert stock item to dictionary"""
return {
'id': self.id,
'sku': self.sku,
'name': self.name,
'description': self.description,
'category': self.category,
'unit': self.unit,
'default_cost': float(self.default_cost) if self.default_cost else None,
'default_price': float(self.default_price) if self.default_price else None,
'currency_code': self.currency_code,
'barcode': self.barcode,
'is_active': self.is_active,
'is_trackable': self.is_trackable,
'reorder_point': float(self.reorder_point) if self.reorder_point else None,
'reorder_quantity': float(self.reorder_quantity) if self.reorder_quantity else None,
'supplier': self.supplier,
'supplier_sku': self.supplier_sku,
'image_url': self.image_url,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': self.created_by,
'total_quantity_on_hand': float(self.total_quantity_on_hand) if self.total_quantity_on_hand else None,
'total_quantity_reserved': float(self.total_quantity_reserved) if self.total_quantity_reserved else None,
'total_quantity_available': float(self.total_quantity_available) if self.total_quantity_available else None,
'is_low_stock': self.is_low_stock
}

View File

@@ -0,0 +1,115 @@
"""StockMovement model for tracking inventory movements"""
from datetime import datetime
from decimal import Decimal
from app import db
class StockMovement(db.Model):
"""StockMovement model - tracks all inventory movements"""
__tablename__ = 'stock_movements'
id = db.Column(db.Integer, primary_key=True)
movement_type = db.Column(db.String(20), nullable=False, index=True) # 'adjustment', 'transfer', 'sale', 'purchase', 'return', 'waste'
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=False, index=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=False, index=True)
quantity = db.Column(db.Numeric(10, 2), nullable=False) # Positive for additions, negative for removals
reference_type = db.Column(db.String(50), nullable=True, index=True) # 'invoice', 'quote', 'project', 'manual', 'purchase_order'
reference_id = db.Column(db.Integer, nullable=True, index=True)
unit_cost = db.Column(db.Numeric(10, 2), nullable=True)
reason = db.Column(db.String(500), nullable=True)
notes = db.Column(db.Text, nullable=True)
moved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
moved_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
moved_by_user = db.relationship('User', foreign_keys=[moved_by])
# Composite index for reference lookups
__table_args__ = (
db.Index('ix_stock_movements_reference', 'reference_type', 'reference_id'),
db.Index('ix_stock_movements_item_date', 'stock_item_id', 'moved_at'),
)
def __init__(self, movement_type, stock_item_id, warehouse_id, quantity, moved_by,
reference_type=None, reference_id=None, unit_cost=None, reason=None, notes=None):
self.movement_type = movement_type
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.quantity = Decimal(str(quantity))
self.moved_by = moved_by
self.reference_type = reference_type
self.reference_id = reference_id
self.unit_cost = Decimal(str(unit_cost)) if unit_cost else None
self.reason = reason.strip() if reason else None
self.notes = notes.strip() if notes else None
def __repr__(self):
return f'<StockMovement {self.movement_type} {self.quantity} of {self.stock_item_id} at {self.warehouse_id}>'
def to_dict(self):
"""Convert stock movement to dictionary"""
return {
'id': self.id,
'movement_type': self.movement_type,
'stock_item_id': self.stock_item_id,
'warehouse_id': self.warehouse_id,
'quantity': float(self.quantity),
'reference_type': self.reference_type,
'reference_id': self.reference_id,
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
'reason': self.reason,
'notes': self.notes,
'moved_by': self.moved_by,
'moved_at': self.moved_at.isoformat() if self.moved_at else None
}
@classmethod
def record_movement(cls, movement_type, stock_item_id, warehouse_id, quantity, moved_by,
reference_type=None, reference_id=None, unit_cost=None, reason=None, notes=None,
update_stock=True):
"""
Record a stock movement and optionally update warehouse stock levels
Returns:
tuple: (StockMovement instance, updated WarehouseStock instance or None)
"""
from .warehouse_stock import WarehouseStock
movement = cls(
movement_type=movement_type,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
quantity=quantity,
moved_by=moved_by,
reference_type=reference_type,
reference_id=reference_id,
unit_cost=unit_cost,
reason=reason,
notes=notes
)
db.session.add(movement)
updated_stock = None
if update_stock:
# Get or create warehouse stock record
stock = WarehouseStock.query.filter_by(
warehouse_id=warehouse_id,
stock_item_id=stock_item_id
).first()
if not stock:
stock = WarehouseStock(
warehouse_id=warehouse_id,
stock_item_id=stock_item_id,
quantity_on_hand=0
)
db.session.add(stock)
# Update stock level
stock.adjust_on_hand(quantity)
updated_stock = stock
return movement, updated_stock

View File

@@ -0,0 +1,177 @@
"""StockReservation model for reserving stock"""
from datetime import datetime, timedelta
from decimal import Decimal
from app import db
class StockReservation(db.Model):
"""StockReservation model - reserves stock for quotes/invoices/projects"""
__tablename__ = 'stock_reservations'
id = db.Column(db.Integer, primary_key=True)
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=False, index=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=False, index=True)
quantity = db.Column(db.Numeric(10, 2), nullable=False)
reservation_type = db.Column(db.String(20), nullable=False, index=True) # 'quote', 'invoice', 'project'
reservation_id = db.Column(db.Integer, nullable=False, index=True)
status = db.Column(db.String(20), nullable=False, default='reserved') # 'reserved', 'fulfilled', 'cancelled', 'expired'
expires_at = db.Column(db.DateTime, nullable=True, index=True)
reserved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
reserved_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
fulfilled_at = db.Column(db.DateTime, nullable=True)
cancelled_at = db.Column(db.DateTime, nullable=True)
notes = db.Column(db.Text, nullable=True)
# Relationships
reserved_by_user = db.relationship('User', foreign_keys=[reserved_by])
# Composite index for reservation lookups
__table_args__ = (
db.Index('ix_stock_reservations_reservation', 'reservation_type', 'reservation_id'),
)
def __init__(self, stock_item_id, warehouse_id, quantity, reservation_type, reservation_id,
reserved_by, expires_at=None, notes=None):
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.quantity = Decimal(str(quantity))
self.reservation_type = reservation_type
self.reservation_id = reservation_id
self.reserved_by = reserved_by
self.expires_at = expires_at
self.notes = notes.strip() if notes else None
self.status = 'reserved'
def __repr__(self):
return f'<StockReservation {self.reservation_type} {self.reservation_id}: {self.quantity}>'
@property
def is_expired(self):
"""Check if reservation has expired"""
if not self.expires_at:
return False
return datetime.utcnow() > self.expires_at and self.status == 'reserved'
def fulfill(self):
"""Mark reservation as fulfilled"""
if self.status != 'reserved':
raise ValueError(f"Cannot fulfill reservation with status: {self.status}")
self.status = 'fulfilled'
self.fulfilled_at = datetime.utcnow()
# Release reserved quantity from warehouse stock
from .warehouse_stock import WarehouseStock
stock = WarehouseStock.query.filter_by(
warehouse_id=self.warehouse_id,
stock_item_id=self.stock_item_id
).first()
if stock:
stock.release_reservation(self.quantity)
def cancel(self):
"""Cancel the reservation"""
if self.status not in ('reserved', 'expired'):
raise ValueError(f"Cannot cancel reservation with status: {self.status}")
# Release reserved quantity from warehouse stock
from .warehouse_stock import WarehouseStock
stock = WarehouseStock.query.filter_by(
warehouse_id=self.warehouse_id,
stock_item_id=self.stock_item_id
).first()
if stock:
stock.release_reservation(self.quantity)
self.status = 'cancelled'
self.cancelled_at = datetime.utcnow()
def expire(self):
"""Mark reservation as expired"""
if self.status != 'reserved':
return
# Release reserved quantity from warehouse stock
from .warehouse_stock import WarehouseStock
stock = WarehouseStock.query.filter_by(
warehouse_id=self.warehouse_id,
stock_item_id=self.stock_item_id
).first()
if stock:
stock.release_reservation(self.quantity)
self.status = 'expired'
@classmethod
def create_reservation(cls, stock_item_id, warehouse_id, quantity, reservation_type,
reservation_id, reserved_by, expires_in_days=30, notes=None):
"""
Create a stock reservation and update warehouse stock
Returns:
tuple: (StockReservation instance, updated WarehouseStock instance)
"""
from .warehouse_stock import WarehouseStock
# Calculate expiration date
expires_at = None
if expires_in_days:
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
# Get or create warehouse stock record
stock = WarehouseStock.query.filter_by(
warehouse_id=warehouse_id,
stock_item_id=stock_item_id
).first()
if not stock:
stock = WarehouseStock(
warehouse_id=warehouse_id,
stock_item_id=stock_item_id,
quantity_on_hand=0
)
db.session.add(stock)
# Check available quantity
available = stock.quantity_available
if Decimal(str(quantity)) > available:
raise ValueError(f"Insufficient stock. Available: {available}, Requested: {quantity}")
# Create reservation
reservation = cls(
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
quantity=quantity,
reservation_type=reservation_type,
reservation_id=reservation_id,
reserved_by=reserved_by,
expires_at=expires_at,
notes=notes
)
# Reserve quantity in warehouse stock
stock.reserve(quantity)
db.session.add(reservation)
return reservation, stock
def to_dict(self):
"""Convert stock reservation to dictionary"""
return {
'id': self.id,
'stock_item_id': self.stock_item_id,
'warehouse_id': self.warehouse_id,
'quantity': float(self.quantity),
'reservation_type': self.reservation_type,
'reservation_id': self.reservation_id,
'status': self.status,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'reserved_by': self.reserved_by,
'reserved_at': self.reserved_at.isoformat() if self.reserved_at else None,
'fulfilled_at': self.fulfilled_at.isoformat() if self.fulfilled_at else None,
'cancelled_at': self.cancelled_at.isoformat() if self.cancelled_at else None,
'notes': self.notes,
'is_expired': self.is_expired
}

74
app/models/supplier.py Normal file
View File

@@ -0,0 +1,74 @@
"""Supplier model for inventory management"""
from datetime import datetime
from app import db
class Supplier(db.Model):
"""Supplier model - represents a supplier/vendor"""
__tablename__ = 'suppliers'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
contact_person = db.Column(db.String(200), nullable=True)
email = db.Column(db.String(200), nullable=True)
phone = db.Column(db.String(50), nullable=True)
address = db.Column(db.Text, nullable=True)
website = db.Column(db.String(500), nullable=True)
tax_id = db.Column(db.String(100), nullable=True)
payment_terms = db.Column(db.String(100), nullable=True) # e.g., "Net 30", "Net 60"
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
is_active = db.Column(db.Boolean, default=True, nullable=False)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# Relationships
supplier_items = db.relationship('SupplierStockItem', backref='supplier', lazy='dynamic', cascade='all, delete-orphan')
def __init__(self, code, name, created_by, description=None, contact_person=None,
email=None, phone=None, address=None, website=None, tax_id=None,
payment_terms=None, currency_code='EUR', is_active=True, notes=None):
self.code = code.strip().upper()
self.name = name.strip()
self.created_by = created_by
self.description = description.strip() if description else None
self.contact_person = contact_person.strip() if contact_person else None
self.email = email.strip() if email else None
self.phone = phone.strip() if phone else None
self.address = address.strip() if address else None
self.website = website.strip() if website else None
self.tax_id = tax_id.strip() if tax_id else None
self.payment_terms = payment_terms.strip() if payment_terms else None
self.currency_code = currency_code.upper()
self.is_active = is_active
self.notes = notes.strip() if notes else None
def __repr__(self):
return f'<Supplier {self.code} ({self.name})>'
def to_dict(self):
"""Convert supplier to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'description': self.description,
'contact_person': self.contact_person,
'email': self.email,
'phone': self.phone,
'address': self.address,
'website': self.website,
'tax_id': self.tax_id,
'payment_terms': self.payment_terms,
'currency_code': self.currency_code,
'is_active': self.is_active,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': self.created_by
}

View File

@@ -0,0 +1,72 @@
"""SupplierStockItem model for many-to-many relationship between suppliers and stock items"""
from datetime import datetime
from decimal import Decimal
from app import db
class SupplierStockItem(db.Model):
"""SupplierStockItem model - links suppliers to stock items with pricing"""
__tablename__ = 'supplier_stock_items'
id = db.Column(db.Integer, primary_key=True)
supplier_id = db.Column(db.Integer, db.ForeignKey('suppliers.id'), nullable=False, index=True)
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=False, index=True)
# Supplier-specific information for this item
supplier_sku = db.Column(db.String(100), nullable=True)
supplier_name = db.Column(db.String(200), nullable=True) # Supplier's name for this product
unit_cost = db.Column(db.Numeric(10, 2), nullable=True) # Cost per unit from this supplier
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
minimum_order_quantity = db.Column(db.Numeric(10, 2), nullable=True) # MOQ
lead_time_days = db.Column(db.Integer, nullable=True) # Lead time in days
is_preferred = db.Column(db.Boolean, default=False, nullable=False) # Preferred supplier for this item
is_active = db.Column(db.Boolean, default=True, nullable=False)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships (backref defined in Supplier and StockItem models)
# Unique constraint: one supplier-item relationship
__table_args__ = (
db.UniqueConstraint('supplier_id', 'stock_item_id', name='uq_supplier_stock_item'),
)
def __init__(self, supplier_id, stock_item_id, supplier_sku=None, supplier_name=None,
unit_cost=None, currency_code='EUR', minimum_order_quantity=None,
lead_time_days=None, is_preferred=False, is_active=True, notes=None):
self.supplier_id = supplier_id
self.stock_item_id = stock_item_id
self.supplier_sku = supplier_sku.strip() if supplier_sku else None
self.supplier_name = supplier_name.strip() if supplier_name else None
self.unit_cost = Decimal(str(unit_cost)) if unit_cost else None
self.currency_code = currency_code.upper()
self.minimum_order_quantity = Decimal(str(minimum_order_quantity)) if minimum_order_quantity else None
self.lead_time_days = lead_time_days
self.is_preferred = is_preferred
self.is_active = is_active
self.notes = notes.strip() if notes else None
def __repr__(self):
return f'<SupplierStockItem supplier_id={self.supplier_id} stock_item_id={self.stock_item_id}>'
def to_dict(self):
"""Convert supplier stock item to dictionary"""
return {
'id': self.id,
'supplier_id': self.supplier_id,
'stock_item_id': self.stock_item_id,
'supplier_sku': self.supplier_sku,
'supplier_name': self.supplier_name,
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
'currency_code': self.currency_code,
'minimum_order_quantity': float(self.minimum_order_quantity) if self.minimum_order_quantity else None,
'lead_time_days': self.lead_time_days,
'is_preferred': self.is_preferred,
'is_active': self.is_active,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}

59
app/models/warehouse.py Normal file
View File

@@ -0,0 +1,59 @@
"""Warehouse model for inventory management"""
from datetime import datetime
from app import db
class Warehouse(db.Model):
"""Warehouse model - represents a storage location"""
__tablename__ = 'warehouses'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
address = db.Column(db.Text, nullable=True)
contact_person = db.Column(db.String(200), nullable=True)
contact_email = db.Column(db.String(200), nullable=True)
contact_phone = db.Column(db.String(50), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# Relationships
stock_levels = db.relationship('WarehouseStock', backref='warehouse', lazy='dynamic', cascade='all, delete-orphan')
stock_movements = db.relationship('StockMovement', backref='warehouse', lazy='dynamic', cascade='all, delete-orphan')
def __init__(self, name, code, created_by, address=None, contact_person=None,
contact_email=None, contact_phone=None, is_active=True, notes=None):
self.name = name.strip()
self.code = code.strip().upper()
self.created_by = created_by
self.address = address.strip() if address else None
self.contact_person = contact_person.strip() if contact_person else None
self.contact_email = contact_email.strip() if contact_email else None
self.contact_phone = contact_phone.strip() if contact_phone else None
self.is_active = is_active
self.notes = notes.strip() if notes else None
def __repr__(self):
return f'<Warehouse {self.code} ({self.name})>'
def to_dict(self):
"""Convert warehouse to dictionary"""
return {
'id': self.id,
'name': self.name,
'code': self.code,
'address': self.address,
'contact_person': self.contact_person,
'contact_email': self.contact_email,
'contact_phone': self.contact_phone,
'is_active': self.is_active,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': self.created_by
}

View File

@@ -0,0 +1,94 @@
"""WarehouseStock model for tracking stock levels per warehouse"""
from datetime import datetime
from decimal import Decimal
from app import db
class WarehouseStock(db.Model):
"""WarehouseStock model - tracks stock levels per warehouse"""
__tablename__ = 'warehouse_stock'
id = db.Column(db.Integer, primary_key=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id', ondelete='CASCADE'), nullable=False, index=True)
stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id', ondelete='CASCADE'), nullable=False, index=True)
quantity_on_hand = db.Column(db.Numeric(10, 2), nullable=False, default=0)
quantity_reserved = db.Column(db.Numeric(10, 2), nullable=False, default=0)
location = db.Column(db.String(100), nullable=True)
last_counted_at = db.Column(db.DateTime, nullable=True)
last_counted_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
counted_by_user = db.relationship('User', foreign_keys=[last_counted_by])
# Unique constraint: one stock record per item per warehouse
__table_args__ = (
db.UniqueConstraint('warehouse_id', 'stock_item_id', name='uq_warehouse_stock'),
)
def __init__(self, warehouse_id, stock_item_id, quantity_on_hand=0, quantity_reserved=0, location=None):
self.warehouse_id = warehouse_id
self.stock_item_id = stock_item_id
self.quantity_on_hand = Decimal(str(quantity_on_hand))
self.quantity_reserved = Decimal(str(quantity_reserved))
self.location = location.strip() if location else None
def __repr__(self):
return f'<WarehouseStock {self.warehouse_id}/{self.stock_item_id}: {self.quantity_on_hand}>'
@property
def quantity_available(self):
"""Calculate available quantity (on-hand minus reserved)"""
return self.quantity_on_hand - self.quantity_reserved
def reserve(self, quantity):
"""Reserve quantity"""
qty = Decimal(str(quantity))
available = self.quantity_available
if qty > available:
raise ValueError(f"Insufficient stock. Available: {available}, Requested: {qty}")
self.quantity_reserved += qty
self.updated_at = datetime.utcnow()
def release_reservation(self, quantity):
"""Release reserved quantity"""
qty = Decimal(str(quantity))
if qty > self.quantity_reserved:
raise ValueError(f"Cannot release more than reserved. Reserved: {self.quantity_reserved}, Requested: {qty}")
self.quantity_reserved -= qty
self.updated_at = datetime.utcnow()
def adjust_on_hand(self, quantity):
"""Adjust on-hand quantity (positive for additions, negative for removals)"""
qty = Decimal(str(quantity))
self.quantity_on_hand += qty
if self.quantity_on_hand < 0:
self.quantity_on_hand = Decimal('0')
self.updated_at = datetime.utcnow()
def record_count(self, counted_quantity, counted_by=None):
"""Record a physical count"""
self.quantity_on_hand = Decimal(str(counted_quantity))
self.last_counted_at = datetime.utcnow()
if counted_by:
self.last_counted_by = counted_by
self.updated_at = datetime.utcnow()
def to_dict(self):
"""Convert warehouse stock to dictionary"""
return {
'id': self.id,
'warehouse_id': self.warehouse_id,
'stock_item_id': self.stock_item_id,
'quantity_on_hand': float(self.quantity_on_hand),
'quantity_reserved': float(self.quantity_reserved),
'quantity_available': float(self.quantity_available),
'location': self.location,
'last_counted_at': self.last_counted_at.isoformat() if self.last_counted_at else None,
'last_counted_by': self.last_counted_by,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}

View File

@@ -35,6 +35,13 @@ from app.models import (
InvoiceTemplate,
Webhook,
WebhookDelivery,
Warehouse,
StockItem,
WarehouseStock,
StockMovement,
StockReservation,
Supplier,
PurchaseOrder,
)
from app.utils.api_auth import require_api_token
from datetime import datetime, timedelta
@@ -4087,6 +4094,379 @@ def list_webhook_events():
return jsonify({'events': events})
# ==================== Inventory ====================
@api_v1_bp.route('/inventory/items', methods=['GET'])
@require_api_token('read:projects') # Use existing scope for now
def list_stock_items_api():
"""List stock items"""
search = request.args.get('search', '').strip()
category = request.args.get('category', '')
active_only = request.args.get('active_only', 'true').lower() == 'true'
query = StockItem.query
if active_only:
query = query.filter_by(is_active=True)
if search:
like = f"%{search}%"
query = query.filter(
or_(
StockItem.sku.ilike(like),
StockItem.name.ilike(like),
StockItem.barcode.ilike(like)
)
)
if category:
query = query.filter_by(category=category)
result = paginate_query(query.order_by(StockItem.name))
result['items'] = [item.to_dict() for item in result['items']]
return jsonify(result)
@api_v1_bp.route('/inventory/items/<int:item_id>', methods=['GET'])
@require_api_token('read:projects')
def get_stock_item_api(item_id):
"""Get stock item details"""
item = StockItem.query.get_or_404(item_id)
return jsonify({'item': item.to_dict()})
@api_v1_bp.route('/inventory/items/<int:item_id>/availability', methods=['GET'])
@require_api_token('read:projects')
def get_stock_availability_api(item_id):
"""Get stock availability for an item across warehouses"""
item = StockItem.query.get_or_404(item_id)
warehouse_id = request.args.get('warehouse_id', type=int)
query = WarehouseStock.query.filter_by(stock_item_id=item_id)
if warehouse_id:
query = query.filter_by(warehouse_id=warehouse_id)
stock_levels = query.all()
availability = []
for stock in stock_levels:
availability.append({
'warehouse_id': stock.warehouse_id,
'warehouse_code': stock.warehouse.code,
'warehouse_name': stock.warehouse.name,
'quantity_on_hand': float(stock.quantity_on_hand),
'quantity_reserved': float(stock.quantity_reserved),
'quantity_available': float(stock.quantity_available),
'location': stock.location
})
return jsonify({
'item_id': item_id,
'item_sku': item.sku,
'item_name': item.name,
'availability': availability
})
@api_v1_bp.route('/inventory/warehouses', methods=['GET'])
@require_api_token('read:projects')
def list_warehouses_api():
"""List warehouses"""
active_only = request.args.get('active_only', 'true').lower() == 'true'
query = Warehouse.query
if active_only:
query = query.filter_by(is_active=True)
result = paginate_query(query.order_by(Warehouse.code))
result['items'] = [wh.to_dict() for wh in result['items']]
return jsonify(result)
@api_v1_bp.route('/inventory/stock-levels', methods=['GET'])
@require_api_token('read:projects')
def get_stock_levels_api():
"""Get stock levels"""
warehouse_id = request.args.get('warehouse_id', type=int)
stock_item_id = request.args.get('stock_item_id', type=int)
category = request.args.get('category', '')
query = WarehouseStock.query.join(StockItem).join(Warehouse)
if warehouse_id:
query = query.filter_by(warehouse_id=warehouse_id)
if stock_item_id:
query = query.filter_by(stock_item_id=stock_item_id)
if category:
query = query.filter(StockItem.category == category)
stock_levels = query.order_by(Warehouse.code, StockItem.name).all()
levels = []
for stock in stock_levels:
levels.append({
'warehouse': stock.warehouse.to_dict(),
'stock_item': stock.stock_item.to_dict(),
'quantity_on_hand': float(stock.quantity_on_hand),
'quantity_reserved': float(stock.quantity_reserved),
'quantity_available': float(stock.quantity_available),
'location': stock.location
})
return jsonify({'stock_levels': levels})
@api_v1_bp.route('/inventory/movements', methods=['POST'])
@require_api_token('write:projects')
def create_stock_movement_api():
"""Create a stock movement"""
data = request.get_json() or {}
movement_type = data.get('movement_type', 'adjustment')
stock_item_id = data.get('stock_item_id')
warehouse_id = data.get('warehouse_id')
quantity = data.get('quantity')
reason = data.get('reason')
notes = data.get('notes')
reference_type = data.get('reference_type')
reference_id = data.get('reference_id')
unit_cost = data.get('unit_cost')
if not stock_item_id or not warehouse_id or quantity is None:
return jsonify({'error': 'stock_item_id, warehouse_id, and quantity are required'}), 400
try:
from decimal import Decimal
movement, updated_stock = StockMovement.record_movement(
movement_type=movement_type,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
quantity=Decimal(str(quantity)),
moved_by=g.api_user.id,
reference_type=reference_type,
reference_id=reference_id,
unit_cost=Decimal(str(unit_cost)) if unit_cost else None,
reason=reason,
notes=notes,
update_stock=True
)
db.session.commit()
return jsonify({
'message': 'Stock movement recorded successfully',
'movement': movement.to_dict(),
'updated_stock': updated_stock.to_dict() if updated_stock else None
}), 201
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400
# ==================== Suppliers API ====================
@api_v1_bp.route('/inventory/suppliers', methods=['GET'])
@require_api_token('read:projects')
def list_suppliers_api():
"""List suppliers"""
from app.models import Supplier
from sqlalchemy import or_
search = request.args.get('search', '').strip()
active_only = request.args.get('active_only', 'true').lower() == 'true'
query = Supplier.query
if active_only:
query = query.filter_by(is_active=True)
if search:
like = f"%{search}%"
query = query.filter(
or_(
Supplier.code.ilike(like),
Supplier.name.ilike(like)
)
)
result = paginate_query(query.order_by(Supplier.name))
result['items'] = [supplier.to_dict() for supplier in result['items']]
return jsonify(result)
@api_v1_bp.route('/inventory/suppliers/<int:supplier_id>', methods=['GET'])
@require_api_token('read:projects')
def get_supplier_api(supplier_id):
"""Get supplier details"""
from app.models import Supplier
supplier = Supplier.query.get_or_404(supplier_id)
return jsonify({'supplier': supplier.to_dict()})
@api_v1_bp.route('/inventory/suppliers/<int:supplier_id>/stock-items', methods=['GET'])
@require_api_token('read:projects')
def get_supplier_stock_items_api(supplier_id):
"""Get stock items from a supplier"""
from app.models import Supplier, SupplierStockItem
supplier = Supplier.query.get_or_404(supplier_id)
supplier_items = SupplierStockItem.query.join(Supplier).filter(
Supplier.id == supplier_id,
SupplierStockItem.is_active == True
).all()
items = []
for si in supplier_items:
item_dict = si.to_dict()
item_dict['stock_item'] = si.stock_item.to_dict() if si.stock_item else None
items.append(item_dict)
return jsonify({'items': items})
# ==================== Purchase Orders API ====================
@api_v1_bp.route('/inventory/purchase-orders', methods=['GET'])
@require_api_token('read:projects')
def list_purchase_orders_api():
"""List purchase orders"""
from app.models import PurchaseOrder
from sqlalchemy import or_
status = request.args.get('status', '')
supplier_id = request.args.get('supplier_id', type=int)
query = PurchaseOrder.query
if status:
query = query.filter_by(status=status)
if supplier_id:
query = query.filter_by(supplier_id=supplier_id)
result = paginate_query(query.order_by(PurchaseOrder.order_date.desc()))
result['items'] = [po.to_dict() for po in result['items']]
return jsonify(result)
@api_v1_bp.route('/inventory/purchase-orders/<int:po_id>', methods=['GET'])
@require_api_token('read:projects')
def get_purchase_order_api(po_id):
"""Get purchase order details"""
from app.models import PurchaseOrder
purchase_order = PurchaseOrder.query.get_or_404(po_id)
return jsonify({'purchase_order': purchase_order.to_dict()})
@api_v1_bp.route('/inventory/purchase-orders', methods=['POST'])
@require_api_token('write:projects')
def create_purchase_order_api():
"""Create a purchase order"""
from app.models import PurchaseOrder, PurchaseOrderItem, Supplier
from datetime import datetime
from decimal import Decimal
data = request.get_json() or {}
supplier_id = data.get('supplier_id')
if not supplier_id:
return jsonify({'error': 'supplier_id is required'}), 400
try:
# Generate PO number
last_po = PurchaseOrder.query.order_by(PurchaseOrder.id.desc()).first()
next_id = (last_po.id + 1) if last_po else 1
po_number = f"PO-{datetime.now().strftime('%Y%m%d')}-{next_id:04d}"
order_date = datetime.strptime(data.get('order_date'), '%Y-%m-%d').date() if data.get('order_date') else datetime.now().date()
expected_delivery_date = datetime.strptime(data.get('expected_delivery_date'), '%Y-%m-%d').date() if data.get('expected_delivery_date') else None
purchase_order = PurchaseOrder(
po_number=po_number,
supplier_id=supplier_id,
order_date=order_date,
created_by=g.api_user.id,
expected_delivery_date=expected_delivery_date,
notes=data.get('notes'),
internal_notes=data.get('internal_notes'),
currency_code=data.get('currency_code', 'EUR')
)
db.session.add(purchase_order)
db.session.flush()
# Handle items
items = data.get('items', [])
for item_data in items:
item = PurchaseOrderItem(
purchase_order_id=purchase_order.id,
description=item_data.get('description', ''),
quantity_ordered=Decimal(str(item_data.get('quantity_ordered', 1))),
unit_cost=Decimal(str(item_data.get('unit_cost', 0))),
stock_item_id=item_data.get('stock_item_id'),
supplier_stock_item_id=item_data.get('supplier_stock_item_id'),
supplier_sku=item_data.get('supplier_sku'),
warehouse_id=item_data.get('warehouse_id'),
currency_code=purchase_order.currency_code
)
db.session.add(item)
purchase_order.calculate_totals()
db.session.commit()
return jsonify({
'message': 'Purchase order created successfully',
'purchase_order': purchase_order.to_dict()
}), 201
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400
@api_v1_bp.route('/inventory/purchase-orders/<int:po_id>/receive', methods=['POST'])
@require_api_token('write:projects')
def receive_purchase_order_api(po_id):
"""Receive a purchase order"""
from app.models import PurchaseOrder
from datetime import datetime
purchase_order = PurchaseOrder.query.get_or_404(po_id)
data = request.get_json() or {}
try:
from decimal import Decimal
# Update received quantities if provided
items_data = data.get('items', [])
if items_data:
for item_data in items_data:
item_id = item_data.get('item_id')
quantity_received = item_data.get('quantity_received')
if item_id and quantity_received is not None:
item = purchase_order.items.filter_by(id=item_id).first()
if item:
item.quantity_received = Decimal(str(quantity_received))
received_date_str = data.get('received_date')
received_date = datetime.strptime(received_date_str, '%Y-%m-%d').date() if received_date_str else datetime.now().date()
purchase_order.mark_as_received(received_date)
db.session.commit()
return jsonify({
'message': 'Purchase order received successfully',
'purchase_order': purchase_order.to_dict()
}), 200
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400
# ==================== Error Handlers ====================
@api_v1_bp.errorhandler(404)

1775
app/routes/inventory.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -280,11 +280,20 @@ def edit_invoice(invoice_id):
quantity = Decimal(quantities[i])
unit_price = Decimal(unit_prices[i])
# Get stock item info if provided
stock_item_id = request.form.getlist('item_stock_item_id[]')
warehouse_id = request.form.getlist('item_warehouse_id[]')
stock_item_id_val = int(stock_item_id[i]) if i < len(stock_item_id) and stock_item_id[i] and stock_item_id[i].strip() else None
warehouse_id_val = int(warehouse_id[i]) if i < len(warehouse_id) and warehouse_id[i] and warehouse_id[i].strip() else None
item = InvoiceItem(
invoice_id=invoice.id,
description=descriptions[i].strip(),
quantity=quantity,
unit_price=unit_price
unit_price=unit_price,
stock_item_id=stock_item_id_val,
warehouse_id=warehouse_id_val
)
db.session.add(item)
except ValueError:
@@ -344,6 +353,34 @@ def edit_invoice(invoice_id):
flash(f'Invalid quantity or price for extra good {i+1}', 'error')
continue
# Reserve stock for invoice items with stock items
from app.models import StockReservation
for item in invoice.items:
if item.is_stock_item and item.stock_item_id and item.warehouse_id:
# Check if reservation already exists
existing = StockReservation.query.filter_by(
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
reservation_type='invoice',
reservation_id=invoice.id,
status='reserved'
).first()
if not existing:
try:
StockReservation.create_reservation(
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
quantity=item.quantity,
reservation_type='invoice',
reservation_id=invoice.id,
reserved_by=current_user.id,
expires_in_days=None # Invoice reservations don't expire
)
except ValueError as e:
flash(_('Warning: Could not reserve stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning')
# Calculate totals
invoice.calculate_totals()
if not safe_commit('edit_invoice', {'invoice_id': invoice.id}):
@@ -354,10 +391,31 @@ def edit_invoice(invoice_id):
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
# GET request - show edit form
from app.models import InvoiceTemplate
from app.models import InvoiceTemplate, StockItem, Warehouse
import json
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
return render_template('invoices/edit.html', invoice=invoice, projects=projects, email_templates=email_templates)
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
# Prepare stock items and warehouses for JavaScript
stock_items_json = json.dumps([{
'id': item.id,
'sku': item.sku,
'name': item.name,
'default_price': float(item.default_price) if item.default_price else None,
'default_cost': float(item.default_cost) if item.default_cost else None,
'unit': item.unit or 'pcs',
'description': item.name
} for item in stock_items])
warehouses_json = json.dumps([{
'id': wh.id,
'code': wh.code,
'name': wh.name
} for wh in warehouses])
return render_template('invoices/edit.html', invoice=invoice, projects=projects, email_templates=email_templates, stock_items=stock_items, warehouses=warehouses, stock_items_json=stock_items_json, warehouses_json=warehouses_json)
@invoices_bp.route('/invoices/<int:invoice_id>/status', methods=['POST'])
@login_required
@@ -382,6 +440,45 @@ def update_invoice_status(invoice_id):
if not invoice.payment_date:
invoice.payment_date = datetime.utcnow().date()
# Reduce stock when invoice is sent or paid (if configured)
from app.models import StockMovement, StockReservation
import os
reduce_on_sent = os.getenv('INVENTORY_REDUCE_ON_INVOICE_SENT', 'true').lower() == 'true'
reduce_on_paid = os.getenv('INVENTORY_REDUCE_ON_INVOICE_PAID', 'false').lower() == 'true'
if (new_status == 'sent' and reduce_on_sent) or (new_status == 'paid' and reduce_on_paid):
for item in invoice.items:
if item.is_stock_item and item.stock_item_id and item.warehouse_id:
try:
# Fulfill any existing reservations
reservation = StockReservation.query.filter_by(
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
reservation_type='invoice',
reservation_id=invoice.id,
status='reserved'
).first()
if reservation:
reservation.fulfill()
# Create stock movement (sale)
StockMovement.record_movement(
movement_type='sale',
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
quantity=-item.quantity, # Negative for removal
moved_by=current_user.id,
reference_type='invoice',
reference_id=invoice.id,
unit_cost=item.stock_item.default_cost if item.stock_item else None,
reason=f'Invoice {invoice.invoice_number}',
update_stock=True
)
except Exception as e:
flash(_('Warning: Could not reduce stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning')
if not safe_commit('update_invoice_status', {'invoice_id': invoice.id, 'status': new_status}):
return jsonify({'error': 'Database error while updating status'}), 500

View File

@@ -221,6 +221,43 @@ def create_payment():
# Update invoice status if fully paid
if invoice.payment_status == 'fully_paid':
invoice.status = 'paid'
# Reduce stock when invoice is fully paid (if configured)
from app.models import StockMovement, StockReservation
import os
reduce_on_paid = os.getenv('INVENTORY_REDUCE_ON_INVOICE_PAID', 'false').lower() == 'true'
if reduce_on_paid:
for item in invoice.items:
if item.is_stock_item and item.stock_item_id and item.warehouse_id:
try:
# Fulfill any existing reservations
reservation = StockReservation.query.filter_by(
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
reservation_type='invoice',
reservation_id=invoice.id,
status='reserved'
).first()
if reservation:
reservation.fulfill()
# Create stock movement (sale)
StockMovement.record_movement(
movement_type='sale',
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
quantity=-item.quantity, # Negative for removal
moved_by=current_user.id,
reference_type='invoice',
reference_id=invoice.id,
unit_cost=item.stock_item.default_cost if item.stock_item else None,
reason=f'Invoice {invoice.invoice_number} payment',
update_stock=True
)
except Exception as e:
pass # Don't fail payment creation on stock errors
if not safe_commit('create_payment', {'invoice_id': invoice_id, 'amount': float(amount)}):
flash('Could not create payment due to a database error. Please check server logs.', 'error')

View File

@@ -230,16 +230,23 @@ def create_quote():
item_quantities = request.form.getlist('item_quantity[]')
item_prices = request.form.getlist('item_price[]')
item_units = request.form.getlist('item_unit[]')
item_stock_ids = request.form.getlist('item_stock_item_id[]')
item_warehouse_ids = request.form.getlist('item_warehouse_id[]')
for desc, qty, price, unit in zip(item_descriptions, item_quantities, item_prices, item_units):
for desc, qty, price, unit, stock_id, wh_id in zip(item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids):
if desc.strip():
try:
stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None
warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None
item = QuoteItem(
quote_id=quote.id,
description=desc.strip(),
quantity=Decimal(qty) if qty else Decimal('1'),
unit_price=Decimal(price) if price else Decimal('0'),
unit=unit.strip() if unit else None
unit=unit.strip() if unit else None,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id
)
db.session.add(item)
except (ValueError, InvalidOperation):
@@ -374,9 +381,21 @@ def edit_quote(quote_id):
db.session.delete(item)
# Update or create items
for item_id, desc, qty, price, unit in zip(item_ids, item_descriptions, item_quantities, item_prices, item_units):
item_stock_ids = request.form.getlist('item_stock_item_id[]')
item_warehouse_ids = request.form.getlist('item_warehouse_id[]')
# Pad lists to match length
while len(item_stock_ids) < len(item_ids):
item_stock_ids.append('')
while len(item_warehouse_ids) < len(item_ids):
item_warehouse_ids.append('')
for item_id, desc, qty, price, unit, stock_id, wh_id in zip(item_ids, item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids):
if desc.strip():
try:
stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None
warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None
if item_id:
# Update existing item
item = QuoteItem.query.get(item_id)
@@ -386,6 +405,9 @@ def edit_quote(quote_id):
item.unit_price = Decimal(price) if price else Decimal('0')
item.total_amount = item.quantity * item.unit_price
item.unit = unit.strip() if unit else None
item.stock_item_id = stock_item_id
item.warehouse_id = warehouse_id
item.is_stock_item = stock_item_id is not None
else:
# Create new item
item = QuoteItem(
@@ -393,7 +415,9 @@ def edit_quote(quote_id):
description=desc.strip(),
quantity=Decimal(qty) if qty else Decimal('1'),
unit_price=Decimal(price) if price else Decimal('0'),
unit=unit.strip() if unit else None
unit=unit.strip() if unit else None,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id
)
db.session.add(item)
except (ValueError, InvalidOperation):
@@ -403,7 +427,13 @@ def edit_quote(quote_id):
if not safe_commit('edit_quote', {'quote_id': quote_id}):
flash(_('Could not update quote due to a database error. Please check server logs.'), 'error')
return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients())
from app.models import StockItem, Warehouse
import json
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
stock_items_json = json.dumps([{'id': item.id, 'sku': item.sku, 'name': item.name, 'default_price': float(item.default_price) if item.default_price else None, 'unit': item.unit or 'pcs', 'description': item.name} for item in stock_items])
warehouses_json = json.dumps([{'id': wh.id, 'code': wh.code, 'name': wh.name} for wh in warehouses])
return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients(), stock_items=stock_items, warehouses=warehouses, stock_items_json=stock_items_json, warehouses_json=warehouses_json)
log_event("quote.updated",
user_id=current_user.id,
@@ -417,7 +447,13 @@ def edit_quote(quote_id):
flash(_('Quote updated successfully'), 'success')
return redirect(url_for('quotes.view_quote', quote_id=quote_id))
return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients())
from app.models import StockItem, Warehouse
import json
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
stock_items_json = json.dumps([{'id': item.id, 'sku': item.sku, 'name': item.name, 'default_price': float(item.default_price) if item.default_price else None, 'unit': item.unit or 'pcs', 'description': item.name} for item in stock_items])
warehouses_json = json.dumps([{'id': wh.id, 'code': wh.code, 'name': wh.name} for wh in warehouses])
return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients(), stock_items=stock_items, warehouses=warehouses, stock_items_json=stock_items_json, warehouses_json=warehouses_json)
@quotes_bp.route('/quotes/<int:quote_id>/send', methods=['POST'])
@login_required
@@ -439,6 +475,28 @@ def send_quote(quote_id):
flash(_('Cannot send quote: %(error)s', error=str(e)), 'error')
return redirect(url_for('quotes.view_quote', quote_id=quote_id))
# Reserve stock for quote items if enabled
from app.models import StockReservation
import os
auto_reserve_on_send = os.getenv('INVENTORY_AUTO_RESERVE_ON_QUOTE_SENT', 'false').lower() == 'true'
if auto_reserve_on_send:
for item in quote.items:
if item.is_stock_item and item.stock_item_id and item.warehouse_id:
try:
expires_in_days = get_setting('INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS', 30)
StockReservation.create_reservation(
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
quantity=item.quantity,
reservation_type='quote',
reservation_id=quote.id,
reserved_by=current_user.id,
expires_in_days=expires_in_days
)
except ValueError as e:
flash(_('Warning: Could not reserve stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning')
if not safe_commit('send_quote', {'quote_id': quote_id}):
flash(_('Could not send quote due to a database error. Please check server logs.'), 'error')
return redirect(url_for('quotes.view_quote', quote_id=quote_id))
@@ -512,6 +570,36 @@ def accept_quote(quote_id):
db.session.rollback()
return redirect(url_for('quotes.view_quote', quote_id=quote_id))
# Reserve stock for quote items when accepted (if not already reserved)
from app.models import StockReservation
import os
for item in quote.items:
if item.is_stock_item and item.stock_item_id and item.warehouse_id:
# Check if reservation already exists
existing = StockReservation.query.filter_by(
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
reservation_type='quote',
reservation_id=quote.id,
status='reserved'
).first()
if not existing:
try:
expires_in_days = int(os.getenv('INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS', '30'))
StockReservation.create_reservation(
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
quantity=item.quantity,
reservation_type='quote',
reservation_id=quote.id,
reserved_by=current_user.id,
expires_in_days=expires_in_days
)
except ValueError as e:
flash(_('Warning: Could not reserve stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning')
if not safe_commit('accept_quote', {'quote_id': quote_id, 'project_id': project.id}):
flash(_('Could not accept quote due to a database error. Please check server logs.'), 'error')
return redirect(url_for('quotes.view_quote', quote_id=quote_id))

View File

@@ -211,6 +211,7 @@
{% set work_open = ep.startswith('projects.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('weekly_goals.') %}
{% set crm_open = ep.startswith('clients.') or ep.startswith('quotes.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('recurring_invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or (ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates')) %}
{% set inventory_open = ep.startswith('inventory.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') or ep.startswith('audit_logs.') %}
@@ -354,6 +355,81 @@
</li>
</ul>
</li>
<li class="mt-2">
<button onclick="toggleDropdown('inventoryDropdown')" data-dropdown="inventoryDropdown" class="w-full flex items-center p-2 rounded-lg {% if inventory_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-boxes w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Inventory') }}</span>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="inventoryDropdown" class="{% if not inventory_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_stock_items = ep.startswith('inventory.list_stock_items') or ep.startswith('inventory.view_stock_item') or ep.startswith('inventory.new_stock_item') or ep.startswith('inventory.edit_stock_item') %}
{% set nav_active_warehouses = ep.startswith('inventory.list_warehouses') or ep.startswith('inventory.view_warehouse') or ep.startswith('inventory.new_warehouse') or ep.startswith('inventory.edit_warehouse') %}
{% set nav_active_suppliers = ep.startswith('inventory.list_suppliers') or ep.startswith('inventory.view_supplier') or ep.startswith('inventory.new_supplier') or ep.startswith('inventory.edit_supplier') %}
{% set nav_active_stock_levels = ep.startswith('inventory.stock_levels') %}
{% set nav_active_movements = ep.startswith('inventory.list_movements') or ep.startswith('inventory.new_movement') %}
{% set nav_active_transfers = ep.startswith('inventory.list_transfers') or ep.startswith('inventory.new_transfer') %}
{% set nav_active_adjustments = ep.startswith('inventory.list_adjustments') or ep.startswith('inventory.new_adjustment') %}
{% set nav_active_reservations = ep.startswith('inventory.list_reservations') %}
{% set nav_active_low_stock = ep.startswith('inventory.low_stock_alerts') %}
{% set nav_active_reports = ep.startswith('inventory.reports') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_stock_items %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_stock_items') }}">
<i class="fas fa-cubes w-4 mr-2"></i>{{ _('Stock Items') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_warehouses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_warehouses') }}">
<i class="fas fa-warehouse w-4 mr-2"></i>{{ _('Warehouses') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_suppliers %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_suppliers') }}">
<i class="fas fa-truck w-4 mr-2"></i>{{ _('Suppliers') }}
</a>
</li>
{% set nav_active_purchase_orders = ep.startswith('inventory.list_purchase_orders') or ep.startswith('inventory.view_purchase_order') or ep.startswith('inventory.new_purchase_order') or ep.startswith('inventory.receive_purchase_order') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_purchase_orders %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_purchase_orders') }}">
<i class="fas fa-shopping-cart w-4 mr-2"></i>{{ _('Purchase Orders') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_stock_levels %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.stock_levels') }}">
<i class="fas fa-list-ul w-4 mr-2"></i>{{ _('Stock Levels') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_movements %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_movements') }}">
<i class="fas fa-exchange-alt w-4 mr-2"></i>{{ _('Stock Movements') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_transfers %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_transfers') }}">
<i class="fas fa-truck w-4 mr-2"></i>{{ _('Transfers') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_adjustments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_adjustments') }}">
<i class="fas fa-edit w-4 mr-2"></i>{{ _('Adjustments') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reservations %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_reservations') }}">
<i class="fas fa-bookmark w-4 mr-2"></i>{{ _('Reservations') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_low_stock %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.low_stock_alerts') }}">
<i class="fas fa-exclamation-triangle w-4 mr-2"></i>{{ _('Low Stock Alerts') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.reports_dashboard') }}">
<i class="fas fa-chart-pie w-4 mr-2"></i>{{ _('Reports') }}
</a>
</li>
</ul>
</li>
<li class="mt-2">
<a href="{{ url_for('analytics.analytics_dashboard') }}" class="flex items-center p-2 rounded-lg {% if analytics_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-chart-line w-6 text-center"></i>

View File

@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Adjustments', 'url': url_for('inventory.list_adjustments')},
{'text': 'New Adjustment'}
] %}
{{ page_header(
icon_class='fas fa-edit',
title_text='New Stock Adjustment',
subtitle_text='Record stock adjustment or correction',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-2xl mx-auto">
<form method="POST" action="{{ url_for('inventory.new_adjustment') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-6">
<div>
<label for="stock_item_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Stock Item') }} *</label>
<select name="stock_item_id" id="stock_item_id" class="form-input" required>
<option value="">{{ _('Select Item') }}</option>
{% for item in stock_items %}
<option value="{{ item.id }}">{{ item.sku }} - {{ item.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Warehouse') }} *</label>
<select name="warehouse_id" id="warehouse_id" class="form-input" required>
<option value="">{{ _('Select Warehouse') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}">{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="quantity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Adjustment Quantity') }} *</label>
<input type="number" name="quantity" id="quantity" step="0.01" class="form-input" required>
<p class="mt-1 text-sm text-gray-500">{{ _('Use positive values to increase stock, negative values to decrease') }}</p>
</div>
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Reason') }} *</label>
<input type="text" name="reason" id="reason" class="form-input" required placeholder="{{ _('e.g., Physical count correction, Damage, Found items') }}">
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input" placeholder="{{ _('Additional details about this adjustment') }}"></textarea>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="{{ url_for('inventory.list_adjustments') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Record Adjustment') }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Adjustments'}
] %}
{{ page_header(
icon_class='fas fa-edit',
title_text='Stock Adjustments',
subtitle_text='View stock adjustments and corrections',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.new_adjustment") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Adjustment</a>' if (current_user.is_admin or has_permission('manage_stock_movements')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Warehouse') }}</label>
<select name="warehouse_id" id="warehouse_id" class="form-input">
<option value="">{{ _('All Warehouses') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if selected_warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</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>
<select name="stock_item_id" id="stock_item_id" class="form-input">
<option value="">{{ _('All Items') }}</option>
{% for item in stock_items %}
<option value="{{ item.id }}" {% if selected_stock_item_id == item.id %}selected{% endif %}>{{ item.sku }} - {{ item.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="date_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date From') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
<div>
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date To') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}" class="form-input">
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Date') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Reason') }}</th>
<th class="p-4">{{ _('User') }}</th>
</tr>
</thead>
<tbody>
{% for adjustment in adjustments %}
<tr>
<td class="p-4">{{ adjustment.moved_at.strftime('%Y-%m-%d %H:%M') if adjustment.moved_at else '—' }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=adjustment.stock_item_id) }}" class="text-primary hover:underline">
{{ adjustment.stock_item.name }} ({{ adjustment.stock_item.sku }})
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=adjustment.warehouse_id) }}" class="text-primary hover:underline">
{{ adjustment.warehouse.code }}
</a>
</td>
<td class="p-4 {% if adjustment.quantity > 0 %}text-green-600{% else %}text-red-600{% endif %} font-semibold">
{{ '+' if adjustment.quantity > 0 else '' }}{{ adjustment.quantity }}
</td>
<td class="p-4">{{ adjustment.reason or '—' }}</td>
<td class="p-4">{{ adjustment.moved_by_user.username if adjustment.moved_by_user else '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="p-8 text-center text-gray-500">
{{ _('No adjustments found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Low Stock Alerts'}
] %}
{{ page_header(
icon_class='fas fa-exclamation-triangle',
title_text='Low Stock Alerts',
subtitle_text='Items below reorder point',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
{% if low_stock_items %}
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('On Hand') }}</th>
<th class="p-4">{{ _('Reorder Point') }}</th>
<th class="p-4">{{ _('Shortfall') }}</th>
<th class="p-4">{{ _('Reorder Qty') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for alert in low_stock_items %}
<tr>
<td class="p-4 font-medium">{{ alert.warehouse.code }} - {{ alert.warehouse.name }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=alert.item.id) }}" class="text-primary hover:underline">
{{ alert.item.name }} ({{ alert.item.sku }})
</a>
</td>
<td class="p-4 text-amber-600 font-semibold">{{ alert.quantity_on_hand }}</td>
<td class="p-4">{{ alert.reorder_point }}</td>
<td class="p-4 text-rose-600 font-semibold">{{ alert.shortfall }}</td>
<td class="p-4">{{ alert.reorder_quantity or '—' }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=alert.item.id) }}" class="text-primary hover:underline">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-8 text-center text-gray-500">
{{ _('No low stock alerts. All items are above reorder point.') }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Movements', 'url': url_for('inventory.list_movements')},
{'text': 'Record Movement'}
] %}
{{ page_header(
icon_class='fas fa-exchange-alt',
title_text='Record Stock Movement',
subtitle_text='Record inventory adjustment or movement',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-2xl mx-auto">
<form method="POST" action="{{ url_for('inventory.new_movement') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-6">
<div>
<label for="movement_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Movement Type') }} *</label>
<select name="movement_type" id="movement_type" class="form-input" required>
<option value="adjustment">{{ _('Adjustment') }}</option>
<option value="transfer">{{ _('Transfer') }}</option>
<option value="sale">{{ _('Sale') }}</option>
<option value="purchase">{{ _('Purchase') }}</option>
<option value="return">{{ _('Return') }}</option>
<option value="waste">{{ _('Waste') }}</option>
</select>
</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>
<select name="stock_item_id" id="stock_item_id" class="form-input" required>
<option value="">{{ _('Select Item') }}</option>
{% for item in stock_items %}
<option value="{{ item.id }}">{{ item.sku }} - {{ item.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Warehouse') }} *</label>
<select name="warehouse_id" id="warehouse_id" class="form-input" required>
<option value="">{{ _('Select Warehouse') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}">{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="quantity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Quantity') }} *</label>
<input type="number" name="quantity" id="quantity" step="0.01" class="form-input" required>
<p class="mt-1 text-sm text-gray-500">{{ _('Use positive values for additions, negative for removals') }}</p>
</div>
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Reason') }}</label>
<input type="text" name="reason" id="reason" class="form-input" placeholder="{{ _('e.g., Physical count correction') }}">
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input"></textarea>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="{{ url_for('inventory.list_movements') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Record Movement') }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Movements'}
] %}
{{ page_header(
icon_class='fas fa-exchange-alt',
title_text='Stock Movements',
subtitle_text='View inventory movement history',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.new_movement") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Record Movement</a>' if (current_user.is_admin or has_permission('manage_stock_movements')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Movement Type') }}</label>
<select name="type" id="type" class="form-input">
<option value="">{{ _('All Types') }}</option>
<option value="adjustment" {% if movement_type == 'adjustment' %}selected{% endif %}>{{ _('Adjustment') }}</option>
<option value="transfer" {% if movement_type == 'transfer' %}selected{% endif %}>{{ _('Transfer') }}</option>
<option value="sale" {% if movement_type == 'sale' %}selected{% endif %}>{{ _('Sale') }}</option>
<option value="purchase" {% if movement_type == 'purchase' %}selected{% endif %}>{{ _('Purchase') }}</option>
<option value="return" {% if movement_type == 'return' %}selected{% endif %}>{{ _('Return') }}</option>
<option value="waste" {% if movement_type == 'waste' %}selected{% endif %}>{{ _('Waste') }}</option>
</select>
</div>
<div>
<label for="reference_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Reference Type') }}</label>
<select name="reference_type" id="reference_type" class="form-input">
<option value="">{{ _('All') }}</option>
<option value="invoice" {% if reference_type == 'invoice' %}selected{% endif %}>{{ _('Invoice') }}</option>
<option value="quote" {% if reference_type == 'quote' %}selected{% endif %}>{{ _('Quote') }}</option>
<option value="project" {% if reference_type == 'project' %}selected{% endif %}>{{ _('Project') }}</option>
<option value="manual" {% if reference_type == 'manual' %}selected{% endif %}>{{ _('Manual') }}</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Date') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Type') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Reference') }}</th>
<th class="p-4">{{ _('Reason') }}</th>
<th class="p-4">{{ _('User') }}</th>
</tr>
</thead>
<tbody>
{% for movement in movements %}
<tr>
<td class="p-4">{{ movement.moved_at.strftime('%Y-%m-%d %H:%M') if movement.moved_at else '—' }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=movement.stock_item_id) }}" class="text-primary hover:underline">
{{ movement.stock_item.name }} ({{ movement.stock_item.sku }})
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=movement.warehouse_id) }}" class="text-primary hover:underline">
{{ movement.warehouse.code }}
</a>
</td>
<td class="p-4">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 capitalize">
{{ movement.movement_type }}
</span>
</td>
<td class="p-4 {% if movement.quantity > 0 %}text-green-600{% else %}text-red-600{% endif %} font-semibold">
{{ '+' if movement.quantity > 0 else '' }}{{ movement.quantity }}
</td>
<td class="p-4">
{% if movement.reference_type and movement.reference_id %}
{% if movement.reference_type == 'invoice' %}
<a href="{{ url_for('invoices.view_invoice', invoice_id=movement.reference_id) }}" class="text-primary hover:underline">
Invoice #{{ movement.reference_id }}
</a>
{% elif movement.reference_type == 'quote' %}
<a href="{{ url_for('quotes.view_quote', quote_id=movement.reference_id) }}" class="text-primary hover:underline">
Quote #{{ movement.reference_id }}
</a>
{% else %}
{{ movement.reference_type }} #{{ movement.reference_id }}
{% endif %}
{% else %}
{% endif %}
</td>
<td class="p-4">{{ movement.reason or '—' }}</td>
<td class="p-4">{{ movement.moved_by_user.username if movement.moved_by_user else '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="p-8 text-center text-gray-500">
{{ _('No stock movements found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Purchase Orders', 'url': url_for('inventory.list_purchase_orders')},
{'text': 'New Purchase Order'}
] %}
{{ page_header(
icon_class='fas fa-shopping-cart',
title_text='New Purchase Order',
subtitle_text='Create a new purchase order to a supplier',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-6xl mx-auto">
<form method="POST" action="{{ url_for('inventory.new_purchase_order') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Basic Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>{{ _('Basic Information') }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="supplier_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Supplier') }} *</label>
<select name="supplier_id" id="supplier_id" class="form-input" required>
<option value="">{{ _('Select Supplier') }}</option>
{% for supplier in suppliers %}
<option value="{{ supplier.id }}">{{ supplier.code }} - {{ supplier.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="order_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Order Date') }} *</label>
<input type="date" name="order_date" id="order_date" value="{{ request.form.get('order_date', '') }}" class="form-input" required>
</div>
<div>
<label for="expected_delivery_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Expected Delivery Date') }}</label>
<input type="date" name="expected_delivery_date" id="expected_delivery_date" value="{{ request.form.get('expected_delivery_date', '') }}" class="form-input">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Currency') }}</label>
<select name="currency_code" id="currency_code" class="form-input">
<option value="EUR" selected>EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
<option value="CHF">CHF</option>
</select>
</div>
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="2" class="form-input" placeholder="{{ _('Notes visible to supplier') }}">{{ request.form.get('notes', '') }}</textarea>
</div>
<div class="md:col-span-2">
<label for="internal_notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Internal Notes') }}</label>
<textarea name="internal_notes" id="internal_notes" rows="2" class="form-input" placeholder="{{ _('Internal notes (not visible to supplier)') }}">{{ request.form.get('internal_notes', '') }}</textarea>
</div>
</div>
</div>
<!-- Purchase Order Items -->
<div class="mb-6">
<div class="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold">
<i class="fas fa-list mr-2"></i>{{ _('Items') }}
</h3>
<button type="button" id="add-item" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-gray-500 uppercase">
<div class="md:col-span-3">{{ _('Stock Item') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Qty') }}</div>
<div class="md:col-span-1">{{ _('Unit Cost') }}</div>
<div class="md:col-span-1">{{ _('Total') }}</div>
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
<div class="md:col-span-1">{{ _('Supplier SKU') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
<div id="po-items" class="space-y-2">
<!-- Items will be added here dynamically -->
</div>
<div class="mt-4 p-3 bg-primary/5 rounded-lg border border-primary/20">
<div class="flex justify-between items-center">
<span class="text-sm font-medium">{{ _('Subtotal') }}:</span>
<span class="text-lg font-bold text-primary" id="po-subtotal">0.00</span>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="{{ url_for('inventory.list_purchase_orders') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Create Purchase Order') }}
</button>
</div>
</form>
</div>
{% block scripts_extra %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const itemsContainer = document.getElementById('po-items');
const addItemBtn = document.getElementById('add-item');
const supplierSelect = document.getElementById('supplier_id');
const stockItems = {{ stock_items | tojson if stock_items else '[]' }};
const warehouses = {{ warehouses | tojson if warehouses else '[]' }};
// Build stock items dropdown HTML
function buildStockItemsHtml(selectedSupplierId = null) {
let html = '<option value="">{{ _("Select Item") }}</option>';
stockItems.forEach(item => {
html += '<option value="' + item.id + '" data-name="' + (item.name || '').replace(/"/g, '&quot;') + '" data-unit="' + (item.unit || '') + '">' + item.sku + ' - ' + item.name + '</option>';
});
return html;
}
// Build warehouses dropdown HTML
function buildWarehousesHtml() {
let html = '<option value="">{{ _("Select Warehouse") }}</option>';
warehouses.forEach(wh => {
html += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
});
return html;
}
// Add item row
function addItemRow(item = null) {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 po-item-row';
row.innerHTML =
'<input type="hidden" name="item_stock_item_id[]" value="' + (item && item.stock_item_id ? item.stock_item_id : '') + '">' +
'<input type="hidden" name="item_supplier_stock_item_id[]" value="' + (item && item.supplier_stock_item_id ? item.supplier_stock_item_id : '') + '">' +
'<select name="item_stock_item_id[]" class="md:col-span-3 form-input text-sm item-stock-select">' + buildStockItemsHtml() + '</select>' +
'<input type="text" name="item_description[]" placeholder="{{ _("Description") }}" value="' + (item ? (item.description || '').replace(/"/g, '&quot;') : '') + '" class="md:col-span-2 form-input text-sm item-description" required>' +
'<input type="number" name="item_quantity[]" placeholder="{{ _("Qty") }}" value="' + (item ? item.quantity_ordered : '1') + '" step="0.01" min="0" class="md:col-span-1 form-input text-sm item-quantity" required data-calc-trigger>' +
'<input type="number" name="item_unit_cost[]" placeholder="{{ _("Cost") }}" value="' + (item ? item.unit_cost : '') + '" step="0.01" min="0" class="md:col-span-1 form-input text-sm item-cost" required data-calc-trigger>' +
'<div class="md:col-span-1 flex items-center font-medium item-total">0.00</div>' +
'<select name="item_warehouse_id[]" class="md:col-span-2 form-input text-sm">' + buildWarehousesHtml() + '</select>' +
'<input type="text" name="item_supplier_sku[]" placeholder="{{ _("Supplier SKU") }}" value="' + (item ? (item.supplier_sku || '') : '') + '" class="md:col-span-1 form-input text-sm">' +
'<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition text-sm" title="{{ _("Remove") }}">' +
'<i class="fas fa-trash"></i>' +
'</button>';
itemsContainer.appendChild(row);
// Setup stock item select handler
const stockSelect = row.querySelector('.item-stock-select');
stockSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const descInput = row.querySelector('.item-description');
if (selectedOption && selectedOption.dataset.name && !descInput.value) {
descInput.value = selectedOption.dataset.name;
}
});
// Setup calculation triggers
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
input.addEventListener('input', calculateTotals);
});
// Setup remove button
row.querySelector('.remove-item').addEventListener('click', function() {
row.remove();
calculateTotals();
});
calculateTotals();
}
// Calculate totals
function calculateTotals() {
let subtotal = 0;
document.querySelectorAll('.po-item-row').forEach(row => {
const qty = parseFloat(row.querySelector('.item-quantity')?.value || 0);
const cost = parseFloat(row.querySelector('.item-cost')?.value || 0);
const total = qty * cost;
const totalEl = row.querySelector('.item-total');
if (totalEl) {
totalEl.textContent = total.toFixed(2);
}
subtotal += total;
});
document.getElementById('po-subtotal').textContent = subtotal.toFixed(2);
}
// Add item button
addItemBtn.addEventListener('click', function() {
addItemRow();
});
// Add initial row
addItemRow();
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Purchase Orders'}
] %}
{{ page_header(
icon_class='fas fa-shopping-cart',
title_text='Purchase Orders',
subtitle_text='Manage purchase orders to suppliers',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.new_purchase_order") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Purchase Order</a>' if (current_user.is_admin or has_permission('manage_inventory')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Status') }}</label>
<select name="status" id="status" class="form-input">
<option value="">{{ _('All') }}</option>
<option value="draft" {% if selected_status == 'draft' %}selected{% endif %}>{{ _('Draft') }}</option>
<option value="sent" {% if selected_status == 'sent' %}selected{% endif %}>{{ _('Sent') }}</option>
<option value="confirmed" {% if selected_status == 'confirmed' %}selected{% endif %}>{{ _('Confirmed') }}</option>
<option value="received" {% if selected_status == 'received' %}selected{% endif %}>{{ _('Received') }}</option>
<option value="cancelled" {% if selected_status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
</select>
</div>
<div>
<label for="supplier_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Supplier') }}</label>
<select name="supplier_id" id="supplier_id" class="form-input">
<option value="">{{ _('All Suppliers') }}</option>
{% for supplier in suppliers %}
<option value="{{ supplier.id }}" {% if selected_supplier_id == supplier.id %}selected{% endif %}>{{ supplier.code }} - {{ supplier.name }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('PO Number') }}</th>
<th class="p-4">{{ _('Supplier') }}</th>
<th class="p-4">{{ _('Order Date') }}</th>
<th class="p-4">{{ _('Expected Delivery') }}</th>
<th class="p-4">{{ _('Status') }}</th>
<th class="p-4">{{ _('Total Amount') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for po in purchase_orders %}
<tr>
<td class="p-4 font-mono text-sm font-medium">
<a href="{{ url_for('inventory.view_purchase_order', po_id=po.id) }}" class="text-primary hover:underline">
{{ po.po_number }}
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_supplier', supplier_id=po.supplier_id) }}" class="text-primary hover:underline">
{{ po.supplier.name }}
</a>
</td>
<td class="p-4">{{ po.order_date.strftime('%Y-%m-%d') if po.order_date else '—' }}</td>
<td class="p-4">{{ po.expected_delivery_date.strftime('%Y-%m-%d') if po.expected_delivery_date else '—' }}</td>
<td class="p-4">
{% if po.status == 'draft' %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Draft') }}</span>
{% elif po.status == 'sent' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Sent') }}</span>
{% elif po.status == 'confirmed' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">{{ _('Confirmed') }}</span>
{% elif po.status == 'received' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Received') }}</span>
{% elif po.status == 'cancelled' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">{{ _('Cancelled') }}</span>
{% endif %}
</td>
<td class="p-4 font-medium">
{{ "%.2f"|format(po.total_amount) }} {{ po.currency_code }}
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_purchase_order', po_id=po.id) }}" class="text-primary hover:underline mr-3">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-8 text-center text-gray-500">
{{ _('No purchase orders found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Purchase Orders', 'url': url_for('inventory.list_purchase_orders')},
{'text': purchase_order.po_number}
] %}
{{ page_header(
icon_class='fas fa-shopping-cart',
title_text=purchase_order.po_number,
subtitle_text=purchase_order.supplier.name,
breadcrumbs=breadcrumbs,
actions_html=('<a href="' + url_for("inventory.list_purchase_orders") + '" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors mr-2"><i class="fas fa-arrow-left mr-2"></i>Back</a>' +
('<a href="' + url_for("inventory.edit_purchase_order", po_id=purchase_order.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors mr-2"><i class="fas fa-edit mr-2"></i>Edit</a>' if (purchase_order.status != 'received' and purchase_order.status != 'cancelled' and (current_user.is_admin or has_permission('manage_inventory'))) else '') +
('<form method="POST" action="' + url_for("inventory.send_purchase_order", po_id=purchase_order.id) + '" class="inline-block mr-2"><input type="hidden" name="csrf_token" value="' + csrf_token() + '"><button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"><i class="fas fa-paper-plane mr-2"></i>Send</button></form>' if (purchase_order.status == 'draft' and (current_user.is_admin or has_permission('manage_inventory'))) else '') +
('<form method="POST" action="' + url_for("inventory.cancel_purchase_order", po_id=purchase_order.id) + '" class="inline-block mr-2"><input type="hidden" name="csrf_token" value="' + csrf_token() + '"><button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors" onclick="return confirm(\'' + _('Are you sure you want to cancel this purchase order?') + '\')"><i class="fas fa-times mr-2"></i>Cancel</button></form>' if (purchase_order.status not in ['received', 'cancelled'] and (current_user.is_admin or has_permission('manage_inventory'))) else '') +
('<form method="POST" action="' + url_for("inventory.delete_purchase_order", po_id=purchase_order.id) + '" class="inline-block"><input type="hidden" name="csrf_token" value="' + csrf_token() + '"><button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors" onclick="return confirm(\'' + _('Are you sure you want to delete this purchase order?') + '\')"><i class="fas fa-trash mr-2"></i>Delete</button></form>' if (purchase_order.status not in ['received', 'cancelled'] and (current_user.is_admin or has_permission('manage_inventory'))) else '')) if (current_user.is_admin or has_permission('view_inventory')) else None
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<!-- Purchase Order Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Purchase Order Details') }}</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('PO Number') }}</label>
<p class="mt-1 font-mono font-semibold">{{ purchase_order.po_number }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Status') }}</label>
<p class="mt-1">
{% if purchase_order.status == 'draft' %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Draft') }}</span>
{% elif purchase_order.status == 'sent' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Sent') }}</span>
{% elif purchase_order.status == 'confirmed' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">{{ _('Confirmed') }}</span>
{% elif purchase_order.status == 'received' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Received') }}</span>
{% elif purchase_order.status == 'cancelled' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">{{ _('Cancelled') }}</span>
{% endif %}
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Supplier') }}</label>
<p class="mt-1">
<a href="{{ url_for('inventory.view_supplier', supplier_id=purchase_order.supplier_id) }}" class="text-primary hover:underline">
{{ purchase_order.supplier.name }}
</a>
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Order Date') }}</label>
<p class="mt-1">{{ purchase_order.order_date.strftime('%Y-%m-%d') if purchase_order.order_date else '—' }}</p>
</div>
{% if purchase_order.expected_delivery_date %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Expected Delivery') }}</label>
<p class="mt-1">{{ purchase_order.expected_delivery_date.strftime('%Y-%m-%d') }}</p>
</div>
{% endif %}
{% if purchase_order.received_date %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Received Date') }}</label>
<p class="mt-1">{{ purchase_order.received_date.strftime('%Y-%m-%d') }}</p>
</div>
{% endif %}
{% if purchase_order.notes %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Notes') }}</label>
<p class="mt-1">{{ purchase_order.notes }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Items -->
{% if purchase_order.items.count() > 0 %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Items') }}</h2>
<div class="overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('Description') }}</th>
<th class="p-4">{{ _('Quantity Ordered') }}</th>
<th class="p-4">{{ _('Quantity Received') }}</th>
<th class="p-4">{{ _('Unit Cost') }}</th>
<th class="p-4">{{ _('Line Total') }}</th>
</tr>
</thead>
<tbody>
{% for item in purchase_order.items %}
<tr>
<td class="p-4">
{% if item.stock_item %}
<a href="{{ url_for('inventory.view_stock_item', item_id=item.stock_item_id) }}" class="text-primary hover:underline">
{{ item.stock_item.sku }}
</a>
{% else %}
{{ item.supplier_sku or '—' }}
{% endif %}
</td>
<td class="p-4">{{ item.description }}</td>
<td class="p-4">{{ item.quantity_ordered }}</td>
<td class="p-4 {% if item.quantity_received < item.quantity_ordered %}text-amber-600{% else %}text-green-600{% endif %}">
{{ item.quantity_received }}
</td>
<td class="p-4">{{ "%.2f"|format(item.unit_cost) }} {{ item.currency_code }}</td>
<td class="p-4 font-medium">{{ "%.2f"|format(item.line_total) }} {{ item.currency_code }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="font-semibold">
<td colspan="5" class="p-4 text-right">{{ _('Subtotal') }}:</td>
<td class="p-4">{{ "%.2f"|format(purchase_order.subtotal) }} {{ purchase_order.currency_code }}</td>
</tr>
{% if purchase_order.shipping_cost > 0 %}
<tr>
<td colspan="5" class="p-4 text-right">{{ _('Shipping') }}:</td>
<td class="p-4">{{ "%.2f"|format(purchase_order.shipping_cost) }} {{ purchase_order.currency_code }}</td>
</tr>
{% endif %}
{% if purchase_order.tax_amount > 0 %}
<tr>
<td colspan="5" class="p-4 text-right">{{ _('Tax') }}:</td>
<td class="p-4">{{ "%.2f"|format(purchase_order.tax_amount) }} {{ purchase_order.currency_code }}</td>
</tr>
{% endif %}
<tr class="font-bold text-lg">
<td colspan="5" class="p-4 text-right">{{ _('Total') }}:</td>
<td class="p-4">{{ "%.2f"|format(purchase_order.total_amount) }} {{ purchase_order.currency_code }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Receive Purchase Order Form -->
{% if purchase_order.status != 'received' and purchase_order.status != 'cancelled' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Receive Purchase Order') }}</h2>
<form method="POST" action="{{ url_for('inventory.receive_purchase_order', po_id=purchase_order.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="received_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Received Date') }}</label>
<input type="date" name="received_date" id="received_date" value="{{ datetime.now().strftime('%Y-%m-%d') }}" class="form-input">
</div>
<div class="space-y-2 mb-4">
{% for item in purchase_order.items %}
<div class="flex items-center gap-4 p-2 bg-gray-50 dark:bg-gray-800 rounded">
<span class="flex-1 text-sm">{{ item.description }}</span>
<span class="text-sm text-gray-500">Ordered: {{ item.quantity_ordered }}</span>
<input type="hidden" name="item_id[]" value="{{ item.id }}">
<input type="number" name="quantity_received[]" value="{{ item.quantity_received or item.quantity_ordered }}" step="0.01" min="0" max="{{ item.quantity_ordered }}" class="w-24 form-input text-sm" placeholder="{{ _('Received') }}">
</div>
{% endfor %}
</div>
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-check mr-2"></i>{{ _('Mark as Received') }}
</button>
</form>
</div>
{% endif %}
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<p class="text-gray-500 text-center">{{ _('No items in this purchase order.') }}</p>
</div>
{% endif %}
</div>
<div>
<!-- Summary -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">{{ _('Summary') }}</h3>
<div class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Total Items') }}</label>
<p class="text-2xl font-bold mt-1">{{ purchase_order.items.count() }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Total Amount') }}</label>
<p class="text-2xl font-bold text-primary mt-1">
{{ "%.2f"|format(purchase_order.total_amount) }} {{ purchase_order.currency_code }}
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Created') }}</label>
<p class="text-sm mt-1">{{ purchase_order.created_at.strftime('%Y-%m-%d %H:%M') if purchase_order.created_at else '—' }}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Reports'}
] %}
{{ page_header(
icon_class='fas fa-chart-pie',
title_text='Inventory Reports',
subtitle_text='Inventory analytics and reporting',
breadcrumbs=breadcrumbs
) }}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('Total Items') }}</p>
<p class="text-3xl font-bold mt-2">{{ total_items }}</p>
</div>
<div class="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<i class="fas fa-cubes text-blue-600 dark:text-blue-400 text-2xl"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('Total Warehouses') }}</p>
<p class="text-3xl font-bold mt-2">{{ total_warehouses }}</p>
</div>
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg">
<i class="fas fa-warehouse text-green-600 dark:text-green-400 text-2xl"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('Total Inventory Value') }}</p>
<p class="text-3xl font-bold mt-2">{{ "%.2f"|format(total_value) }} EUR</p>
</div>
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<i class="fas fa-euro-sign text-purple-600 dark:text-purple-400 text-2xl"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('Low Stock Items') }}</p>
<p class="text-3xl font-bold mt-2 {% if low_stock_count > 0 %}text-red-600{% else %}text-green-600{% endif %}">{{ low_stock_count }}</p>
</div>
<div class="p-3 bg-red-100 dark:bg-red-900/30 rounded-lg">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-2xl"></i>
</div>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Available Reports') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="{{ url_for('inventory.reports_valuation') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-primary/90 transition-colors">
<i class="fas fa-coins text-2xl mb-2"></i>
<p class="font-semibold">{{ _('Stock Valuation') }}</p>
<p class="text-sm opacity-90">{{ _('View inventory value by warehouse') }}</p>
</a>
<a href="{{ url_for('inventory.reports_movement_history') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-primary/90 transition-colors">
<i class="fas fa-history text-2xl mb-2"></i>
<p class="font-semibold">{{ _('Movement History') }}</p>
<p class="text-sm opacity-90">{{ _('Detailed movement log') }}</p>
</a>
<a href="{{ url_for('inventory.reports_turnover') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-primary/90 transition-colors">
<i class="fas fa-chart-line text-2xl mb-2"></i>
<p class="font-semibold">{{ _('Turnover Analysis') }}</p>
<p class="text-sm opacity-90">{{ _('Inventory turnover rates') }}</p>
</a>
<a href="{{ url_for('inventory.reports_low_stock') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-primary/90 transition-colors">
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
<p class="font-semibold">{{ _('Low Stock Report') }}</p>
<p class="text-sm opacity-90">{{ _('Items below reorder point') }}</p>
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Reports', 'url': url_for('inventory.reports_dashboard')},
{'text': 'Low Stock Report'}
] %}
{{ page_header(
icon_class='fas fa-exclamation-triangle',
title_text='Low Stock Report',
subtitle_text='Items below reorder point',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
{% if low_stock_items %}
<div class="mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ _('Found %(count)s items below their reorder point.', count=low_stock_items|length) }}
</p>
</div>
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('SKU') }}</th>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Quantity On Hand') }}</th>
<th class="p-4">{{ _('Reorder Point') }}</th>
<th class="p-4">{{ _('Shortfall') }}</th>
<th class="p-4">{{ _('Reorder Quantity') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for item_data in low_stock_items %}
<tr>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=item_data.item.id) }}" class="text-primary hover:underline font-medium">
{{ item_data.item.name }}
</a>
</td>
<td class="p-4 font-mono text-sm">{{ item_data.item.sku }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=item_data.warehouse.id) }}" class="text-primary hover:underline">
{{ item_data.warehouse.code }}
</a>
</td>
<td class="p-4 font-semibold text-red-600">{{ item_data.quantity_on_hand }}</td>
<td class="p-4">{{ item_data.reorder_point }}</td>
<td class="p-4 font-semibold text-red-600">{{ item_data.shortfall }}</td>
<td class="p-4">{{ item_data.reorder_quantity }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.new_purchase_order') }}" class="text-primary hover:underline text-sm">
<i class="fas fa-shopping-cart mr-1"></i>{{ _('Create PO') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-8 text-center">
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">{{ _('All Stock Levels are Good') }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('No items are currently below their reorder point.') }}</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Reports', 'url': url_for('inventory.reports_dashboard')},
{'text': 'Movement History'}
] %}
{{ page_header(
icon_class='fas fa-history',
title_text='Movement History Report',
subtitle_text='Detailed inventory movement log',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div>
<label for="warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Warehouse') }}</label>
<select name="warehouse_id" id="warehouse_id" class="form-input">
<option value="">{{ _('All Warehouses') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if selected_warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</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>
<select name="stock_item_id" id="stock_item_id" class="form-input">
<option value="">{{ _('All Items') }}</option>
{% for item in stock_items %}
<option value="{{ item.id }}" {% if selected_stock_item_id == item.id %}selected{% endif %}>{{ item.sku }} - {{ item.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="movement_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Movement Type') }}</label>
<select name="movement_type" id="movement_type" class="form-input">
<option value="">{{ _('All Types') }}</option>
<option value="adjustment" {% if selected_movement_type == 'adjustment' %}selected{% endif %}>{{ _('Adjustment') }}</option>
<option value="transfer" {% if selected_movement_type == 'transfer' %}selected{% endif %}>{{ _('Transfer') }}</option>
<option value="sale" {% if selected_movement_type == 'sale' %}selected{% endif %}>{{ _('Sale') }}</option>
<option value="purchase" {% if selected_movement_type == 'purchase' %}selected{% endif %}>{{ _('Purchase') }}</option>
<option value="return" {% if selected_movement_type == 'return' %}selected{% endif %}>{{ _('Return') }}</option>
</select>
</div>
<div>
<label for="date_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date From') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
<div>
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date To') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}" class="form-input">
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Date') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Type') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Reference') }}</th>
<th class="p-4">{{ _('Reason') }}</th>
<th class="p-4">{{ _('User') }}</th>
</tr>
</thead>
<tbody>
{% for movement in movements %}
<tr>
<td class="p-4">{{ movement.moved_at.strftime('%Y-%m-%d %H:%M') if movement.moved_at else '—' }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=movement.stock_item_id) }}" class="text-primary hover:underline">
{{ movement.stock_item.name }} ({{ movement.stock_item.sku }})
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=movement.warehouse_id) }}" class="text-primary hover:underline">
{{ movement.warehouse.code }}
</a>
</td>
<td class="p-4">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 capitalize">
{{ movement.movement_type }}
</span>
</td>
<td class="p-4 {% if movement.quantity > 0 %}text-green-600{% else %}text-red-600{% endif %} font-semibold">
{{ '+' if movement.quantity > 0 else '' }}{{ movement.quantity }}
</td>
<td class="p-4">
{% if movement.reference_type and movement.reference_id %}
{% if movement.reference_type == 'invoice' %}
<a href="{{ url_for('invoices.view_invoice', invoice_id=movement.reference_id) }}" class="text-primary hover:underline">
Invoice #{{ movement.reference_id }}
</a>
{% elif movement.reference_type == 'quote' %}
<a href="{{ url_for('quotes.view_quote', quote_id=movement.reference_id) }}" class="text-primary hover:underline">
Quote #{{ movement.reference_id }}
</a>
{% elif movement.reference_type == 'purchase_order' %}
<a href="{{ url_for('inventory.view_purchase_order', po_id=movement.reference_id) }}" class="text-primary hover:underline">
PO #{{ movement.reference_id }}
</a>
{% else %}
{{ movement.reference_type }} #{{ movement.reference_id }}
{% endif %}
{% else %}
{% endif %}
</td>
<td class="p-4">{{ movement.reason or '—' }}</td>
<td class="p-4">{{ movement.moved_by_user.username if movement.moved_by_user else '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="p-8 text-center text-gray-500">
{{ _('No movements found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Reports', 'url': url_for('inventory.reports_dashboard')},
{'text': 'Turnover Analysis'}
] %}
{{ page_header(
icon_class='fas fa-chart-line',
title_text='Inventory Turnover Analysis',
subtitle_text='Item turnover rates and sales analysis',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="date_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date From') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}" class="form-input">
</div>
<div>
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date To') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ _('Turnover rate indicates how many times inventory is sold and replaced in a year. Higher rates indicate faster-moving items.') }}
</p>
</div>
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('SKU') }}</th>
<th class="p-4">{{ _('Total Sold') }}</th>
<th class="p-4">{{ _('Avg Stock Level') }}</th>
<th class="p-4">{{ _('Turnover Rate') }}</th>
<th class="p-4">{{ _('Status') }}</th>
</tr>
</thead>
<tbody>
{% for data in turnover_data %}
<tr>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=data.item.id) }}" class="text-primary hover:underline">
{{ data.item.name }}
</a>
</td>
<td class="p-4 font-mono text-sm">{{ data.item.sku }}</td>
<td class="p-4">{{ "%.2f"|format(data.total_sold) }}</td>
<td class="p-4">{{ "%.2f"|format(data.avg_stock) }}</td>
<td class="p-4 font-semibold">{{ "%.2f"|format(data.turnover_rate) }}</td>
<td class="p-4">
{% if data.turnover_rate >= 4 %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Fast Moving') }}</span>
{% elif data.turnover_rate >= 2 %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Normal') }}</span>
{% elif data.turnover_rate >= 1 %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">{{ _('Slow Moving') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">{{ _('Very Slow') }}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="p-8 text-center text-gray-500">
{{ _('No sales data found for the selected period.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Reports', 'url': url_for('inventory.reports_dashboard')},
{'text': 'Stock Valuation'}
] %}
{{ page_header(
icon_class='fas fa-coins',
title_text='Stock Valuation Report',
subtitle_text='Inventory value by warehouse and category',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Warehouse') }}</label>
<select name="warehouse_id" id="warehouse_id" class="form-input">
<option value="">{{ _('All Warehouses') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if selected_warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Category') }}</label>
<select name="category" id="category" class="form-input">
<option value="">{{ _('All Categories') }}</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if selected_category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">{{ _('Inventory Valuation') }}</h2>
<div class="text-right">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('Total Value') }}</p>
<p class="text-2xl font-bold text-primary">{{ "%.2f"|format(total_value) }} EUR</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('SKU') }}</th>
<th class="p-4">{{ _('Category') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Unit Cost') }}</th>
<th class="p-4">{{ _('Total Value') }}</th>
</tr>
</thead>
<tbody>
{% for item_data in items_with_value %}
<tr>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=item_data.stock.warehouse_id) }}" class="text-primary hover:underline">
{{ item_data.stock.warehouse.code }}
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=item_data.stock.stock_item_id) }}" class="text-primary hover:underline">
{{ item_data.stock.stock_item.name }}
</a>
</td>
<td class="p-4 font-mono text-sm">{{ item_data.stock.stock_item.sku }}</td>
<td class="p-4">{{ item_data.stock.stock_item.category or '—' }}</td>
<td class="p-4">{{ item_data.stock.quantity_on_hand }}</td>
<td class="p-4">{{ "%.2f"|format(item_data.stock.stock_item.default_cost or 0) }} EUR</td>
<td class="p-4 font-semibold">{{ "%.2f"|format(item_data.value) }} EUR</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-8 text-center text-gray-500">
{{ _('No items found with cost information.') }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="font-bold">
<td colspan="6" class="p-4 text-right">{{ _('Total Value') }}:</td>
<td class="p-4">{{ "%.2f"|format(total_value) }} EUR</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Reservations'}
] %}
{{ page_header(
icon_class='fas fa-bookmark',
title_text='Stock Reservations',
subtitle_text='Manage reserved stock for quotes and invoices',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Status') }}</label>
<select name="status" id="status" class="form-input">
<option value="all" {% if status == 'all' %}selected{% endif %}>{{ _('All') }}</option>
<option value="reserved" {% if status == 'reserved' %}selected{% endif %}>{{ _('Reserved') }}</option>
<option value="fulfilled" {% if status == 'fulfilled' %}selected{% endif %}>{{ _('Fulfilled') }}</option>
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
<option value="expired" {% if status == 'expired' %}selected{% endif %}>{{ _('Expired') }}</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Type') }}</th>
<th class="p-4">{{ _('Reference') }}</th>
<th class="p-4">{{ _('Status') }}</th>
<th class="p-4">{{ _('Reserved At') }}</th>
<th class="p-4">{{ _('Expires At') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations %}
<tr>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=reservation.stock_item_id) }}" class="text-primary hover:underline">
{{ reservation.stock_item.name }} ({{ reservation.stock_item.sku }})
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=reservation.warehouse_id) }}" class="text-primary hover:underline">
{{ reservation.warehouse.code }}
</a>
</td>
<td class="p-4 font-semibold">{{ reservation.quantity }}</td>
<td class="p-4 capitalize">{{ reservation.reservation_type }}</td>
<td class="p-4">
{% if reservation.reservation_type == 'invoice' %}
<a href="{{ url_for('invoices.view_invoice', invoice_id=reservation.reservation_id) }}" class="text-primary hover:underline">
Invoice #{{ reservation.reservation_id }}
</a>
{% elif reservation.reservation_type == 'quote' %}
<a href="{{ url_for('quotes.view_quote', quote_id=reservation.reservation_id) }}" class="text-primary hover:underline">
Quote #{{ reservation.reservation_id }}
</a>
{% else %}
{{ reservation.reservation_type }} #{{ reservation.reservation_id }}
{% endif %}
</td>
<td class="p-4">
{% if reservation.status == 'reserved' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Reserved') }}</span>
{% elif reservation.status == 'fulfilled' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Fulfilled') }}</span>
{% elif reservation.status == 'cancelled' %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Cancelled') }}</span>
{% elif reservation.status == 'expired' %}
<span class="px-2 py-1 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">{{ _('Expired') }}</span>
{% endif %}
</td>
<td class="p-4">{{ reservation.reserved_at.strftime('%Y-%m-%d %H:%M') if reservation.reserved_at else '—' }}</td>
<td class="p-4">
{% if reservation.expires_at %}
{{ reservation.expires_at.strftime('%Y-%m-%d') if reservation.expires_at else '—' }}
{% if reservation.is_expired %}
<span class="ml-2 text-amber-500" title="{{ _('Expired') }}"><i class="fas fa-exclamation-triangle"></i></span>
{% endif %}
{% else %}
{% endif %}
</td>
<td class="p-4">
{% if reservation.status == 'reserved' and (current_user.is_admin or has_permission('manage_stock_reservations')) %}
<form method="POST" action="{{ url_for('inventory.fulfill_reservation', reservation_id=reservation.id) }}" class="inline mr-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-green-600 hover:underline" onclick="return confirm('{{ _('Fulfill this reservation?') }}')">
<i class="fas fa-check"></i>
</button>
</form>
<form method="POST" action="{{ url_for('inventory.cancel_reservation', reservation_id=reservation.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:underline" onclick="return confirm('{{ _('Cancel this reservation?') }}')">
<i class="fas fa-times"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="p-8 text-center text-gray-500">
{{ _('No reservations found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Items', 'url': url_for('inventory.list_stock_items')},
{'text': item.name if item else 'New Stock Item'}
] %}
{{ page_header(
icon_class='fas fa-cubes',
title_text=item.name if item else 'New Stock Item',
subtitle_text='Create or edit stock item' if not item else 'Edit stock item',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-4xl mx-auto">
<form method="POST" action="{% if item %}{{ url_for('inventory.edit_stock_item', item_id=item.id) }}{% else %}{{ url_for('inventory.new_stock_item') }}{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="sku" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('SKU') }} *</label>
<input type="text" name="sku" id="sku" value="{{ item.sku if item else '' }}" class="form-input" required>
</div>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Name') }} *</label>
<input type="text" name="name" id="name" value="{{ item.name if item else '' }}" class="form-input" required>
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Description') }}</label>
<textarea name="description" id="description" rows="3" class="form-input">{{ item.description if item else '' }}</textarea>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Category') }}</label>
<input type="text" name="category" id="category" value="{{ item.category if item else '' }}" class="form-input" list="categories">
<datalist id="categories">
<option value="Electronics">
<option value="Tools">
<option value="Materials">
<option value="Services">
</datalist>
</div>
<div>
<label for="unit" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Unit') }}</label>
<input type="text" name="unit" id="unit" value="{{ item.unit if item else 'pcs' }}" class="form-input">
</div>
<div>
<label for="barcode" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Barcode') }}</label>
<input type="text" name="barcode" id="barcode" value="{{ item.barcode if item else '' }}" class="form-input">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Currency') }}</label>
<select name="currency_code" id="currency_code" class="form-input">
<option value="EUR" {% if not item or item.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
<option value="USD" {% if item and item.currency_code == 'USD' %}selected{% endif %}>USD</option>
<option value="GBP" {% if item and item.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
</select>
</div>
<div>
<label for="default_cost" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Default Cost') }}</label>
<input type="number" name="default_cost" id="default_cost" value="{{ item.default_cost if item else '' }}" step="0.01" class="form-input">
</div>
<div>
<label for="default_price" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Default Price') }}</label>
<input type="number" name="default_price" id="default_price" value="{{ item.default_price if item else '' }}" step="0.01" class="form-input">
</div>
<div>
<label for="reorder_point" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Reorder Point') }}</label>
<input type="number" name="reorder_point" id="reorder_point" value="{{ item.reorder_point if item else '' }}" step="0.01" class="form-input">
</div>
<div>
<label for="reorder_quantity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Reorder Quantity') }}</label>
<input type="number" name="reorder_quantity" id="reorder_quantity" value="{{ item.reorder_quantity if item else '' }}" step="0.01" class="form-input">
</div>
<div class="md:col-span-2">
<label for="image_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Image URL') }}</label>
<input type="url" name="image_url" id="image_url" value="{{ item.image_url if item else '' }}" class="form-input">
</div>
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input">{{ item.notes if item else '' }}</textarea>
</div>
<div class="md:col-span-2 space-y-2">
<label class="flex items-center">
<input type="checkbox" name="is_active" {% if not item or item.is_active %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm">{{ _('Active') }}</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="is_trackable" {% if not item or item.is_trackable %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm">{{ _('Track Inventory') }}</span>
</label>
</div>
</div>
<!-- Suppliers Section -->
<div class="mt-8 border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Suppliers') }}</h3>
<p class="text-sm text-gray-500 mb-4">{{ _('Manage multiple suppliers for this item with different pricing.') }}</p>
<div id="suppliers-container" class="space-y-4">
{% if item %}
{% for supplier_item in item.supplier_items.filter_by(is_active=True).all() %}
<div class="supplier-row grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<input type="hidden" name="supplier_item_id[]" value="{{ supplier_item.id }}">
<select name="supplier_id[]" class="md:col-span-3 form-input text-sm" required>
<option value="">{{ _('Select Supplier') }}</option>
{% for supplier in suppliers %}
<option value="{{ supplier.id }}" {% if supplier_item.supplier_id == supplier.id %}selected{% endif %}>{{ supplier.code }} - {{ supplier.name }}</option>
{% endfor %}
</select>
<input type="text" name="supplier_sku[]" placeholder="{{ _('Supplier SKU') }}" value="{{ supplier_item.supplier_sku or '' }}" class="md:col-span-2 form-input text-sm">
<input type="number" name="supplier_unit_cost[]" placeholder="{{ _('Unit Cost') }}" value="{{ supplier_item.unit_cost or '' }}" step="0.01" class="md:col-span-2 form-input text-sm">
<input type="number" name="supplier_moq[]" placeholder="{{ _('MOQ') }}" value="{{ supplier_item.minimum_order_quantity or '' }}" step="0.01" class="md:col-span-1 form-input text-sm">
<input type="number" name="supplier_lead_time[]" placeholder="{{ _('Lead Time (days)') }}" value="{{ supplier_item.lead_time_days or '' }}" class="md:col-span-1 form-input text-sm">
<label class="md:col-span-1 flex items-center">
<input type="checkbox" name="supplier_preferred[]" value="{{ supplier_item.id }}" {% if supplier_item.is_preferred %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-xs">{{ _('Preferred') }}</span>
</label>
<button type="button" class="remove-supplier md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition text-sm" title="{{ _('Remove') }}">
<i class="fas fa-trash"></i>
</button>
</div>
{% endfor %}
{% endif %}
</div>
<button type="button" id="add-supplier" class="mt-4 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-plus mr-2"></i>{{ _('Add Supplier') }}
</button>
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="{{ url_for('inventory.list_stock_items') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Save') }}
</button>
</div>
</form>
</div>
{% block scripts_extra %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const suppliersContainer = document.getElementById('suppliers-container');
const addSupplierBtn = document.getElementById('add-supplier');
const suppliers = {{ suppliers | tojson if suppliers else '[]' }};
// Add supplier row
addSupplierBtn.addEventListener('click', function() {
const row = document.createElement('div');
row.className = 'supplier-row grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700';
let suppliersHtml = '<option value="">{{ _("Select Supplier") }}</option>';
suppliers.forEach(supplier => {
suppliersHtml += '<option value="' + supplier.id + '">' + supplier.code + ' - ' + supplier.name + '</option>';
});
row.innerHTML =
'<input type="hidden" name="supplier_item_id[]" value="">' +
'<select name="supplier_id[]" class="md:col-span-3 form-input text-sm" required>' + suppliersHtml + '</select>' +
'<input type="text" name="supplier_sku[]" placeholder="{{ _("Supplier SKU") }}" class="md:col-span-2 form-input text-sm">' +
'<input type="number" name="supplier_unit_cost[]" placeholder="{{ _("Unit Cost") }}" step="0.01" class="md:col-span-2 form-input text-sm">' +
'<input type="number" name="supplier_moq[]" placeholder="{{ _("MOQ") }}" step="0.01" class="md:col-span-1 form-input text-sm">' +
'<input type="number" name="supplier_lead_time[]" placeholder="{{ _("Lead Time (days)") }}" class="md:col-span-1 form-input text-sm">' +
'<label class="md:col-span-1 flex items-center">' +
'<input type="checkbox" name="supplier_preferred[]" value="" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">' +
'<span class="ml-2 text-xs">{{ _("Preferred") }}</span>' +
'</label>' +
'<button type="button" class="remove-supplier md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition text-sm" title="{{ _("Remove") }}">' +
'<i class="fas fa-trash"></i>' +
'</button>';
suppliersContainer.appendChild(row);
// Setup remove button
row.querySelector('.remove-supplier').addEventListener('click', function() {
row.remove();
});
});
// Setup remove buttons for existing rows
document.querySelectorAll('.remove-supplier').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.supplier-row').remove();
});
});
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Items', 'url': url_for('inventory.list_stock_items')},
{'text': item.name, 'url': url_for('inventory.view_stock_item', item_id=item.id)},
{'text': 'History'}
] %}
{{ page_header(
icon_class='fas fa-history',
title_text='Movement History',
subtitle_text=item.name + ' (' + item.sku + ')',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Warehouse') }}</label>
<select name="warehouse_id" id="warehouse_id" class="form-input">
<option value="">{{ _('All Warehouses') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if selected_warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="movement_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Movement Type') }}</label>
<select name="movement_type" id="movement_type" class="form-input">
<option value="">{{ _('All Types') }}</option>
<option value="adjustment" {% if selected_movement_type == 'adjustment' %}selected{% endif %}>{{ _('Adjustment') }}</option>
<option value="transfer" {% if selected_movement_type == 'transfer' %}selected{% endif %}>{{ _('Transfer') }}</option>
<option value="sale" {% if selected_movement_type == 'sale' %}selected{% endif %}>{{ _('Sale') }}</option>
<option value="purchase" {% if selected_movement_type == 'purchase' %}selected{% endif %}>{{ _('Purchase') }}</option>
<option value="return" {% if selected_movement_type == 'return' %}selected{% endif %}>{{ _('Return') }}</option>
</select>
</div>
<div>
<label for="date_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date From') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
<div>
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date To') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}" class="form-input">
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Date') }}</th>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Type') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Reference') }}</th>
<th class="p-4">{{ _('Reason') }}</th>
<th class="p-4">{{ _('User') }}</th>
</tr>
</thead>
<tbody>
{% for movement in movements %}
<tr>
<td class="p-4">{{ movement.moved_at.strftime('%Y-%m-%d %H:%M') if movement.moved_at else '—' }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=movement.warehouse_id) }}" class="text-primary hover:underline">
{{ movement.warehouse.code }}
</a>
</td>
<td class="p-4">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 capitalize">
{{ movement.movement_type }}
</span>
</td>
<td class="p-4 {% if movement.quantity > 0 %}text-green-600{% else %}text-red-600{% endif %} font-semibold">
{{ '+' if movement.quantity > 0 else '' }}{{ movement.quantity }}
</td>
<td class="p-4">
{% if movement.reference_type and movement.reference_id %}
{% if movement.reference_type == 'invoice' %}
<a href="{{ url_for('invoices.view_invoice', invoice_id=movement.reference_id) }}" class="text-primary hover:underline">
Invoice #{{ movement.reference_id }}
</a>
{% elif movement.reference_type == 'quote' %}
<a href="{{ url_for('quotes.view_quote', quote_id=movement.reference_id) }}" class="text-primary hover:underline">
Quote #{{ movement.reference_id }}
</a>
{% elif movement.reference_type == 'purchase_order' %}
<a href="{{ url_for('inventory.view_purchase_order', po_id=movement.reference_id) }}" class="text-primary hover:underline">
PO #{{ movement.reference_id }}
</a>
{% else %}
{{ movement.reference_type }} #{{ movement.reference_id }}
{% endif %}
{% else %}
{% endif %}
</td>
<td class="p-4">{{ movement.reason or '—' }}</td>
<td class="p-4">{{ movement.moved_by_user.username if movement.moved_by_user else '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-8 text-center text-gray-500">
{{ _('No movement history found for this item.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Items'}
] %}
{{ page_header(
icon_class='fas fa-cubes',
title_text='Stock Items',
subtitle_text='Manage your inventory items',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.new_stock_item") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Stock Item</a>' if (current_user.is_admin or has_permission('manage_stock_items')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Search') }}</label>
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input" placeholder="{{ _('SKU, Name, Barcode...') }}">
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Category') }}</label>
<select name="category" id="category" class="form-input">
<option value="">{{ _('All Categories') }}</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end">
<label class="flex items-center">
<input type="checkbox" name="active" value="true" {% if active_only %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm">{{ _('Active Only') }}</span>
</label>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('SKU') }}</th>
<th class="p-4">{{ _('Name') }}</th>
<th class="p-4">{{ _('Category') }}</th>
<th class="p-4">{{ _('Unit') }}</th>
<th class="p-4">{{ _('Total Qty') }}</th>
<th class="p-4">{{ _('Available') }}</th>
<th class="p-4">{{ _('Status') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="p-4 font-mono text-sm">{{ item.sku }}</td>
<td class="p-4 font-medium">
<a href="{{ url_for('inventory.view_stock_item', item_id=item.id) }}" class="text-primary hover:underline">
{{ item.name }}
</a>
</td>
<td class="p-4">{{ item.category or '—' }}</td>
<td class="p-4">{{ item.unit }}</td>
<td class="p-4">
{% if item.is_trackable %}
{{ item.total_quantity_on_hand or 0 }}
{% else %}
<span class="text-gray-400">N/A</span>
{% endif %}
</td>
<td class="p-4">
{% if item.is_trackable %}
{{ item.total_quantity_available or 0 }}
{% if item.is_low_stock %}
<span class="ml-2 text-amber-500" title="{{ _('Low Stock') }}"><i class="fas fa-exclamation-triangle"></i></span>
{% endif %}
{% else %}
<span class="text-gray-400">N/A</span>
{% endif %}
</td>
<td class="p-4">
{% if item.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Inactive') }}</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=item.id) }}" class="text-primary hover:underline mr-3">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or has_permission('manage_stock_items') %}
<a href="{{ url_for('inventory.edit_stock_item', item_id=item.id) }}" class="text-primary hover:underline">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="p-8 text-center text-gray-500">
{{ _('No stock items found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,245 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Items', 'url': url_for('inventory.list_stock_items')},
{'text': item.name}
] %}
{{ page_header(
icon_class='fas fa-cube',
title_text=item.name,
subtitle_text=item.sku,
breadcrumbs=breadcrumbs,
actions_html=('<a href="' + url_for("inventory.edit_stock_item", item_id=item.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors mr-2"><i class="fas fa-edit mr-2"></i>Edit</a>' if (current_user.is_admin or has_permission('manage_stock_items')) else '') +
('<a href="' + url_for("inventory.stock_item_history", item_id=item.id) + '" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors mr-2"><i class="fas fa-history mr-2"></i>History</a>' if (current_user.is_admin or has_permission('view_stock_history')) else '') +
('<a href="' + url_for("inventory.stock_levels_by_item", item_id=item.id) + '" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"><i class="fas fa-list-ul mr-2"></i>Stock Levels</a>' if (current_user.is_admin or has_permission('view_stock_levels')) else '')
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<!-- Item Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Item Details') }}</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('SKU') }}</label>
<p class="mt-1 font-mono">{{ item.sku }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Category') }}</label>
<p class="mt-1">{{ item.category or '—' }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Unit') }}</label>
<p class="mt-1">{{ item.unit }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Barcode') }}</label>
<p class="mt-1 font-mono">{{ item.barcode or '—' }}</p>
</div>
{% if item.description %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Description') }}</label>
<p class="mt-1">{{ item.description }}</p>
</div>
{% endif %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Default Cost') }}</label>
<p class="mt-1">{{ "%.2f"|format(item.default_cost) if item.default_cost else '—' }} {{ item.currency_code }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Default Price') }}</label>
<p class="mt-1">{{ "%.2f"|format(item.default_price) if item.default_price else '—' }} {{ item.currency_code }}</p>
</div>
<!-- Suppliers -->
{% if item.supplier_items.filter_by(is_active=True).count() > 0 %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Suppliers') }}</label>
<div class="mt-2 space-y-2">
{% for supplier_item in item.supplier_items.filter_by(is_active=True).all() %}
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded">
<div class="flex items-center justify-between">
<div>
<a href="{{ url_for('inventory.view_supplier', supplier_id=supplier_item.supplier_id) }}" class="text-primary hover:underline font-medium">
{{ supplier_item.supplier.name }}
</a>
{% if supplier_item.is_preferred %}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<i class="fas fa-star"></i> {{ _('Preferred') }}
</span>
{% endif %}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{% if supplier_item.unit_cost %}
{{ "%.2f"|format(supplier_item.unit_cost) }} {{ supplier_item.currency_code }}
{% endif %}
{% if supplier_item.supplier_sku %}
| SKU: {{ supplier_item.supplier_sku }}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if item.reorder_point %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Reorder Point') }}</label>
<p class="mt-1">{{ item.reorder_point }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Reorder Quantity') }}</label>
<p class="mt-1">{{ item.reorder_quantity or '—' }}</p>
</div>
{% endif %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Status') }}</label>
<p class="mt-1">
{% if item.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Inactive') }}</span>
{% endif %}
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Trackable') }}</label>
<p class="mt-1">
{% if item.is_trackable %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Yes') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('No') }}</span>
{% endif %}
</p>
</div>
</div>
</div>
<!-- Stock Levels by Warehouse -->
{% if item.is_trackable and stock_levels %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Stock Levels by Warehouse') }}</h2>
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('On Hand') }}</th>
<th class="p-4">{{ _('Reserved') }}</th>
<th class="p-4">{{ _('Available') }}</th>
<th class="p-4">{{ _('Location') }}</th>
</tr>
</thead>
<tbody>
{% for stock in stock_levels %}
<tr>
<td class="p-4 font-medium">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=stock.warehouse_id) }}" class="text-primary hover:underline">
{{ stock.warehouse.code }} - {{ stock.warehouse.name }}
</a>
</td>
<td class="p-4">{{ stock.quantity_on_hand }}</td>
<td class="p-4">{{ stock.quantity_reserved }}</td>
<td class="p-4">
{{ stock.quantity_available }}
{% if item.reorder_point and stock.quantity_on_hand < item.reorder_point %}
<span class="ml-2 text-amber-500" title="{{ _('Low Stock') }}"><i class="fas fa-exclamation-triangle"></i></span>
{% endif %}
</td>
<td class="p-4">{{ stock.location or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Recent Movements -->
{% if recent_movements %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Recent Stock Movements') }}</h2>
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Date') }}</th>
<th class="p-4">{{ _('Type') }}</th>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Reason') }}</th>
</tr>
</thead>
<tbody>
{% for movement in recent_movements %}
<tr>
<td class="p-4">{{ movement.moved_at.strftime('%Y-%m-%d %H:%M') if movement.moved_at else '—' }}</td>
<td class="p-4">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 capitalize">
{{ movement.movement_type }}
</span>
</td>
<td class="p-4">{{ movement.warehouse.code }}</td>
<td class="p-4 {% if movement.quantity > 0 %}text-green-600{% else %}text-red-600{% endif %}">
{{ '+' if movement.quantity > 0 else '' }}{{ movement.quantity }}
</td>
<td class="p-4">{{ movement.reason or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<div class="space-y-6">
<!-- Summary Card -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">{{ _('Summary') }}</h3>
{% if item.is_trackable %}
<div class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Total On Hand') }}</label>
<p class="text-2xl font-bold mt-1">{{ item.total_quantity_on_hand or 0 }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Total Reserved') }}</label>
<p class="text-xl font-semibold mt-1">{{ item.total_quantity_reserved or 0 }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Total Available') }}</label>
<p class="text-2xl font-bold text-primary mt-1">{{ item.total_quantity_available or 0 }}</p>
</div>
{% if item.is_low_stock %}
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<p class="text-sm text-amber-800 dark:text-amber-200">
<i class="fas fa-exclamation-triangle mr-2"></i>{{ _('Low Stock Alert') }}
</p>
</div>
{% endif %}
</div>
{% else %}
<p class="text-gray-500">{{ _('This item is not trackable.') }}</p>
{% endif %}
</div>
<!-- Active Reservations -->
{% if active_reservations %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">{{ _('Active Reservations') }}</h3>
<div class="space-y-2">
{% for reservation in active_reservations %}
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<p class="text-sm font-medium">{{ reservation.quantity }} {{ item.unit }}</p>
<p class="text-xs text-gray-500">{{ reservation.reservation_type }} #{{ reservation.reservation_id }}</p>
<p class="text-xs text-gray-500">{{ reservation.warehouse.code }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Items', 'url': url_for('inventory.list_stock_items')},
{'text': item.name, 'url': url_for('inventory.view_stock_item', item_id=item.id)},
{'text': 'Stock Levels'}
] %}
{{ page_header(
icon_class='fas fa-list-ul',
title_text='Stock Levels - ' + item.name,
subtitle_text=item.sku,
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Quantity On Hand') }}</th>
<th class="p-4">{{ _('Quantity Reserved') }}</th>
<th class="p-4">{{ _('Available') }}</th>
<th class="p-4">{{ _('Location') }}</th>
<th class="p-4">{{ _('Last Counted') }}</th>
</tr>
</thead>
<tbody>
{% for stock in stock_levels %}
<tr>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=stock.warehouse_id) }}" class="text-primary hover:underline font-medium">
{{ stock.warehouse.code }} - {{ stock.warehouse.name }}
</a>
</td>
<td class="p-4 font-semibold">{{ stock.quantity_on_hand }}</td>
<td class="p-4">{{ stock.quantity_reserved }}</td>
<td class="p-4 {% if stock.quantity_available < 0 %}text-red-600{% elif stock.quantity_available == 0 %}text-amber-600{% else %}text-green-600{% endif %} font-semibold">
{{ stock.quantity_available }}
</td>
<td class="p-4">{{ stock.location or '—' }}</td>
<td class="p-4">
{% if stock.last_counted_at %}
{{ stock.last_counted_at.strftime('%Y-%m-%d') }}
{% else %}
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="p-8 text-center text-gray-500">
{{ _('No stock found for this item in any warehouse.') }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="font-bold">
<td class="p-4">{{ _('Total') }}:</td>
<td class="p-4">{{ stock_levels|sum(attribute='quantity_on_hand') }}</td>
<td class="p-4">{{ stock_levels|sum(attribute='quantity_reserved') }}</td>
<td class="p-4">{{ stock_levels|sum(attribute='quantity_available') }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Levels'}
] %}
{{ page_header(
icon_class='fas fa-list-ul',
title_text='Stock Levels',
subtitle_text='View inventory levels across warehouses',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Warehouse') }}</label>
<select name="warehouse_id" id="warehouse_id" class="form-input">
<option value="">{{ _('All Warehouses') }}</option>
{% for wh in warehouses %}
<option value="{{ wh.id }}" {% if selected_warehouse_id == wh.id %}selected{% endif %}>{{ wh.code }} - {{ wh.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Category') }}</label>
<select name="category" id="category" class="form-input">
<option value="">{{ _('All Categories') }}</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if selected_category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Warehouse') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('On Hand') }}</th>
<th class="p-4">{{ _('Reserved') }}</th>
<th class="p-4">{{ _('Available') }}</th>
<th class="p-4">{{ _('Location') }}</th>
</tr>
</thead>
<tbody>
{% for stock in stock_levels %}
<tr>
<td class="p-4 font-medium">{{ stock.warehouse.code }} - {{ stock.warehouse.name }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=stock.stock_item.id) }}" class="text-primary hover:underline">
{{ stock.stock_item.name }} ({{ stock.stock_item.sku }})
</a>
</td>
<td class="p-4">{{ stock.quantity_on_hand }}</td>
<td class="p-4">{{ stock.quantity_reserved }}</td>
<td class="p-4">
{{ stock.quantity_available }}
{% if stock.stock_item.reorder_point and stock.quantity_on_hand < stock.stock_item.reorder_point %}
<span class="ml-2 text-amber-500" title="{{ _('Low Stock') }}"><i class="fas fa-exclamation-triangle"></i></span>
{% endif %}
</td>
<td class="p-4">{{ stock.location or '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="p-8 text-center text-gray-500">
{{ _('No stock levels found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Stock Levels', 'url': url_for('inventory.stock_levels')},
{'text': warehouse.code}
] %}
{{ page_header(
icon_class='fas fa-list-ul',
title_text='Stock Levels - ' + warehouse.code,
subtitle_text=warehouse.name,
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Category') }}</label>
<select name="category" id="category" class="form-input">
<option value="">{{ _('All Categories') }}</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if selected_category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-center">
<label class="flex items-center">
<input type="checkbox" name="low_stock" value="true" {% if low_stock_only %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm">{{ _('Low Stock Only') }}</span>
</label>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('SKU') }}</th>
<th class="p-4">{{ _('Category') }}</th>
<th class="p-4">{{ _('Quantity On Hand') }}</th>
<th class="p-4">{{ _('Quantity Reserved') }}</th>
<th class="p-4">{{ _('Available') }}</th>
<th class="p-4">{{ _('Reorder Point') }}</th>
<th class="p-4">{{ _('Status') }}</th>
</tr>
</thead>
<tbody>
{% for stock in stock_levels %}
<tr>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=stock.stock_item_id) }}" class="text-primary hover:underline font-medium">
{{ stock.stock_item.name }}
</a>
</td>
<td class="p-4 font-mono text-sm">{{ stock.stock_item.sku }}</td>
<td class="p-4">{{ stock.stock_item.category or '—' }}</td>
<td class="p-4 font-semibold">{{ stock.quantity_on_hand }}</td>
<td class="p-4">{{ stock.quantity_reserved }}</td>
<td class="p-4 {% if stock.quantity_available < 0 %}text-red-600{% elif stock.quantity_available == 0 %}text-amber-600{% else %}text-green-600{% endif %} font-semibold">
{{ stock.quantity_available }}
</td>
<td class="p-4">{{ stock.stock_item.reorder_point or '—' }}</td>
<td class="p-4">
{% if stock.stock_item.reorder_point and stock.quantity_on_hand < stock.stock_item.reorder_point %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">{{ _('Low Stock') }}</span>
{% elif stock.quantity_available < 0 %}
<span class="px-2 py-1 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">{{ _('Oversold') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('OK') }}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="p-8 text-center text-gray-500">
{{ _('No stock items found in this warehouse.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Suppliers', 'url': url_for('inventory.list_suppliers')},
{'text': supplier.name if supplier else 'New Supplier'}
] %}
{{ page_header(
icon_class='fas fa-truck',
title_text=supplier.name if supplier else 'New Supplier',
subtitle_text='Create or edit supplier' if not supplier else 'Edit supplier',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-4xl mx-auto">
<form method="POST" action="{% if supplier %}{{ url_for('inventory.edit_supplier', supplier_id=supplier.id) }}{% else %}{{ url_for('inventory.new_supplier') }}{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Supplier Code') }} *</label>
<input type="text" name="code" id="code" value="{{ supplier.code if supplier else '' }}" class="form-input" required>
<p class="mt-1 text-sm text-gray-500">{{ _('Unique code for this supplier (e.g., SUP-001)') }}</p>
</div>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Name') }} *</label>
<input type="text" name="name" id="name" value="{{ supplier.name if supplier else '' }}" class="form-input" required>
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Description') }}</label>
<textarea name="description" id="description" rows="2" class="form-input">{{ supplier.description if supplier else '' }}</textarea>
</div>
<div>
<label for="contact_person" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Contact Person') }}</label>
<input type="text" name="contact_person" id="contact_person" value="{{ supplier.contact_person if supplier else '' }}" class="form-input">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Email') }}</label>
<input type="email" name="email" id="email" value="{{ supplier.email if supplier else '' }}" class="form-input">
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Phone') }}</label>
<input type="tel" name="phone" id="phone" value="{{ supplier.phone if supplier else '' }}" class="form-input">
</div>
<div class="md:col-span-2">
<label for="address" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Address') }}</label>
<textarea name="address" id="address" rows="3" class="form-input">{{ supplier.address if supplier else '' }}</textarea>
</div>
<div>
<label for="website" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Website') }}</label>
<input type="url" name="website" id="website" value="{{ supplier.website if supplier else '' }}" class="form-input">
</div>
<div>
<label for="tax_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Tax ID') }}</label>
<input type="text" name="tax_id" id="tax_id" value="{{ supplier.tax_id if supplier else '' }}" class="form-input">
</div>
<div>
<label for="payment_terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Payment Terms') }}</label>
<input type="text" name="payment_terms" id="payment_terms" value="{{ supplier.payment_terms if supplier else '' }}" class="form-input" placeholder="{{ _('e.g., Net 30, Net 60') }}">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Currency') }}</label>
<select name="currency_code" id="currency_code" class="form-input">
<option value="EUR" {% if not supplier or supplier.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
<option value="USD" {% if supplier and supplier.currency_code == 'USD' %}selected{% endif %}>USD</option>
<option value="GBP" {% if supplier and supplier.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
<option value="CHF" {% if supplier and supplier.currency_code == 'CHF' %}selected{% endif %}>CHF</option>
</select>
</div>
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input">{{ supplier.notes if supplier else '' }}</textarea>
</div>
<div class="md:col-span-2">
<label class="flex items-center">
<input type="checkbox" name="is_active" {% if not supplier or supplier.is_active %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm">{{ _('Active') }}</span>
</label>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="{{ url_for('inventory.list_suppliers') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Save') }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Suppliers'}
] %}
{{ page_header(
icon_class='fas fa-truck',
title_text='Suppliers',
subtitle_text='Manage suppliers and vendors',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.new_supplier") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Supplier</a>' if (current_user.is_admin or has_permission('manage_inventory')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Search') }}</label>
<input type="text" name="search" id="search" value="{{ search }}" placeholder="{{ _('Code, name, email') }}" class="form-input">
</div>
<div>
<label for="active" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Status') }}</label>
<select name="active" id="active" class="form-input">
<option value="true" {% if active_only %}selected{% endif %}>{{ _('Active Only') }}</option>
<option value="false" {% if not active_only %}selected{% endif %}>{{ _('All') }}</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Code') }}</th>
<th class="p-4">{{ _('Name') }}</th>
<th class="p-4">{{ _('Contact Person') }}</th>
<th class="p-4">{{ _('Email') }}</th>
<th class="p-4">{{ _('Phone') }}</th>
<th class="p-4">{{ _('Status') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for supplier in suppliers %}
<tr>
<td class="p-4 font-mono text-sm">{{ supplier.code }}</td>
<td class="p-4 font-medium">
<a href="{{ url_for('inventory.view_supplier', supplier_id=supplier.id) }}" class="text-primary hover:underline">
{{ supplier.name }}
</a>
</td>
<td class="p-4">{{ supplier.contact_person or '—' }}</td>
<td class="p-4">
{% if supplier.email %}
<a href="mailto:{{ supplier.email }}" class="text-primary hover:underline">{{ supplier.email }}</a>
{% else %}
{% endif %}
</td>
<td class="p-4">
{% if supplier.phone %}
<a href="tel:{{ supplier.phone }}" class="text-primary hover:underline">{{ supplier.phone }}</a>
{% else %}
{% endif %}
</td>
<td class="p-4">
{% if supplier.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Inactive') }}</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_supplier', supplier_id=supplier.id) }}" class="text-primary hover:underline mr-3">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or has_permission('manage_inventory') %}
<a href="{{ url_for('inventory.edit_supplier', supplier_id=supplier.id) }}" class="text-primary hover:underline">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-8 text-center text-gray-500">
{{ _('No suppliers found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Suppliers', 'url': url_for('inventory.list_suppliers')},
{'text': supplier.name}
] %}
{{ page_header(
icon_class='fas fa-truck',
title_text=supplier.name,
subtitle_text=supplier.code,
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.edit_supplier", supplier_id=supplier.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if (current_user.is_admin or has_permission('manage_inventory')) else None
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<!-- Supplier Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Supplier Details') }}</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Code') }}</label>
<p class="mt-1 font-mono">{{ supplier.code }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Status') }}</label>
<p class="mt-1">
{% if supplier.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Inactive') }}</span>
{% endif %}
</p>
</div>
{% if supplier.description %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Description') }}</label>
<p class="mt-1">{{ supplier.description }}</p>
</div>
{% endif %}
{% if supplier.contact_person %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Contact Person') }}</label>
<p class="mt-1">{{ supplier.contact_person }}</p>
</div>
{% endif %}
{% if supplier.email %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Email') }}</label>
<p class="mt-1">
<a href="mailto:{{ supplier.email }}" class="text-primary hover:underline">{{ supplier.email }}</a>
</p>
</div>
{% endif %}
{% if supplier.phone %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Phone') }}</label>
<p class="mt-1">
<a href="tel:{{ supplier.phone }}" class="text-primary hover:underline">{{ supplier.phone }}</a>
</p>
</div>
{% endif %}
{% if supplier.address %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Address') }}</label>
<p class="mt-1">{{ supplier.address }}</p>
</div>
{% endif %}
{% if supplier.website %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Website') }}</label>
<p class="mt-1">
<a href="{{ supplier.website }}" target="_blank" rel="noopener" class="text-primary hover:underline">{{ supplier.website }}</a>
</p>
</div>
{% endif %}
{% if supplier.tax_id %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Tax ID') }}</label>
<p class="mt-1">{{ supplier.tax_id }}</p>
</div>
{% endif %}
{% if supplier.payment_terms %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Payment Terms') }}</label>
<p class="mt-1">{{ supplier.payment_terms }}</p>
</div>
{% endif %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Currency') }}</label>
<p class="mt-1">{{ supplier.currency_code }}</p>
</div>
{% if supplier.notes %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Notes') }}</label>
<p class="mt-1">{{ supplier.notes }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Stock Items from this Supplier -->
{% if supplier_items %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Stock Items from this Supplier') }}</h2>
<div class="overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('Supplier SKU') }}</th>
<th class="p-4">{{ _('Unit Cost') }}</th>
<th class="p-4">{{ _('MOQ') }}</th>
<th class="p-4">{{ _('Lead Time') }}</th>
<th class="p-4">{{ _('Preferred') }}</th>
</tr>
</thead>
<tbody>
{% for supplier_item in supplier_items %}
<tr>
<td class="p-4 font-medium">
<a href="{{ url_for('inventory.view_stock_item', item_id=supplier_item.stock_item_id) }}" class="text-primary hover:underline">
{{ supplier_item.stock_item.name }} ({{ supplier_item.stock_item.sku }})
</a>
</td>
<td class="p-4 font-mono text-sm">{{ supplier_item.supplier_sku or '—' }}</td>
<td class="p-4">
{% if supplier_item.unit_cost %}
{{ "%.2f"|format(supplier_item.unit_cost) }} {{ supplier_item.currency_code }}
{% else %}
{% endif %}
</td>
<td class="p-4">{{ supplier_item.minimum_order_quantity or '—' }}</td>
<td class="p-4">
{% if supplier_item.lead_time_days %}
{{ supplier_item.lead_time_days }} {{ _('days') }}
{% else %}
{% endif %}
</td>
<td class="p-4">
{% if supplier_item.is_preferred %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<i class="fas fa-star"></i> {{ _('Preferred') }}
</span>
{% else %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<p class="text-gray-500 text-center">{{ _('No stock items associated with this supplier.') }}</p>
</div>
{% endif %}
</div>
<div>
<!-- Summary -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">{{ _('Summary') }}</h3>
<div class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Stock Items') }}</label>
<p class="text-2xl font-bold mt-1">{{ supplier_items|length }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Preferred Items') }}</label>
<p class="text-xl font-semibold mt-1">
{{ supplier_items|selectattr('is_preferred', 'equalto', True)|list|length }}
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Transfers', 'url': url_for('inventory.list_transfers')},
{'text': 'New Transfer'}
] %}
{{ page_header(
icon_class='fas fa-truck',
title_text='New Stock Transfer',
subtitle_text='Transfer stock between warehouses',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-2xl mx-auto">
<form method="POST" action="{{ url_for('inventory.new_transfer') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-6">
<div>
<label for="stock_item_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Stock Item') }} *</label>
<select name="stock_item_id" id="stock_item_id" class="form-input" required>
<option value="">{{ _('Select Item') }}</option>
{% for item in stock_items %}
<option value="{{ item.id }}">{{ item.sku }} - {{ item.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="from_warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('From Warehouse') }} *</label>
<select name="from_warehouse_id" id="from_warehouse_id" class="form-input" required>
<option value="">{{ _('Select Source Warehouse') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}">{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="to_warehouse_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('To Warehouse') }} *</label>
<select name="to_warehouse_id" id="to_warehouse_id" class="form-input" required>
<option value="">{{ _('Select Destination Warehouse') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}">{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="quantity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Quantity') }} *</label>
<input type="number" name="quantity" id="quantity" step="0.01" min="0.01" class="form-input" required>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input" placeholder="{{ _('Optional notes about this transfer') }}"></textarea>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="{{ url_for('inventory.list_transfers') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Create Transfer') }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Transfers'}
] %}
{{ page_header(
icon_class='fas fa-truck',
title_text='Stock Transfers',
subtitle_text='View transfers between warehouses',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.new_transfer") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Transfer</a>' if (current_user.is_admin or has_permission('transfer_stock')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="date_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date From') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}" class="form-input">
</div>
<div>
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Date To') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Date') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('From Warehouse') }}</th>
<th class="p-4">{{ _('To Warehouse') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('User') }}</th>
</tr>
</thead>
<tbody>
{% for transfer_id, movements in transfer_groups.items() %}
{% if movements|length >= 2 %}
{% set out_movement = movements|selectattr('quantity', 'lt', 0)|first %}
{% set in_movement = movements|selectattr('quantity', 'gt', 0)|first %}
{% if out_movement and in_movement %}
<tr>
<td class="p-4">{{ out_movement.moved_at.strftime('%Y-%m-%d %H:%M') if out_movement.moved_at else '—' }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=out_movement.stock_item_id) }}" class="text-primary hover:underline">
{{ out_movement.stock_item.name }} ({{ out_movement.stock_item.sku }})
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=out_movement.warehouse_id) }}" class="text-primary hover:underline">
{{ out_movement.warehouse.code }}
</a>
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=in_movement.warehouse_id) }}" class="text-primary hover:underline">
{{ in_movement.warehouse.code }}
</a>
</td>
<td class="p-4 font-semibold">{{ in_movement.quantity }}</td>
<td class="p-4">{{ out_movement.moved_by_user.username if out_movement.moved_by_user else '—' }}</td>
</tr>
{% endif %}
{% endif %}
{% else %}
<tr>
<td colspan="6" class="p-8 text-center text-gray-500">
{{ _('No transfers found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Warehouses', 'url': url_for('inventory.list_warehouses')},
{'text': warehouse.name if warehouse else 'New Warehouse'}
] %}
{{ page_header(
icon_class='fas fa-warehouse',
title_text=warehouse.name if warehouse else 'New Warehouse',
subtitle_text='Create or edit warehouse' if not warehouse else 'Edit warehouse',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-4xl mx-auto">
<form method="POST" action="{% if warehouse %}{{ url_for('inventory.edit_warehouse', warehouse_id=warehouse.id) }}{% else %}{{ url_for('inventory.new_warehouse') }}{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Warehouse Code') }} *</label>
<input type="text" name="code" id="code" value="{{ warehouse.code if warehouse else '' }}" class="form-input" required>
<p class="mt-1 text-sm text-gray-500">{{ _('Unique code for this warehouse (e.g., WH-001)') }}</p>
</div>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Name') }} *</label>
<input type="text" name="name" id="name" value="{{ warehouse.name if warehouse else '' }}" class="form-input" required>
</div>
<div class="md:col-span-2">
<label for="address" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Address') }}</label>
<textarea name="address" id="address" rows="3" class="form-input">{{ warehouse.address if warehouse else '' }}</textarea>
</div>
<div>
<label for="contact_person" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Contact Person') }}</label>
<input type="text" name="contact_person" id="contact_person" value="{{ warehouse.contact_person if warehouse else '' }}" class="form-input">
</div>
<div>
<label for="contact_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Contact Email') }}</label>
<input type="email" name="contact_email" id="contact_email" value="{{ warehouse.contact_email if warehouse else '' }}" class="form-input">
</div>
<div>
<label for="contact_phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Contact Phone') }}</label>
<input type="tel" name="contact_phone" id="contact_phone" value="{{ warehouse.contact_phone if warehouse else '' }}" class="form-input">
</div>
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input">{{ warehouse.notes if warehouse else '' }}</textarea>
</div>
<div class="md:col-span-2">
<label class="flex items-center">
<input type="checkbox" name="is_active" {% if not warehouse or warehouse.is_active %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm">{{ _('Active') }}</span>
</label>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="{{ url_for('inventory.list_warehouses') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Save') }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Warehouses'}
] %}
{{ page_header(
icon_class='fas fa-warehouse',
title_text='Warehouses',
subtitle_text='Manage warehouse locations',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.new_warehouse") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Warehouse</a>' if (current_user.is_admin or has_permission('manage_warehouses')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Code') }}</th>
<th class="p-4">{{ _('Name') }}</th>
<th class="p-4">{{ _('Contact Person') }}</th>
<th class="p-4">{{ _('Contact Email') }}</th>
<th class="p-4">{{ _('Status') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for warehouse in warehouses %}
<tr>
<td class="p-4 font-mono text-sm">{{ warehouse.code }}</td>
<td class="p-4 font-medium">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=warehouse.id) }}" class="text-primary hover:underline">
{{ warehouse.name }}
</a>
</td>
<td class="p-4">{{ warehouse.contact_person or '—' }}</td>
<td class="p-4">
{% if warehouse.contact_email %}
<a href="mailto:{{ warehouse.contact_email }}" class="text-primary hover:underline">{{ warehouse.contact_email }}</a>
{% else %}
{% endif %}
</td>
<td class="p-4">
{% if warehouse.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Inactive') }}</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_warehouse', warehouse_id=warehouse.id) }}" class="text-primary hover:underline mr-3">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or has_permission('manage_warehouses') %}
<a href="{{ url_for('inventory.edit_warehouse', warehouse_id=warehouse.id) }}" class="text-primary hover:underline">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="p-8 text-center text-gray-500">
{{ _('No warehouses found.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Inventory', 'url': url_for('inventory.list_stock_items')},
{'text': 'Warehouses', 'url': url_for('inventory.list_warehouses')},
{'text': warehouse.name}
] %}
{{ page_header(
icon_class='fas fa-warehouse',
title_text=warehouse.name,
subtitle_text=warehouse.code,
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("inventory.edit_warehouse", warehouse_id=warehouse.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if (current_user.is_admin or has_permission('manage_warehouses')) else None
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<!-- Warehouse Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Warehouse Details') }}</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Code') }}</label>
<p class="mt-1 font-mono">{{ warehouse.code }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Status') }}</label>
<p class="mt-1">
{% if warehouse.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">{{ _('Inactive') }}</span>
{% endif %}
</p>
</div>
{% if warehouse.address %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Address') }}</label>
<p class="mt-1">{{ warehouse.address }}</p>
</div>
{% endif %}
{% if warehouse.contact_person %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Contact Person') }}</label>
<p class="mt-1">{{ warehouse.contact_person }}</p>
</div>
{% endif %}
{% if warehouse.contact_email %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Contact Email') }}</label>
<p class="mt-1">
<a href="mailto:{{ warehouse.contact_email }}" class="text-primary hover:underline">{{ warehouse.contact_email }}</a>
</p>
</div>
{% endif %}
{% if warehouse.contact_phone %}
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Contact Phone') }}</label>
<p class="mt-1">
<a href="tel:{{ warehouse.contact_phone }}" class="text-primary hover:underline">{{ warehouse.contact_phone }}</a>
</p>
</div>
{% endif %}
{% if warehouse.notes %}
<div class="col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Notes') }}</label>
<p class="mt-1">{{ warehouse.notes }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Stock Levels -->
{% if stock_levels %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Stock Levels') }}</h2>
<div class="overflow-x-auto">
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('SKU') }}</th>
<th class="p-4">{{ _('On Hand') }}</th>
<th class="p-4">{{ _('Reserved') }}</th>
<th class="p-4">{{ _('Available') }}</th>
<th class="p-4">{{ _('Location') }}</th>
</tr>
</thead>
<tbody>
{% for stock in stock_levels %}
<tr>
<td class="p-4 font-medium">
<a href="{{ url_for('inventory.view_stock_item', item_id=stock.stock_item_id) }}" class="text-primary hover:underline">
{{ stock.stock_item.name }}
</a>
</td>
<td class="p-4 font-mono text-sm">{{ stock.stock_item.sku }}</td>
<td class="p-4">{{ stock.quantity_on_hand }}</td>
<td class="p-4">{{ stock.quantity_reserved }}</td>
<td class="p-4">
{{ stock.quantity_available }}
{% if stock.stock_item.reorder_point and stock.quantity_on_hand < stock.stock_item.reorder_point %}
<span class="ml-2 text-amber-500" title="{{ _('Low Stock') }}"><i class="fas fa-exclamation-triangle"></i></span>
{% endif %}
</td>
<td class="p-4">{{ stock.location or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<p class="text-gray-500 text-center">{{ _('No stock items in this warehouse.') }}</p>
</div>
{% endif %}
<!-- Recent Movements -->
{% if recent_movements %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Recent Stock Movements') }}</h2>
<table class="table table-zebra w-full text-left">
<thead>
<tr>
<th class="p-4">{{ _('Date') }}</th>
<th class="p-4">{{ _('Item') }}</th>
<th class="p-4">{{ _('Type') }}</th>
<th class="p-4">{{ _('Quantity') }}</th>
<th class="p-4">{{ _('Reason') }}</th>
</tr>
</thead>
<tbody>
{% for movement in recent_movements %}
<tr>
<td class="p-4">{{ movement.moved_at.strftime('%Y-%m-%d %H:%M') if movement.moved_at else '—' }}</td>
<td class="p-4">
<a href="{{ url_for('inventory.view_stock_item', item_id=movement.stock_item_id) }}" class="text-primary hover:underline">
{{ movement.stock_item.name }}
</a>
</td>
<td class="p-4">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 capitalize">
{{ movement.movement_type }}
</span>
</td>
<td class="p-4 {% if movement.quantity > 0 %}text-green-600{% else %}text-red-600{% endif %}">
{{ '+' if movement.quantity > 0 else '' }}{{ movement.quantity }}
</td>
<td class="p-4">{{ movement.reason or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<div>
<!-- Summary -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">{{ _('Summary') }}</h3>
<div class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Total Items') }}</label>
<p class="text-2xl font-bold mt-1">{{ stock_levels|length }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Total Quantity') }}</label>
<p class="text-2xl font-bold mt-1">
{{ stock_levels|sum(attribute='quantity_on_hand')|default(0) }}
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -69,18 +69,34 @@
<!-- Items header (desktop) -->
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-6">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Quantity') }}</div>
<div class="md:col-span-3">{{ _('Unit Price') }}</div>
<div class="md:col-span-2">{{ _('Stock Item') }}</div>
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
<div class="md:col-span-4">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Quantity') }}</div>
<div class="md:col-span-2">{{ _('Unit Price') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
<div id="invoice-items" class="space-y-2">
{% for item in invoice.items %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200/50 dark:border-blue-800/50 invoice-item-row hover:shadow-sm transition">
<input type="text" name="description[]" placeholder="{{ _('e.g., Web Development Services') }}" value="{{ item.description }}" class="md:col-span-6 form-input" data-calc-trigger>
<input type="number" name="quantity[]" placeholder="{{ _('Quantity') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-2 form-input item-quantity" data-calc-trigger>
<input type="number" name="unit_price[]" placeholder="{{ _('Unit Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-3 form-input item-price" data-calc-trigger>
<input type="hidden" name="item_stock_item_id[]" value="{{ item.stock_item_id if item.stock_item_id else '' }}">
<input type="hidden" name="item_warehouse_id[]" value="{{ item.warehouse_id if item.warehouse_id else '' }}">
<select class="md:col-span-2 form-input item-stock-select text-sm" data-row-index="{{ loop.index0 }}" title="{{ _('Select Stock Item') }}">
<option value="">{{ _('None') }}</option>
{% for stock_item in stock_items %}
<option value="{{ stock_item.id }}" data-price="{{ stock_item.default_price or 0 }}" data-unit="{{ stock_item.unit }}" data-description="{{ stock_item.name }}" {% if item.stock_item_id == stock_item.id %}selected{% endif %}>{{ stock_item.sku }} - {{ stock_item.name }}</option>
{% endfor %}
</select>
<select class="md:col-span-2 form-input item-warehouse-select text-sm" data-row-index="{{ loop.index0 }}" title="{{ _('Select Warehouse') }}">
<option value="">{{ _('None') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if item.warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
<input type="text" name="description[]" placeholder="{{ _('e.g., Web Development Services') }}" value="{{ item.description }}" class="md:col-span-4 form-input item-description" data-calc-trigger>
<input type="number" name="quantity[]" placeholder="{{ _('Quantity') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-1 form-input item-quantity" data-calc-trigger>
<input type="number" name="unit_price[]" placeholder="{{ _('Unit Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-2 form-input item-price" data-calc-trigger>
<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove item') }}">
<i class="fas fa-trash"></i>
</button>
@@ -393,22 +409,123 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('preview-outstanding').textContent = outstanding.toFixed(2);
}
// Stock items and warehouses data
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
// Handle stock item selection
function setupStockItemHandlers() {
document.querySelectorAll('.item-stock-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.invoice-item-row');
const stockItemId = this.value;
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = stockItemId || '';
if (stockItemId && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
// Auto-populate fields
if (description && !row.querySelector('.item-description').value) {
row.querySelector('.item-description').value = description;
}
if (price > 0 && !row.querySelector('.item-price').value) {
row.querySelector('.item-price').value = price.toFixed(2);
}
calculateTotals();
}
});
});
}
// Add item button
const addBtn = document.getElementById('add-item');
addBtn && addBtn.addEventListener('click', function() {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200/50 dark:border-blue-800/50 invoice-item-row hover:shadow-sm transition space-y-2';
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200/50 dark:border-blue-800/50 invoice-item-row hover:shadow-sm transition';
// Build stock items dropdown
let stockItemsHtml = '<option value="">{{ _("None") }}</option>';
if (stockItems && Array.isArray(stockItems)) {
stockItems.forEach(item => {
const price = item.default_price || 0;
const unit = item.unit || '';
const desc = item.description || item.name || '';
stockItemsHtml += '<option value="' + item.id + '" data-price="' + price + '" data-unit="' + unit + '" data-description="' + desc.replace(/"/g, '&quot;') + '">' + item.sku + ' - ' + item.name + '</option>';
});
}
// Build warehouses dropdown
let warehousesHtml = '<option value="">{{ _("None") }}</option>';
if (warehouses && Array.isArray(warehouses)) {
warehouses.forEach(wh => {
warehousesHtml += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
});
}
row.innerHTML = `
<input type="text" name="description[]" placeholder="{{ _('e.g., Web Development Services') }}" class="md:col-span-6 form-input" data-calc-trigger>
<input type="number" name="quantity[]" placeholder="{{ _('Quantity') }}" value="1" step="0.01" min="0" class="md:col-span-2 form-input item-quantity" data-calc-trigger>
<input type="number" name="unit_price[]" placeholder="{{ _('Unit Price') }}" step="0.01" min="0" class="md:col-span-3 form-input item-price" data-calc-trigger>
<input type="hidden" name="item_stock_item_id[]" value="">
<input type="hidden" name="item_warehouse_id[]" value="">
<select class="md:col-span-2 form-input item-stock-select text-sm">
${stockItemsHtml}
</select>
<select class="md:col-span-2 form-input item-warehouse-select text-sm">
${warehousesHtml}
</select>
<input type="text" name="description[]" placeholder="{{ _('e.g., Web Development Services') }}" class="md:col-span-4 form-input item-description" data-calc-trigger>
<input type="number" name="quantity[]" placeholder="{{ _('Quantity') }}" value="1" step="0.01" min="0" class="md:col-span-1 form-input item-quantity" data-calc-trigger>
<input type="number" name="unit_price[]" placeholder="{{ _('Unit Price') }}" step="0.01" min="0" class="md:col-span-2 form-input item-price" data-calc-trigger>
<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove item') }}">
<i class="fas fa-trash"></i>
</button>
`;
itemsContainer.appendChild(row);
// Setup handlers for new row
const stockSelect = row.querySelector('.item-stock-select');
stockSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = this.value || '';
if (this.value && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
if (description) row.querySelector('.item-description').value = description;
if (price > 0) row.querySelector('.item-price').value = price.toFixed(2);
calculateTotals();
}
});
const warehouseSelect = row.querySelector('.item-warehouse-select');
warehouseSelect.addEventListener('change', function() {
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
// Setup calculation triggers
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
input.addEventListener('input', calculateTotals);
});
calculateTotals();
row.querySelector('.item-quantity').focus();
setupStockItemHandlers();
row.querySelector('.item-stock-select').focus();
});
// Initialize stock item handlers for existing rows
setupStockItemHandlers();
// Setup warehouse handlers
document.querySelectorAll('.item-warehouse-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.invoice-item-row');
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
});
// Add good button

View File

@@ -61,10 +61,12 @@
<!-- Items header (desktop) -->
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-4">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Quantity') }}</div>
<div class="md:col-span-2">{{ _('Stock Item') }}</div>
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Quantity') }}</div>
<div class="md:col-span-1">{{ _('Unit') }}</div>
<div class="md:col-span-3">{{ _('Unit Price') }}</div>
<div class="md:col-span-2">{{ _('Unit Price') }}</div>
<div class="md:col-span-1">{{ _('Total') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
@@ -212,23 +214,113 @@ document.addEventListener('DOMContentLoaded', function() {
const addItemBtn = document.getElementById('add-item');
let itemIndex = 0;
// Stock items and warehouses data
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
// Handle stock item selection
function setupStockItemHandlers() {
document.querySelectorAll('.item-stock-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.quote-item-row');
const stockItemId = this.value;
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = stockItemId || '';
if (stockItemId && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
// Auto-populate fields
if (description && !row.querySelector('.item-description').value) {
row.querySelector('.item-description').value = description;
}
if (price > 0 && !row.querySelector('.item-price').value) {
row.querySelector('.item-price').value = price.toFixed(2);
}
if (unit && !row.querySelector('.item-unit').value) {
row.querySelector('.item-unit').value = unit;
}
calculateTotals();
}
});
});
}
// Add new item row
function addItemRow(item = null) {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row hover:shadow-sm transition';
row.innerHTML = `
<input type="hidden" name="item_id[]" value="${item ? item.id : ''}">
<input type="text" name="item_description[]" placeholder="${'{{ _('Item description') }}'}" value="${item ? item.description : ''}" class="md:col-span-4 form-input item-description" data-calc-trigger>
<input type="number" name="item_quantity[]" placeholder="${'{{ _('Qty') }}'}" value="${item ? item.quantity : '1'}" step="0.01" min="0" class="md:col-span-2 form-input item-quantity" data-calc-trigger>
<input type="text" name="item_unit[]" placeholder="${'{{ _('Unit') }}'}" value="${item ? (item.unit || '') : ''}" class="md:col-span-1 form-input item-unit" placeholder="hrs, pcs, etc.">
<input type="number" name="item_price[]" placeholder="${'{{ _('Price') }}'}" value="${item ? item.unit_price : ''}" step="0.01" min="0" class="md:col-span-3 form-input item-price" data-calc-trigger>
<div class="md:col-span-1 flex items-center font-medium item-total">0.00</div>
<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="${'{{ _('Remove item') }}'}">
<i class="fas fa-trash"></i>
</button>
`;
// Build stock items dropdown
let stockItemsHtml = '<option value="">{{ _("None") }}</option>';
if (stockItems && Array.isArray(stockItems)) {
stockItems.forEach(stockItem => {
const price = stockItem.default_price || 0;
const unit = stockItem.unit || '';
const desc = stockItem.description || stockItem.name || '';
stockItemsHtml += '<option value="' + stockItem.id + '" data-price="' + price + '" data-unit="' + unit + '" data-description="' + desc.replace(/"/g, '&quot;') + '">' + stockItem.sku + ' - ' + stockItem.name + '</option>';
});
}
// Build warehouses dropdown
let warehousesHtml = '<option value="">{{ _("None") }}</option>';
if (warehouses && Array.isArray(warehouses)) {
warehouses.forEach(wh => {
warehousesHtml += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
});
}
// Translated strings
const placeholderDesc = '{{ _("Item description") }}';
const placeholderQty = '{{ _("Qty") }}';
const placeholderUnit = '{{ _("Unit") }}';
const placeholderPrice = '{{ _("Price") }}';
const removeTitle = '{{ _("Remove item") }}';
row.innerHTML =
'<input type="hidden" name="item_id[]" value="' + (item ? item.id : '') + '">' +
'<input type="hidden" name="item_stock_item_id[]" value="">' +
'<input type="hidden" name="item_warehouse_id[]" value="">' +
'<select class="md:col-span-2 form-input item-stock-select text-sm">' + stockItemsHtml + '</select>' +
'<select class="md:col-span-2 form-input item-warehouse-select text-sm">' + warehousesHtml + '</select>' +
'<input type="text" name="item_description[]" placeholder="' + placeholderDesc + '" value="' + (item ? (item.description || '').replace(/"/g, '&quot;') : '') + '" class="md:col-span-2 form-input item-description" data-calc-trigger>' +
'<input type="number" name="item_quantity[]" placeholder="' + placeholderQty + '" value="' + (item ? item.quantity : '1') + '" step="0.01" min="0" class="md:col-span-1 form-input item-quantity" data-calc-trigger>' +
'<input type="text" name="item_unit[]" placeholder="' + placeholderUnit + '" value="' + (item ? (item.unit || '') : '') + '" class="md:col-span-1 form-input item-unit" placeholder="hrs, pcs, etc.">' +
'<input type="number" name="item_price[]" placeholder="' + placeholderPrice + '" value="' + (item ? item.unit_price : '') + '" step="0.01" min="0" class="md:col-span-2 form-input item-price" data-calc-trigger>' +
'<div class="md:col-span-1 flex items-center font-medium item-total">0.00</div>' +
'<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="' + removeTitle + '">' +
'<i class="fas fa-trash"></i>' +
'</button>';
itemsContainer.appendChild(row);
// Setup handlers for new row
const stockSelect = row.querySelector('.item-stock-select');
stockSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = this.value || '';
if (this.value && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
if (description) row.querySelector('.item-description').value = description;
if (price > 0) row.querySelector('.item-price').value = price.toFixed(2);
if (unit) row.querySelector('.item-unit').value = unit;
calculateTotals();
}
});
const warehouseSelect = row.querySelector('.item-warehouse-select');
warehouseSelect.addEventListener('change', function() {
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
// Add event listeners
row.querySelector('.remove-item').addEventListener('click', function() {
row.remove();
@@ -240,6 +332,7 @@ document.addEventListener('DOMContentLoaded', function() {
input.addEventListener('input', calculateTotals);
});
setupStockItemHandlers();
itemIndex++;
calculateTotals();
}
@@ -276,6 +369,9 @@ document.addEventListener('DOMContentLoaded', function() {
addItemRow();
});
// Initialize stock item handlers
setupStockItemHandlers();
// Add initial empty row
addItemRow();

View File

@@ -62,10 +62,12 @@
<!-- Items header (desktop) -->
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-4">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Quantity') }}</div>
<div class="md:col-span-2">{{ _('Stock Item') }}</div>
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Quantity') }}</div>
<div class="md:col-span-1">{{ _('Unit') }}</div>
<div class="md:col-span-3">{{ _('Unit Price') }}</div>
<div class="md:col-span-2">{{ _('Unit Price') }}</div>
<div class="md:col-span-1">{{ _('Total') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
@@ -74,10 +76,24 @@
{% for item in quote.items %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row hover:shadow-sm transition">
<input type="hidden" name="item_id[]" value="{{ item.id }}">
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="md:col-span-4 form-input item-description" data-calc-trigger>
<input type="number" name="item_quantity[]" placeholder="{{ _('Qty') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-2 form-input item-quantity" data-calc-trigger>
<input type="hidden" name="item_stock_item_id[]" value="{{ item.stock_item_id if item.stock_item_id else '' }}">
<input type="hidden" name="item_warehouse_id[]" value="{{ item.warehouse_id if item.warehouse_id else '' }}">
<select class="md:col-span-2 form-input item-stock-select text-sm" title="{{ _('Select Stock Item') }}">
<option value="">{{ _('None') }}</option>
{% for stock_item in stock_items %}
<option value="{{ stock_item.id }}" data-price="{{ stock_item.default_price or 0 }}" data-unit="{{ stock_item.unit }}" data-description="{{ stock_item.name }}" {% if item.stock_item_id == stock_item.id %}selected{% endif %}>{{ stock_item.sku }} - {{ stock_item.name }}</option>
{% endfor %}
</select>
<select class="md:col-span-2 form-input item-warehouse-select text-sm" title="{{ _('Select Warehouse') }}">
<option value="">{{ _('None') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if item.warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="md:col-span-2 form-input item-description" data-calc-trigger>
<input type="number" name="item_quantity[]" placeholder="{{ _('Qty') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-1 form-input item-quantity" data-calc-trigger>
<input type="text" name="item_unit[]" placeholder="{{ _('Unit') }}" value="{{ item.unit or '' }}" class="md:col-span-1 form-input item-unit" placeholder="hrs, pcs, etc.">
<input type="number" name="item_price[]" placeholder="{{ _('Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-3 form-input item-price" data-calc-trigger>
<input type="number" name="item_price[]" placeholder="{{ _('Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-2 form-input item-price" data-calc-trigger>
<div class="md:col-span-1 flex items-center font-medium item-total">{{ "%.2f"|format(item.total_amount) }}</div>
<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove item') }}">
<i class="fas fa-trash"></i>
@@ -211,23 +227,113 @@ document.addEventListener('DOMContentLoaded', function() {
const addItemBtn = document.getElementById('add-item');
let itemIndex = 0;
// Stock items and warehouses data
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
// Handle stock item selection
function setupStockItemHandlers() {
document.querySelectorAll('.item-stock-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.quote-item-row');
const stockItemId = this.value;
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = stockItemId || '';
if (stockItemId && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
// Auto-populate fields
if (description && !row.querySelector('.item-description').value) {
row.querySelector('.item-description').value = description;
}
if (price > 0 && !row.querySelector('.item-price').value) {
row.querySelector('.item-price').value = price.toFixed(2);
}
if (unit && !row.querySelector('.item-unit').value) {
row.querySelector('.item-unit').value = unit;
}
calculateTotals();
}
});
});
}
// Add new item row
function addItemRow(item = null) {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row hover:shadow-sm transition';
row.innerHTML = `
<input type="hidden" name="item_id[]" value="${item ? item.id : ''}">
<input type="text" name="item_description[]" placeholder="${'{{ _('Item description') }}'}" value="${item ? item.description : ''}" class="md:col-span-4 form-input item-description" data-calc-trigger>
<input type="number" name="item_quantity[]" placeholder="${'{{ _('Qty') }}'}" value="${item ? item.quantity : '1'}" step="0.01" min="0" class="md:col-span-2 form-input item-quantity" data-calc-trigger>
<input type="text" name="item_unit[]" placeholder="${'{{ _('Unit') }}'}" value="${item ? (item.unit || '') : ''}" class="md:col-span-1 form-input item-unit" placeholder="hrs, pcs, etc.">
<input type="number" name="item_price[]" placeholder="${'{{ _('Price') }}'}" value="${item ? item.unit_price : ''}" step="0.01" min="0" class="md:col-span-3 form-input item-price" data-calc-trigger>
<div class="md:col-span-1 flex items-center font-medium item-total">0.00</div>
<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="${'{{ _('Remove item') }}'}">
<i class="fas fa-trash"></i>
</button>
`;
// Build stock items dropdown
let stockItemsHtml = '<option value="">{{ _("None") }}</option>';
if (stockItems && Array.isArray(stockItems)) {
stockItems.forEach(stockItem => {
const price = stockItem.default_price || 0;
const unit = stockItem.unit || '';
const desc = stockItem.description || stockItem.name || '';
stockItemsHtml += '<option value="' + stockItem.id + '" data-price="' + price + '" data-unit="' + unit + '" data-description="' + desc.replace(/"/g, '&quot;') + '">' + stockItem.sku + ' - ' + stockItem.name + '</option>';
});
}
// Build warehouses dropdown
let warehousesHtml = '<option value="">{{ _("None") }}</option>';
if (warehouses && Array.isArray(warehouses)) {
warehouses.forEach(wh => {
warehousesHtml += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
});
}
// Translated strings
const placeholderDesc = '{{ _("Item description") }}';
const placeholderQty = '{{ _("Qty") }}';
const placeholderUnit = '{{ _("Unit") }}';
const placeholderPrice = '{{ _("Price") }}';
const removeTitle = '{{ _("Remove item") }}';
row.innerHTML =
'<input type="hidden" name="item_id[]" value="' + (item ? item.id : '') + '">' +
'<input type="hidden" name="item_stock_item_id[]" value="' + (item && item.stock_item_id ? item.stock_item_id : '') + '">' +
'<input type="hidden" name="item_warehouse_id[]" value="' + (item && item.warehouse_id ? item.warehouse_id : '') + '">' +
'<select class="md:col-span-2 form-input item-stock-select text-sm">' + stockItemsHtml + '</select>' +
'<select class="md:col-span-2 form-input item-warehouse-select text-sm">' + warehousesHtml + '</select>' +
'<input type="text" name="item_description[]" placeholder="' + placeholderDesc + '" value="' + (item ? (item.description || '').replace(/"/g, '&quot;') : '') + '" class="md:col-span-2 form-input item-description" data-calc-trigger>' +
'<input type="number" name="item_quantity[]" placeholder="' + placeholderQty + '" value="' + (item ? item.quantity : '1') + '" step="0.01" min="0" class="md:col-span-1 form-input item-quantity" data-calc-trigger>' +
'<input type="text" name="item_unit[]" placeholder="' + placeholderUnit + '" value="' + (item ? (item.unit || '') : '') + '" class="md:col-span-1 form-input item-unit" placeholder="hrs, pcs, etc.">' +
'<input type="number" name="item_price[]" placeholder="' + placeholderPrice + '" value="' + (item ? item.unit_price : '') + '" step="0.01" min="0" class="md:col-span-2 form-input item-price" data-calc-trigger>' +
'<div class="md:col-span-1 flex items-center font-medium item-total">0.00</div>' +
'<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="' + removeTitle + '">' +
'<i class="fas fa-trash"></i>' +
'</button>';
itemsContainer.appendChild(row);
// Setup handlers for new row
const stockSelect = row.querySelector('.item-stock-select');
stockSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = this.value || '';
if (this.value && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
if (description) row.querySelector('.item-description').value = description;
if (price > 0) row.querySelector('.item-price').value = price.toFixed(2);
if (unit) row.querySelector('.item-unit').value = unit;
calculateTotals();
}
});
const warehouseSelect = row.querySelector('.item-warehouse-select');
warehouseSelect.addEventListener('change', function() {
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
// Add event listeners
row.querySelector('.remove-item').addEventListener('click', function() {
row.remove();
@@ -239,10 +345,23 @@ document.addEventListener('DOMContentLoaded', function() {
input.addEventListener('input', calculateTotals);
});
setupStockItemHandlers();
itemIndex++;
calculateTotals();
}
// Initialize stock item handlers for existing rows
setupStockItemHandlers();
// Setup warehouse handlers
document.querySelectorAll('.item-warehouse-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.quote-item-row');
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
});
// Calculate totals
function calculateTotals() {
let itemsTotal = 0;

View File

@@ -73,6 +73,20 @@ DEFAULT_PERMISSIONS = [
{'name': 'manage_roles', 'description': 'Create, edit, and delete roles', 'category': 'administration'},
{'name': 'manage_permissions', 'description': 'Assign permissions to roles', 'category': 'administration'},
{'name': 'view_permissions', 'description': 'View permissions and roles', 'category': 'administration'},
# Inventory Management Permissions
{'name': 'view_inventory', 'description': 'View inventory items and stock levels', 'category': 'inventory'},
{'name': 'manage_stock_items', 'description': 'Create, edit, and delete stock items', 'category': 'inventory'},
{'name': 'manage_warehouses', 'description': 'Create, edit, and delete warehouses', 'category': 'inventory'},
{'name': 'view_stock_levels', 'description': 'View current stock levels', 'category': 'inventory'},
{'name': 'manage_stock_movements', 'description': 'Record stock movements and adjustments', 'category': 'inventory'},
{'name': 'transfer_stock', 'description': 'Transfer stock between warehouses', 'category': 'inventory'},
{'name': 'view_stock_history', 'description': 'View stock movement history', 'category': 'inventory'},
{'name': 'manage_stock_reservations', 'description': 'Create and manage stock reservations', 'category': 'inventory'},
{'name': 'view_inventory_reports', 'description': 'View inventory reports', 'category': 'inventory'},
{'name': 'approve_stock_adjustments', 'description': 'Approve stock adjustments (if approval workflow enabled)', 'category': 'inventory'},
{'name': 'manage_suppliers', 'description': 'Create, edit, and delete suppliers', 'category': 'inventory'},
{'name': 'manage_purchase_orders', 'description': 'Create, edit, and manage purchase orders', 'category': 'inventory'},
]
@@ -103,6 +117,10 @@ DEFAULT_ROLES = {
'view_users', 'create_users', 'edit_users', 'delete_users',
# System
'manage_settings', 'view_system_info', 'manage_backups', 'manage_telemetry', 'view_audit_logs',
# Inventory
'view_inventory', 'manage_stock_items', 'manage_warehouses', 'view_stock_levels', 'manage_stock_movements',
'transfer_stock', 'view_stock_history', 'manage_stock_reservations', 'view_inventory_reports', 'approve_stock_adjustments',
'manage_suppliers', 'manage_purchase_orders',
]
},
'manager': {
@@ -123,6 +141,9 @@ DEFAULT_ROLES = {
'view_all_reports', 'export_reports', 'create_saved_reports',
# Users
'view_users',
# Inventory
'view_inventory', 'view_stock_levels', 'manage_stock_movements', 'transfer_stock',
'view_stock_history', 'manage_stock_reservations', 'view_inventory_reports',
]
},
'user': {
@@ -141,6 +162,8 @@ DEFAULT_ROLES = {
'view_own_invoices',
# Reports
'view_own_reports', 'export_reports',
# Inventory
'view_inventory', 'view_stock_levels',
]
},
'viewer': {
@@ -153,6 +176,8 @@ DEFAULT_ROLES = {
'view_clients',
'view_own_invoices',
'view_own_reports',
# Inventory
'view_inventory', 'view_stock_levels',
]
}
}

View File

@@ -0,0 +1,100 @@
# Inventory Management System - Implementation Status
## ✅ Completed Features
### 1. Stock Transfers ✅
- **Routes**:
- `GET /inventory/transfers` - List all stock transfers
- `GET /inventory/transfers/new` - Create new transfer form
- `POST /inventory/transfers` - Create transfer (creates dual movements)
- **Templates**: `transfers/list.html`, `transfers/form.html`
- **Functionality**: Complete transfer between warehouses with validation
### 2. Stock Adjustments ✅
- **Routes**:
- `GET /inventory/adjustments` - List all adjustments
- `GET /inventory/adjustments/new` - Create adjustment form
- `POST /inventory/adjustments` - Record adjustment
- **Templates**: `adjustments/list.html`, `adjustments/form.html`
- **Functionality**: Dedicated interface for stock corrections
### 3. Stock Item History ✅
- **Route**: `GET /inventory/items/<id>/history` - View movement history for item
- **Template**: `stock_items/history.html`
- **Functionality**: Complete audit trail with filters
### 4. Additional Stock Level Views ✅
- **Routes**:
- `GET /inventory/stock-levels/warehouse/<warehouse_id>` - Stock levels for warehouse
- `GET /inventory/stock-levels/item/<item_id>` - Stock levels for item across warehouses
- **Templates**: `stock_levels/warehouse.html`, `stock_levels/item.html`
### 5. Purchase Order Management ✅
- **Routes**:
- `GET/POST /inventory/purchase-orders/<id>/edit` - Edit purchase order
- `POST /inventory/purchase-orders/<id>/send` - Mark as sent
- `POST /inventory/purchase-orders/<id>/cancel` - Cancel PO
- `POST /inventory/purchase-orders/<id>/delete` - Delete PO
- `POST /inventory/purchase-orders/<id>/receive` - Receive PO (already existed)
- **Functionality**: Complete PO lifecycle management
### 6. Supplier Code Validation ✅
- **Fix**: Added duplicate code check in `new_supplier` and `edit_supplier` routes
- **Error Handling**: User-friendly error messages
### 7. Inventory Reports (Partially) ✅
- **Routes Added** (in code but need to verify):
- `GET /inventory/reports` - Reports dashboard
- `GET /inventory/reports/valuation` - Stock valuation
- `GET /inventory/reports/movement-history` - Movement history report
- `GET /inventory/reports/turnover` - Turnover analysis
- `GET /inventory/reports/low-stock` - Low stock report
## 🔄 Still Need Templates
### Reports Templates Needed:
- `inventory/reports/dashboard.html`
- `inventory/reports/valuation.html`
- `inventory/reports/movement_history.html`
- `inventory/reports/turnover.html`
- `inventory/reports/low_stock.html`
## ⏳ Still Pending
### 1. API Endpoints
- Supplier API endpoints
- Purchase Order API endpoints
- Enhanced inventory API endpoints
### 2. Menu Updates
- Add "Transfers" link to inventory menu
- Add "Adjustments" link to inventory menu
- Add "Reports" link to inventory menu
- Update navigation active states
### 3. Tests
- Supplier model and route tests
- Purchase Order model and route tests
- Transfer tests
- Report tests
### 4. Documentation
- User guide (`docs/features/INVENTORY_MANAGEMENT.md`)
- API documentation (`docs/features/INVENTORY_API.md`)
- Update main README
## 📝 Notes
1. Most core functionality has been implemented
2. Reports routes are in the code but templates need to be created
3. Menu navigation needs to be updated to include new routes
4. API endpoints can be added incrementally
5. Tests should be created as per project standards
## Next Steps
1. Create report templates
2. Update menu in `base.html`
3. Add API endpoints
4. Create comprehensive tests
5. Write documentation

View File

@@ -0,0 +1,735 @@
# Inventory Management System - Implementation Plan
## Overview
This document outlines the complete implementation plan for adding a comprehensive Inventory Management System to TimeTracker. The system will manage warehouses, stock items, and integrate seamlessly with quotes, invoices, and projects.
## 1. Core Database Models
### 1.1 Warehouse Model (`app/models/warehouse.py`)
**Purpose**: Store warehouse/location information
**Fields**:
- `id` (Integer, Primary Key)
- `name` (String(200), Required) - Warehouse name
- `code` (String(50), Unique, Indexed) - Warehouse code (e.g., "WH-001")
- `address` (Text, Optional) - Physical address
- `contact_person` (String(200), Optional) - Warehouse manager/contact
- `contact_email` (String(200), Optional)
- `contact_phone` (String(50), Optional)
- `is_active` (Boolean, Default: True) - Whether warehouse is active
- `notes` (Text, Optional) - Internal notes
- `created_at` (DateTime)
- `updated_at` (DateTime)
- `created_by` (Integer, ForeignKey -> users.id)
**Relationships**:
- `stock_items` - One-to-many with StockItem (stock levels per warehouse)
- `stock_movements` - One-to-many with StockMovement (transfers to/from this warehouse)
---
### 1.2 StockItem Model (`app/models/stock_item.py`)
**Purpose**: Define master product/item catalog
**Fields**:
- `id` (Integer, Primary Key)
- `sku` (String(100), Unique, Indexed) - Stock Keeping Unit
- `name` (String(200), Required) - Product name
- `description` (Text, Optional) - Detailed description
- `category` (String(100), Optional) - Product category
- `unit` (String(20), Default: "pcs") - Unit of measure (pcs, kg, m, L, etc.)
- `default_cost` (Numeric(10, 2), Optional) - Default purchase cost
- `default_price` (Numeric(10, 2), Optional) - Default selling price
- `currency_code` (String(3), Default: 'EUR')
- `barcode` (String(100), Optional, Indexed) - Barcode/UPC
- `is_active` (Boolean, Default: True)
- `is_trackable` (Boolean, Default: True) - Whether to track inventory levels
- `reorder_point` (Numeric(10, 2), Optional) - Alert when stock falls below this
- `reorder_quantity` (Numeric(10, 2), Optional) - Suggested reorder amount
- `supplier` (String(200), Optional) - Supplier information
- `supplier_sku` (String(100), Optional) - Supplier's product code
- `image_url` (String(500), Optional) - Product image
- `notes` (Text, Optional)
- `created_at` (DateTime)
- `updated_at` (DateTime)
- `created_by` (Integer, ForeignKey -> users.id)
**Relationships**:
- `warehouse_stock` - One-to-many with WarehouseStock (stock levels per warehouse)
- `quote_items` - Many-to-many with QuoteItem via stock_item_id
- `invoice_items` - Many-to-many with InvoiceItem via stock_item_id
- `project_items` - Many-to-many with Project (items allocated to projects)
- `stock_movements` - One-to-many with StockMovement
**Computed Properties**:
- `total_quantity_on_hand` - Sum of all warehouse stock levels
- `is_low_stock` - Boolean if any warehouse is below reorder point
---
### 1.3 WarehouseStock Model (`app/models/warehouse_stock.py`)
**Purpose**: Track stock levels per warehouse
**Fields**:
- `id` (Integer, Primary Key)
- `warehouse_id` (Integer, ForeignKey -> warehouses.id, Required, Indexed)
- `stock_item_id` (Integer, ForeignKey -> stock_items.id, Required, Indexed)
- `quantity_on_hand` (Numeric(10, 2), Default: 0) - Current stock level
- `quantity_reserved` (Numeric(10, 2), Default: 0) - Reserved for quotes/invoices
- `quantity_available` (Computed) - `quantity_on_hand - quantity_reserved`
- `location` (String(100), Optional) - Bin/shelf location within warehouse
- `last_counted_at` (DateTime, Optional) - Last physical count date
- `last_counted_by` (Integer, ForeignKey -> users.id, Optional)
- `updated_at` (DateTime)
- `created_at` (DateTime)
**Unique Constraint**: `(warehouse_id, stock_item_id)` - One stock record per item per warehouse
**Relationships**:
- `warehouse` - Many-to-one with Warehouse
- `stock_item` - Many-to-one with StockItem
---
### 1.4 StockMovement Model (`app/models/stock_movement.py`)
**Purpose**: Track all inventory movements (adjustments, transfers, sales, purchases)
**Fields**:
- `id` (Integer, Primary Key)
- `movement_type` (String(20), Required) - 'adjustment', 'transfer', 'sale', 'purchase', 'return', 'waste'
- `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
- `reference_type` (String(50), Optional) - 'invoice', 'quote', 'project', 'manual', 'purchase_order'
- `reference_id` (Integer, Optional) - ID of related invoice/quote/project
- `unit_cost` (Numeric(10, 2), Optional) - Cost at time of movement (for costing)
- `reason` (String(500), Optional) - Reason for movement
- `notes` (Text, Optional)
- `moved_by` (Integer, ForeignKey -> users.id, Required)
- `moved_at` (DateTime, Default: now)
**Relationships**:
- `stock_item` - Many-to-one with StockItem
- `warehouse` - Many-to-one with Warehouse
- `moved_by_user` - Many-to-one with User
**Indexes**:
- `(reference_type, reference_id)` - For quick lookup of related movements
- `(stock_item_id, moved_at)` - For stock history
---
### 1.5 StockReservation Model (`app/models/stock_reservation.py`)
**Purpose**: Reserve stock for quotes/invoices before actual sale
**Fields**:
- `id` (Integer, Primary Key)
- `stock_item_id` (Integer, ForeignKey -> stock_items.id, Required, Indexed)
- `warehouse_id` (Integer, ForeignKey -> warehouses.id, Required, Indexed)
- `quantity` (Numeric(10, 2), Required)
- `reservation_type` (String(20), Required) - 'quote', 'invoice', 'project'
- `reservation_id` (Integer, Required) - ID of quote/invoice/project
- `status` (String(20), Default: 'reserved') - 'reserved', 'fulfilled', 'cancelled', 'expired'
- `expires_at` (DateTime, Optional) - For quote reservations
- `reserved_by` (Integer, ForeignKey -> users.id, Required)
- `reserved_at` (DateTime, Default: now)
- `fulfilled_at` (DateTime, Optional)
- `cancelled_at` (DateTime, Optional)
- `notes` (Text, Optional)
**Unique Constraint**: Ensure no double-reservations (per item/warehouse/reservation)
**Relationships**:
- `stock_item` - Many-to-one with StockItem
- `warehouse` - Many-to-one with Warehouse
---
## 2. Integration with Existing Models
### 2.1 QuoteItem Enhancements
**Changes to `app/models/quote.py`**:
- Add `stock_item_id` (Integer, ForeignKey -> stock_items.id, Optional, Indexed)
- Add `warehouse_id` (Integer, ForeignKey -> warehouses.id, Optional) - Preferred warehouse
- Add `is_stock_item` (Boolean, Default: False) - Flag to indicate if linked to inventory
**Behavior**:
- When quote item is linked to stock item, show current available quantity
- Allow reserving stock when quote is sent (optional)
- Auto-reserve on quote acceptance (if enabled)
- Release reservation if quote is rejected/expired
---
### 2.2 InvoiceItem Enhancements
**Changes to `app/models/invoice.py`** (InvoiceItem class):
- Add `stock_item_id` (Integer, ForeignKey -> stock_items.id, Optional, Indexed)
- Add `warehouse_id` (Integer, ForeignKey -> warehouses.id, Optional)
- Add `is_stock_item` (Boolean, Default: False)
**Behavior**:
- When invoice item is linked to stock item and invoice is created:
- Reserve stock if not already reserved
- Optionally reduce stock when invoice status changes to 'sent' or 'paid'
- Track cost at time of sale for profit analysis
- Create StockMovement record when stock is allocated
---
### 2.3 Project Integration
**New Model: ProjectStockAllocation (`app/models/project_stock_allocation.py`)**:
- `id` (Integer, Primary Key)
- `project_id` (Integer, ForeignKey -> projects.id, Required, Indexed)
- `stock_item_id` (Integer, ForeignKey -> stock_items.id, Required, Indexed)
- `warehouse_id` (Integer, ForeignKey -> warehouses.id, Required, Indexed)
- `quantity_allocated` (Numeric(10, 2), Required)
- `quantity_used` (Numeric(10, 2), Default: 0)
- `allocated_by` (Integer, ForeignKey -> users.id, Required)
- `allocated_at` (DateTime, Default: now)
- `notes` (Text, Optional)
**Purpose**: Track which items are allocated to which projects (for project-based inventory)
---
### 2.4 ExtraGood Enhancement
**Changes to `app/models/extra_good.py`**:
- Add `stock_item_id` (Integer, ForeignKey -> stock_items.id, Optional, Indexed)
- Link ExtraGood to StockItem when applicable
---
## 3. Menu Structure
### 3.1 New Menu Group: "Inventory"
Add to `app/templates/base.html` after "Finance & Expenses" section:
```html
<li class="mt-2">
<button onclick="toggleDropdown('inventoryDropdown')" data-dropdown="inventoryDropdown"
class="w-full flex items-center p-2 rounded-lg {% if inventory_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-boxes w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Inventory') }}</span>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="inventoryDropdown" class="{% if not inventory_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
<li>
<a class="block px-2 py-1 rounded {% if nav_active_stock_items %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.list_stock_items') }}">
<i class="fas fa-cubes w-4 mr-2"></i>{{ _('Stock Items') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_warehouses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.list_warehouses') }}">
<i class="fas fa-warehouse w-4 mr-2"></i>{{ _('Warehouses') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_stock_levels %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.stock_levels') }}">
<i class="fas fa-list-ul w-4 mr-2"></i>{{ _('Stock Levels') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_movements %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.list_movements') }}">
<i class="fas fa-exchange-alt w-4 mr-2"></i>{{ _('Stock Movements') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_transfers %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.list_transfers') }}">
<i class="fas fa-truck w-4 mr-2"></i>{{ _('Transfers') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reservations %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.list_reservations') }}">
<i class="fas fa-bookmark w-4 mr-2"></i>{{ _('Reservations') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_adjustments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.list_adjustments') }}">
<i class="fas fa-edit w-4 mr-2"></i>{{ _('Stock Adjustments') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.reports') }}">
<i class="fas fa-chart-pie w-4 mr-2"></i>{{ _('Inventory Reports') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_low_stock %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}"
href="{{ url_for('inventory.low_stock_alerts') }}">
<i class="fas fa-exclamation-triangle w-4 mr-2"></i>{{ _('Low Stock Alerts') }}
</a>
</li>
</ul>
</li>
```
**Menu Variable**:
```python
{% set inventory_open = ep.startswith('inventory.') %}
```
---
## 4. Routes and Endpoints
### 4.1 Main Routes File (`app/routes/inventory.py`)
**Stock Items**:
- `GET /inventory/items` - List all stock items
- `GET /inventory/items/new` - Create new stock item form
- `POST /inventory/items` - Create stock item
- `GET /inventory/items/<id>` - View stock item details
- `GET /inventory/items/<id>/edit` - Edit stock item form
- `POST /inventory/items/<id>` - Update stock item
- `POST /inventory/items/<id>/delete` - Delete stock item
- `GET /inventory/items/<id>/history` - Stock movement history for item
**Warehouses**:
- `GET /inventory/warehouses` - List all warehouses
- `GET /inventory/warehouses/new` - Create new warehouse form
- `POST /inventory/warehouses` - Create warehouse
- `GET /inventory/warehouses/<id>` - View warehouse details
- `GET /inventory/warehouses/<id>/edit` - Edit warehouse form
- `POST /inventory/warehouses/<id>` - Update warehouse
- `POST /inventory/warehouses/<id>/delete` - Delete warehouse (if no stock)
**Stock Levels**:
- `GET /inventory/stock-levels` - View stock levels (multi-warehouse view)
- `GET /inventory/stock-levels/warehouse/<warehouse_id>` - Stock levels for specific warehouse
- `GET /inventory/stock-levels/item/<item_id>` - Stock levels for specific item across warehouses
**Stock Movements**:
- `GET /inventory/movements` - List all stock movements (with filters)
- `GET /inventory/movements/new` - Create manual movement/adjustment
- `POST /inventory/movements` - Record movement
**Stock Transfers**:
- `GET /inventory/transfers` - List transfers between warehouses
- `GET /inventory/transfers/new` - Create new transfer
- `POST /inventory/transfers` - Create transfer (creates two movements)
**Stock Adjustments**:
- `GET /inventory/adjustments` - List adjustments
- `GET /inventory/adjustments/new` - Create adjustment
- `POST /inventory/adjustments` - Record adjustment
**Reservations**:
- `GET /inventory/reservations` - List all reservations
- `POST /inventory/reservations/<id>/fulfill` - Fulfill reservation
- `POST /inventory/reservations/<id>/cancel` - Cancel reservation
**Reports**:
- `GET /inventory/reports` - Inventory reports dashboard
- `GET /inventory/reports/valuation` - Stock valuation report
- `GET /inventory/reports/movement-history` - Movement history report
- `GET /inventory/reports/turnover` - Inventory turnover analysis
- `GET /inventory/reports/low-stock` - Low stock alerts
**API Endpoints** (also add to `app/routes/api_v1.py`):
- `GET /api/v1/inventory/items` - List stock items (JSON)
- `GET /api/v1/inventory/items/<id>` - Get stock item details
- `GET /api/v1/inventory/items/<id>/availability` - Check availability across warehouses
- `GET /api/v1/inventory/warehouses` - List warehouses
- `GET /api/v1/inventory/stock-levels` - Get stock levels (filterable)
- `POST /api/v1/inventory/movements` - Create movement via API
---
## 5. Key Features
### 5.1 Standard Inventory Management Features
1. **Multi-Warehouse Support**
- Manage multiple warehouse locations
- Track stock levels per warehouse
- Transfer stock between warehouses
2. **Stock Item Master Data**
- SKU/barcode management
- Product categories
- Unit of measure support
- Default cost/price tracking
- Supplier information
- Product images
3. **Real-Time Stock Tracking**
- Current stock levels per warehouse
- Available quantity (on-hand minus reserved)
- Stock history and movement audit trail
4. **Stock Reservations**
- Reserve stock for quotes
- Reserve stock for invoices
- Reserve stock for projects
- Automatic expiration for quote reservations
- Fulfillment tracking
5. **Stock Movements**
- Record all inventory changes
- Movement types: adjustment, transfer, sale, purchase, return, waste
- Link movements to invoices/quotes/projects
- Cost tracking at movement time
6. **Low Stock Alerts**
- Configurable reorder points per item
- Automatic alerts when stock falls below threshold
- Dashboard widget showing low stock items
- Email notifications (optional)
7. **Stock Adjustments**
- Manual stock adjustments
- Physical count corrections
- Reason tracking for all adjustments
- Approval workflow (optional, via permissions)
8. **Transfers Between Warehouses**
- Create transfer requests
- Track transfer status (pending, in-transit, completed)
- Update stock levels automatically
9. **Inventory Reports**
- Stock valuation report (current stock value)
- Movement history report
- Inventory turnover analysis
- Low stock report
- Stock level by warehouse
- Stock level by category
- ABC analysis (optional future feature)
10. **Barcode Scanning Support**
- Barcode field per stock item
- Search by barcode
- API support for barcode scanners
### 5.2 Integration Features
1. **Quote Integration**
- Add stock items to quotes
- Show available quantity when adding items
- Reserve stock when quote is sent (optional setting)
- Auto-reserve on quote acceptance
- Release reservation on rejection/expiration
2. **Invoice Integration**
- Add stock items to invoices
- Automatic stock reservation on invoice creation
- Reduce stock when invoice is marked as sent/paid (configurable)
- Track cost vs. price for profit analysis
- Create StockMovement records automatically
3. **Project Integration**
- Allocate stock items to projects
- Track quantity used vs. allocated
- Link project stock to invoices/quotes
- Project stock consumption reports
4. **ExtraGood Integration**
- Link ExtraGood records to StockItems
- Convert ExtraGood to StockItem (migration path)
---
## 6. Permissions
Add to `app/utils/permissions_seed.py`:
```python
# Inventory Management Permissions
inventory_permissions = [
Permission('view_inventory', 'View inventory items and stock levels', 'inventory'),
Permission('manage_stock_items', 'Create, edit, and delete stock items', 'inventory'),
Permission('manage_warehouses', 'Create, edit, and delete warehouses', 'inventory'),
Permission('view_stock_levels', 'View current stock levels', 'inventory'),
Permission('manage_stock_movements', 'Record stock movements and adjustments', 'inventory'),
Permission('transfer_stock', 'Transfer stock between warehouses', 'inventory'),
Permission('view_stock_history', 'View stock movement history', 'inventory'),
Permission('manage_stock_reservations', 'Create and manage stock reservations', 'inventory'),
Permission('view_inventory_reports', 'View inventory reports', 'inventory'),
Permission('approve_stock_adjustments', 'Approve stock adjustments (if approval workflow enabled)', 'inventory'),
]
```
**Default Role Assignments**:
- Super Admin: All permissions
- Admin: All permissions
- Manager: view_inventory, view_stock_levels, manage_stock_movements, transfer_stock, view_stock_history, manage_stock_reservations, view_inventory_reports
- User: view_inventory, view_stock_levels
- Viewer: view_inventory (read-only)
---
## 7. Database Migrations
### 7.1 Initial Migration Structure
**Migration File**: `migrations/versions/059_add_inventory_management.py`
**Tables to Create**:
1. `warehouses`
2. `stock_items`
3. `warehouse_stock`
4. `stock_movements`
5. `stock_reservations`
6. `project_stock_allocations`
**Alterations to Existing Tables**:
1. `quote_items` - Add `stock_item_id`, `warehouse_id`, `is_stock_item`
2. `invoice_items` - Add `stock_item_id`, `warehouse_id`, `is_stock_item`
3. `extra_goods` - Add `stock_item_id`
**Indexes**:
- Index on `stock_items.sku`
- Index on `stock_items.barcode`
- Index on `warehouse_stock(warehouse_id, stock_item_id)` (unique)
- Index on `stock_movements(reference_type, reference_id)`
- Index on `stock_movements(stock_item_id, moved_at)`
- Index on `stock_reservations(reservation_type, reservation_id)`
**Foreign Keys**:
- All appropriate foreign key constraints
- Cascade deletes where appropriate
- Set null for optional references
---
## 8. UI/UX Considerations
### 8.1 Stock Items List View
- Table with columns: SKU, Name, Category, Total Qty, Low Stock, Actions
- Filters: Category, Active/Inactive, Low Stock
- Search: By SKU, Name, Barcode
- Quick actions: View, Edit, Adjust Stock, View History
### 8.2 Stock Item Detail View
- Item information
- Stock levels per warehouse (table)
- Recent movements (last 10)
- Related quotes/invoices/projects
- Stock level graph (optional)
### 8.3 Stock Levels Dashboard
- Multi-warehouse view
- Filter by warehouse, category, low stock
- Quick adjust buttons
- Color coding for low stock
### 8.4 Add Stock Item to Quote/Invoice
- Product selector with search/filter
- Show available quantity per warehouse
- Select warehouse for reservation
- Quantity validation (ensure available)
### 8.5 Stock Movement Form
- Movement type selector
- Stock item selector (with search)
- Warehouse selector
- Quantity (positive/negative)
- Reference (link to invoice/quote/project)
- Reason field
### 8.6 Warehouse Management
- List view with active/inactive toggle
- Detail view showing stock levels
- Transfer in/out history
---
## 9. Implementation Phases
### Phase 1: Core Models and Database (Week 1)
- Create all database models
- Create Alembic migration
- Update model __init__.py
- Basic model tests
### Phase 2: Basic CRUD Operations (Week 2)
- Stock Items CRUD routes and templates
- Warehouses CRUD routes and templates
- Basic stock level views
- Integration tests
### Phase 3: Stock Movements and Tracking (Week 3)
- Stock movement recording
- Stock level updates on movements
- Movement history views
- Transfer functionality
### Phase 4: Integration with Quotes/Invoices (Week 4)
- Add stock_item_id to QuoteItem and InvoiceItem
- Stock item selector in quote/invoice forms
- Stock reservation logic
- Stock reduction on invoice creation/update
- Integration tests
### Phase 5: Advanced Features (Week 5)
- Low stock alerts
- Inventory reports
- Project stock allocation
- Barcode support
### Phase 6: Permissions and Polish (Week 6)
- Add permissions
- Update menu
- UI/UX improvements
- Documentation
- Final testing
---
## 10. Testing Requirements
### 10.1 Model Tests (`tests/test_models/test_inventory_models.py`)
- Warehouse model creation and validation
- StockItem model creation and validation
- WarehouseStock stock level calculations
- StockMovement creation and stock updates
- StockReservation lifecycle (reserve, fulfill, cancel)
### 10.2 Route Tests (`tests/test_routes/test_inventory_routes.py`)
- Stock items CRUD operations
- Warehouses CRUD operations
- Stock movement recording
- Stock level queries
- Permission checks
### 10.3 Integration Tests (`tests/test_integration/test_inventory_integration.py`)
- Quote with stock items (reservation)
- Invoice with stock items (stock reduction)
- Project stock allocation
- Stock transfer between warehouses
- Low stock alert triggering
### 10.4 Smoke Tests
- Create stock item
- Add stock item to quote
- Create invoice with stock item
- Record stock adjustment
- View stock levels
---
## 11. Configuration Settings
Add to Settings model or environment:
- `INVENTORY_AUTO_RESERVE_ON_QUOTE_SENT` (Boolean, Default: False)
- `INVENTORY_REDUCE_ON_INVOICE_SENT` (Boolean, Default: True)
- `INVENTORY_REDUCE_ON_INVOICE_PAID` (Boolean, Default: False)
- `INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS` (Integer, Default: 30)
- `INVENTORY_LOW_STOCK_ALERT_ENABLED` (Boolean, Default: True)
- `INVENTORY_REQUIRE_APPROVAL_FOR_ADJUSTMENTS` (Boolean, Default: False)
---
## 12. Future Enhancements (Post-MVP)
1. **Advanced Costing Methods**
- FIFO (First In, First Out)
- LIFO (Last In, First Out)
- Average Cost
- Specific Identification
2. **Purchase Orders**
- Create purchase orders
- Receive goods (update stock)
- Supplier management
3. **Stocktaking/Physical Counts**
- Schedule physical counts
- Count sheets
- Variance reports
4. **Serial Number Tracking**
- Track individual items by serial number
- Lot/batch tracking
5. **ABC Analysis**
- Classify items by value
- Focus management on high-value items
6. **Demand Forecasting**
- Analyze historical sales
- Predict future demand
- Auto-generate reorder suggestions
7. **Multi-Currency Support**
- Track costs in different currencies
- Currency conversion for valuations
8. **Barcode Scanner Integration**
- Mobile barcode scanning
- Real-time stock updates via scanner
9. **Inventory Templates**
- Pre-defined stock item templates
- Quick add from templates
10. **Email Notifications**
- Low stock alerts via email
- Daily/weekly inventory summaries
---
## 13. Documentation
Create the following documentation:
1. `docs/features/INVENTORY_MANAGEMENT.md` - User guide
2. `docs/features/INVENTORY_API.md` - API documentation
3. Update main README with inventory features
4. Video tutorial (optional)
---
## 14. Migration Strategy
### 14.1 Existing Data
- ExtraGood records with SKUs can be migrated to StockItems
- Create default warehouse "Main Warehouse" if none exists
- Set initial stock levels (if known)
### 14.2 Backward Compatibility
- Quotes/Invoices without stock_item_id continue to work
- ExtraGood remains functional
- Gradual migration path for existing data
---
## 15. Success Criteria
1. ✅ Can create and manage warehouses
2. ✅ Can create and manage stock items
3. ✅ Can track stock levels per warehouse
4. ✅ Can add stock items to quotes and see availability
5. ✅ Can add stock items to invoices and reduce stock
6. ✅ Stock reservations work correctly
7. ✅ Stock movements are recorded and auditable
8. ✅ Low stock alerts function properly
9. ✅ Inventory reports generate correctly
10. ✅ All tests pass
11. ✅ Permissions work correctly
12. ✅ Integration with quotes/invoices/projects is seamless
---
## Conclusion
This comprehensive inventory management system will provide TimeTracker with professional-grade inventory tracking capabilities while maintaining seamless integration with existing quote, invoice, and project workflows. The phased implementation approach ensures steady progress while maintaining code quality and test coverage.

View File

@@ -0,0 +1,439 @@
# Inventory Management System - Missing Features Analysis
## Summary
This document outlines all missing features, routes, and improvements needed to complete the inventory management system implementation.
---
## 1. Missing Routes
### 1.1 Stock Transfers (Completely Missing)
**Status**: ❌ Not Implemented
**Required Routes**:
- `GET /inventory/transfers` - List all stock transfers between warehouses
- `GET /inventory/transfers/new` - Create new transfer form
- `POST /inventory/transfers` - Create transfer (creates two movements: negative from source, positive to destination)
**Purpose**: Allow users to transfer stock between warehouses with proper tracking
---
### 1.2 Stock Adjustments (Separate from Movements)
**Status**: ⚠️ Partially Implemented (adjustments can be done via movements/new, but no dedicated route)
**Required Routes**:
- `GET /inventory/adjustments` - List all adjustments (filtered movements)
- `GET /inventory/adjustments/new` - Create adjustment form
- `POST /inventory/adjustments` - Record adjustment
**Purpose**: Dedicated interface for stock corrections and physical count adjustments
---
### 1.3 Inventory Reports (Completely Missing)
**Status**: ❌ Not Implemented
**Required Routes**:
- `GET /inventory/reports` - Reports dashboard
- `GET /inventory/reports/valuation` - Stock valuation report (total value of inventory)
- `GET /inventory/reports/movement-history` - Detailed movement history report
- `GET /inventory/reports/turnover` - Inventory turnover analysis
- `GET /inventory/reports/low-stock` - Low stock report (currently only alerts page exists)
**Purpose**: Provide comprehensive inventory analytics and reporting
---
### 1.4 Stock Item History
**Status**: ❌ Not Implemented
**Required Route**:
- `GET /inventory/items/<id>/history` - Detailed movement history for a specific item
**Purpose**: View complete audit trail for a stock item across all warehouses
---
### 1.5 Purchase Order Management
**Status**: ⚠️ Partially Implemented
**Missing Routes**:
- `GET /inventory/purchase-orders/<id>/edit` - Edit purchase order form
- `POST /inventory/purchase-orders/<id>/edit` - Update purchase order
- `POST /inventory/purchase-orders/<id>/delete` - Delete/cancel purchase order
- `POST /inventory/purchase-orders/<id>/send` - Mark PO as sent to supplier
- `POST /inventory/purchase-orders/<id>/confirm` - Mark PO as confirmed
**Purpose**: Complete purchase order lifecycle management
---
### 1.6 Additional Stock Levels Views
**Status**: ⚠️ Partially Implemented
**Missing Routes**:
- `GET /inventory/stock-levels/warehouse/<warehouse_id>` - Stock levels for specific warehouse
- `GET /inventory/stock-levels/item/<item_id>` - Stock levels for specific item across all warehouses
**Purpose**: More granular views of stock levels
---
## 2. Missing API Endpoints
### 2.1 Supplier API Endpoints
**Status**: ❌ Not Implemented
**Required Endpoints**:
- `GET /api/v1/inventory/suppliers` - List suppliers (JSON)
- `GET /api/v1/inventory/suppliers/<id>` - Get supplier details
- `POST /api/v1/inventory/suppliers` - Create supplier
- `PUT /api/v1/inventory/suppliers/<id>` - Update supplier
- `DELETE /api/v1/inventory/suppliers/<id>` - Delete supplier
- `GET /api/v1/inventory/suppliers/<id>/stock-items` - Get stock items from supplier
---
### 2.2 Purchase Order API Endpoints
**Status**: ❌ Not Implemented
**Required Endpoints**:
- `GET /api/v1/inventory/purchase-orders` - List purchase orders
- `GET /api/v1/inventory/purchase-orders/<id>` - Get purchase order details
- `POST /api/v1/inventory/purchase-orders` - Create purchase order
- `PUT /api/v1/inventory/purchase-orders/<id>` - Update purchase order
- `POST /api/v1/inventory/purchase-orders/<id>/receive` - Receive purchase order
- `POST /api/v1/inventory/purchase-orders/<id>/cancel` - Cancel purchase order
---
### 2.3 Additional Inventory API Endpoints
**Status**: ⚠️ Partially Implemented
**Missing Endpoints**:
- `GET /api/v1/inventory/suppliers` - List suppliers
- `GET /api/v1/inventory/supplier-stock-items` - Get supplier stock items
- `GET /api/v1/inventory/transfers` - List transfers
- `POST /api/v1/inventory/transfers` - Create transfer
- `GET /api/v1/inventory/reports/valuation` - Stock valuation (API)
- `GET /api/v1/inventory/reports/turnover` - Turnover analysis (API)
---
## 3. Missing Features
### 3.1 Stock Transfers Between Warehouses
**Status**: ❌ Not Implemented
**Requirements**:
- Create transfer with source and destination warehouses
- Quantity validation (ensure source has enough stock)
- Create two stock movements automatically (negative from source, positive to destination)
- Transfer status tracking (pending, in-transit, completed)
- Transfer history and audit trail
---
### 3.2 Inventory Reports and Analytics
**Status**: ❌ Not Implemented
**Required Reports**:
1. **Stock Valuation Report**
- Total inventory value per warehouse
- Total inventory value by category
- Value trends over time
- Cost basis calculation (FIFO/LIFO/Average)
2. **Inventory Turnover Analysis**
- Turnover rate per item
- Days on hand calculation
- Slow-moving items identification
- Fast-moving items identification
3. **Movement History Report**
- Detailed movement log with filters
- Export to CSV/Excel
- Summary statistics
4. **Low Stock Report**
- Comprehensive low stock items list
- Reorder suggestions
- Stock level trends
---
### 3.3 Stock Item Movement History View
**Status**: ❌ Not Implemented
**Requirements**:
- Dedicated page showing all movements for a specific stock item
- Filter by date range, warehouse, movement type
- Visual timeline/graph of stock levels
- Export capability
---
### 3.4 Purchase Order Enhancements
**Status**: ⚠️ Partially Implemented
**Missing Features**:
- Edit purchase orders (before receiving)
- Delete/cancel purchase orders
- Send PO to supplier (mark as sent)
- PO confirmation workflow
- PO status management (draft → sent → confirmed → received)
- PO printing/PDF generation
- Email PO to supplier (future enhancement)
---
### 3.5 Supplier Stock Item Management
**Status**: ⚠️ Partially Implemented
**Missing Features**:
- Add/edit supplier items directly from stock item view
- Remove supplier items from stock item view
- Bulk import supplier items
- Supplier price history tracking
- Best price recommendation
---
### 3.6 Stock Item History View
**Status**: ❌ Not Implemented
**Requirements**:
- Detailed movement history page
- Stock level graphs/charts
- Filter by date, warehouse, movement type
- Export history to CSV
---
## 4. Missing Menu Items
**Status**: ⚠️ Partially Implemented
**Missing from Navigation**:
- "Transfers" menu item (under Inventory)
- "Adjustments" menu item (under Inventory) - or consolidate with Movements
- "Reports" menu item (under Inventory) - consolidate all inventory reports
---
## 5. Missing Tests
### 5.1 Supplier Tests
**Status**: ❌ Not Implemented
**Required Tests**:
- `tests/test_models/test_supplier.py` - Supplier model tests
- `tests/test_routes/test_supplier_routes.py` - Supplier route tests
- Supplier CRUD operations
- Supplier stock item relationships
- Supplier deletion with associated items
---
### 5.2 Purchase Order Tests
**Status**: ❌ Not Implemented
**Required Tests**:
- `tests/test_models/test_purchase_order.py` - Purchase order model tests
- `tests/test_routes/test_purchase_order_routes.py` - Purchase order route tests
- PO creation and item handling
- PO receiving and stock movement creation
- PO cancellation
---
### 5.3 Transfer Tests
**Status**: ❌ Not Implemented (feature doesn't exist)
**Required Tests**:
- Transfer creation
- Stock level updates on transfer
- Transfer validation (enough stock, etc.)
---
### 5.4 Report Tests
**Status**: ❌ Not Implemented (feature doesn't exist)
**Required Tests**:
- Valuation report accuracy
- Turnover calculation correctness
- Report data aggregation
---
## 6. Code Issues and Improvements
### 6.1 Supplier Code Validation
**Status**: ⚠️ Needs Improvement
**Issue**: Supplier creation route doesn't check for duplicate codes before creating
**Required Fix**: Add code uniqueness check in `new_supplier` route
```python
# Check if code already exists
existing = Supplier.query.filter_by(code=code).first()
if existing:
flash(_('Supplier code already exists...'), 'error')
return render_template(...)
```
---
### 6.2 Purchase Order Form Enhancement
**Status**: ⚠️ Needs Improvement
**Issue**: Purchase order form doesn't auto-populate supplier stock items when supplier is selected
**Required Enhancement**:
- When supplier is selected, load their stock items
- Pre-fill cost prices from supplier stock items
- Pre-fill supplier SKUs
---
### 6.3 Stock Item Supplier Management
**Status**: ⚠️ Needs Improvement
**Issues**:
- No way to add/edit supplier items from stock item view page
- Supplier items management only available in stock item edit form
**Required Enhancement**:
- Add "Manage Suppliers" button on stock item view page
- Quick add/edit supplier items modal or separate page
---
### 6.4 Warehouse Stock Location Field
**Status**: ✅ Implemented in model, ⚠️ Not used in UI
**Issue**: `WarehouseStock` model has `location` field but it's not exposed in forms/views
**Required Enhancement**: Add location field to stock level views and forms
---
## 7. Integration Gaps
### 7.1 Project Cost Integration
**Status**: ⚠️ Partial
**Missing**:
- Link purchase orders to project costs
- Track project-specific inventory purchases
- Project inventory cost allocation
---
### 7.2 ExtraGood Integration
**Status**: ⚠️ Partial
**Issue**: ExtraGood model has `stock_item_id` field but integration is incomplete
**Missing**:
- Auto-create stock items from ExtraGoods
- Link existing ExtraGoods to stock items
- Migration path for existing ExtraGoods
---
## 8. Configuration Settings
**Status**: ⚠️ Partially Implemented
**Missing Settings**:
- `INVENTORY_AUTO_RESERVE_ON_QUOTE_SENT` - Auto-reserve on quote send
- `INVENTORY_REDUCE_ON_INVOICE_SENT` - Reduce stock when invoice sent
- `INVENTORY_REDUCE_ON_INVOICE_PAID` - Reduce stock when invoice paid
- `INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS` - Reservation expiry (mentioned but not used)
- `INVENTORY_LOW_STOCK_ALERT_ENABLED` - Enable/disable low stock alerts
- `INVENTORY_REQUIRE_APPROVAL_FOR_ADJUSTMENTS` - Approval workflow for adjustments
**Note**: Some of these settings exist but aren't fully utilized in the code.
---
## 9. UI/UX Improvements Needed
### 9.1 Stock Item View Page
**Missing Elements**:
- Stock level graphs/charts
- Movement history table with pagination
- Quick actions (adjust stock, transfer, etc.)
- Supplier management section
---
### 9.2 Purchase Order View Page
**Missing Elements**:
- Edit button (for draft POs)
- Send/Cancel buttons
- Print PO functionality
- Supplier contact information display
---
### 9.3 Stock Levels Page
**Missing Features**:
- Location field display
- Bulk operations
- Export to CSV/Excel
- Advanced filtering
---
## 10. Documentation
**Status**: ❌ Not Created
**Missing Documentation**:
- `docs/features/INVENTORY_MANAGEMENT.md` - User guide
- `docs/features/INVENTORY_API.md` - API documentation
- Update main README with inventory features
- Migration guide for existing data
---
## Priority Summary
### High Priority (Core Functionality)
1. ✅ Stock Transfers - Essential for multi-warehouse management
2. ✅ Inventory Reports - Critical for inventory management decisions
3. ✅ Purchase Order edit/delete - Complete PO lifecycle
4. ✅ Supplier code validation - Bug fix
5. ✅ Stock item history view - Important for tracking
### Medium Priority (Enhanced Features)
1. Stock Adjustments dedicated routes
2. Additional stock level views
3. Supplier API endpoints
4. Purchase Order API endpoints
5. Stock item supplier management from view page
### Low Priority (Nice to Have)
1. Advanced inventory analytics
2. Inventory turnover analysis
3. PO printing/PDF generation
4. Bulk operations
5. Additional documentation
---
## Next Steps
1. Implement stock transfers functionality
2. Create inventory reports dashboard
3. Add purchase order edit/delete routes
4. Fix supplier code validation
5. Add missing API endpoints
6. Create comprehensive tests
7. Complete documentation

View File

@@ -0,0 +1,264 @@
"""Add inventory management system
Revision ID: 059
Revises: 058
Create Date: 2025-01-28
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '059'
down_revision = '058'
branch_labels = None
depends_on = None
def upgrade():
"""Add inventory management tables and fields"""
# Create warehouses table
op.create_table('warehouses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('contact_person', sa.String(length=200), nullable=True),
sa.Column('contact_email', sa.String(length=200), nullable=True),
sa.Column('contact_phone', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_warehouses_code', 'warehouses', ['code'], unique=True)
op.create_index('ix_warehouses_created_by', 'warehouses', ['created_by'], unique=False)
# Create stock_items table
op.create_table('stock_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sku', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('unit', sa.String(length=20), nullable=False, server_default='pcs'),
sa.Column('default_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('default_price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('barcode', sa.String(length=100), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('is_trackable', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('reorder_point', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('reorder_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('supplier', sa.String(length=200), nullable=True),
sa.Column('supplier_sku', sa.String(length=100), nullable=True),
sa.Column('image_url', sa.String(length=500), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_stock_items_sku', 'stock_items', ['sku'], unique=True)
op.create_index('ix_stock_items_barcode', 'stock_items', ['barcode'], unique=False)
op.create_index('ix_stock_items_category', 'stock_items', ['category'], unique=False)
op.create_index('ix_stock_items_created_by', 'stock_items', ['created_by'], unique=False)
# Create warehouse_stock table
op.create_table('warehouse_stock',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('stock_item_id', sa.Integer(), nullable=False),
sa.Column('quantity_on_hand', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('quantity_reserved', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('location', sa.String(length=100), nullable=True),
sa.Column('last_counted_at', sa.DateTime(), nullable=True),
sa.Column('last_counted_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['stock_item_id'], ['stock_items.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['last_counted_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('warehouse_id', 'stock_item_id', name='uq_warehouse_stock')
)
op.create_index('ix_warehouse_stock_warehouse_id', 'warehouse_stock', ['warehouse_id'], unique=False)
op.create_index('ix_warehouse_stock_stock_item_id', 'warehouse_stock', ['stock_item_id'], unique=False)
# Create stock_movements table
op.create_table('stock_movements',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('movement_type', sa.String(length=20), nullable=False),
sa.Column('stock_item_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('reference_type', sa.String(length=50), nullable=True),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('unit_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('reason', sa.String(length=500), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('moved_by', sa.Integer(), nullable=False),
sa.Column('moved_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['stock_item_id'], ['stock_items.id'], ),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.ForeignKeyConstraint(['moved_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_stock_movements_movement_type', 'stock_movements', ['movement_type'], unique=False)
op.create_index('ix_stock_movements_stock_item_id', 'stock_movements', ['stock_item_id'], unique=False)
op.create_index('ix_stock_movements_warehouse_id', 'stock_movements', ['warehouse_id'], unique=False)
op.create_index('ix_stock_movements_reference_type', 'stock_movements', ['reference_type'], unique=False)
op.create_index('ix_stock_movements_reference_id', 'stock_movements', ['reference_id'], unique=False)
op.create_index('ix_stock_movements_moved_by', 'stock_movements', ['moved_by'], unique=False)
op.create_index('ix_stock_movements_moved_at', 'stock_movements', ['moved_at'], unique=False)
op.create_index('ix_stock_movements_reference', 'stock_movements', ['reference_type', 'reference_id'], unique=False)
op.create_index('ix_stock_movements_item_date', 'stock_movements', ['stock_item_id', 'moved_at'], unique=False)
# Create stock_reservations table
op.create_table('stock_reservations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stock_item_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('reservation_type', sa.String(length=20), nullable=False),
sa.Column('reservation_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='reserved'),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('reserved_by', sa.Integer(), nullable=False),
sa.Column('reserved_at', sa.DateTime(), nullable=False),
sa.Column('fulfilled_at', sa.DateTime(), nullable=True),
sa.Column('cancelled_at', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['stock_item_id'], ['stock_items.id'], ),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.ForeignKeyConstraint(['reserved_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_stock_reservations_stock_item_id', 'stock_reservations', ['stock_item_id'], unique=False)
op.create_index('ix_stock_reservations_warehouse_id', 'stock_reservations', ['warehouse_id'], unique=False)
op.create_index('ix_stock_reservations_reservation_type', 'stock_reservations', ['reservation_type'], unique=False)
op.create_index('ix_stock_reservations_reservation_id', 'stock_reservations', ['reservation_id'], unique=False)
op.create_index('ix_stock_reservations_reserved_by', 'stock_reservations', ['reserved_by'], unique=False)
op.create_index('ix_stock_reservations_expires_at', 'stock_reservations', ['expires_at'], unique=False)
op.create_index('ix_stock_reservations_reservation', 'stock_reservations', ['reservation_type', 'reservation_id'], unique=False)
# Create project_stock_allocations table
op.create_table('project_stock_allocations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('stock_item_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('quantity_allocated', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('quantity_used', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('allocated_by', sa.Integer(), nullable=False),
sa.Column('allocated_at', sa.DateTime(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['stock_item_id'], ['stock_items.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.ForeignKeyConstraint(['allocated_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_project_stock_allocations_project_id', 'project_stock_allocations', ['project_id'], unique=False)
op.create_index('ix_project_stock_allocations_stock_item_id', 'project_stock_allocations', ['stock_item_id'], unique=False)
op.create_index('ix_project_stock_allocations_warehouse_id', 'project_stock_allocations', ['warehouse_id'], unique=False)
op.create_index('ix_project_stock_allocations_allocated_by', 'project_stock_allocations', ['allocated_by'], unique=False)
# Add inventory fields to quote_items
op.add_column('quote_items', sa.Column('stock_item_id', sa.Integer(), nullable=True))
op.add_column('quote_items', sa.Column('warehouse_id', sa.Integer(), nullable=True))
op.add_column('quote_items', sa.Column('is_stock_item', sa.Boolean(), nullable=False, server_default='0'))
op.create_index('ix_quote_items_stock_item_id', 'quote_items', ['stock_item_id'], unique=False)
op.create_foreign_key('fk_quote_items_stock_item_id', 'quote_items', 'stock_items', ['stock_item_id'], ['id'])
op.create_foreign_key('fk_quote_items_warehouse_id', 'quote_items', 'warehouses', ['warehouse_id'], ['id'])
# Add inventory fields to invoice_items
op.add_column('invoice_items', sa.Column('stock_item_id', sa.Integer(), nullable=True))
op.add_column('invoice_items', sa.Column('warehouse_id', sa.Integer(), nullable=True))
op.add_column('invoice_items', sa.Column('is_stock_item', sa.Boolean(), nullable=False, server_default='0'))
op.create_index('ix_invoice_items_stock_item_id', 'invoice_items', ['stock_item_id'], unique=False)
op.create_foreign_key('fk_invoice_items_stock_item_id', 'invoice_items', 'stock_items', ['stock_item_id'], ['id'])
op.create_foreign_key('fk_invoice_items_warehouse_id', 'invoice_items', 'warehouses', ['warehouse_id'], ['id'])
# Add inventory field to extra_goods
op.add_column('extra_goods', sa.Column('stock_item_id', sa.Integer(), nullable=True))
op.create_index('ix_extra_goods_stock_item_id', 'extra_goods', ['stock_item_id'], unique=False)
op.create_foreign_key('fk_extra_goods_stock_item_id', 'extra_goods', 'stock_items', ['stock_item_id'], ['id'])
def downgrade():
"""Remove inventory management tables and fields"""
# Remove inventory fields from extra_goods
op.drop_constraint('fk_extra_goods_stock_item_id', 'extra_goods', type_='foreignkey')
op.drop_index('ix_extra_goods_stock_item_id', table_name='extra_goods')
op.drop_column('extra_goods', 'stock_item_id')
# Remove inventory fields from invoice_items
op.drop_constraint('fk_invoice_items_warehouse_id', 'invoice_items', type_='foreignkey')
op.drop_constraint('fk_invoice_items_stock_item_id', 'invoice_items', type_='foreignkey')
op.drop_index('ix_invoice_items_stock_item_id', table_name='invoice_items')
op.drop_column('invoice_items', 'is_stock_item')
op.drop_column('invoice_items', 'warehouse_id')
op.drop_column('invoice_items', 'stock_item_id')
# Remove inventory fields from quote_items
op.drop_constraint('fk_quote_items_warehouse_id', 'quote_items', type_='foreignkey')
op.drop_constraint('fk_quote_items_stock_item_id', 'quote_items', type_='foreignkey')
op.drop_index('ix_quote_items_stock_item_id', table_name='quote_items')
op.drop_column('quote_items', 'is_stock_item')
op.drop_column('quote_items', 'warehouse_id')
op.drop_column('quote_items', 'stock_item_id')
# Drop project_stock_allocations table
op.drop_index('ix_project_stock_allocations_allocated_by', table_name='project_stock_allocations')
op.drop_index('ix_project_stock_allocations_warehouse_id', table_name='project_stock_allocations')
op.drop_index('ix_project_stock_allocations_stock_item_id', table_name='project_stock_allocations')
op.drop_index('ix_project_stock_allocations_project_id', table_name='project_stock_allocations')
op.drop_table('project_stock_allocations')
# Drop stock_reservations table
op.drop_index('ix_stock_reservations_reservation', table_name='stock_reservations')
op.drop_index('ix_stock_reservations_expires_at', table_name='stock_reservations')
op.drop_index('ix_stock_reservations_reserved_by', table_name='stock_reservations')
op.drop_index('ix_stock_reservations_reservation_id', table_name='stock_reservations')
op.drop_index('ix_stock_reservations_reservation_type', table_name='stock_reservations')
op.drop_index('ix_stock_reservations_warehouse_id', table_name='stock_reservations')
op.drop_index('ix_stock_reservations_stock_item_id', table_name='stock_reservations')
op.drop_table('stock_reservations')
# Drop stock_movements table
op.drop_index('ix_stock_movements_item_date', table_name='stock_movements')
op.drop_index('ix_stock_movements_reference', table_name='stock_movements')
op.drop_index('ix_stock_movements_moved_at', table_name='stock_movements')
op.drop_index('ix_stock_movements_moved_by', table_name='stock_movements')
op.drop_index('ix_stock_movements_reference_id', table_name='stock_movements')
op.drop_index('ix_stock_movements_reference_type', table_name='stock_movements')
op.drop_index('ix_stock_movements_warehouse_id', table_name='stock_movements')
op.drop_index('ix_stock_movements_stock_item_id', table_name='stock_movements')
op.drop_index('ix_stock_movements_movement_type', table_name='stock_movements')
op.drop_table('stock_movements')
# Drop warehouse_stock table
op.drop_index('ix_warehouse_stock_stock_item_id', table_name='warehouse_stock')
op.drop_index('ix_warehouse_stock_warehouse_id', table_name='warehouse_stock')
op.drop_table('warehouse_stock')
# Drop stock_items table
op.drop_index('ix_stock_items_created_by', table_name='stock_items')
op.drop_index('ix_stock_items_category', table_name='stock_items')
op.drop_index('ix_stock_items_barcode', table_name='stock_items')
op.drop_index('ix_stock_items_sku', table_name='stock_items')
op.drop_table('stock_items')
# Drop warehouses table
op.drop_index('ix_warehouses_created_by', table_name='warehouses')
op.drop_index('ix_warehouses_code', table_name='warehouses')
op.drop_table('warehouses')

View File

@@ -0,0 +1,80 @@
"""Add supplier management system
Revision ID: 060
Revises: 059
Create Date: 2025-01-28
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '060'
down_revision = '059'
branch_labels = None
depends_on = None
def upgrade():
"""Add supplier management tables"""
# Create suppliers table
op.create_table('suppliers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('contact_person', sa.String(length=200), nullable=True),
sa.Column('email', sa.String(length=200), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('website', sa.String(length=500), nullable=True),
sa.Column('tax_id', sa.String(length=100), nullable=True),
sa.Column('payment_terms', sa.String(length=100), nullable=True),
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_suppliers_code', 'suppliers', ['code'], unique=True)
op.create_index('ix_suppliers_created_by', 'suppliers', ['created_by'], unique=False)
# Create supplier_stock_items table (many-to-many with pricing)
op.create_table('supplier_stock_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('supplier_id', sa.Integer(), nullable=False),
sa.Column('stock_item_id', sa.Integer(), nullable=False),
sa.Column('supplier_sku', sa.String(length=100), nullable=True),
sa.Column('supplier_name', sa.String(length=200), nullable=True),
sa.Column('unit_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('minimum_order_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('lead_time_days', sa.Integer(), nullable=True),
sa.Column('is_preferred', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['stock_item_id'], ['stock_items.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('supplier_id', 'stock_item_id', name='uq_supplier_stock_item')
)
op.create_index('ix_supplier_stock_items_supplier_id', 'supplier_stock_items', ['supplier_id'], unique=False)
op.create_index('ix_supplier_stock_items_stock_item_id', 'supplier_stock_items', ['stock_item_id'], unique=False)
def downgrade():
"""Remove supplier management tables"""
op.drop_index('ix_supplier_stock_items_stock_item_id', table_name='supplier_stock_items')
op.drop_index('ix_supplier_stock_items_supplier_id', table_name='supplier_stock_items')
op.drop_table('supplier_stock_items')
op.drop_index('ix_suppliers_created_by', table_name='suppliers')
op.drop_index('ix_suppliers_code', table_name='suppliers')
op.drop_table('suppliers')

View File

@@ -0,0 +1,93 @@
"""Add purchase order system
Revision ID: 061
Revises: 060
Create Date: 2025-01-28
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '061'
down_revision = '060'
branch_labels = None
depends_on = None
def upgrade():
"""Add purchase order tables"""
# Create purchase_orders table
op.create_table('purchase_orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('po_number', sa.String(length=50), nullable=False),
sa.Column('supplier_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='draft'),
sa.Column('order_date', sa.Date(), nullable=False),
sa.Column('expected_delivery_date', sa.Date(), nullable=True),
sa.Column('received_date', sa.Date(), nullable=True),
sa.Column('subtotal', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('shipping_cost', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('internal_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_purchase_orders_po_number', 'purchase_orders', ['po_number'], unique=True)
op.create_index('ix_purchase_orders_supplier_id', 'purchase_orders', ['supplier_id'], unique=False)
op.create_index('ix_purchase_orders_status', 'purchase_orders', ['status'], unique=False)
op.create_index('ix_purchase_orders_order_date', 'purchase_orders', ['order_date'], unique=False)
op.create_index('ix_purchase_orders_created_by', 'purchase_orders', ['created_by'], unique=False)
# Create purchase_order_items table
op.create_table('purchase_order_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('purchase_order_id', sa.Integer(), nullable=False),
sa.Column('stock_item_id', sa.Integer(), nullable=True),
sa.Column('supplier_stock_item_id', sa.Integer(), nullable=True),
sa.Column('description', sa.String(length=500), nullable=False),
sa.Column('supplier_sku', sa.String(length=100), nullable=True),
sa.Column('quantity_ordered', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('quantity_received', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
sa.Column('unit_cost', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('line_total', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('warehouse_id', sa.Integer(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['stock_item_id'], ['stock_items.id'], ),
sa.ForeignKeyConstraint(['supplier_stock_item_id'], ['supplier_stock_items.id'], ),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_purchase_order_items_purchase_order_id', 'purchase_order_items', ['purchase_order_id'], unique=False)
op.create_index('ix_purchase_order_items_stock_item_id', 'purchase_order_items', ['stock_item_id'], unique=False)
op.create_index('ix_purchase_order_items_supplier_stock_item_id', 'purchase_order_items', ['supplier_stock_item_id'], unique=False)
op.create_index('ix_purchase_order_items_warehouse_id', 'purchase_order_items', ['warehouse_id'], unique=False)
def downgrade():
"""Remove purchase order tables"""
op.drop_index('ix_purchase_order_items_warehouse_id', table_name='purchase_order_items')
op.drop_index('ix_purchase_order_items_supplier_stock_item_id', table_name='purchase_order_items')
op.drop_index('ix_purchase_order_items_stock_item_id', table_name='purchase_order_items')
op.drop_index('ix_purchase_order_items_purchase_order_id', table_name='purchase_order_items')
op.drop_table('purchase_order_items')
op.drop_index('ix_purchase_orders_created_by', table_name='purchase_orders')
op.drop_index('ix_purchase_orders_order_date', table_name='purchase_orders')
op.drop_index('ix_purchase_orders_status', table_name='purchase_orders')
op.drop_index('ix_purchase_orders_supplier_id', table_name='purchase_orders')
op.drop_index('ix_purchase_orders_po_number', table_name='purchase_orders')
op.drop_table('purchase_orders')

View File

@@ -24,7 +24,9 @@ from app.models import (
DataImport, DataExport, InvoicePDFTemplate, ClientPrepaidConsumption,
AuditLog, RecurringInvoice, InvoiceEmail, Webhook, WebhookDelivery,
InvoiceTemplate, Currency, ExchangeRate, TaxRule, Payment,
CreditNote, InvoiceReminderSchedule, SavedReportView, ReportEmailSchedule
CreditNote, InvoiceReminderSchedule, SavedReportView, ReportEmailSchedule,
Warehouse, StockItem, WarehouseStock, StockMovement, StockReservation,
ProjectStockAllocation, Quote, QuoteItem
)

View File

@@ -0,0 +1,315 @@
"""Integration tests for inventory with quotes and invoices"""
import pytest
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_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
quote_item = quote.items.first()
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_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]
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'

View File

@@ -0,0 +1,498 @@
"""Tests for inventory management models"""
import pytest
from decimal import Decimal
from datetime import datetime, timedelta
from app import db
from app.models import (
Warehouse, StockItem, WarehouseStock, StockMovement, StockReservation,
ProjectStockAllocation, User, Project
)
@pytest.fixture
def test_user(db_session):
"""Create a test user"""
user = User(username='testuser', role='admin')
db_session.add(user)
db_session.commit()
return user
@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"""
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,
reorder_point=Decimal('10.00')
)
db_session.add(item)
db_session.commit()
return item
class TestWarehouse:
"""Test Warehouse model"""
def test_create_warehouse(self, db_session, test_user):
"""Test creating a warehouse"""
warehouse = Warehouse(
name='Test Warehouse',
code='WH-TEST',
created_by=test_user.id,
address='123 Test St',
contact_person='John Doe',
contact_email='john@test.com'
)
db_session.add(warehouse)
db_session.commit()
assert warehouse.id is not None
assert warehouse.name == 'Test Warehouse'
assert warehouse.code == 'WH-TEST'
assert warehouse.is_active is True
def test_warehouse_code_uppercase(self, db_session, test_user):
"""Test that warehouse code is automatically uppercased"""
warehouse = Warehouse(
name='Test',
code='wh-test',
created_by=test_user.id
)
assert warehouse.code == 'WH-TEST'
def test_warehouse_to_dict(self, db_session, test_user):
"""Test warehouse to_dict method"""
warehouse = Warehouse(
name='Test Warehouse',
code='WH-TEST',
created_by=test_user.id
)
db_session.add(warehouse)
db_session.commit()
data = warehouse.to_dict()
assert data['name'] == 'Test Warehouse'
assert data['code'] == 'WH-TEST'
assert 'created_at' in data
class TestStockItem:
"""Test StockItem model"""
def test_create_stock_item(self, db_session, test_user):
"""Test creating a stock item"""
item = StockItem(
sku='PROD-001',
name='Test Product',
created_by=test_user.id,
default_price=Decimal('25.50'),
default_cost=Decimal('15.00')
)
db_session.add(item)
db_session.commit()
assert item.id is not None
assert item.sku == 'PROD-001'
assert item.name == 'Test Product'
assert item.is_active is True
assert item.is_trackable is True
def test_sku_uppercase(self, db_session, test_user):
"""Test that SKU is automatically uppercased"""
item = StockItem(
sku='prod-001',
name='Test',
created_by=test_user.id
)
assert item.sku == 'PROD-001'
def test_total_quantity_on_hand(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test calculating total quantity on hand"""
# Create stock in warehouse
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('50.00')
)
db_session.add(stock)
db_session.commit()
# Refresh item to get updated quantities
db_session.refresh(test_stock_item)
assert test_stock_item.total_quantity_on_hand == Decimal('50.00')
def test_is_low_stock(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test low stock detection"""
# Create stock below reorder point
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('5.00') # Below reorder_point of 10
)
db_session.add(stock)
db_session.commit()
db_session.refresh(test_stock_item)
assert test_stock_item.is_low_stock is True
# Increase stock above reorder point
stock.quantity_on_hand = Decimal('15.00')
db_session.commit()
db_session.refresh(test_stock_item)
assert test_stock_item.is_low_stock is False
class TestWarehouseStock:
"""Test WarehouseStock model"""
def test_create_warehouse_stock(self, db_session, test_stock_item, test_warehouse):
"""Test creating warehouse stock"""
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('100.00'),
quantity_reserved=Decimal('10.00')
)
db_session.add(stock)
db_session.commit()
assert stock.id is not None
assert stock.quantity_on_hand == Decimal('100.00')
assert stock.quantity_reserved == Decimal('10.00')
assert stock.quantity_available == Decimal('90.00')
def test_reserve_quantity(self, db_session, test_stock_item, test_warehouse):
"""Test reserving 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()
stock.reserve(Decimal('20.00'))
db_session.commit()
assert stock.quantity_reserved == Decimal('20.00')
assert stock.quantity_available == Decimal('80.00')
def test_reserve_insufficient_stock(self, db_session, test_stock_item, test_warehouse):
"""Test that reserving more than available raises error"""
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('100.00'),
quantity_reserved=Decimal('90.00')
)
db_session.add(stock)
db_session.commit()
with pytest.raises(ValueError, match='Insufficient stock'):
stock.reserve(Decimal('20.00')) # Only 10 available
def test_release_reservation(self, db_session, test_stock_item, test_warehouse):
"""Test releasing reserved quantity"""
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('100.00'),
quantity_reserved=Decimal('30.00')
)
db_session.add(stock)
db_session.commit()
stock.release_reservation(Decimal('10.00'))
db_session.commit()
assert stock.quantity_reserved == Decimal('20.00')
assert stock.quantity_available == Decimal('80.00')
def test_adjust_on_hand(self, db_session, test_stock_item, test_warehouse):
"""Test adjusting on-hand 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()
stock.adjust_on_hand(Decimal('25.00')) # Add
db_session.commit()
assert stock.quantity_on_hand == Decimal('125.00')
stock.adjust_on_hand(Decimal('-50.00')) # Remove
db_session.commit()
assert stock.quantity_on_hand == Decimal('75.00')
class TestStockMovement:
"""Test StockMovement model"""
def test_record_movement(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test recording a stock movement"""
movement, updated_stock = StockMovement.record_movement(
movement_type='adjustment',
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal('50.00'),
moved_by=test_user.id,
reason='Initial stock',
update_stock=True
)
db_session.commit()
assert movement.id is not None
assert movement.quantity == Decimal('50.00')
assert updated_stock is not None
assert updated_stock.quantity_on_hand == Decimal('50.00')
def test_movement_updates_stock(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test that movement updates warehouse stock"""
# Create initial stock
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()
# Record removal
movement, updated_stock = StockMovement.record_movement(
movement_type='sale',
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal('-25.00'),
moved_by=test_user.id,
update_stock=True
)
db_session.commit()
assert updated_stock.quantity_on_hand == Decimal('75.00')
class TestStockReservation:
"""Test StockReservation model"""
def test_create_reservation(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test creating a stock reservation"""
# Create stock first
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()
reservation, updated_stock = StockReservation.create_reservation(
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal('20.00'),
reservation_type='quote',
reservation_id=1,
reserved_by=test_user.id,
expires_in_days=30
)
db_session.commit()
assert reservation.id is not None
assert reservation.status == 'reserved'
assert updated_stock.quantity_reserved == Decimal('20.00')
assert updated_stock.quantity_available == Decimal('80.00')
def test_reservation_insufficient_stock(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test that creating reservation with insufficient stock raises error"""
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('10.00')
)
db_session.add(stock)
db_session.commit()
with pytest.raises(ValueError, match='Insufficient stock'):
StockReservation.create_reservation(
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal('20.00'),
reservation_type='quote',
reservation_id=1,
reserved_by=test_user.id
)
def test_fulfill_reservation(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test fulfilling a reservation"""
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('100.00'),
quantity_reserved=Decimal('20.00')
)
db_session.add(stock)
db_session.commit()
reservation = StockReservation(
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal('20.00'),
reservation_type='quote',
reservation_id=1,
reserved_by=test_user.id
)
db_session.add(reservation)
db_session.commit()
reservation.fulfill()
db_session.commit()
assert reservation.status == 'fulfilled'
assert reservation.fulfilled_at is not None
db_session.refresh(stock)
assert stock.quantity_reserved == Decimal('0.00')
def test_cancel_reservation(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test cancelling a reservation"""
stock = WarehouseStock(
warehouse_id=test_warehouse.id,
stock_item_id=test_stock_item.id,
quantity_on_hand=Decimal('100.00'),
quantity_reserved=Decimal('20.00')
)
db_session.add(stock)
db_session.commit()
reservation = StockReservation(
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal('20.00'),
reservation_type='quote',
reservation_id=1,
reserved_by=test_user.id
)
db_session.add(reservation)
db_session.commit()
reservation.cancel()
db_session.commit()
assert reservation.status == 'cancelled'
assert reservation.cancelled_at is not None
db_session.refresh(stock)
assert stock.quantity_reserved == Decimal('0.00')
def test_expired_reservation(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test expired reservation detection"""
reservation = StockReservation(
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity=Decimal('10.00'),
reservation_type='quote',
reservation_id=1,
reserved_by=test_user.id,
expires_at=datetime.utcnow() - timedelta(days=1) # Expired yesterday
)
db_session.add(reservation)
db_session.commit()
assert reservation.is_expired is True
class TestProjectStockAllocation:
"""Test ProjectStockAllocation model"""
def test_create_allocation(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test creating a project stock allocation"""
project = Project(
name='Test Project',
client_id=1, # Assuming client exists
billable=True
)
db_session.add(project)
db_session.commit()
allocation = ProjectStockAllocation(
project_id=project.id,
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity_allocated=Decimal('50.00'),
allocated_by=test_user.id
)
db_session.add(allocation)
db_session.commit()
assert allocation.id is not None
assert allocation.quantity_allocated == Decimal('50.00')
assert allocation.quantity_used == Decimal('0.00')
assert allocation.quantity_remaining == Decimal('50.00')
def test_record_usage(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test recording usage of allocated stock"""
project = Project(
name='Test Project',
client_id=1,
billable=True
)
db_session.add(project)
db_session.commit()
allocation = ProjectStockAllocation(
project_id=project.id,
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity_allocated=Decimal('50.00'),
allocated_by=test_user.id
)
db_session.add(allocation)
db_session.commit()
allocation.record_usage(Decimal('15.00'))
db_session.commit()
assert allocation.quantity_used == Decimal('15.00')
assert allocation.quantity_remaining == Decimal('35.00')
def test_record_usage_exceeds_allocation(self, db_session, test_user, test_stock_item, test_warehouse):
"""Test that using more than allocated raises error"""
project = Project(
name='Test Project',
client_id=1,
billable=True
)
db_session.add(project)
db_session.commit()
allocation = ProjectStockAllocation(
project_id=project.id,
stock_item_id=test_stock_item.id,
warehouse_id=test_warehouse.id,
quantity_allocated=Decimal('50.00'),
allocated_by=test_user.id
)
db_session.add(allocation)
db_session.commit()
with pytest.raises(ValueError, match='Cannot use more than allocated'):
allocation.record_usage(Decimal('60.00'))

View File

@@ -0,0 +1,219 @@
"""Tests for inventory routes"""
import pytest
from decimal import Decimal
from flask import url_for
from app import db
from app.models import Warehouse, StockItem, WarehouseStock, 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_warehouse(db_session, test_user):
"""Create a test warehouse"""
warehouse = Warehouse(
name='Test Warehouse',
code='WH-TEST',
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"""
item = StockItem(
sku='TEST-001',
name='Test Product',
created_by=test_user.id,
default_price=Decimal('10.00')
)
db_session.add(item)
db_session.commit()
return item
class TestStockItemsRoutes:
"""Test stock items routes"""
def test_list_stock_items(self, client, test_user, test_stock_item):
"""Test listing stock items"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.get(url_for('inventory.list_stock_items'))
assert response.status_code == 200
assert b'Stock Items' in response.data
assert b'TEST-001' in response.data
def test_create_stock_item(self, client, test_user):
"""Test creating a stock item"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.post(
url_for('inventory.new_stock_item'),
data={
'sku': 'NEW-001',
'name': 'New Product',
'unit': 'pcs',
'default_price': '15.00',
'is_active': 'on',
'is_trackable': 'on'
},
follow_redirects=True
)
assert response.status_code == 200
# Check if item was created
item = StockItem.query.filter_by(sku='NEW-001').first()
assert item is not None
assert item.name == 'New Product'
def test_view_stock_item(self, client, test_user, test_stock_item):
"""Test viewing stock item details"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.get(url_for('inventory.view_stock_item', item_id=test_stock_item.id))
assert response.status_code == 200
assert b'TEST-001' in response.data
assert b'Test Product' in response.data
def test_edit_stock_item(self, client, test_user, test_stock_item):
"""Test editing stock item"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.post(
url_for('inventory.edit_stock_item', item_id=test_stock_item.id),
data={
'sku': 'TEST-001',
'name': 'Updated Product',
'unit': 'pcs',
'default_price': '20.00',
'is_active': 'on',
'is_trackable': 'on'
},
follow_redirects=True
)
assert response.status_code == 200
db.session.refresh(test_stock_item)
assert test_stock_item.name == 'Updated Product'
class TestWarehousesRoutes:
"""Test warehouses routes"""
def test_list_warehouses(self, client, test_user, test_warehouse):
"""Test listing warehouses"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.get(url_for('inventory.list_warehouses'))
assert response.status_code == 200
assert b'Warehouses' in response.data
assert b'WH-TEST' in response.data
def test_create_warehouse(self, client, test_user):
"""Test creating a warehouse"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.post(
url_for('inventory.new_warehouse'),
data={
'name': 'New Warehouse',
'code': 'WH-NEW',
'is_active': 'on'
},
follow_redirects=True
)
assert response.status_code == 200
warehouse = Warehouse.query.filter_by(code='WH-NEW').first()
assert warehouse is not None
assert warehouse.name == 'New Warehouse'
def test_view_warehouse(self, client, test_user, test_warehouse):
"""Test viewing warehouse details"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.get(url_for('inventory.view_warehouse', warehouse_id=test_warehouse.id))
assert response.status_code == 200
assert b'WH-TEST' in response.data
assert b'Test Warehouse' in response.data
class TestStockLevelsRoutes:
"""Test stock levels routes"""
def test_view_stock_levels(self, client, test_user, test_stock_item, test_warehouse):
"""Test viewing stock levels"""
# Create stock
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()
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.get(url_for('inventory.stock_levels'))
assert response.status_code == 200
assert b'Stock Levels' in response.data
class TestStockMovementsRoutes:
"""Test stock movements routes"""
def test_list_movements(self, client, test_user):
"""Test listing stock movements"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.get(url_for('inventory.list_movements'))
assert response.status_code == 200
assert b'Stock Movements' in response.data
def test_create_movement(self, client, test_user, test_stock_item, test_warehouse):
"""Test creating a stock movement"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
response = client.post(
url_for('inventory.new_movement'),
data={
'movement_type': 'adjustment',
'stock_item_id': test_stock_item.id,
'warehouse_id': test_warehouse.id,
'quantity': '50.00',
'reason': 'Initial stock'
},
follow_redirects=True
)
assert response.status_code == 200
# Check if stock was updated
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')