mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
5fd0625e3a
Purchase orders with line items crashed the detail page because the template referenced item.stock_item without a SQLAlchemy relationship, and used datetime.now() in Jinja where datetime was not in context. Add PurchaseOrderItem.stock_item, pass default_received_date from the view route, and add a regression test for viewing a PO that includes line items.
245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""Purchase Order models for inventory management"""
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from app import db
|
|
|
|
|
|
def _normalize_optional_text(value):
|
|
"""Normalize optional text input to a trimmed string or None."""
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _normalize_required_text(value, field_name):
|
|
"""Normalize required text and fail fast for empty values."""
|
|
text = _normalize_optional_text(value)
|
|
if not text:
|
|
raise ValueError(f"{field_name} is required")
|
|
return text
|
|
|
|
|
|
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")
|
|
supplier = db.relationship("Supplier", backref="purchase_orders", lazy="select")
|
|
created_by_user = db.relationship("User", foreign_keys=[created_by], lazy="select")
|
|
|
|
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 = _normalize_required_text(po_number, "po_number").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 = _normalize_optional_text(notes)
|
|
self.internal_notes = _normalize_optional_text(internal_notes)
|
|
self.currency_code = _normalize_required_text(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,
|
|
"supplier_name": self.supplier.name if self.supplier else None,
|
|
"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,
|
|
"items": [item.to_dict() for item in self.items],
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
stock_item = db.relationship("StockItem", foreign_keys=[stock_item_id], lazy="joined")
|
|
|
|
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 = _normalize_required_text(description, "description")
|
|
self.supplier_sku = _normalize_optional_text(supplier_sku)
|
|
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 = _normalize_optional_text(notes)
|
|
self.currency_code = _normalize_required_text(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,
|
|
}
|