Compare commits

4 Commits
main ... ui

Author SHA1 Message Date
82fd1cb2da update pricing i guess 2025-06-09 12:28:15 -04:00
77d6fd6e29 part of pricing idk i dont remember 2025-06-09 08:28:14 -04:00
7bc64115f2 color identity in pull sheet 2025-05-31 12:51:25 -04:00
fa089adb53 flag special orders in ui 2025-05-31 12:00:28 -04:00
17 changed files with 498 additions and 133 deletions

View File

@@ -90,6 +90,12 @@ tr:hover {
text-align: center;
}
.color-identity {
width: 40px;
text-align: center;
font-weight: bold;
}
.product-name {
width: 200px;
}
@@ -119,6 +125,7 @@ tbody tr:hover {
<th class="set">Set</th>
<th class="rarity">Rarity</th>
<th class="card-number">Card #</th>
<th class="color-identity">Colors</th>
</tr>
</thead>
<tbody>
@@ -129,6 +136,7 @@ tbody tr:hover {
<td class="set">{{ item.set }}</td>
<td class="rarity">{{ item.rarity }}</td>
<td class="card-number">{{ item.card_number }}</td>
<td class="color-identity">{{ item.color_identity }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -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)

View File

@@ -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))
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"
}
)

View File

@@ -32,7 +32,8 @@ router = APIRouter(prefix="/orders")
@router.get("/", response_model=List[TCGPlayerAPIOrderSummary])
async def get_orders(
search_range: SearchRange = SearchRange.LAST_THREE_MONTHS,
open_only: bool = False
open_only: bool = False,
db: Session = Depends(get_db)
) -> List[TCGPlayerAPIOrderSummary]:
"""
Retrieve orders from TCGPlayer based on search criteria.
@@ -47,6 +48,7 @@ async def get_orders(
try:
order_management = service_manager.get_service('order_management')
orders = await order_management.get_orders(search_range, open_only)
orders = await order_management.add_item_quantity(db, orders)
return orders
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch orders: {str(e)}")

View File

@@ -76,6 +76,7 @@ class TCGPlayerAPIOrderSummary(BaseModel):
orderStatus: str
buyerName: str
shippingType: str
itemQuantity: int
productAmount: float
shippingAmount: float
totalAmount: float

View File

@@ -35,5 +35,6 @@ __all__ = [
'OrderManagementService',
'TCGPlayerInventoryService',
'PricingService',
'MarketplaceListingService'
'MarketplaceListingService',
'ScryfallService'
]

View File

@@ -0,0 +1,11 @@
from app.services.external_api.base_external_service import BaseExternalService
class ScryfallService(BaseExternalService):
def __init__(self):
super().__init__(base_url="https://api.scryfall.com/")
async def get_color_identity(self, scryfall_id: str) -> str:
"""Get the color identity of a card from Scryfall API"""
endpoint = f"cards/{scryfall_id}"
results = await self._make_request("GET", endpoint)
return results['color_identity']

View File

@@ -24,6 +24,7 @@ import csv
import io
from app.schemas.file import FileInDB
from datetime import datetime
from sqlalchemy import func
logger = logging.getLogger(__name__)
class OrderManagementService(BaseTCGPlayerService):
@@ -40,7 +41,34 @@ class OrderManagementService(BaseTCGPlayerService):
self.pull_sheet_endpoint = f"/pull-sheets/export{self.API_VERSION}"
self.shipping_endpoint = f"/shipping/export{self.API_VERSION}"
async def add_item_quantity(self, db: Session, orders: list[TCGPlayerAPIOrderSummary]) -> list[TCGPlayerAPIOrderSummary]:
"""
Add item quantity to orders using SQL aggregation for better performance
"""
# Get order numbers from the input orders
order_numbers = [order["orderNumber"] for order in orders]
# Use SQL aggregation to get the sum of quantities directly from the database
quantity_sums = (
db.query(
TCGPlayerOrderProduct.order_number,
func.sum(TCGPlayerOrderProduct.quantity).label('total_quantity')
)
.filter(TCGPlayerOrderProduct.order_number.in_(order_numbers))
.group_by(TCGPlayerOrderProduct.order_number)
.all()
)
# Create a lookup dictionary for faster access
quantity_lookup = {order_number: total_quantity for order_number, total_quantity in quantity_sums}
# Update orders with quantities
for order in orders:
order["itemQuantity"] = quantity_lookup.get(order["orderNumber"], 0)
return orders
async def get_orders(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []) -> list[TCGPlayerAPIOrderSummary]:
"""
search range options:
@@ -79,6 +107,9 @@ class OrderManagementService(BaseTCGPlayerService):
orders = [order for order in orders if order.get("orderNumber") not in filter_out]
if filter_in:
orders = [order for order in orders if order.get("orderNumber") in filter_in]
# add item quantity to orders as none
for order in orders:
order["itemQuantity"] = 0
return orders
async def get_order_ids(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []):

View File

@@ -71,59 +71,4 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
with transaction(db):
# Bulk insert new data
db.bulk_insert_mappings(TCGPlayerInventory, inventory_data)
async def refresh_unmanaged_tcgplayer_inventory_table(self, db: Session):
"""
Refresh the TCGPlayer unmanaged inventory table
unmanaged inventory is any inventory that cannot be mapped to a card with a marketplace listing
"""
with transaction(db):
# Get active marketplace listings with their physical items in a single query
listed_cards = (
db.query(MarketplaceListing)
.join(MarketplaceListing.inventory_item)
.join(InventoryItem.physical_item)
.filter(
func.lower(Marketplace.name) == func.lower("tcgplayer"),
MarketplaceListing.delisting_date == None,
MarketplaceListing.deleted_at == None,
MarketplaceListing.listing_date != None
)
.all()
)
# Get current inventory and create lookup dict
current_inventory = db.query(TCGPlayerInventory).all()
# Create a set of SKUs that have active listings
listed_skus = {
card.inventory_item.physical_item.tcgplayer_sku_id
for card in listed_cards
}
unmanaged_inventory = []
for inventory in current_inventory:
# Only include SKUs that have no active listings
if inventory.tcgplayer_sku_id not in listed_skus:
unmanaged_inventory.append({
"tcgplayer_inventory_id": inventory.id,
"tcgplayer_sku_id": inventory.tcgplayer_sku_id,
"product_line": inventory.product_line,
"set_name": inventory.set_name,
"product_name": inventory.product_name,
"title": inventory.title,
"number": inventory.number,
"rarity": inventory.rarity,
"condition": inventory.condition,
"tcg_market_price": inventory.tcg_market_price,
"tcg_direct_low": inventory.tcg_direct_low,
"tcg_low_price_with_shipping": inventory.tcg_low_price_with_shipping,
"tcg_low_price": inventory.tcg_low_price,
"total_quantity": inventory.total_quantity,
"add_to_quantity": inventory.add_to_quantity,
"tcg_marketplace_price": inventory.tcg_marketplace_price,
"photo_url": inventory.photo_url
})
db.bulk_insert_mappings(UnmanagedTCGPlayerInventory, unmanaged_inventory)
db.bulk_insert_mappings(TCGPlayerInventory, inventory_data)

View File

@@ -375,7 +375,7 @@ class MarketplaceListingService(BaseService[MarketplaceListing]):
async def create_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
try:
with db_transaction(db):
recommended_price = await self.pricing_service.set_price(db, inventory_item)
recommended_price = await self.pricing_service.set_price_for_inventory_item(db, inventory_item)
logger.info(f"recommended_price: {recommended_price.price}")
marketplace_listing = MarketplaceListing(
inventory_item=inventory_item,
@@ -392,9 +392,10 @@ class MarketplaceListingService(BaseService[MarketplaceListing]):
raise e
async def update_marketplace_listing_price(self, db: Session, marketplace_listing: MarketplaceListing) -> MarketplaceListing:
# idk if this was ever even finished so prob doesnt work idk
try:
with db_transaction(db):
marketplace_listing.listed_price = self.pricing_service.set_price(marketplace_listing.inventory_item)
marketplace_listing.listed_price = self.pricing_service.set_price_for_inventory_item(db, marketplace_listing.inventory_item)
db.flush()
return marketplace_listing
@@ -503,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

View File

@@ -1,8 +1,12 @@
import logging
from dataclasses import dataclass
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
from app.models.inventory_management import InventoryItem, MarketplaceListing, PhysicalItem
from app.models.tcgplayer_inventory import TCGPlayerInventory
from app.models.tcgplayer_products import TCGPlayerPriceHistory, TCGPlayerProduct, MTGJSONSKU
from app.models.pricing import PricingEvent
from app.db.database import transaction
from decimal import Decimal
@@ -11,24 +15,187 @@ from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class PriceData:
cost_basis: Optional[Decimal]
market_price: Optional[Decimal]
tcg_low: Optional[Decimal]
tcg_mid: Optional[Decimal]
direct_low: Optional[Decimal]
listed_price: Optional[Decimal]
quantity: int
lowest_price_for_qty: Optional[Decimal]
velocity: Optional[Decimal]
age_of_inventory: Optional[int]
class PricingService(BaseService):
def __init__(self):
super().__init__(None)
async def get_unmanaged_inventory(self, db: Session):
unmanaged_inventory = db.query(TCGPlayerInventory).filter(
TCGPlayerInventory.tcgplayer_sku_id.notin_(
db.query(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)
)
),
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:
price_data = price_data_map.get(inventory.tcgplayer_sku_id)
if price_data:
inventory.tcg_marketplace_price = await self.set_price(db, price_data)
async def set_price(self, db: Session, inventory_item: InventoryItem) -> float:
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)) 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(
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
).first() else 0,
lowest_price_for_qty=None,
velocity=None,
age_of_inventory=None
)
return await self.set_price(db, price_data, inventory_item)
async def set_price(self, db: Session, price_data: PriceData, inventory_item: InventoryItem=None):
"""
TODO This sets listed_price per inventory_item but listed_price can only be applied to a product
however, this may be desired on other marketplaces
when generating pricing file for tcgplayer, give the option to set min, max, avg price for product?
"""
# Fetch base pricing data
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))
listed_price = Decimal(str(inventory_item.marketplace_listing.listed_price)) if inventory_item.marketplace_listing else None
cost_basis = price_data.cost_basis
market_price = price_data.market_price
tcg_low = price_data.tcg_low
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}")
@@ -45,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')
@@ -66,12 +233,11 @@ 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_record = db.query(TCGPlayerInventory).filter(
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
).first()
quantity_in_stock = quantity_record.total_quantity if quantity_record else 0
quantity_in_stock = price_data.quantity
# Determine quantity multiplier based on stock levels
if quantity_in_stock < 4:
@@ -91,6 +257,8 @@ class PricingService(BaseService):
# limit shipping cost offset to 10% of market price
shipping_cost_offset = min(shipping_cost / average_cards_per_order, market_price * Decimal('0.1'))
if cost_basis is None:
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
@@ -102,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 < (market_price * Decimal('0.8')) 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 market price and 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"
@@ -140,43 +308,34 @@ 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
with transaction(db):
pricing_event = PricingEvent(
inventory_item_id=inventory_item.id,
price=float(adjusted_price),
price_used=price_used,
price_reason=price_reason,
free_shipping_adjustment=free_shipping_adjustment
)
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:
with transaction(db):
pricing_event = PricingEvent(
inventory_item_id=inventory_item.id,
price=float(adjusted_price),
price_used=price_used,
price_reason=price_reason,
free_shipping_adjustment=free_shipping_adjustment
)
db.add(pricing_event)
# delete previous pricing events for inventory item
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
def set_price_for_unmanaged_inventory(self, db: Session, tcgplayer_sku_id: int, quantity: int) -> float:
pass
def update_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> list[PricingEvent]:
# get inventory items for sku
updated_prices = []
inventory_items = db.query(InventoryItem).filter(
InventoryItem.physical_item.tcgplayer_sku_id == tcgplayer_sku_id
).all()
for inventory_item in inventory_items:
pricing_event = self.set_price(db, inventory_item)
updated_prices.append(pricing_event)
return updated_prices
def set_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> float:
# update price for all inventory items for sku
prices = self.update_price_for_product(db, tcgplayer_sku_id)
sum_prices = sum(price.price for price in prices)
average_price = sum_prices / len(prices)
return average_price
return pricing_event
else:
return adjusted_price
# BAD BAD BAD FIX PLS TODO

View File

@@ -1,4 +1,5 @@
from typing import List, Dict
import json
import pandas as pd
from datetime import datetime
from pathlib import Path
@@ -9,7 +10,7 @@ import asyncio
from app.schemas.file import FileInDB
from app.services.base_service import BaseService
from sqlalchemy.orm import Session
from app.models.tcgplayer_products import TCGPlayerProduct, TCGPlayerGroup, MTGJSONSKU, MTGJSONCard
logger = logging.getLogger(__name__)
@@ -48,7 +49,7 @@ class PullSheetService(BaseService):
"""
try:
# Read and process CSV data
items = await self._read_and_process_csv(file.path)
items = await self._read_and_process_csv(db, file.path)
# Prepare template data
template_data = {
@@ -79,8 +80,51 @@ class PullSheetService(BaseService):
except Exception as e:
logger.error(f"Error generating pull sheet PDF: {str(e)}")
raise
async def _get_color_identity(self, db: Session, row: pd.Series) -> str:
"""Get color identity from a row.
Args:
row: pandas Series
"""
# get category id from set name
group_id = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.name == row['Set']).first().group_id
# format number
number = str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else ''
# get product info from category id
product_id = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.group_id == group_id).filter(TCGPlayerProduct.name == row['Product Name']).filter(TCGPlayerProduct.ext_number == number).filter(TCGPlayerProduct.ext_rarity == row['Rarity']).first().tcgplayer_product_id
# get scryfall id from product id
mtgjson_id = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_product_id == product_id).first().mtgjson_uuid
scryfall_id = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == mtgjson_id).first().scryfall_id
# get color identity from scryfall
scryfall_service = self.get_service('scryfall')
color_identity = await scryfall_service.get_color_identity(scryfall_id)
if color_identity is None:
return '?'
# color identity is str of json array, convert to human readable string of list
color_identity = [str(color) for color in color_identity]
# if color identity is empty, return C for colorless
if not color_identity:
return 'C'
# ensure order, W > U > B > R > G
color_identity = sorted(color_identity, key=lambda x: ['W', 'U', 'B', 'R', 'G'].index(x))
color_identity = ''.join(color_identity)
return color_identity
async def _update_row_color_identity(self, db: Session, row: pd.Series) -> pd.Series:
"""Update color identity from a row.
Args:
row: pandas Series
"""
# get color identity from row
color_identity = await self._get_color_identity(db, row)
# update row with color identity
row['Color Identity'] = color_identity
return row
async def _read_and_process_csv(self, csv_path: str) -> List[Dict]:
async def _read_and_process_csv(self, db: Session, csv_path: str) -> List[Dict]:
"""Read and process CSV data using pandas.
Args:
@@ -103,6 +147,15 @@ class PullSheetService(BaseService):
# Sort by Set Release Date (descending) and then Product Name (ascending)
df = df.sort_values(['Set Release Date', 'Set', 'Product Name'], ascending=[False, True, True])
# Process color identities for all rows
color_identities = []
for _, row in df.iterrows():
color_identity = await self._get_color_identity(db, row)
color_identities.append(color_identity)
# Add color identity column to dataframe
df['Color Identity'] = color_identities
# Convert to list of dictionaries
items = []
@@ -113,7 +166,8 @@ class PullSheetService(BaseService):
'quantity': str(int(row['Quantity'])), # Convert to string for template
'set': row['Set'],
'rarity': row['Rarity'],
'card_number': str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else ''
'card_number': str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else '',
'color_identity': row['Color Identity']
})
return items

View File

@@ -4,6 +4,7 @@ from app.services.base_service import BaseService
from sqlalchemy import text
import logging
from app.models.tcgplayer_inventory import UnmanagedTCGPlayerInventory, TCGPlayerInventory
from datetime import datetime
logger = logging.getLogger(__name__)
class SchedulerService(BaseService):
@@ -67,7 +68,6 @@ class SchedulerService(BaseService):
db.flush()
await tcgplayer_inventory_service.refresh_tcgplayer_inventory_table(db)
db.flush()
await tcgplayer_inventory_service.refresh_unmanaged_tcgplayer_inventory_table(db)
async def start_scheduled_tasks(self, db):
"""Start all scheduled tasks"""
@@ -75,7 +75,7 @@ class SchedulerService(BaseService):
await self.scheduler.schedule_task(
task_name="update_open_orders_hourly",
func=self.update_open_orders_hourly,
cron_expression="10 * * * *", # Run at minute 10 of every hour
cron_expression="*/10 * * * *", # Run at minute 10 of every hour
db=db
)
# Schedule all orders update to run daily at 3 AM
@@ -93,6 +93,12 @@ class SchedulerService(BaseService):
db=db
)
# Run initial inventory refresh on startup if inventory update was not run today
# get last inventory update date
last_inventory_update = db.query(TCGPlayerInventory).order_by(TCGPlayerInventory.created_at.desc()).first()
if last_inventory_update is None or last_inventory_update.created_at.date() != datetime.now().date():
await self.refresh_tcgplayer_inventory_table(db)
self.scheduler.start()
logger.info("All scheduled tasks started")

View File

@@ -34,7 +34,8 @@ class ServiceManager:
'inventory': 'app.services.inventory_service.InventoryService',
'box': 'app.services.inventory_service.BoxService',
'case': 'app.services.inventory_service.CaseService',
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService'
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService',
'scryfall': 'app.services.external_api.scryfall.scryfall_service.ScryfallService'
}
self._service_configs = {

View File

@@ -67,16 +67,30 @@ function displayOrders(orders) {
}
orders.forEach(order => {
const hasHighQuantity = order.itemQuantity > 9;
const hasHighAmount = order.productAmount > 40.00;
const orderCard = document.createElement('div');
orderCard.className = `bg-gray-700 rounded-lg shadow-sm p-4 border border-gray-600 hover:shadow-md transition-shadow cursor-pointer ${
selectedOrders.has(order.orderNumber) ? 'ring-2 ring-blue-500' : ''
}`;
} ${hasHighQuantity || hasHighAmount ? 'border-yellow-500' : ''}`;
orderCard.dataset.orderId = order.orderNumber;
orderCard.innerHTML = `
<div class="flex flex-col h-full">
<div class="flex justify-between items-start mb-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-bold text-blue-400 truncate">#${order.orderNumber || 'N/A'}</h3>
<div class="flex items-center gap-2">
<h3 class="text-lg font-bold text-blue-400 truncate">
<a href="https://sellerportal.tcgplayer.com/orders/${order.orderNumber}" target="_blank" rel="noopener noreferrer" class="hover:underline" onclick="event.stopPropagation()">${order.orderNumber || 'N/A'}</a>
</h3>
${(hasHighQuantity || hasHighAmount) ? `
<span class="text-yellow-400" title="${hasHighQuantity ? 'High item quantity' : ''}${hasHighQuantity && hasHighAmount ? ' and ' : ''}${hasHighAmount ? 'High product amount' : ''}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</span>
` : ''}
</div>
<p class="text-sm text-gray-400">${order.buyerName || 'N/A'}</p>
</div>
<span class="px-2 py-1 text-xs rounded-full ${
@@ -86,8 +100,23 @@ function displayOrders(orders) {
<div class="mt-auto">
<div class="flex justify-between items-center">
<p class="text-sm text-gray-400">${order.orderDate ? new Date(order.orderDate).toLocaleString() : 'N/A'}</p>
<p class="text-lg font-bold text-white">$${order.totalAmount ? order.totalAmount.toFixed(2) : '0.00'}</p>
<div class="flex items-center gap-2">
${hasHighAmount ? `
<span class="text-yellow-400 text-sm">⚠️</span>
` : ''}
<p class="text-lg font-bold ${hasHighAmount ? 'text-yellow-400' : 'text-white'}">$${order.totalAmount ? order.totalAmount.toFixed(2) : '0.00'}</p>
</div>
</div>
${hasHighQuantity ? `
<div class="mt-2 text-sm text-yellow-400">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
</svg>
High quantity: ${order.itemQuantity} items
</span>
</div>
` : ''}
</div>
</div>
`;

View File

@@ -28,9 +28,14 @@
<!-- Create Transaction Button -->
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
<button id="createTransactionBtn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
Create New Transaction
</button>
<div class="flex space-x-4">
<button id="createTransactionBtn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
Create New Transaction
</button>
<button id="downloadTcgplayerUpdateBtn" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors">
Download TCGPlayer Update File
</button>
</div>
</div>
<!-- Transaction List -->

View File

@@ -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.');
}
}