diff --git a/app/main.py b/app/main.py index 89ec4cf..935e7c0 100644 --- a/app/main.py +++ b/app/main.py @@ -59,11 +59,11 @@ async def lifespan(app: FastAPI): db = SessionLocal() try: #data_init_service = service_manager.get_service('data_initialization') - #data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=False, archived_prices_start_date="2025-05-22", archived_prices_end_date="2025-05-23") + #data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=False, archived_prices_start_date="2025-06-07", archived_prices_end_date="2025-06-08") #logger.info(f"Data initialization results: {data_init}") # Update most recent prices #MostRecentTCGPlayerPrice.update_most_recent_prices(db) - logger.info("Most recent prices updated successfully") + #logger.info("Most recent prices updated successfully") # Create default customer, vendor, and marketplace #inv_data_init = await data_init_service.initialize_inventory_data(db) diff --git a/app/routes/inventory_management_routes.py b/app/routes/inventory_management_routes.py index 24d8eb4..c31f6ef 100644 --- a/app/routes/inventory_management_routes.py +++ b/app/routes/inventory_management_routes.py @@ -485,4 +485,20 @@ async def confirm_listings( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/tcgplayer/update-file") +async def get_tcgplayer_update_file( + db: Session = Depends(get_db) +): + inventory_service = service_manager.get_service("inventory") + marketplace_listing_service = service_manager.get_service("marketplace_listing") + marketplace = await inventory_service.create_marketplace(db, "Tcgplayer") + csv_string = await marketplace_listing_service.create_tcgplayer_update_file(db, marketplace) + return StreamingResponse( + iter([csv_string]), + media_type="text/csv", + headers={ + "Content-Disposition": f"attachment; filename=tcgplayer_update_file_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv" + } + ) \ No newline at end of file diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py index e332092..c129c7a 100644 --- a/app/services/inventory_service.py +++ b/app/services/inventory_service.py @@ -504,3 +504,62 @@ class MarketplaceListingService(BaseService[MarketplaceListing]): data_rows = [",".join([escape_csv_value(data[tcgplayer_id][header]) for header in headers]) for tcgplayer_id in data] csv_data = "\n".join([header_row] + data_rows) return csv_data + + async def create_tcgplayer_update_file(self, db: Session, marketplace: Marketplace=None) -> str: + # TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low Price With Shipping,TCG Low Price,Total Quantity,Add to Quantity,TCG Marketplace Price,Photo URL + headers = [ + "TCGplayer Id", + "Product Line", + "Set Name", + "Product Name", + "Title", + "Number", + "Rarity", + "Condition", + "TCG Market Price", + "TCG Direct Low", + "TCG Low Price With Shipping", + "TCG Low Price", + "Total Quantity", + "Add to Quantity", + "TCG Marketplace Price", + "Photo URL" + ] + unmanaged_inventory = await self.pricing_service.update_prices_for_unmanaged_inventory(db) + managed_inventory = await self.pricing_service.update_prices_for_managed_inventory(db) + # combine and convert to csv + inventory = unmanaged_inventory + managed_inventory + data = {} + for inventory_item in inventory: + data[inventory_item.tcgplayer_sku_id] = { + "TCGplayer Id": inventory_item.tcgplayer_sku_id, + "Product Line": inventory_item.product_line, + "Set Name": inventory_item.set_name, + "Product Name": inventory_item.product_name, + "Title": inventory_item.title, + "Number": inventory_item.number, + "Rarity": inventory_item.rarity, + "Condition": inventory_item.condition, + "TCG Market Price": inventory_item.tcg_market_price, + "TCG Direct Low": inventory_item.tcg_direct_low, + "TCG Low Price With Shipping": inventory_item.tcg_low_price_with_shipping, + "TCG Low Price": inventory_item.tcg_low_price, + "Total Quantity": "", + "Add to Quantity": "0", + "TCG Marketplace Price": f"{Decimal(inventory_item.tcg_marketplace_price):.2f}", + "Photo URL": "" + } + # format data into csv + # header + header_row = ",".join(headers) + # data + def escape_csv_value(value): + if value is None: + return "" + value = str(value) + if any(c in value for c in [',', '"', '\n']): + return f'"{value.replace('"', '""')}"' + return value + data_rows = [",".join([escape_csv_value(data[tcgplayer_id][header]) for header in headers]) for tcgplayer_id in data] + csv_data = "\n".join([header_row] + data_rows) + return csv_data \ No newline at end of file diff --git a/app/services/pricing_service.py b/app/services/pricing_service.py index e3fd9f4..aa91c9c 100644 --- a/app/services/pricing_service.py +++ b/app/services/pricing_service.py @@ -1,7 +1,8 @@ import logging from dataclasses import dataclass -from typing import Optional +from typing import Optional, Dict, List from sqlalchemy.orm import Session +from sqlalchemy import select from app.services.base_service import BaseService from app.models.inventory_management import InventoryItem, MarketplaceListing, PhysicalItem from app.models.tcgplayer_inventory import TCGPlayerInventory @@ -33,67 +34,136 @@ class PricingService(BaseService): super().__init__(None) async def get_unmanaged_inventory(self, db: Session): - #select * from tcgplayer_inventory ti where ti.tcgplayer_sku_id not in (select pi2.tcgplayer_sku_id from marketplace_listings ml - # join inventory_items ii on ml.inventory_item_id = ii.id - # join physical_items pi2 on ii.physical_item_id = pi2.id - # where ml.delisting_date is null and ml.deleted_at is null and ii.deleted_at is null and pi2.deleted_at is null); unmanaged_inventory = db.query(TCGPlayerInventory).filter( TCGPlayerInventory.tcgplayer_sku_id.notin_( - db.query(MarketplaceListing.inventory_item.physical_item.tcgplayer_sku_id).join( - InventoryItem, MarketplaceListing.inventory_item_id == InventoryItem.id + db.query(PhysicalItem.tcgplayer_sku_id).join( + InventoryItem, PhysicalItem.id == InventoryItem.physical_item_id ).join( - PhysicalItem, InventoryItem.physical_item_id == PhysicalItem.id + MarketplaceListing, InventoryItem.id == MarketplaceListing.inventory_item_id ).filter( MarketplaceListing.delisting_date.is_(None), MarketplaceListing.deleted_at.is_(None), InventoryItem.deleted_at.is_(None), PhysicalItem.deleted_at.is_(None) ) - ) + ), + TCGPlayerInventory.total_quantity >= 1 ).all() return unmanaged_inventory + async def get_managed_inventory(self, db: Session): + # First get the TCGPlayerInventory IDs that are managed + managed_ids = select(TCGPlayerInventory.id).join( + PhysicalItem, TCGPlayerInventory.tcgplayer_sku_id == PhysicalItem.tcgplayer_sku_id + ).join( + InventoryItem, PhysicalItem.id == InventoryItem.physical_item_id + ).join( + MarketplaceListing, InventoryItem.id == MarketplaceListing.inventory_item_id + ).filter( + MarketplaceListing.delisting_date.is_(None), + MarketplaceListing.deleted_at.is_(None), + InventoryItem.deleted_at.is_(None), + PhysicalItem.deleted_at.is_(None) + ) + + # Then get just the TCGPlayerInventory data for those IDs + managed_inventory = db.query(TCGPlayerInventory).filter( + TCGPlayerInventory.id.in_(managed_ids) + ).all() + return managed_inventory + + async def get_pricing_data_for_unmanaged_inventory(self, db: Session) -> Dict[int, PriceData]: + """Gather all pricing data for unmanaged inventory in a single query.""" + unmanaged_inventory = await self.get_unmanaged_inventory(db) + + # Get all SKU IDs + sku_ids = [inv.tcgplayer_sku_id for inv in unmanaged_inventory] + + # Fetch all MTGJSON SKUs and their products in one query + mtgjson_skus = db.query(MTGJSONSKU).filter( + MTGJSONSKU.tcgplayer_sku_id.in_(sku_ids) + ).all() + + # Create a mapping of SKU ID to MTGJSON SKU + sku_map = {sku.tcgplayer_sku_id: sku for sku in mtgjson_skus} + + # Create price data for each inventory item + price_data_map = {} + for inventory in unmanaged_inventory: + mtgjson_sku = sku_map.get(inventory.tcgplayer_sku_id) + if mtgjson_sku and mtgjson_sku.product and mtgjson_sku.product.most_recent_tcgplayer_price: + recent_price = mtgjson_sku.product.most_recent_tcgplayer_price + price_data = PriceData( + cost_basis=None, + market_price=Decimal(str(recent_price.market_price)) if recent_price.market_price else None, + tcg_low=Decimal(str(recent_price.low_price)) if recent_price.low_price else None, + tcg_mid=Decimal(str(recent_price.mid_price)) if recent_price.mid_price else None, + direct_low=Decimal(str(recent_price.direct_low_price)) if recent_price.direct_low_price else None, + listed_price=Decimal(str(inventory.tcg_marketplace_price)) if inventory.tcg_marketplace_price else None, + quantity=inventory.total_quantity, + lowest_price_for_qty=None, + velocity=None, + age_of_inventory=None + ) + price_data_map[inventory.tcgplayer_sku_id] = price_data + + return price_data_map + async def update_prices_for_unmanaged_inventory(self, db: Session): + # Get all pricing data upfront + price_data_map = await self.get_pricing_data_for_unmanaged_inventory(db) + + # Update prices using the pre-fetched data unmanaged_inventory = await self.get_unmanaged_inventory(db) for inventory in unmanaged_inventory: - inventory.tcg_marketplace_price = await self.set_price_for_unmanaged_inventory(db, inventory) - - async def set_price_for_unmanaged_inventory(self, db: Session, inventory: TCGPlayerInventory): - # available columns - # id, tcgplayer_sku_id, product_line, set_name, product_name, title, - # number, rarity, condition, tcg_market_price, tcg_direct_low, - # tcg_low_price_with_shipping, tcg_low_price, total_quantity, add_to_quantity, - # tcg_marketplace_price, photo_url, created_at, updated_at - # get mtgjson sku - mtgjson_sku = db.query(MTGJSONSKU).filter( - MTGJSONSKU.tcgplayer_sku_id == inventory.tcgplayer_sku_id - ).first() - if mtgjson_sku: - tcgplayer_product = mtgjson_sku.product - price_data = PriceData( - cost_basis=None, - market_price=Decimal(str(tcgplayer_product.most_recent_tcgplayer_price.market_price)) if tcgplayer_product.most_recent_tcgplayer_price else None, - tcg_low=Decimal(str(tcgplayer_product.most_recent_tcgplayer_price.low_price)) if tcgplayer_product.most_recent_tcgplayer_price else None, - tcg_mid=Decimal(str(tcgplayer_product.most_recent_tcgplayer_price.mid_price)) if tcgplayer_product.most_recent_tcgplayer_price else None, - direct_low=Decimal(str(tcgplayer_product.most_recent_tcgplayer_price.direct_low_price)) if tcgplayer_product.most_recent_tcgplayer_price else None, - listed_price=inventory.tcg_marketplace_price, - quantity=inventory.total_quantity, - lowest_price_for_qty=None, - velocity=None, - age_of_inventory=None - ) - return await self.set_price(db, price_data) - else: - return None - + price_data = price_data_map.get(inventory.tcgplayer_sku_id) + if price_data: + inventory.tcg_marketplace_price = await self.set_price(db, price_data) + + return unmanaged_inventory + + async def update_prices_for_managed_inventory(self, db: Session): + """Update prices for managed inventory items and return updated TCGPlayerInventory data.""" + managed_inventory = await self.get_managed_inventory(db) + + # Get all the inventory items we need in one query + inventory_items = db.query(InventoryItem).join( + PhysicalItem, InventoryItem.physical_item_id == PhysicalItem.id + ).filter( + PhysicalItem.tcgplayer_sku_id.in_([inv.tcgplayer_sku_id for inv in managed_inventory]), + InventoryItem.deleted_at.is_(None) + ).all() + + # Create a map of sku_id to inventory_item for easy lookup + inventory_map = {item.physical_item.tcgplayer_sku_id: item for item in inventory_items} + + for tcg_inventory in managed_inventory: + inventory_item = inventory_map.get(tcg_inventory.tcgplayer_sku_id) + if inventory_item: + pricing_event = await self.set_price_for_inventory_item(db, inventory_item) + if pricing_event: + tcg_inventory.tcg_marketplace_price = pricing_event.price + + return managed_inventory + async def set_price_for_inventory_item(self, db: Session, inventory_item: InventoryItem): + recent_price = inventory_item.physical_item.sku.product.most_recent_tcgplayer_price + + # Get the most recent active marketplace listing + active_listing = None + if inventory_item.marketplace_listing: + active_listings = [listing for listing in inventory_item.marketplace_listing + if listing.delisting_date is None and listing.deleted_at is None] + if active_listings: + active_listing = active_listings[0] # Get the first active listing + price_data = PriceData( - cost_basis=Decimal(str(inventory_item.cost_basis)), - market_price=Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.market_price)), - tcg_low=Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.low_price)), - tcg_mid=Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.mid_price)), - direct_low=Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.direct_low_price)), - listed_price=Decimal(str(inventory_item.marketplace_listing.listed_price)), + cost_basis=Decimal(str(inventory_item.cost_basis)) if inventory_item.cost_basis is not None else None, + market_price=Decimal(str(recent_price.market_price)) if recent_price and recent_price.market_price is not None else None, + tcg_low=Decimal(str(recent_price.low_price)) if recent_price and recent_price.low_price is not None else None, + tcg_mid=Decimal(str(recent_price.mid_price)) if recent_price and recent_price.mid_price is not None else None, + direct_low=Decimal(str(recent_price.direct_low_price)) if recent_price and recent_price.direct_low_price is not None else None, + listed_price=Decimal(str(active_listing.listed_price.price)) if active_listing and active_listing.listed_price and active_listing.listed_price.price is not None else None, quantity=db.query(TCGPlayerInventory).filter( TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id ).first().total_quantity if db.query(TCGPlayerInventory).filter( @@ -118,6 +188,15 @@ class PricingService(BaseService): tcg_mid = price_data.tcg_mid listed_price = price_data.listed_price + if inventory_item: + # average cost basis for all inventory items with the same tcgplayer_sku_id + average_cost_basis = db.query(InventoryItem.cost_basis).filter( + InventoryItem.physical_item_id == inventory_item.physical_item_id + ).all() + cost_basis_values = [row[0] for row in average_cost_basis if row[0] is not None] + if cost_basis_values: + cost_basis = Decimal(str(sum(cost_basis_values) / len(cost_basis_values))) + logger.info(f"listed_price: {listed_price}") logger.info(f"market_price: {market_price}") logger.info(f"tcg_low: {tcg_low}") @@ -133,11 +212,11 @@ class PricingService(BaseService): tcgplayer_shipping_fee = Decimal('1.31') average_cards_per_order = Decimal('3.0') marketplace_fee_percentage = Decimal('0.20') - target_margin = Decimal('0.20') + target_margin = Decimal('0.10') velocity_multiplier = Decimal('0.0') global_margin_multiplier = Decimal('0.00') min_floor_price = Decimal('0.25') - price_drop_threshold = Decimal('0.20') + price_drop_threshold = Decimal('0.50') # TODO add age of inventory price decrease multiplier age_of_inventory_multiplier = Decimal('0.0') @@ -154,6 +233,8 @@ class PricingService(BaseService): card_cost_margin_multiplier = Decimal('0.033') elif market_price >= 100 and market_price < 200: card_cost_margin_multiplier = Decimal('0.05') + else: + card_cost_margin_multiplier = Decimal('0.0') # Fetch current total quantity in stock for SKU quantity_in_stock = price_data.quantity @@ -177,7 +258,7 @@ class PricingService(BaseService): shipping_cost_offset = min(shipping_cost / average_cards_per_order, market_price * Decimal('0.1')) if cost_basis is None: - cost_basis = tcg_low * Decimal('0.65') + cost_basis = tcg_mid * Decimal('0.65') # Calculate base price considering cost, shipping, fees, and margin targets base_price = (cost_basis + shipping_cost_offset) / ( (Decimal('1.0') - marketplace_fee_percentage) - adjusted_target_margin @@ -189,19 +270,19 @@ class PricingService(BaseService): base_price + tcgplayer_shipping_fee ) - # Enforce minimum floor price to ensure profitability - if adjusted_price < min_floor_price: - adjusted_price = min_floor_price - # Adjust price based on market prices (TCG low and TCG mid) if adjusted_price < tcg_low: adjusted_price = tcg_mid price_used = "tcg mid" price_reason = "adjusted price below tcg low" - elif adjusted_price > tcg_low and adjusted_price < (tcg_mid * Decimal('0.8')): + elif adjusted_price > tcg_low and adjusted_price < (tcg_mid * Decimal('0.85')): adjusted_price = tcg_mid price_used = "tcg mid" price_reason = f"adjusted price below 80% of tcg mid" + elif adjusted_price > (tcg_mid * Decimal('1.1')): + adjusted_price = max(tcg_mid, cost_basis) + price_used = "max tcg mid/cost basis" + price_reason = f"adjusted price above 110% of tcg mid, using max of tcg mid and cost basis" else: price_used = "adjusted price" price_reason = "valid price assigned based on margin targets" @@ -227,6 +308,12 @@ class PricingService(BaseService): adjusted_price = listed_price price_used = "listed price" price_reason = "adjusted price below price drop threshold" + + # Enforce minimum floor price + if adjusted_price < min_floor_price: + adjusted_price = min_floor_price + price_used = "min floor price" + price_reason = "adjusted price below min floor price" # Record pricing event in database transaction if inventory_item: @@ -241,8 +328,12 @@ class PricingService(BaseService): db.add(pricing_event) # delete previous pricing events for inventory item - if inventory_item.marketplace_listing and inventory_item.marketplace_listing.listed_price: - inventory_item.marketplace_listing.listed_price.deleted_at = datetime.now() + if inventory_item.marketplace_listing: + for listing in inventory_item.marketplace_listing: + if listing.listed_price: + listing.listed_price.deleted_at = datetime.now() + db.flush() + listing.listed_price = pricing_event return pricing_event else: diff --git a/app/static/transactions.html b/app/static/transactions.html index 94ac6fc..dbaaa55 100644 --- a/app/static/transactions.html +++ b/app/static/transactions.html @@ -28,9 +28,14 @@
- +
+ + +
diff --git a/app/static/transactions.js b/app/static/transactions.js index 4b52043..5656c3b 100644 --- a/app/static/transactions.js +++ b/app/static/transactions.js @@ -4,6 +4,8 @@ document.addEventListener('DOMContentLoaded', function() { showTransactionModal(); }); + document.getElementById('downloadTcgplayerUpdateBtn').addEventListener('click', downloadTcgplayerUpdateFile); + document.getElementById('addVendorBtn').addEventListener('click', () => { const vendorName = prompt('Enter vendor name:'); if (vendorName) { @@ -1074,4 +1076,38 @@ async function saveTransaction() { console.error('Error saving transaction:', error); alert('Failed to save transaction. Please try again.'); } +} + +// TCGPlayer Update File Functions +async function downloadTcgplayerUpdateFile() { + try { + const response = await fetch('/api/inventory/tcgplayer/update-file'); + if (!response.ok) { + throw new Error('Failed to download TCGPlayer update file'); + } + + // Get the filename from the Content-Disposition header + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'tcgplayer_update_file.csv'; + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename=(.+)/); + if (filenameMatch) { + filename = filenameMatch[1]; + } + } + + // Create a blob from the response and download it + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error('Error downloading TCGPlayer update file:', error); + alert('Failed to download TCGPlayer update file. Please try again.'); + } } \ No newline at end of file