Compare commits

..

2 Commits
main ... ui

Author SHA1 Message Date
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
10 changed files with 150 additions and 12 deletions

View File

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

View File

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

View File

@ -35,5 +35,6 @@ __all__ = [
'OrderManagementService', 'OrderManagementService',
'TCGPlayerInventoryService', 'TCGPlayerInventoryService',
'PricingService', '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 import io
from app.schemas.file import FileInDB from app.schemas.file import FileInDB
from datetime import datetime from datetime import datetime
from sqlalchemy import func
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OrderManagementService(BaseTCGPlayerService): class OrderManagementService(BaseTCGPlayerService):
@ -40,7 +41,34 @@ class OrderManagementService(BaseTCGPlayerService):
self.pull_sheet_endpoint = f"/pull-sheets/export{self.API_VERSION}" self.pull_sheet_endpoint = f"/pull-sheets/export{self.API_VERSION}"
self.shipping_endpoint = f"/shipping/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]: 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: search range options:
@ -79,6 +107,9 @@ class OrderManagementService(BaseTCGPlayerService):
orders = [order for order in orders if order.get("orderNumber") not in filter_out] orders = [order for order in orders if order.get("orderNumber") not in filter_out]
if filter_in: if filter_in:
orders = [order for order in orders if order.get("orderNumber") in 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 return orders
async def get_order_ids(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []): async def get_order_ids(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []):

View File

@ -1,4 +1,5 @@
from typing import List, Dict from typing import List, Dict
import json
import pandas as pd import pandas as pd
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -9,7 +10,7 @@ import asyncio
from app.schemas.file import FileInDB from app.schemas.file import FileInDB
from app.services.base_service import BaseService from app.services.base_service import BaseService
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.tcgplayer_products import TCGPlayerProduct, TCGPlayerGroup, MTGJSONSKU, MTGJSONCard
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -48,7 +49,7 @@ class PullSheetService(BaseService):
""" """
try: try:
# Read and process CSV data # 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 # Prepare template data
template_data = { template_data = {
@ -79,8 +80,51 @@ class PullSheetService(BaseService):
except Exception as e: except Exception as e:
logger.error(f"Error generating pull sheet PDF: {str(e)}") logger.error(f"Error generating pull sheet PDF: {str(e)}")
raise 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. """Read and process CSV data using pandas.
Args: Args:
@ -103,6 +147,15 @@ class PullSheetService(BaseService):
# Sort by Set Release Date (descending) and then Product Name (ascending) # 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]) 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 # Convert to list of dictionaries
items = [] items = []
@ -113,7 +166,8 @@ class PullSheetService(BaseService):
'quantity': str(int(row['Quantity'])), # Convert to string for template 'quantity': str(int(row['Quantity'])), # Convert to string for template
'set': row['Set'], 'set': row['Set'],
'rarity': row['Rarity'], '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 return items

View File

@ -75,7 +75,7 @@ class SchedulerService(BaseService):
await self.scheduler.schedule_task( await self.scheduler.schedule_task(
task_name="update_open_orders_hourly", task_name="update_open_orders_hourly",
func=self.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 db=db
) )
# Schedule all orders update to run daily at 3 AM # Schedule all orders update to run daily at 3 AM

View File

@ -34,7 +34,8 @@ class ServiceManager:
'inventory': 'app.services.inventory_service.InventoryService', 'inventory': 'app.services.inventory_service.InventoryService',
'box': 'app.services.inventory_service.BoxService', 'box': 'app.services.inventory_service.BoxService',
'case': 'app.services.inventory_service.CaseService', '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 = { self._service_configs = {

View File

@ -67,16 +67,30 @@ function displayOrders(orders) {
} }
orders.forEach(order => { orders.forEach(order => {
const hasHighQuantity = order.itemQuantity > 9;
const hasHighAmount = order.productAmount > 40.00;
const orderCard = document.createElement('div'); 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 ${ 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' : '' selectedOrders.has(order.orderNumber) ? 'ring-2 ring-blue-500' : ''
}`; } ${hasHighQuantity || hasHighAmount ? 'border-yellow-500' : ''}`;
orderCard.dataset.orderId = order.orderNumber; orderCard.dataset.orderId = order.orderNumber;
orderCard.innerHTML = ` orderCard.innerHTML = `
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div class="flex-1 min-w-0"> <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> <p class="text-sm text-gray-400">${order.buyerName || 'N/A'}</p>
</div> </div>
<span class="px-2 py-1 text-xs rounded-full ${ <span class="px-2 py-1 text-xs rounded-full ${
@ -86,8 +100,23 @@ function displayOrders(orders) {
<div class="mt-auto"> <div class="mt-auto">
<div class="flex justify-between items-center"> <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-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> </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>
</div> </div>
`; `;