refactor: migrate modules from re-exports to canonical implementations
Move actual code implementations into module directories: - orders: 5 services, 4 models, order/invoice schemas - inventory: 3 services, 2 models, 30+ schemas - customers: 3 services, 2 models, customer schemas - messaging: 3 services, 2 models, message/notification schemas - monitoring: background_tasks_service - marketplace: 5+ services including letzshop submodule - dev_tools: code_quality_service, test_runner_service - billing: billing_service - contracts: definition.py Legacy files in app/services/, models/database/, models/schema/ now re-export from canonical module locations for backwards compatibility. Architecture validator passes with 0 errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
"""
|
||||
Inventory module database models.
|
||||
|
||||
Re-exports inventory-related models from their source locations.
|
||||
This module contains the canonical implementations of inventory-related models.
|
||||
"""
|
||||
|
||||
from models.database.inventory import Inventory
|
||||
from models.database.inventory_transaction import (
|
||||
from app.modules.inventory.models.inventory import Inventory
|
||||
from app.modules.inventory.models.inventory_transaction import (
|
||||
InventoryTransaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
61
app/modules/inventory/models/inventory.py
Normal file
61
app/modules/inventory/models/inventory.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# app/modules/inventory/models/inventory.py
|
||||
"""
|
||||
Inventory model for tracking stock at warehouse/bin locations.
|
||||
|
||||
Each entry represents a quantity of a product at a specific bin location
|
||||
within a warehouse. Products can be scattered across multiple bins.
|
||||
|
||||
Example:
|
||||
Warehouse: "strassen"
|
||||
Bin: "SA-10-02"
|
||||
Product: GTIN 4007817144145
|
||||
Quantity: 3
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Inventory(Base, TimestampMixin):
|
||||
__tablename__ = "inventory"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
||||
|
||||
# Location: warehouse + bin
|
||||
warehouse = Column(String, nullable=False, default="strassen", index=True)
|
||||
bin_location = Column(String, nullable=False, index=True) # e.g., "SA-10-02"
|
||||
|
||||
# Legacy field - kept for backward compatibility, will be removed
|
||||
location = Column(String, index=True)
|
||||
|
||||
quantity = Column(Integer, nullable=False, default=0)
|
||||
reserved_quantity = Column(Integer, default=0)
|
||||
|
||||
# Keep GTIN for reference/reporting (matches Product.gtin)
|
||||
gtin = Column(String, index=True)
|
||||
|
||||
# Relationships
|
||||
product = relationship("Product", back_populates="inventory_entries")
|
||||
vendor = relationship("Vendor")
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"product_id", "warehouse", "bin_location", name="uq_inventory_product_warehouse_bin"
|
||||
),
|
||||
Index("idx_inventory_vendor_product", "vendor_id", "product_id"),
|
||||
Index("idx_inventory_warehouse_bin", "warehouse", "bin_location"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Inventory(product_id={self.product_id}, location='{self.location}', quantity={self.quantity})>"
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
"""Calculate available quantity (total - reserved)."""
|
||||
return max(0, self.quantity - self.reserved_quantity)
|
||||
170
app/modules/inventory/models/inventory_transaction.py
Normal file
170
app/modules/inventory/models/inventory_transaction.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# app/modules/inventory/models/inventory_transaction.py
|
||||
"""
|
||||
Inventory Transaction Model - Audit trail for all stock movements.
|
||||
|
||||
This model tracks every change to inventory quantities, providing:
|
||||
- Complete audit trail for compliance and debugging
|
||||
- Order-linked transactions for traceability
|
||||
- Support for different transaction types (reserve, fulfill, adjust, etc.)
|
||||
|
||||
All stock movements should create a transaction record.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Enum as SQLEnum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TransactionType(str, Enum):
|
||||
"""Types of inventory transactions."""
|
||||
|
||||
# Order-related
|
||||
RESERVE = "reserve" # Stock reserved for order
|
||||
FULFILL = "fulfill" # Reserved stock consumed (shipped)
|
||||
RELEASE = "release" # Reserved stock released (cancelled)
|
||||
|
||||
# Manual adjustments
|
||||
ADJUST = "adjust" # Manual adjustment (+/-)
|
||||
SET = "set" # Set to exact quantity
|
||||
|
||||
# Imports
|
||||
IMPORT = "import" # Initial import/sync
|
||||
|
||||
# Returns
|
||||
RETURN = "return" # Stock returned from customer
|
||||
|
||||
|
||||
class InventoryTransaction(Base):
|
||||
"""
|
||||
Audit log for inventory movements.
|
||||
|
||||
Every change to inventory quantity creates a transaction record,
|
||||
enabling complete traceability of stock levels over time.
|
||||
"""
|
||||
|
||||
__tablename__ = "inventory_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Core references
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
inventory_id = Column(
|
||||
Integer, ForeignKey("inventory.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
# Transaction details
|
||||
transaction_type = Column(
|
||||
SQLEnum(TransactionType), nullable=False, index=True
|
||||
)
|
||||
quantity_change = Column(Integer, nullable=False) # Positive = add, negative = remove
|
||||
|
||||
# Quantities after transaction (snapshot)
|
||||
quantity_after = Column(Integer, nullable=False)
|
||||
reserved_after = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Location context
|
||||
location = Column(String, nullable=True)
|
||||
warehouse = Column(String, nullable=True)
|
||||
|
||||
# Order reference (for order-related transactions)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True, index=True)
|
||||
order_number = Column(String, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
reason = Column(Text, nullable=True) # Human-readable reason
|
||||
created_by = Column(String, nullable=True) # User/system that created
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor")
|
||||
product = relationship("Product")
|
||||
inventory = relationship("Inventory")
|
||||
order = relationship("Order")
|
||||
|
||||
# Indexes for common queries
|
||||
__table_args__ = (
|
||||
Index("idx_inv_tx_vendor_product", "vendor_id", "product_id"),
|
||||
Index("idx_inv_tx_vendor_created", "vendor_id", "created_at"),
|
||||
Index("idx_inv_tx_order", "order_id"),
|
||||
Index("idx_inv_tx_type_created", "transaction_type", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<InventoryTransaction {self.id}: "
|
||||
f"{self.transaction_type.value} {self.quantity_change:+d} "
|
||||
f"for product {self.product_id}>"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_transaction(
|
||||
cls,
|
||||
vendor_id: int,
|
||||
product_id: int,
|
||||
transaction_type: TransactionType,
|
||||
quantity_change: int,
|
||||
quantity_after: int,
|
||||
reserved_after: int = 0,
|
||||
inventory_id: int | None = None,
|
||||
location: str | None = None,
|
||||
warehouse: str | None = None,
|
||||
order_id: int | None = None,
|
||||
order_number: str | None = None,
|
||||
reason: str | None = None,
|
||||
created_by: str | None = None,
|
||||
) -> "InventoryTransaction":
|
||||
"""
|
||||
Factory method to create a transaction record.
|
||||
|
||||
Args:
|
||||
vendor_id: Vendor ID
|
||||
product_id: Product ID
|
||||
transaction_type: Type of transaction
|
||||
quantity_change: Change in quantity (positive = add, negative = remove)
|
||||
quantity_after: Total quantity after this transaction
|
||||
reserved_after: Reserved quantity after this transaction
|
||||
inventory_id: Optional inventory record ID
|
||||
location: Optional location
|
||||
warehouse: Optional warehouse
|
||||
order_id: Optional order ID (for order-related transactions)
|
||||
order_number: Optional order number for display
|
||||
reason: Optional human-readable reason
|
||||
created_by: Optional user/system identifier
|
||||
|
||||
Returns:
|
||||
InventoryTransaction instance (not yet added to session)
|
||||
"""
|
||||
return cls(
|
||||
vendor_id=vendor_id,
|
||||
product_id=product_id,
|
||||
inventory_id=inventory_id,
|
||||
transaction_type=transaction_type,
|
||||
quantity_change=quantity_change,
|
||||
quantity_after=quantity_after,
|
||||
reserved_after=reserved_after,
|
||||
location=location,
|
||||
warehouse=warehouse,
|
||||
order_id=order_id,
|
||||
order_number=order_number,
|
||||
reason=reason,
|
||||
created_by=created_by,
|
||||
)
|
||||
@@ -2,27 +2,77 @@
|
||||
"""
|
||||
Inventory module Pydantic schemas.
|
||||
|
||||
Re-exports inventory-related schemas from their source locations.
|
||||
This module contains the canonical implementations of inventory-related schemas.
|
||||
"""
|
||||
|
||||
from models.schema.inventory import (
|
||||
from app.modules.inventory.schemas.inventory import (
|
||||
# Base schemas
|
||||
InventoryBase,
|
||||
InventoryCreate,
|
||||
InventoryAdjust,
|
||||
InventoryUpdate,
|
||||
InventoryReserve,
|
||||
# Response schemas
|
||||
InventoryResponse,
|
||||
InventoryLocationResponse,
|
||||
ProductInventorySummary,
|
||||
InventoryListResponse,
|
||||
InventoryTransactionResponse,
|
||||
InventoryMessageResponse,
|
||||
InventorySummaryResponse,
|
||||
# Admin schemas
|
||||
AdminInventoryCreate,
|
||||
AdminInventoryAdjust,
|
||||
AdminInventoryItem,
|
||||
AdminInventoryListResponse,
|
||||
AdminInventoryStats,
|
||||
AdminLowStockItem,
|
||||
AdminVendorWithInventory,
|
||||
AdminVendorsWithInventoryResponse,
|
||||
AdminInventoryLocationsResponse,
|
||||
# Transaction schemas
|
||||
InventoryTransactionResponse,
|
||||
InventoryTransactionWithProduct,
|
||||
InventoryTransactionListResponse,
|
||||
ProductTransactionHistoryResponse,
|
||||
OrderTransactionHistoryResponse,
|
||||
# Admin transaction schemas
|
||||
AdminInventoryTransactionItem,
|
||||
AdminInventoryTransactionListResponse,
|
||||
AdminTransactionStatsResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base schemas
|
||||
"InventoryBase",
|
||||
"InventoryCreate",
|
||||
"InventoryAdjust",
|
||||
"InventoryUpdate",
|
||||
"InventoryReserve",
|
||||
# Response schemas
|
||||
"InventoryResponse",
|
||||
"InventoryLocationResponse",
|
||||
"ProductInventorySummary",
|
||||
"InventoryListResponse",
|
||||
"InventoryTransactionResponse",
|
||||
"InventoryMessageResponse",
|
||||
"InventorySummaryResponse",
|
||||
# Admin schemas
|
||||
"AdminInventoryCreate",
|
||||
"AdminInventoryAdjust",
|
||||
"AdminInventoryItem",
|
||||
"AdminInventoryListResponse",
|
||||
"AdminInventoryStats",
|
||||
"AdminLowStockItem",
|
||||
"AdminVendorWithInventory",
|
||||
"AdminVendorsWithInventoryResponse",
|
||||
"AdminInventoryLocationsResponse",
|
||||
# Transaction schemas
|
||||
"InventoryTransactionResponse",
|
||||
"InventoryTransactionWithProduct",
|
||||
"InventoryTransactionListResponse",
|
||||
"ProductTransactionHistoryResponse",
|
||||
"OrderTransactionHistoryResponse",
|
||||
# Admin transaction schemas
|
||||
"AdminInventoryTransactionItem",
|
||||
"AdminInventoryTransactionListResponse",
|
||||
"AdminTransactionStatsResponse",
|
||||
]
|
||||
|
||||
294
app/modules/inventory/schemas/inventory.py
Normal file
294
app/modules/inventory/schemas/inventory.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# app/modules/inventory/schemas/inventory.py
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InventoryBase(BaseModel):
|
||||
product_id: int = Field(..., description="Product ID in vendor catalog")
|
||||
location: str = Field(..., description="Storage location")
|
||||
|
||||
|
||||
class InventoryCreate(InventoryBase):
|
||||
"""Set exact inventory quantity (replaces existing)."""
|
||||
|
||||
quantity: int = Field(..., description="Exact inventory quantity", ge=0)
|
||||
|
||||
|
||||
class InventoryAdjust(InventoryBase):
|
||||
"""Add or remove inventory quantity."""
|
||||
|
||||
quantity: int = Field(
|
||||
..., description="Quantity to add (positive) or remove (negative)"
|
||||
)
|
||||
|
||||
|
||||
class InventoryUpdate(BaseModel):
|
||||
"""Update inventory fields."""
|
||||
|
||||
quantity: int | None = Field(None, ge=0)
|
||||
reserved_quantity: int | None = Field(None, ge=0)
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class InventoryReserve(BaseModel):
|
||||
"""Reserve inventory for orders."""
|
||||
|
||||
product_id: int
|
||||
location: str
|
||||
quantity: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class InventoryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
product_id: int
|
||||
vendor_id: int
|
||||
location: str
|
||||
quantity: int
|
||||
reserved_quantity: int
|
||||
gtin: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return max(0, self.quantity - self.reserved_quantity)
|
||||
|
||||
|
||||
class InventoryLocationResponse(BaseModel):
|
||||
location: str
|
||||
quantity: int
|
||||
reserved_quantity: int
|
||||
available_quantity: int
|
||||
|
||||
|
||||
class ProductInventorySummary(BaseModel):
|
||||
"""Inventory summary for a product."""
|
||||
|
||||
product_id: int
|
||||
vendor_id: int
|
||||
product_sku: str | None
|
||||
product_title: str
|
||||
total_quantity: int
|
||||
total_reserved: int
|
||||
total_available: int
|
||||
locations: list[InventoryLocationResponse]
|
||||
|
||||
|
||||
class InventoryListResponse(BaseModel):
|
||||
inventories: list[InventoryResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class InventoryMessageResponse(BaseModel):
|
||||
"""Simple message response for inventory operations."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class InventorySummaryResponse(BaseModel):
|
||||
"""Inventory summary response for marketplace product service."""
|
||||
|
||||
gtin: str
|
||||
total_quantity: int
|
||||
locations: list[InventoryLocationResponse]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Admin Inventory Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminInventoryCreate(BaseModel):
|
||||
"""Admin version of inventory create - requires explicit vendor_id."""
|
||||
|
||||
vendor_id: int = Field(..., description="Target vendor ID")
|
||||
product_id: int = Field(..., description="Product ID in vendor catalog")
|
||||
location: str = Field(..., description="Storage location")
|
||||
quantity: int = Field(..., description="Exact inventory quantity", ge=0)
|
||||
|
||||
|
||||
class AdminInventoryAdjust(BaseModel):
|
||||
"""Admin version of inventory adjust - requires explicit vendor_id."""
|
||||
|
||||
vendor_id: int = Field(..., description="Target vendor ID")
|
||||
product_id: int = Field(..., description="Product ID in vendor catalog")
|
||||
location: str = Field(..., description="Storage location")
|
||||
quantity: int = Field(
|
||||
..., description="Quantity to add (positive) or remove (negative)"
|
||||
)
|
||||
reason: str | None = Field(None, description="Reason for adjustment")
|
||||
|
||||
|
||||
class AdminInventoryItem(BaseModel):
|
||||
"""Inventory item with vendor info for admin list view."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
product_id: int
|
||||
vendor_id: int
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
product_title: str | None = None
|
||||
product_sku: str | None = None
|
||||
location: str
|
||||
quantity: int
|
||||
reserved_quantity: int
|
||||
available_quantity: int
|
||||
gtin: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AdminInventoryListResponse(BaseModel):
|
||||
"""Cross-vendor inventory list for admin."""
|
||||
|
||||
inventories: list[AdminInventoryItem]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
vendor_filter: int | None = None
|
||||
location_filter: str | None = None
|
||||
|
||||
|
||||
class AdminInventoryStats(BaseModel):
|
||||
"""Inventory statistics for admin dashboard."""
|
||||
|
||||
total_entries: int
|
||||
total_quantity: int
|
||||
total_reserved: int
|
||||
total_available: int
|
||||
low_stock_count: int
|
||||
vendors_with_inventory: int
|
||||
unique_locations: int
|
||||
|
||||
|
||||
class AdminLowStockItem(BaseModel):
|
||||
"""Low stock item for admin alerts."""
|
||||
|
||||
id: int
|
||||
product_id: int
|
||||
vendor_id: int
|
||||
vendor_name: str | None = None
|
||||
product_title: str | None = None
|
||||
location: str
|
||||
quantity: int
|
||||
reserved_quantity: int
|
||||
available_quantity: int
|
||||
|
||||
|
||||
class AdminVendorWithInventory(BaseModel):
|
||||
"""Vendor with inventory entries."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
vendor_code: str
|
||||
|
||||
|
||||
class AdminVendorsWithInventoryResponse(BaseModel):
|
||||
"""Response for vendors with inventory list."""
|
||||
|
||||
vendors: list[AdminVendorWithInventory]
|
||||
|
||||
|
||||
class AdminInventoryLocationsResponse(BaseModel):
|
||||
"""Response for unique inventory locations."""
|
||||
|
||||
locations: list[str]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Inventory Transaction Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InventoryTransactionResponse(BaseModel):
|
||||
"""Single inventory transaction record."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
product_id: int
|
||||
inventory_id: int | None = None
|
||||
transaction_type: str
|
||||
quantity_change: int
|
||||
quantity_after: int
|
||||
reserved_after: int
|
||||
location: str | None = None
|
||||
warehouse: str | None = None
|
||||
order_id: int | None = None
|
||||
order_number: str | None = None
|
||||
reason: str | None = None
|
||||
created_by: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class InventoryTransactionWithProduct(InventoryTransactionResponse):
|
||||
"""Transaction with product details for list views."""
|
||||
|
||||
product_title: str | None = None
|
||||
product_sku: str | None = None
|
||||
|
||||
|
||||
class InventoryTransactionListResponse(BaseModel):
|
||||
"""Paginated list of inventory transactions."""
|
||||
|
||||
transactions: list[InventoryTransactionWithProduct]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class ProductTransactionHistoryResponse(BaseModel):
|
||||
"""Transaction history for a specific product."""
|
||||
|
||||
product_id: int
|
||||
product_title: str | None = None
|
||||
product_sku: str | None = None
|
||||
current_quantity: int
|
||||
current_reserved: int
|
||||
transactions: list[InventoryTransactionResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class OrderTransactionHistoryResponse(BaseModel):
|
||||
"""Transaction history for a specific order."""
|
||||
|
||||
order_id: int
|
||||
order_number: str
|
||||
transactions: list[InventoryTransactionWithProduct]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Admin Inventory Transaction Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminInventoryTransactionItem(InventoryTransactionWithProduct):
|
||||
"""Transaction with vendor details for admin views."""
|
||||
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
|
||||
|
||||
class AdminInventoryTransactionListResponse(BaseModel):
|
||||
"""Paginated list of transactions for admin."""
|
||||
|
||||
transactions: list[AdminInventoryTransactionItem]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class AdminTransactionStatsResponse(BaseModel):
|
||||
"""Transaction statistics for admin dashboard."""
|
||||
|
||||
total_transactions: int
|
||||
transactions_today: int
|
||||
by_type: dict[str, int]
|
||||
@@ -2,20 +2,21 @@
|
||||
"""
|
||||
Inventory module services.
|
||||
|
||||
Re-exports inventory-related services from their source locations.
|
||||
This module contains the canonical implementations of inventory-related services.
|
||||
"""
|
||||
|
||||
from app.services.inventory_service import (
|
||||
from app.modules.inventory.services.inventory_service import (
|
||||
inventory_service,
|
||||
InventoryService,
|
||||
)
|
||||
from app.services.inventory_transaction_service import (
|
||||
from app.modules.inventory.services.inventory_transaction_service import (
|
||||
inventory_transaction_service,
|
||||
InventoryTransactionService,
|
||||
)
|
||||
from app.services.inventory_import_service import (
|
||||
from app.modules.inventory.services.inventory_import_service import (
|
||||
inventory_import_service,
|
||||
InventoryImportService,
|
||||
ImportResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -25,4 +26,5 @@ __all__ = [
|
||||
"InventoryTransactionService",
|
||||
"inventory_import_service",
|
||||
"InventoryImportService",
|
||||
"ImportResult",
|
||||
]
|
||||
|
||||
250
app/modules/inventory/services/inventory_import_service.py
Normal file
250
app/modules/inventory/services/inventory_import_service.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# app/modules/inventory/services/inventory_import_service.py
|
||||
"""
|
||||
Inventory import service for bulk importing stock from TSV/CSV files.
|
||||
|
||||
Supports two formats:
|
||||
1. One row per unit (quantity = count of rows):
|
||||
BIN EAN PRODUCT
|
||||
SA-10-02 0810050910101 Product Name
|
||||
SA-10-02 0810050910101 Product Name (2nd unit)
|
||||
|
||||
2. With explicit quantity column:
|
||||
BIN EAN PRODUCT QUANTITY
|
||||
SA-10-02 0810050910101 Product Name 12
|
||||
|
||||
Products are matched by GTIN/EAN to existing vendor products.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.inventory.models.inventory import Inventory
|
||||
from models.database.product import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
"""Result of an inventory import operation."""
|
||||
|
||||
success: bool = True
|
||||
total_rows: int = 0
|
||||
entries_created: int = 0
|
||||
entries_updated: int = 0
|
||||
quantity_imported: int = 0
|
||||
unmatched_gtins: list = field(default_factory=list)
|
||||
errors: list = field(default_factory=list)
|
||||
|
||||
|
||||
class InventoryImportService:
|
||||
"""Service for importing inventory from TSV/CSV files."""
|
||||
|
||||
def import_from_text(
|
||||
self,
|
||||
db: Session,
|
||||
content: str,
|
||||
vendor_id: int,
|
||||
warehouse: str = "strassen",
|
||||
delimiter: str = "\t",
|
||||
clear_existing: bool = False,
|
||||
) -> ImportResult:
|
||||
"""
|
||||
Import inventory from TSV/CSV text content.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
content: TSV/CSV content as string
|
||||
vendor_id: Vendor ID for inventory
|
||||
warehouse: Warehouse name (default: "strassen")
|
||||
delimiter: Column delimiter (default: tab)
|
||||
clear_existing: If True, clear existing inventory before import
|
||||
|
||||
Returns:
|
||||
ImportResult with summary and errors
|
||||
"""
|
||||
result = ImportResult()
|
||||
|
||||
try:
|
||||
# Parse CSV/TSV
|
||||
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
|
||||
|
||||
# Normalize headers (case-insensitive, strip whitespace)
|
||||
if reader.fieldnames:
|
||||
reader.fieldnames = [h.strip().upper() for h in reader.fieldnames]
|
||||
|
||||
# Validate required columns
|
||||
required = {"BIN", "EAN"}
|
||||
if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
|
||||
result.success = False
|
||||
result.errors.append(
|
||||
f"Missing required columns. Found: {reader.fieldnames}, Required: {required}"
|
||||
)
|
||||
return result
|
||||
|
||||
has_quantity = "QUANTITY" in reader.fieldnames
|
||||
|
||||
# Group entries by (EAN, BIN)
|
||||
# Key: (ean, bin) -> quantity
|
||||
inventory_data: dict[tuple[str, str], int] = defaultdict(int)
|
||||
product_names: dict[str, str] = {} # EAN -> product name (for logging)
|
||||
|
||||
for row in reader:
|
||||
result.total_rows += 1
|
||||
|
||||
ean = row.get("EAN", "").strip()
|
||||
bin_loc = row.get("BIN", "").strip()
|
||||
product_name = row.get("PRODUCT", "").strip()
|
||||
|
||||
if not ean or not bin_loc:
|
||||
result.errors.append(f"Row {result.total_rows}: Missing EAN or BIN")
|
||||
continue
|
||||
|
||||
# Get quantity
|
||||
if has_quantity:
|
||||
try:
|
||||
qty = int(row.get("QUANTITY", "1").strip())
|
||||
except ValueError:
|
||||
result.errors.append(
|
||||
f"Row {result.total_rows}: Invalid quantity '{row.get('QUANTITY')}'"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
qty = 1 # Each row = 1 unit
|
||||
|
||||
inventory_data[(ean, bin_loc)] += qty
|
||||
if product_name:
|
||||
product_names[ean] = product_name
|
||||
|
||||
# Clear existing inventory if requested
|
||||
if clear_existing:
|
||||
db.query(Inventory).filter(
|
||||
Inventory.vendor_id == vendor_id,
|
||||
Inventory.warehouse == warehouse,
|
||||
).delete()
|
||||
db.flush()
|
||||
|
||||
# Build EAN to Product mapping for this vendor
|
||||
products = (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
Product.vendor_id == vendor_id,
|
||||
Product.gtin.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
ean_to_product: dict[str, Product] = {p.gtin: p for p in products if p.gtin}
|
||||
|
||||
# Track unmatched GTINs
|
||||
unmatched: dict[str, int] = {} # EAN -> total quantity
|
||||
|
||||
# Process inventory entries
|
||||
for (ean, bin_loc), quantity in inventory_data.items():
|
||||
product = ean_to_product.get(ean)
|
||||
|
||||
if not product:
|
||||
# Track unmatched
|
||||
if ean not in unmatched:
|
||||
unmatched[ean] = 0
|
||||
unmatched[ean] += quantity
|
||||
continue
|
||||
|
||||
# Upsert inventory entry
|
||||
existing = (
|
||||
db.query(Inventory)
|
||||
.filter(
|
||||
Inventory.product_id == product.id,
|
||||
Inventory.warehouse == warehouse,
|
||||
Inventory.bin_location == bin_loc,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.quantity = quantity
|
||||
existing.gtin = ean
|
||||
result.entries_updated += 1
|
||||
else:
|
||||
inv = Inventory(
|
||||
product_id=product.id,
|
||||
vendor_id=vendor_id,
|
||||
warehouse=warehouse,
|
||||
bin_location=bin_loc,
|
||||
location=bin_loc, # Legacy field
|
||||
quantity=quantity,
|
||||
gtin=ean,
|
||||
)
|
||||
db.add(inv)
|
||||
result.entries_created += 1
|
||||
|
||||
result.quantity_imported += quantity
|
||||
|
||||
db.flush()
|
||||
|
||||
# Format unmatched GTINs for result
|
||||
for ean, qty in unmatched.items():
|
||||
product_name = product_names.get(ean, "Unknown")
|
||||
result.unmatched_gtins.append(
|
||||
{"gtin": ean, "quantity": qty, "product_name": product_name}
|
||||
)
|
||||
|
||||
if result.unmatched_gtins:
|
||||
logger.warning(
|
||||
f"Import had {len(result.unmatched_gtins)} unmatched GTINs"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Inventory import failed")
|
||||
result.success = False
|
||||
result.errors.append(str(e))
|
||||
|
||||
return result
|
||||
|
||||
def import_from_file(
|
||||
self,
|
||||
db: Session,
|
||||
file_path: str,
|
||||
vendor_id: int,
|
||||
warehouse: str = "strassen",
|
||||
clear_existing: bool = False,
|
||||
) -> ImportResult:
|
||||
"""
|
||||
Import inventory from a TSV/CSV file.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
file_path: Path to TSV/CSV file
|
||||
vendor_id: Vendor ID for inventory
|
||||
warehouse: Warehouse name
|
||||
clear_existing: If True, clear existing inventory before import
|
||||
|
||||
Returns:
|
||||
ImportResult with summary and errors
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
return ImportResult(success=False, errors=[f"Failed to read file: {e}"])
|
||||
|
||||
# Detect delimiter
|
||||
first_line = content.split("\n")[0] if content else ""
|
||||
delimiter = "\t" if "\t" in first_line else ","
|
||||
|
||||
return self.import_from_text(
|
||||
db=db,
|
||||
content=content,
|
||||
vendor_id=vendor_id,
|
||||
warehouse=warehouse,
|
||||
delimiter=delimiter,
|
||||
clear_existing=clear_existing,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
inventory_import_service = InventoryImportService()
|
||||
949
app/modules/inventory/services/inventory_service.py
Normal file
949
app/modules/inventory/services/inventory_service.py
Normal file
@@ -0,0 +1,949 @@
|
||||
# app/modules/inventory/services/inventory_service.py
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
InsufficientInventoryException,
|
||||
InvalidQuantityException,
|
||||
InventoryNotFoundException,
|
||||
InventoryValidationException,
|
||||
ProductNotFoundException,
|
||||
ValidationException,
|
||||
VendorNotFoundException,
|
||||
)
|
||||
from app.modules.inventory.models.inventory import Inventory
|
||||
from app.modules.inventory.schemas.inventory import (
|
||||
AdminInventoryItem,
|
||||
AdminInventoryListResponse,
|
||||
AdminInventoryLocationsResponse,
|
||||
AdminInventoryStats,
|
||||
AdminLowStockItem,
|
||||
AdminVendorsWithInventoryResponse,
|
||||
AdminVendorWithInventory,
|
||||
InventoryAdjust,
|
||||
InventoryCreate,
|
||||
InventoryLocationResponse,
|
||||
InventoryReserve,
|
||||
InventoryUpdate,
|
||||
ProductInventorySummary,
|
||||
)
|
||||
from models.database.product import Product
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InventoryService:
|
||||
"""Service for inventory operations with vendor isolation."""
|
||||
|
||||
def set_inventory(
|
||||
self, db: Session, vendor_id: int, inventory_data: InventoryCreate
|
||||
) -> Inventory:
|
||||
"""
|
||||
Set exact inventory quantity for a product at a location (replaces existing).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (from middleware)
|
||||
inventory_data: Inventory data
|
||||
|
||||
Returns:
|
||||
Inventory object
|
||||
"""
|
||||
try:
|
||||
# Validate product belongs to vendor
|
||||
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
|
||||
|
||||
# Validate location
|
||||
location = self._validate_location(inventory_data.location)
|
||||
|
||||
# Validate quantity
|
||||
self._validate_quantity(inventory_data.quantity, allow_zero=True)
|
||||
|
||||
# Check if inventory entry exists
|
||||
existing = self._get_inventory_entry(
|
||||
db, inventory_data.product_id, location
|
||||
)
|
||||
|
||||
if existing:
|
||||
old_qty = existing.quantity
|
||||
existing.quantity = inventory_data.quantity
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(existing)
|
||||
|
||||
logger.info(
|
||||
f"Set inventory for product {inventory_data.product_id} at {location}: "
|
||||
f"{old_qty} → {inventory_data.quantity}"
|
||||
)
|
||||
return existing
|
||||
# Create new inventory entry
|
||||
new_inventory = Inventory(
|
||||
product_id=inventory_data.product_id,
|
||||
vendor_id=vendor_id,
|
||||
warehouse="strassen", # Default warehouse
|
||||
bin_location=location, # Use location as bin location
|
||||
location=location, # Keep for backward compatibility
|
||||
quantity=inventory_data.quantity,
|
||||
gtin=product.marketplace_product.gtin, # Optional reference
|
||||
)
|
||||
db.add(new_inventory)
|
||||
db.flush()
|
||||
db.refresh(new_inventory)
|
||||
|
||||
logger.info(
|
||||
f"Created inventory for product {inventory_data.product_id} at {location}: "
|
||||
f"{inventory_data.quantity}"
|
||||
)
|
||||
return new_inventory
|
||||
|
||||
except (
|
||||
ProductNotFoundException,
|
||||
InvalidQuantityException,
|
||||
InventoryValidationException,
|
||||
):
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error setting inventory: {str(e)}")
|
||||
raise ValidationException("Failed to set inventory")
|
||||
|
||||
def adjust_inventory(
|
||||
self, db: Session, vendor_id: int, inventory_data: InventoryAdjust
|
||||
) -> Inventory:
|
||||
"""
|
||||
Adjust inventory by adding or removing quantity.
|
||||
Positive quantity = add, negative = remove.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
inventory_data: Adjustment data
|
||||
|
||||
Returns:
|
||||
Updated Inventory object
|
||||
"""
|
||||
try:
|
||||
# Validate product belongs to vendor
|
||||
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
|
||||
|
||||
# Validate location
|
||||
location = self._validate_location(inventory_data.location)
|
||||
|
||||
# Check if inventory exists
|
||||
existing = self._get_inventory_entry(
|
||||
db, inventory_data.product_id, location
|
||||
)
|
||||
|
||||
if not existing:
|
||||
# Create new if adding, error if removing
|
||||
if inventory_data.quantity < 0:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {inventory_data.product_id} at {location}"
|
||||
)
|
||||
|
||||
# Create with positive quantity
|
||||
new_inventory = Inventory(
|
||||
product_id=inventory_data.product_id,
|
||||
vendor_id=vendor_id,
|
||||
warehouse="strassen", # Default warehouse
|
||||
bin_location=location, # Use location as bin location
|
||||
location=location, # Keep for backward compatibility
|
||||
quantity=inventory_data.quantity,
|
||||
gtin=product.marketplace_product.gtin,
|
||||
)
|
||||
db.add(new_inventory)
|
||||
db.flush()
|
||||
db.refresh(new_inventory)
|
||||
|
||||
logger.info(
|
||||
f"Created inventory for product {inventory_data.product_id} at {location}: "
|
||||
f"+{inventory_data.quantity}"
|
||||
)
|
||||
return new_inventory
|
||||
|
||||
# Adjust existing inventory
|
||||
old_qty = existing.quantity
|
||||
new_qty = old_qty + inventory_data.quantity
|
||||
|
||||
# Validate resulting quantity
|
||||
if new_qty < 0:
|
||||
raise InsufficientInventoryException(
|
||||
f"Insufficient inventory. Available: {old_qty}, "
|
||||
f"Requested removal: {abs(inventory_data.quantity)}"
|
||||
)
|
||||
|
||||
existing.quantity = new_qty
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(existing)
|
||||
|
||||
logger.info(
|
||||
f"Adjusted inventory for product {inventory_data.product_id} at {location}: "
|
||||
f"{old_qty} {'+' if inventory_data.quantity >= 0 else ''}{inventory_data.quantity} = {new_qty}"
|
||||
)
|
||||
return existing
|
||||
|
||||
except (
|
||||
ProductNotFoundException,
|
||||
InventoryNotFoundException,
|
||||
InsufficientInventoryException,
|
||||
InventoryValidationException,
|
||||
):
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adjusting inventory: {str(e)}")
|
||||
raise ValidationException("Failed to adjust inventory")
|
||||
|
||||
def reserve_inventory(
|
||||
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
|
||||
) -> Inventory:
|
||||
"""
|
||||
Reserve inventory for an order (increases reserved_quantity).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
reserve_data: Reservation data
|
||||
|
||||
Returns:
|
||||
Updated Inventory object
|
||||
"""
|
||||
try:
|
||||
# Validate product
|
||||
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
|
||||
|
||||
# Validate location and quantity
|
||||
location = self._validate_location(reserve_data.location)
|
||||
self._validate_quantity(reserve_data.quantity, allow_zero=False)
|
||||
|
||||
# Get inventory entry
|
||||
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
|
||||
if not inventory:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {reserve_data.product_id} at {location}"
|
||||
)
|
||||
|
||||
# Check available quantity
|
||||
available = inventory.quantity - inventory.reserved_quantity
|
||||
if available < reserve_data.quantity:
|
||||
raise InsufficientInventoryException(
|
||||
f"Insufficient available inventory. Available: {available}, "
|
||||
f"Requested: {reserve_data.quantity}"
|
||||
)
|
||||
|
||||
# Reserve inventory
|
||||
inventory.reserved_quantity += reserve_data.quantity
|
||||
inventory.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(inventory)
|
||||
|
||||
logger.info(
|
||||
f"Reserved {reserve_data.quantity} units for product {reserve_data.product_id} "
|
||||
f"at {location}"
|
||||
)
|
||||
return inventory
|
||||
|
||||
except (
|
||||
ProductNotFoundException,
|
||||
InventoryNotFoundException,
|
||||
InsufficientInventoryException,
|
||||
InvalidQuantityException,
|
||||
):
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error reserving inventory: {str(e)}")
|
||||
raise ValidationException("Failed to reserve inventory")
|
||||
|
||||
def release_reservation(
|
||||
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
|
||||
) -> Inventory:
|
||||
"""
|
||||
Release reserved inventory (decreases reserved_quantity).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
reserve_data: Reservation data
|
||||
|
||||
Returns:
|
||||
Updated Inventory object
|
||||
"""
|
||||
try:
|
||||
# Validate product
|
||||
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
|
||||
|
||||
location = self._validate_location(reserve_data.location)
|
||||
self._validate_quantity(reserve_data.quantity, allow_zero=False)
|
||||
|
||||
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
|
||||
if not inventory:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {reserve_data.product_id} at {location}"
|
||||
)
|
||||
|
||||
# Validate reserved quantity
|
||||
if inventory.reserved_quantity < reserve_data.quantity:
|
||||
logger.warning(
|
||||
f"Attempting to release more than reserved. Reserved: {inventory.reserved_quantity}, "
|
||||
f"Requested: {reserve_data.quantity}"
|
||||
)
|
||||
inventory.reserved_quantity = 0
|
||||
else:
|
||||
inventory.reserved_quantity -= reserve_data.quantity
|
||||
|
||||
inventory.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(inventory)
|
||||
|
||||
logger.info(
|
||||
f"Released {reserve_data.quantity} units for product {reserve_data.product_id} "
|
||||
f"at {location}"
|
||||
)
|
||||
return inventory
|
||||
|
||||
except (
|
||||
ProductNotFoundException,
|
||||
InventoryNotFoundException,
|
||||
InvalidQuantityException,
|
||||
):
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error releasing reservation: {str(e)}")
|
||||
raise ValidationException("Failed to release reservation")
|
||||
|
||||
def fulfill_reservation(
|
||||
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
|
||||
) -> Inventory:
|
||||
"""
|
||||
Fulfill a reservation (decreases both quantity and reserved_quantity).
|
||||
Use when order is shipped/completed.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
reserve_data: Reservation data
|
||||
|
||||
Returns:
|
||||
Updated Inventory object
|
||||
"""
|
||||
try:
|
||||
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
|
||||
location = self._validate_location(reserve_data.location)
|
||||
self._validate_quantity(reserve_data.quantity, allow_zero=False)
|
||||
|
||||
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
|
||||
if not inventory:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {reserve_data.product_id} at {location}"
|
||||
)
|
||||
|
||||
# Validate quantities
|
||||
if inventory.quantity < reserve_data.quantity:
|
||||
raise InsufficientInventoryException(
|
||||
f"Insufficient inventory. Available: {inventory.quantity}, "
|
||||
f"Requested: {reserve_data.quantity}"
|
||||
)
|
||||
|
||||
if inventory.reserved_quantity < reserve_data.quantity:
|
||||
logger.warning(
|
||||
f"Fulfilling more than reserved. Reserved: {inventory.reserved_quantity}, "
|
||||
f"Fulfilling: {reserve_data.quantity}"
|
||||
)
|
||||
|
||||
# Fulfill (remove from both quantity and reserved)
|
||||
inventory.quantity -= reserve_data.quantity
|
||||
inventory.reserved_quantity = max(
|
||||
0, inventory.reserved_quantity - reserve_data.quantity
|
||||
)
|
||||
inventory.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(inventory)
|
||||
|
||||
logger.info(
|
||||
f"Fulfilled {reserve_data.quantity} units for product {reserve_data.product_id} "
|
||||
f"at {location}"
|
||||
)
|
||||
return inventory
|
||||
|
||||
except (
|
||||
ProductNotFoundException,
|
||||
InventoryNotFoundException,
|
||||
InsufficientInventoryException,
|
||||
InvalidQuantityException,
|
||||
):
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error fulfilling reservation: {str(e)}")
|
||||
raise ValidationException("Failed to fulfill reservation")
|
||||
|
||||
def get_product_inventory(
|
||||
self, db: Session, vendor_id: int, product_id: int
|
||||
) -> ProductInventorySummary:
|
||||
"""
|
||||
Get inventory summary for a product across all locations.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
product_id: Product ID
|
||||
|
||||
Returns:
|
||||
ProductInventorySummary
|
||||
"""
|
||||
try:
|
||||
product = self._get_vendor_product(db, vendor_id, product_id)
|
||||
|
||||
inventory_entries = (
|
||||
db.query(Inventory).filter(Inventory.product_id == product_id).all()
|
||||
)
|
||||
|
||||
if not inventory_entries:
|
||||
return ProductInventorySummary(
|
||||
product_id=product_id,
|
||||
vendor_id=vendor_id,
|
||||
product_sku=product.vendor_sku,
|
||||
product_title=product.marketplace_product.get_title() or "",
|
||||
total_quantity=0,
|
||||
total_reserved=0,
|
||||
total_available=0,
|
||||
locations=[],
|
||||
)
|
||||
|
||||
total_qty = sum(inv.quantity for inv in inventory_entries)
|
||||
total_reserved = sum(inv.reserved_quantity for inv in inventory_entries)
|
||||
total_available = sum(inv.available_quantity for inv in inventory_entries)
|
||||
|
||||
locations = [
|
||||
InventoryLocationResponse(
|
||||
location=inv.location,
|
||||
quantity=inv.quantity,
|
||||
reserved_quantity=inv.reserved_quantity,
|
||||
available_quantity=inv.available_quantity,
|
||||
)
|
||||
for inv in inventory_entries
|
||||
]
|
||||
|
||||
return ProductInventorySummary(
|
||||
product_id=product_id,
|
||||
vendor_id=vendor_id,
|
||||
product_sku=product.vendor_sku,
|
||||
product_title=product.marketplace_product.get_title() or "",
|
||||
total_quantity=total_qty,
|
||||
total_reserved=total_reserved,
|
||||
total_available=total_available,
|
||||
locations=locations,
|
||||
)
|
||||
|
||||
except ProductNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting product inventory: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve product inventory")
|
||||
|
||||
def get_vendor_inventory(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
location: str | None = None,
|
||||
low_stock_threshold: int | None = None,
|
||||
) -> list[Inventory]:
|
||||
"""
|
||||
Get all inventory for a vendor with filtering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
location: Filter by location
|
||||
low_stock_threshold: Filter items below threshold
|
||||
|
||||
Returns:
|
||||
List of Inventory objects
|
||||
"""
|
||||
try:
|
||||
query = db.query(Inventory).filter(Inventory.vendor_id == vendor_id)
|
||||
|
||||
if location:
|
||||
query = query.filter(Inventory.location.ilike(f"%{location}%"))
|
||||
|
||||
if low_stock_threshold is not None:
|
||||
query = query.filter(Inventory.quantity <= low_stock_threshold)
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting vendor inventory: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve vendor inventory")
|
||||
|
||||
def update_inventory(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
inventory_id: int,
|
||||
inventory_update: InventoryUpdate,
|
||||
) -> Inventory:
|
||||
"""Update inventory entry."""
|
||||
try:
|
||||
inventory = self._get_inventory_by_id(db, inventory_id)
|
||||
|
||||
# Verify ownership
|
||||
if inventory.vendor_id != vendor_id:
|
||||
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
|
||||
|
||||
# Update fields
|
||||
if inventory_update.quantity is not None:
|
||||
self._validate_quantity(inventory_update.quantity, allow_zero=True)
|
||||
inventory.quantity = inventory_update.quantity
|
||||
|
||||
if inventory_update.reserved_quantity is not None:
|
||||
self._validate_quantity(
|
||||
inventory_update.reserved_quantity, allow_zero=True
|
||||
)
|
||||
inventory.reserved_quantity = inventory_update.reserved_quantity
|
||||
|
||||
if inventory_update.location:
|
||||
inventory.location = self._validate_location(inventory_update.location)
|
||||
|
||||
inventory.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(inventory)
|
||||
|
||||
logger.info(f"Updated inventory {inventory_id}")
|
||||
return inventory
|
||||
|
||||
except (
|
||||
InventoryNotFoundException,
|
||||
InvalidQuantityException,
|
||||
InventoryValidationException,
|
||||
):
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error updating inventory: {str(e)}")
|
||||
raise ValidationException("Failed to update inventory")
|
||||
|
||||
def delete_inventory(self, db: Session, vendor_id: int, inventory_id: int) -> bool:
|
||||
"""Delete inventory entry."""
|
||||
try:
|
||||
inventory = self._get_inventory_by_id(db, inventory_id)
|
||||
|
||||
# Verify ownership
|
||||
if inventory.vendor_id != vendor_id:
|
||||
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
|
||||
|
||||
db.delete(inventory)
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Deleted inventory {inventory_id}")
|
||||
return True
|
||||
|
||||
except InventoryNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting inventory: {str(e)}")
|
||||
raise ValidationException("Failed to delete inventory")
|
||||
|
||||
# =========================================================================
|
||||
# Admin Methods (cross-vendor operations)
|
||||
# =========================================================================
|
||||
|
||||
def get_all_inventory_admin(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
vendor_id: int | None = None,
|
||||
location: str | None = None,
|
||||
low_stock: int | None = None,
|
||||
search: str | None = None,
|
||||
) -> AdminInventoryListResponse:
|
||||
"""
|
||||
Get inventory across all vendors with filtering (admin only).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
vendor_id: Filter by vendor
|
||||
location: Filter by location
|
||||
low_stock: Filter items below threshold
|
||||
search: Search by product title or SKU
|
||||
|
||||
Returns:
|
||||
AdminInventoryListResponse
|
||||
"""
|
||||
query = db.query(Inventory).join(Product).join(Vendor)
|
||||
|
||||
# Apply filters
|
||||
if vendor_id is not None:
|
||||
query = query.filter(Inventory.vendor_id == vendor_id)
|
||||
|
||||
if location:
|
||||
query = query.filter(Inventory.location.ilike(f"%{location}%"))
|
||||
|
||||
if low_stock is not None:
|
||||
query = query.filter(Inventory.quantity <= low_stock)
|
||||
|
||||
if search:
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.marketplace_product_translation import (
|
||||
MarketplaceProductTranslation,
|
||||
)
|
||||
|
||||
query = (
|
||||
query.join(MarketplaceProduct)
|
||||
.outerjoin(MarketplaceProductTranslation)
|
||||
.filter(
|
||||
(MarketplaceProductTranslation.title.ilike(f"%{search}%"))
|
||||
| (Product.vendor_sku.ilike(f"%{search}%"))
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count before pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
inventories = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Build response with vendor/product info
|
||||
items = []
|
||||
for inv in inventories:
|
||||
product = inv.product
|
||||
vendor = inv.vendor
|
||||
title = None
|
||||
if product and product.marketplace_product:
|
||||
title = product.marketplace_product.get_title()
|
||||
|
||||
items.append(
|
||||
AdminInventoryItem(
|
||||
id=inv.id,
|
||||
product_id=inv.product_id,
|
||||
vendor_id=inv.vendor_id,
|
||||
vendor_name=vendor.name if vendor else None,
|
||||
vendor_code=vendor.vendor_code if vendor else None,
|
||||
product_title=title,
|
||||
product_sku=product.vendor_sku if product else None,
|
||||
location=inv.location,
|
||||
quantity=inv.quantity,
|
||||
reserved_quantity=inv.reserved_quantity,
|
||||
available_quantity=inv.available_quantity,
|
||||
gtin=inv.gtin,
|
||||
created_at=inv.created_at,
|
||||
updated_at=inv.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminInventoryListResponse(
|
||||
inventories=items,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
vendor_filter=vendor_id,
|
||||
location_filter=location,
|
||||
)
|
||||
|
||||
def get_inventory_stats_admin(self, db: Session) -> AdminInventoryStats:
|
||||
"""Get platform-wide inventory statistics (admin only)."""
|
||||
# Total entries
|
||||
total_entries = db.query(func.count(Inventory.id)).scalar() or 0
|
||||
|
||||
# Aggregate quantities
|
||||
totals = db.query(
|
||||
func.sum(Inventory.quantity).label("total_qty"),
|
||||
func.sum(Inventory.reserved_quantity).label("total_reserved"),
|
||||
).first()
|
||||
|
||||
total_quantity = totals.total_qty or 0
|
||||
total_reserved = totals.total_reserved or 0
|
||||
total_available = total_quantity - total_reserved
|
||||
|
||||
# Low stock count (default threshold: 10)
|
||||
low_stock_count = (
|
||||
db.query(func.count(Inventory.id))
|
||||
.filter(Inventory.quantity <= 10)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Vendors with inventory
|
||||
vendors_with_inventory = (
|
||||
db.query(func.count(func.distinct(Inventory.vendor_id))).scalar() or 0
|
||||
)
|
||||
|
||||
# Unique locations
|
||||
unique_locations = (
|
||||
db.query(func.count(func.distinct(Inventory.location))).scalar() or 0
|
||||
)
|
||||
|
||||
return AdminInventoryStats(
|
||||
total_entries=total_entries,
|
||||
total_quantity=total_quantity,
|
||||
total_reserved=total_reserved,
|
||||
total_available=total_available,
|
||||
low_stock_count=low_stock_count,
|
||||
vendors_with_inventory=vendors_with_inventory,
|
||||
unique_locations=unique_locations,
|
||||
)
|
||||
|
||||
def get_low_stock_items_admin(
|
||||
self,
|
||||
db: Session,
|
||||
threshold: int = 10,
|
||||
vendor_id: int | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[AdminLowStockItem]:
|
||||
"""Get items with low stock levels (admin only)."""
|
||||
query = (
|
||||
db.query(Inventory)
|
||||
.join(Product)
|
||||
.join(Vendor)
|
||||
.filter(Inventory.quantity <= threshold)
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(Inventory.vendor_id == vendor_id)
|
||||
|
||||
# Order by quantity ascending (most critical first)
|
||||
query = query.order_by(Inventory.quantity.asc())
|
||||
|
||||
inventories = query.limit(limit).all()
|
||||
|
||||
items = []
|
||||
for inv in inventories:
|
||||
product = inv.product
|
||||
vendor = inv.vendor
|
||||
title = None
|
||||
if product and product.marketplace_product:
|
||||
title = product.marketplace_product.get_title()
|
||||
|
||||
items.append(
|
||||
AdminLowStockItem(
|
||||
id=inv.id,
|
||||
product_id=inv.product_id,
|
||||
vendor_id=inv.vendor_id,
|
||||
vendor_name=vendor.name if vendor else None,
|
||||
product_title=title,
|
||||
location=inv.location,
|
||||
quantity=inv.quantity,
|
||||
reserved_quantity=inv.reserved_quantity,
|
||||
available_quantity=inv.available_quantity,
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def get_vendors_with_inventory_admin(
|
||||
self, db: Session
|
||||
) -> AdminVendorsWithInventoryResponse:
|
||||
"""Get list of vendors that have inventory entries (admin only)."""
|
||||
# noqa: SVC-005 - Admin function, intentionally cross-vendor
|
||||
# Use subquery to avoid DISTINCT on JSON columns (PostgreSQL can't compare JSON)
|
||||
vendor_ids_subquery = (
|
||||
db.query(Inventory.vendor_id)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
vendors = (
|
||||
db.query(Vendor)
|
||||
.filter(Vendor.id.in_(db.query(vendor_ids_subquery.c.vendor_id)))
|
||||
.order_by(Vendor.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
return AdminVendorsWithInventoryResponse(
|
||||
vendors=[
|
||||
AdminVendorWithInventory(
|
||||
id=v.id, name=v.name, vendor_code=v.vendor_code
|
||||
)
|
||||
for v in vendors
|
||||
]
|
||||
)
|
||||
|
||||
def get_inventory_locations_admin(
|
||||
self, db: Session, vendor_id: int | None = None
|
||||
) -> AdminInventoryLocationsResponse:
|
||||
"""Get list of unique inventory locations (admin only)."""
|
||||
query = db.query(func.distinct(Inventory.location))
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(Inventory.vendor_id == vendor_id)
|
||||
|
||||
locations = [loc[0] for loc in query.all()]
|
||||
|
||||
return AdminInventoryLocationsResponse(locations=sorted(locations))
|
||||
|
||||
def get_vendor_inventory_admin(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
location: str | None = None,
|
||||
low_stock: int | None = None,
|
||||
) -> AdminInventoryListResponse:
|
||||
"""Get inventory for a specific vendor (admin only)."""
|
||||
# Verify vendor exists
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundException(f"Vendor {vendor_id} not found")
|
||||
|
||||
# Use the existing method
|
||||
inventories = self.get_vendor_inventory(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
location=location,
|
||||
low_stock_threshold=low_stock,
|
||||
)
|
||||
|
||||
# Build response with product info
|
||||
items = []
|
||||
for inv in inventories:
|
||||
product = inv.product
|
||||
title = None
|
||||
if product and product.marketplace_product:
|
||||
title = product.marketplace_product.get_title()
|
||||
|
||||
items.append(
|
||||
AdminInventoryItem(
|
||||
id=inv.id,
|
||||
product_id=inv.product_id,
|
||||
vendor_id=inv.vendor_id,
|
||||
vendor_name=vendor.name,
|
||||
vendor_code=vendor.vendor_code,
|
||||
product_title=title,
|
||||
product_sku=product.vendor_sku if product else None,
|
||||
location=inv.location,
|
||||
quantity=inv.quantity,
|
||||
reserved_quantity=inv.reserved_quantity,
|
||||
available_quantity=inv.available_quantity,
|
||||
gtin=inv.gtin,
|
||||
created_at=inv.created_at,
|
||||
updated_at=inv.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count for pagination
|
||||
total_query = db.query(func.count(Inventory.id)).filter(
|
||||
Inventory.vendor_id == vendor_id
|
||||
)
|
||||
if location:
|
||||
total_query = total_query.filter(Inventory.location.ilike(f"%{location}%"))
|
||||
if low_stock is not None:
|
||||
total_query = total_query.filter(Inventory.quantity <= low_stock)
|
||||
total = total_query.scalar() or 0
|
||||
|
||||
return AdminInventoryListResponse(
|
||||
inventories=items,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
vendor_filter=vendor_id,
|
||||
location_filter=location,
|
||||
)
|
||||
|
||||
def get_product_inventory_admin(
|
||||
self, db: Session, product_id: int
|
||||
) -> ProductInventorySummary:
|
||||
"""Get inventory summary for a product (admin only - no vendor check)."""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise ProductNotFoundException(f"Product {product_id} not found")
|
||||
|
||||
# Use existing method with the product's vendor_id
|
||||
return self.get_product_inventory(db, product.vendor_id, product_id)
|
||||
|
||||
def verify_vendor_exists(self, db: Session, vendor_id: int) -> Vendor:
|
||||
"""Verify vendor exists and return it."""
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundException(f"Vendor {vendor_id} not found")
|
||||
return vendor
|
||||
|
||||
def get_inventory_by_id_admin(self, db: Session, inventory_id: int) -> Inventory:
|
||||
"""Get inventory by ID (admin only - returns inventory with vendor_id)."""
|
||||
inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first()
|
||||
if not inventory:
|
||||
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
|
||||
return inventory
|
||||
|
||||
# =========================================================================
|
||||
# Private helper methods
|
||||
# =========================================================================
|
||||
|
||||
def _get_vendor_product(
|
||||
self, db: Session, vendor_id: int, product_id: int
|
||||
) -> Product:
|
||||
"""Get product and verify it belongs to vendor."""
|
||||
product = (
|
||||
db.query(Product)
|
||||
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not product:
|
||||
raise ProductNotFoundException(
|
||||
f"Product {product_id} not found in your catalog"
|
||||
)
|
||||
|
||||
return product
|
||||
|
||||
def _get_inventory_entry(
|
||||
self, db: Session, product_id: int, location: str
|
||||
) -> Inventory | None:
|
||||
"""Get inventory entry by product and location."""
|
||||
return (
|
||||
db.query(Inventory)
|
||||
.filter(Inventory.product_id == product_id, Inventory.location == location)
|
||||
.first()
|
||||
)
|
||||
|
||||
def _get_inventory_by_id(self, db: Session, inventory_id: int) -> Inventory:
|
||||
"""Get inventory by ID or raise exception."""
|
||||
inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first()
|
||||
if not inventory:
|
||||
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
|
||||
return inventory
|
||||
|
||||
def _validate_location(self, location: str) -> str:
|
||||
"""Validate and normalize location."""
|
||||
if not location or not location.strip():
|
||||
raise InventoryValidationException("Location is required")
|
||||
return location.strip().upper()
|
||||
|
||||
def _validate_quantity(self, quantity: int, allow_zero: bool = True) -> None:
|
||||
"""Validate quantity value."""
|
||||
if quantity is None:
|
||||
raise InvalidQuantityException("Quantity is required")
|
||||
|
||||
if not isinstance(quantity, int):
|
||||
raise InvalidQuantityException("Quantity must be an integer")
|
||||
|
||||
if quantity < 0:
|
||||
raise InvalidQuantityException("Quantity cannot be negative")
|
||||
|
||||
if not allow_zero and quantity == 0:
|
||||
raise InvalidQuantityException("Quantity must be positive")
|
||||
|
||||
|
||||
# Create service instance
|
||||
inventory_service = InventoryService()
|
||||
431
app/modules/inventory/services/inventory_transaction_service.py
Normal file
431
app/modules/inventory/services/inventory_transaction_service.py
Normal file
@@ -0,0 +1,431 @@
|
||||
# app/modules/inventory/services/inventory_transaction_service.py
|
||||
"""
|
||||
Inventory Transaction Service.
|
||||
|
||||
Provides query operations for inventory transaction history.
|
||||
All transaction WRITES are handled by OrderInventoryService.
|
||||
This service handles transaction READS for reporting and auditing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import OrderNotFoundException, ProductNotFoundException
|
||||
from app.modules.inventory.models.inventory import Inventory
|
||||
from app.modules.inventory.models.inventory_transaction import InventoryTransaction
|
||||
from models.database.order import Order
|
||||
from models.database.product import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InventoryTransactionService:
|
||||
"""Service for querying inventory transaction history."""
|
||||
|
||||
def get_vendor_transactions(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
product_id: int | None = None,
|
||||
transaction_type: str | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get inventory transactions for a vendor with optional filters.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
product_id: Optional product filter
|
||||
transaction_type: Optional transaction type filter
|
||||
|
||||
Returns:
|
||||
Tuple of (transactions with product details, total count)
|
||||
"""
|
||||
# Build query
|
||||
query = db.query(InventoryTransaction).filter(
|
||||
InventoryTransaction.vendor_id == vendor_id
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if product_id:
|
||||
query = query.filter(InventoryTransaction.product_id == product_id)
|
||||
if transaction_type:
|
||||
query = query.filter(
|
||||
InventoryTransaction.transaction_type == transaction_type
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Get transactions with pagination (newest first)
|
||||
transactions = (
|
||||
query.order_by(InventoryTransaction.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build result with product details
|
||||
result = []
|
||||
for tx in transactions:
|
||||
product = db.query(Product).filter(Product.id == tx.product_id).first()
|
||||
product_title = None
|
||||
product_sku = None
|
||||
if product:
|
||||
product_sku = product.vendor_sku
|
||||
if product.marketplace_product:
|
||||
product_title = product.marketplace_product.get_title()
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": tx.id,
|
||||
"vendor_id": tx.vendor_id,
|
||||
"product_id": tx.product_id,
|
||||
"inventory_id": tx.inventory_id,
|
||||
"transaction_type": (
|
||||
tx.transaction_type.value if tx.transaction_type else None
|
||||
),
|
||||
"quantity_change": tx.quantity_change,
|
||||
"quantity_after": tx.quantity_after,
|
||||
"reserved_after": tx.reserved_after,
|
||||
"location": tx.location,
|
||||
"warehouse": tx.warehouse,
|
||||
"order_id": tx.order_id,
|
||||
"order_number": tx.order_number,
|
||||
"reason": tx.reason,
|
||||
"created_by": tx.created_by,
|
||||
"created_at": tx.created_at,
|
||||
"product_title": product_title,
|
||||
"product_sku": product_sku,
|
||||
}
|
||||
)
|
||||
|
||||
return result, total
|
||||
|
||||
def get_product_history(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
product_id: int,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""
|
||||
Get transaction history for a specific product.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
product_id: Product ID
|
||||
limit: Max transactions to return
|
||||
|
||||
Returns:
|
||||
Dict with product info, current inventory, and transactions
|
||||
|
||||
Raises:
|
||||
ProductNotFoundException: If product not found or doesn't belong to vendor
|
||||
"""
|
||||
# Get product details
|
||||
product = (
|
||||
db.query(Product)
|
||||
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not product:
|
||||
raise ProductNotFoundException(
|
||||
f"Product {product_id} not found in vendor catalog"
|
||||
)
|
||||
|
||||
product_title = None
|
||||
product_sku = product.vendor_sku
|
||||
if product.marketplace_product:
|
||||
product_title = product.marketplace_product.get_title()
|
||||
|
||||
# Get current inventory
|
||||
inventory = (
|
||||
db.query(Inventory)
|
||||
.filter(Inventory.product_id == product_id, Inventory.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
current_quantity = inventory.quantity if inventory else 0
|
||||
current_reserved = inventory.reserved_quantity if inventory else 0
|
||||
|
||||
# Get transactions
|
||||
transactions = (
|
||||
db.query(InventoryTransaction)
|
||||
.filter(
|
||||
InventoryTransaction.vendor_id == vendor_id,
|
||||
InventoryTransaction.product_id == product_id,
|
||||
)
|
||||
.order_by(InventoryTransaction.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
total = (
|
||||
db.query(func.count(InventoryTransaction.id))
|
||||
.filter(
|
||||
InventoryTransaction.vendor_id == vendor_id,
|
||||
InventoryTransaction.product_id == product_id,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"product_id": product_id,
|
||||
"product_title": product_title,
|
||||
"product_sku": product_sku,
|
||||
"current_quantity": current_quantity,
|
||||
"current_reserved": current_reserved,
|
||||
"transactions": [
|
||||
{
|
||||
"id": tx.id,
|
||||
"vendor_id": tx.vendor_id,
|
||||
"product_id": tx.product_id,
|
||||
"inventory_id": tx.inventory_id,
|
||||
"transaction_type": (
|
||||
tx.transaction_type.value if tx.transaction_type else None
|
||||
),
|
||||
"quantity_change": tx.quantity_change,
|
||||
"quantity_after": tx.quantity_after,
|
||||
"reserved_after": tx.reserved_after,
|
||||
"location": tx.location,
|
||||
"warehouse": tx.warehouse,
|
||||
"order_id": tx.order_id,
|
||||
"order_number": tx.order_number,
|
||||
"reason": tx.reason,
|
||||
"created_by": tx.created_by,
|
||||
"created_at": tx.created_at,
|
||||
}
|
||||
for tx in transactions
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
def get_order_history(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Get all inventory transactions for a specific order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
order_id: Order ID
|
||||
|
||||
Returns:
|
||||
Dict with order info and transactions
|
||||
|
||||
Raises:
|
||||
OrderNotFoundException: If order not found or doesn't belong to vendor
|
||||
"""
|
||||
# Verify order belongs to vendor
|
||||
order = (
|
||||
db.query(Order)
|
||||
.filter(Order.id == order_id, Order.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not order:
|
||||
raise OrderNotFoundException(f"Order {order_id} not found")
|
||||
|
||||
# Get transactions for this order
|
||||
transactions = (
|
||||
db.query(InventoryTransaction)
|
||||
.filter(InventoryTransaction.order_id == order_id)
|
||||
.order_by(InventoryTransaction.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build result with product details
|
||||
result = []
|
||||
for tx in transactions:
|
||||
product = db.query(Product).filter(Product.id == tx.product_id).first()
|
||||
product_title = None
|
||||
product_sku = None
|
||||
if product:
|
||||
product_sku = product.vendor_sku
|
||||
if product.marketplace_product:
|
||||
product_title = product.marketplace_product.get_title()
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": tx.id,
|
||||
"vendor_id": tx.vendor_id,
|
||||
"product_id": tx.product_id,
|
||||
"inventory_id": tx.inventory_id,
|
||||
"transaction_type": (
|
||||
tx.transaction_type.value if tx.transaction_type else None
|
||||
),
|
||||
"quantity_change": tx.quantity_change,
|
||||
"quantity_after": tx.quantity_after,
|
||||
"reserved_after": tx.reserved_after,
|
||||
"location": tx.location,
|
||||
"warehouse": tx.warehouse,
|
||||
"order_id": tx.order_id,
|
||||
"order_number": tx.order_number,
|
||||
"reason": tx.reason,
|
||||
"created_by": tx.created_by,
|
||||
"created_at": tx.created_at,
|
||||
"product_title": product_title,
|
||||
"product_sku": product_sku,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"order_number": order.order_number,
|
||||
"transactions": result,
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Admin Methods (cross-vendor operations)
|
||||
# =========================================================================
|
||||
|
||||
def get_all_transactions_admin(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
vendor_id: int | None = None,
|
||||
product_id: int | None = None,
|
||||
transaction_type: str | None = None,
|
||||
order_id: int | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get inventory transactions across all vendors (admin only).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
vendor_id: Optional vendor filter
|
||||
product_id: Optional product filter
|
||||
transaction_type: Optional transaction type filter
|
||||
order_id: Optional order filter
|
||||
|
||||
Returns:
|
||||
Tuple of (transactions with details, total count)
|
||||
"""
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
# Build query
|
||||
query = db.query(InventoryTransaction)
|
||||
|
||||
# Apply filters
|
||||
if vendor_id:
|
||||
query = query.filter(InventoryTransaction.vendor_id == vendor_id)
|
||||
if product_id:
|
||||
query = query.filter(InventoryTransaction.product_id == product_id)
|
||||
if transaction_type:
|
||||
query = query.filter(
|
||||
InventoryTransaction.transaction_type == transaction_type
|
||||
)
|
||||
if order_id:
|
||||
query = query.filter(InventoryTransaction.order_id == order_id)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Get transactions with pagination (newest first)
|
||||
transactions = (
|
||||
query.order_by(InventoryTransaction.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build result with vendor and product details
|
||||
result = []
|
||||
for tx in transactions:
|
||||
vendor = db.query(Vendor).filter(Vendor.id == tx.vendor_id).first()
|
||||
product = db.query(Product).filter(Product.id == tx.product_id).first()
|
||||
|
||||
product_title = None
|
||||
product_sku = None
|
||||
if product:
|
||||
product_sku = product.vendor_sku
|
||||
if product.marketplace_product:
|
||||
product_title = product.marketplace_product.get_title()
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": tx.id,
|
||||
"vendor_id": tx.vendor_id,
|
||||
"vendor_name": vendor.name if vendor else None,
|
||||
"vendor_code": vendor.vendor_code if vendor else None,
|
||||
"product_id": tx.product_id,
|
||||
"inventory_id": tx.inventory_id,
|
||||
"transaction_type": (
|
||||
tx.transaction_type.value if tx.transaction_type else None
|
||||
),
|
||||
"quantity_change": tx.quantity_change,
|
||||
"quantity_after": tx.quantity_after,
|
||||
"reserved_after": tx.reserved_after,
|
||||
"location": tx.location,
|
||||
"warehouse": tx.warehouse,
|
||||
"order_id": tx.order_id,
|
||||
"order_number": tx.order_number,
|
||||
"reason": tx.reason,
|
||||
"created_by": tx.created_by,
|
||||
"created_at": tx.created_at,
|
||||
"product_title": product_title,
|
||||
"product_sku": product_sku,
|
||||
}
|
||||
)
|
||||
|
||||
return result, total
|
||||
|
||||
def get_transaction_stats_admin(self, db: Session) -> dict:
|
||||
"""
|
||||
Get transaction statistics across the platform (admin only).
|
||||
|
||||
Returns:
|
||||
Dict with transaction counts by type
|
||||
"""
|
||||
from sqlalchemy import func as sql_func
|
||||
|
||||
# Count by transaction type
|
||||
type_counts = (
|
||||
db.query(
|
||||
InventoryTransaction.transaction_type,
|
||||
sql_func.count(InventoryTransaction.id).label("count"),
|
||||
)
|
||||
.group_by(InventoryTransaction.transaction_type)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Total transactions
|
||||
total = db.query(sql_func.count(InventoryTransaction.id)).scalar() or 0
|
||||
|
||||
# Transactions today
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_count = (
|
||||
db.query(sql_func.count(InventoryTransaction.id))
|
||||
.filter(InventoryTransaction.created_at >= today_start)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_transactions": total,
|
||||
"transactions_today": today_count,
|
||||
"by_type": {tc.transaction_type.value: tc.count for tc in type_counts},
|
||||
}
|
||||
|
||||
|
||||
# Create service instance
|
||||
inventory_transaction_service = InventoryTransactionService()
|
||||
Reference in New Issue
Block a user