more inventory management work
This commit is contained in:
81
app/contexts/inventory_item.py
Normal file
81
app/contexts/inventory_item.py
Normal 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)
|
28
app/contexts/inventory_product.py
Normal file
28
app/contexts/inventory_product.py
Normal 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
|
@ -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")
|
@ -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")
|
@ -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"""
|
||||
|
@ -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()
|
||||
|
Reference in New Issue
Block a user