more inventory management work

This commit is contained in:
2025-04-22 16:44:47 -04:00
parent d8ae45c025
commit 210a033695
11 changed files with 1070 additions and 131 deletions

View File

@ -0,0 +1,81 @@
from app.services.base_service import BaseService
from app.models.inventory_management import InventoryItem
from app.models.tcgplayer_product import TCGPlayerProduct
from app.contexts.inventory_product import InventoryProductContext
from sqlalchemy.orm import Session
from datetime import datetime
class InventoryItemContext:
def __init__(self, item: InventoryItem, db: Session):
self.item = item
self.physical_item = item.physical_item
self.marketplace_listing = item.marketplace_listing
self.parent = item.parent
self.children = item.children
self.product = item.physical_item.product
self.db = db
@property
def cost_basis(self) -> float:
return self.item.cost_basis
@property
def product_id(self) -> int:
return self.physical_item.product_id
@property
def product_name(self) -> str:
return self.physical_item.product_name
@property
def item_type(self) -> str:
return self.physical_item.item_type
@property
def market_price(self) -> float:
return self.product.most_recent_tcgplayer_price.market_price
@property
def tcg_low_price(self) -> float:
return self.product.most_recent_tcgplayer_price.low_price
@property
def listed_price(self) -> float:
return self.marketplace_listing.listed_price
def top_level_parent(self) -> "InventoryItemContext":
if self.parent:
return self.parent.top_level_parent()
else:
return self
def box_expected_value(self) -> float:
top_level_parent_item = self.top_level_parent_item()
if 'case' in top_level_parent_item.item_type:
return top_level_parent_item.open_event.sealed_case.expected_value
elif 'box' in top_level_parent_item.item_type:
return top_level_parent_item.open_event.sealed_box.expected_value
else:
raise ValueError("Unknown top level parent item type")
def box_acquisition_cost(self) -> float:
if self.physical_item.transaction_item:
return self.physical_item.transaction_item.unit_price
elif self.parent:
return self.parent.box_acquisition_cost()
else:
raise ValueError("Cannot find transaction unit price for this item")
def age_on_marketplace(self) -> int:
return (datetime.now() - self.marketplace_listing.listing_date).days
class InventoryItemContextFactory:
def __init__(self, db: Session):
self.db = db
def get_context(self, item: InventoryItem) -> InventoryItemContext:
return InventoryItemContext(item, self.db)
def get_context_for_product(self, product: TCGPlayerProduct) -> InventoryProductContext:
return InventoryProductContext(product, self.db)

View File

@ -0,0 +1,28 @@
from app.models.tcgplayer_product import TCGPlayerProduct
from sqlalchemy.orm import Session
class InventoryProductContext:
def __init__(self, product: TCGPlayerProduct, db: Session):
self.product = product
self.prices = product.most_recent_tcgplayer_price
self.db = db
@property
def market_price(self) -> float:
return self.product.most_recent_tcgplayer_price.market_price
@property
def low_price(self) -> float:
return self.product.most_recent_tcgplayer_price.low_price
@property
def name(self) -> str:
return self.product.name
@property
def image_url(self) -> str:
return self.product.image_url
@property
def id(self) -> int:
return self.product.id

View File

@ -1,13 +1,15 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.db.database import Base
from sqlalchemy import event
from sqlalchemy.orm import Session
class PhysicalItem(Base):
__tablename__ = "physical_items"
id = Column(Integer, primary_key=True)
item_type = Column(String)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
product_id = Column(Integer, ForeignKey("tcgplayer_products.id"), nullable=False)
created_at = Column(DateTime(timezone=True))
updated_at = Column(DateTime(timezone=True))
deleted_at = Column(DateTime(timezone=True), nullable=True)
@ -18,15 +20,17 @@ class PhysicalItem(Base):
}
# Relationships
product = relationship("Product")
product = relationship("TCGPlayerProduct")
inventory_item = relationship("InventoryItem", uselist=False, back_populates="physical_item")
transaction_items = relationship("TransactionItem", back_populates="physical_item")
transaction_item = relationship("TransactionItem", back_populates="physical_item")
class SealedCase(PhysicalItem):
__tablename__ = "sealed_cases"
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
expected_value = Column(Float)
num_boxes = Column(Integer)
__mapper_args__ = {
'polymorphic_identity': 'sealed_case'
}
@ -40,6 +44,7 @@ class SealedBox(PhysicalItem):
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
case_id = Column(Integer, ForeignKey("sealed_cases.id"), nullable=True)
expected_value = Column(Float)
__mapper_args__ = {
'polymorphic_identity': 'sealed_box'
@ -49,6 +54,27 @@ class SealedBox(PhysicalItem):
case = relationship("SealedCase", back_populates="boxes", foreign_keys=[case_id])
open_event = relationship("OpenEvent", uselist=False, back_populates="sealed_box")
# event listeners
@event.listens_for(SealedCase, 'before_insert')
def set_expected_value(mapper, connection, target):
session = Session.object_session(target)
if session:
expected_value = session.query(SealedExpectedValue).filter(SealedExpectedValue.product_id == target.product_id).filter(SealedExpectedValue.deleted_at == None).order_by(SealedExpectedValue.created_at.desc()).first()
if expected_value:
target.expected_value = expected_value.expected_value
else:
raise ValueError("No expected value found for this product")
@event.listens_for(SealedBox, 'before_insert')
def set_expected_value(mapper, connection, target):
session = Session.object_session(target)
if session:
expected_value = session.query(SealedExpectedValue).filter(SealedExpectedValue.product_id == target.product_id).filter(SealedExpectedValue.deleted_at == None).order_by(SealedExpectedValue.created_at.desc()).first()
if expected_value:
target.expected_value = expected_value.expected_value
else:
raise ValueError("No expected value found for this product")
class OpenBox(PhysicalItem):
__tablename__ = "open_boxes"
@ -95,6 +121,7 @@ class InventoryItem(Base):
physical_item = relationship("PhysicalItem", back_populates="inventory_item")
parent = relationship("InventoryItem", remote_side=[id], back_populates="children")
children = relationship("InventoryItem", back_populates="parent", overlaps="parent")
marketplace_listing = relationship("MarketplaceListing", back_populates="inventory_item")
class TransactionItem(Base):
__tablename__ = "transaction_items"
@ -151,6 +178,7 @@ class Transaction(Base):
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=True)
transaction_type = Column(String) # 'purchase' or 'sale'
transaction_date = Column(DateTime(timezone=True))
transaction_total_amount = Column(Float)
@ -160,4 +188,61 @@ class Transaction(Base):
deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
transaction_items = relationship("TransactionItem", back_populates="transaction")
transaction_items = relationship("TransactionItem", back_populates="transaction")
class SealedExpectedValue(Base):
__tablename__ = "sealed_expected_values"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("tcgplayer_products.id"), nullable=True)
expected_value = Column(Float)
created_at = Column(DateTime(timezone=True))
updated_at = Column(DateTime(timezone=True))
deleted_at = Column(DateTime(timezone=True), nullable=True)
class MostRecentTCGPlayerPrice(Base):
__tablename__ = "most_recent_tcgplayer_price"
__table_args__ = {'extend_existing': True, 'autoload_with': None, 'info': {'is_view': True}}
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("tcgplayer_products.id"))
date = Column(DateTime)
low_price = Column(Float)
mid_price = Column(Float)
high_price = Column(Float)
market_price = Column(Float)
direct_low_price = Column(Float)
sub_type_name = Column(String)
created_at = Column(DateTime(timezone=True))
updated_at = Column(DateTime(timezone=True))
# Relationships
product = relationship("TCGPlayerProduct", back_populates="most_recent_tcgplayer_price")
class Marketplace(Base):
__tablename__ = "marketplaces"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True)
created_at = Column(DateTime(timezone=True))
updated_at = Column(DateTime(timezone=True))
listings = relationship("MarketplaceListing", back_populates="marketplace")
class MarketplaceListing(Base):
__tablename__ = "marketplace_listings"
id = Column(Integer, primary_key=True, index=True)
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"), nullable=False)
marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=False)
product_id = Column(Integer, ForeignKey("tcgplayer_products.id"), nullable=False)
listing_date = Column(DateTime(timezone=True))
delisting_date = Column(DateTime(timezone=True), nullable=True)
listed_price = Column(Float)
created_at = Column(DateTime(timezone=True))
updated_at = Column(DateTime(timezone=True))
deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
inventory_item = relationship("InventoryItem", back_populates="marketplace_listings")
marketplace = relationship("Marketplace", back_populates="listings")
product = relationship("TCGPlayerProduct", back_populates="marketplace_listings")

View File

@ -1,5 +1,6 @@
from sqlalchemy import Column, Integer, String, Float, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.db.database import Base
class TCGPlayerProduct(Base):
@ -30,4 +31,8 @@ class TCGPlayerProduct(Base):
ext_flavor_text = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
most_recent_tcgplayer_price = relationship("MostRecentTCGPlayerPrice", back_populates="product")
marketplace_listings = relationship("MarketplaceListing", back_populates="product")
inventory_items = relationship("InventoryItem", back_populates="product")

View File

@ -1,6 +1,7 @@
from typing import Callable, Dict, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
import logging
from apscheduler.schedulers.base import SchedulerNotRunningError
@ -16,18 +17,38 @@ class BaseScheduler:
self,
task_name: str,
func: Callable,
interval_seconds: int,
interval_seconds: int = None,
cron_expression: str = None,
*args,
**kwargs
) -> None:
"""Schedule a task to run at regular intervals using APScheduler"""
"""Schedule a task to run at regular intervals or at specific times using APScheduler
Args:
task_name: Name of the task
func: Function to execute
interval_seconds: Interval in seconds for periodic execution (mutually exclusive with cron_expression)
cron_expression: Cron expression for time-based scheduling (mutually exclusive with interval_seconds)
*args: Additional positional arguments for the function
**kwargs: Additional keyword arguments for the function
"""
if task_name in self.jobs:
logger.warning(f"Task {task_name} already exists. Removing existing job.")
self.jobs[task_name].remove()
if interval_seconds and cron_expression:
raise ValueError("Cannot specify both interval_seconds and cron_expression")
elif not interval_seconds and not cron_expression:
raise ValueError("Must specify either interval_seconds or cron_expression")
if interval_seconds:
trigger = IntervalTrigger(seconds=interval_seconds)
else:
trigger = CronTrigger.from_crontab(cron_expression)
job = self.scheduler.add_job(
func,
trigger=IntervalTrigger(seconds=interval_seconds),
trigger=trigger,
args=args,
kwargs=kwargs,
id=task_name,
@ -35,7 +56,10 @@ class BaseScheduler:
)
self.jobs[task_name] = job
logger.info(f"Scheduled task {task_name} to run every {interval_seconds} seconds")
if interval_seconds:
logger.info(f"Scheduled task {task_name} to run every {interval_seconds} seconds")
else:
logger.info(f"Scheduled task {task_name} with cron expression: {cron_expression}")
def remove_task(self, task_name: str) -> None:
"""Remove a scheduled task"""

View File

@ -1,21 +1,25 @@
from app.db.database import transaction
from app.services.scheduler.base_scheduler import BaseScheduler
from app.services.base_service import BaseService
from sqlalchemy import text
import logging
logger = logging.getLogger(__name__)
class SchedulerService:
class SchedulerService(BaseService):
def __init__(self):
# Initialize BaseService with None as model since this service doesn't have a specific model
super().__init__(None)
self.scheduler = BaseScheduler()
# Service manager will be set during initialization
self._service_manager = None
@property
def service_manager(self):
if self._service_manager is None:
from app.services.service_manager import ServiceManager
self._service_manager = ServiceManager()
return self._service_manager
async def update_tcgplayer_price_history_daily(self, db):
"""
Update the TCGPlayer price history table
"""
with transaction(db):
await db.execute(text("""REFRESH MATERIALIZED VIEW CONCURRENTLY most_recent_tcgplayer_price;"""))
logger.info("TCGPlayer price history refreshed")
async def update_open_orders_hourly(self, db):
"""
@ -67,13 +71,19 @@ class SchedulerService:
await self.scheduler.schedule_task(
task_name="update_open_orders_hourly",
func=lambda: self.update_open_orders_hourly(db),
interval_seconds=60 * 60, # 1 hour
cron_expression="0 * * * *" # Run at minute 0 of every hour
)
# Schedule all orders update to run daily at 1 AM
# Schedule all orders update to run daily at 3 AM
await self.scheduler.schedule_task(
task_name="update_all_orders_daily",
func=lambda: self.update_all_orders_daily(db),
interval_seconds=24 * 60 * 60, # 24 hours
cron_expression="0 3 * * *" # Run at 3:00 AM every day
)
# Schedule TCGPlayer price history update to run daily at 1 AM
await self.scheduler.schedule_task(
task_name="update_tcgplayer_price_history_daily",
func=lambda: self.update_tcgplayer_price_history_daily(db),
cron_expression="0 1 * * *" # Run at 1:00 AM every day
)
self.scheduler.start()