Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
7bc64115f2 | |||
fa089adb53 |
@ -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>
|
||||||
|
@ -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)}")
|
||||||
|
@ -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
|
||||||
|
@ -35,5 +35,6 @@ __all__ = [
|
|||||||
'OrderManagementService',
|
'OrderManagementService',
|
||||||
'TCGPlayerInventoryService',
|
'TCGPlayerInventoryService',
|
||||||
'PricingService',
|
'PricingService',
|
||||||
'MarketplaceListingService'
|
'MarketplaceListingService',
|
||||||
|
'ScryfallService'
|
||||||
]
|
]
|
11
app/services/external_api/scryfall/scryfall_service.py
Normal file
11
app/services/external_api/scryfall/scryfall_service.py
Normal 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']
|
@ -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,6 +41,33 @@ 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]:
|
||||||
"""
|
"""
|
||||||
@ -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] = []):
|
||||||
|
@ -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 = {
|
||||||
@ -80,7 +81,50 @@ class PullSheetService(BaseService):
|
|||||||
logger.error(f"Error generating pull sheet PDF: {str(e)}")
|
logger.error(f"Error generating pull sheet PDF: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _read_and_process_csv(self, csv_path: str) -> List[Dict]:
|
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, db: Session, csv_path: str) -> List[Dict]:
|
||||||
"""Read and process CSV data using pandas.
|
"""Read and process CSV data using pandas.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -104,6 +148,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 = []
|
||||||
for _, row in df.iterrows():
|
for _, row in df.iterrows():
|
||||||
@ -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
|
@ -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
|
||||||
|
@ -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 = {
|
||||||
|
@ -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,9 +100,24 @@ 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>
|
</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>
|
||||||
`;
|
`;
|
||||||
ordersList.appendChild(orderCard);
|
ordersList.appendChild(orderCard);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user