PRICING
This commit is contained in:
@ -14,6 +14,8 @@ from app.services.set_label_service import SetLabelService
|
||||
from app.services.scheduler.scheduler_service import SchedulerService
|
||||
from app.services.external_api.tcgplayer.order_management_service import OrderManagementService
|
||||
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
|
||||
from app.services.pricing_service import PricingService
|
||||
from app.services.inventory_service import MarketplaceListingService
|
||||
|
||||
__all__ = [
|
||||
'BaseService',
|
||||
@ -31,5 +33,7 @@ __all__ = [
|
||||
'SetLabelService',
|
||||
'SchedulerService',
|
||||
'OrderManagementService',
|
||||
'TCGPlayerInventoryService'
|
||||
'TCGPlayerInventoryService',
|
||||
'PricingService',
|
||||
'MarketplaceListingService'
|
||||
]
|
@ -12,6 +12,7 @@ from app.schemas.transaction import PurchaseTransactionCreate, PurchaseItem
|
||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||
from app.models.tcgplayer_products import MTGJSONSKU, MTGJSONCard
|
||||
from app.models.tcgplayer_products import TCGPlayerPriceHistory
|
||||
from app.models.critical_error_log import CriticalErrorLog
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
@ -349,7 +350,7 @@ class DataInitializationService(BaseService):
|
||||
for price_data in archived_prices_data.get("results", []):
|
||||
try:
|
||||
# Get the subtype name from the price data
|
||||
sub_type_name = price_data.get("subTypeName", "other")
|
||||
sub_type_name = price_data.get("subTypeName", "None")
|
||||
|
||||
# First try to find product with the requested subtype
|
||||
product = db.query(TCGPlayerProduct).filter(
|
||||
@ -521,120 +522,178 @@ class DataInitializationService(BaseService):
|
||||
}
|
||||
|
||||
async def sync_mtgjson_identifiers(self, db: Session, identifiers_data: List[dict]) -> int:
|
||||
"""Sync MTGJSON identifiers data to the database"""
|
||||
|
||||
|
||||
count = 0
|
||||
with db_transaction(db):
|
||||
# Load all existing UUIDs once
|
||||
existing_cards = {
|
||||
card.mtgjson_uuid: card
|
||||
for card in db.query(MTGJSONCard).all()
|
||||
}
|
||||
|
||||
new_cards = []
|
||||
|
||||
for card_data in identifiers_data:
|
||||
if not isinstance(card_data, dict):
|
||||
logger.debug(f"Skipping non-dict item: {card_data}")
|
||||
continue
|
||||
|
||||
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == card_data.get("uuid")).first()
|
||||
if existing_card:
|
||||
# Update existing card
|
||||
for key, value in {
|
||||
|
||||
uuid = card_data.get("uuid")
|
||||
identifiers = card_data.get("identifiers", {})
|
||||
|
||||
if uuid in existing_cards:
|
||||
card = existing_cards[uuid]
|
||||
updates = {
|
||||
"name": card_data.get("name"),
|
||||
"set_code": card_data.get("setCode"),
|
||||
"abu_id": card_data.get("identifiers", {}).get("abuId"),
|
||||
"card_kingdom_etched_id": card_data.get("identifiers", {}).get("cardKingdomEtchedId"),
|
||||
"card_kingdom_foil_id": card_data.get("identifiers", {}).get("cardKingdomFoilId"),
|
||||
"card_kingdom_id": card_data.get("identifiers", {}).get("cardKingdomId"),
|
||||
"cardsphere_id": card_data.get("identifiers", {}).get("cardsphereId"),
|
||||
"cardsphere_foil_id": card_data.get("identifiers", {}).get("cardsphereFoilId"),
|
||||
"cardtrader_id": card_data.get("identifiers", {}).get("cardtraderId"),
|
||||
"csi_id": card_data.get("identifiers", {}).get("csiId"),
|
||||
"mcm_id": card_data.get("identifiers", {}).get("mcmId"),
|
||||
"mcm_meta_id": card_data.get("identifiers", {}).get("mcmMetaId"),
|
||||
"miniaturemarket_id": card_data.get("identifiers", {}).get("miniaturemarketId"),
|
||||
"mtg_arena_id": card_data.get("identifiers", {}).get("mtgArenaId"),
|
||||
"mtgjson_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"),
|
||||
"mtgjson_non_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"),
|
||||
"mtgjson_v4_id": card_data.get("identifiers", {}).get("mtgjsonV4Id"),
|
||||
"mtgo_foil_id": card_data.get("identifiers", {}).get("mtgoFoilId"),
|
||||
"mtgo_id": card_data.get("identifiers", {}).get("mtgoId"),
|
||||
"multiverse_id": card_data.get("identifiers", {}).get("multiverseId"),
|
||||
"scg_id": card_data.get("identifiers", {}).get("scgId"),
|
||||
"scryfall_id": card_data.get("identifiers", {}).get("scryfallId"),
|
||||
"scryfall_card_back_id": card_data.get("identifiers", {}).get("scryfallCardBackId"),
|
||||
"scryfall_oracle_id": card_data.get("identifiers", {}).get("scryfallOracleId"),
|
||||
"scryfall_illustration_id": card_data.get("identifiers", {}).get("scryfallIllustrationId"),
|
||||
"tcgplayer_product_id": card_data.get("identifiers", {}).get("tcgplayerProductId"),
|
||||
"tcgplayer_etched_product_id": card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"),
|
||||
"tnt_id": card_data.get("identifiers", {}).get("tntId")
|
||||
}.items():
|
||||
setattr(existing_card, key, value)
|
||||
"abu_id": identifiers.get("abuId"),
|
||||
"card_kingdom_etched_id": identifiers.get("cardKingdomEtchedId"),
|
||||
"card_kingdom_foil_id": identifiers.get("cardKingdomFoilId"),
|
||||
"card_kingdom_id": identifiers.get("cardKingdomId"),
|
||||
"cardsphere_id": identifiers.get("cardsphereId"),
|
||||
"cardsphere_foil_id": identifiers.get("cardsphereFoilId"),
|
||||
"cardtrader_id": identifiers.get("cardtraderId"),
|
||||
"csi_id": identifiers.get("csiId"),
|
||||
"mcm_id": identifiers.get("mcmId"),
|
||||
"mcm_meta_id": identifiers.get("mcmMetaId"),
|
||||
"miniaturemarket_id": identifiers.get("miniaturemarketId"),
|
||||
"mtg_arena_id": identifiers.get("mtgArenaId"),
|
||||
"mtgjson_foil_version_id": identifiers.get("mtgjsonFoilVersionId"),
|
||||
"mtgjson_non_foil_version_id": identifiers.get("mtgjsonNonFoilVersionId"),
|
||||
"mtgjson_v4_id": identifiers.get("mtgjsonV4Id"),
|
||||
"mtgo_foil_id": identifiers.get("mtgoFoilId"),
|
||||
"mtgo_id": identifiers.get("mtgoId"),
|
||||
"multiverse_id": identifiers.get("multiverseId"),
|
||||
"scg_id": identifiers.get("scgId"),
|
||||
"scryfall_id": identifiers.get("scryfallId"),
|
||||
"scryfall_card_back_id": identifiers.get("scryfallCardBackId"),
|
||||
"scryfall_oracle_id": identifiers.get("scryfallOracleId"),
|
||||
"scryfall_illustration_id": identifiers.get("scryfallIllustrationId"),
|
||||
"tcgplayer_product_id": identifiers.get("tcgplayerProductId"),
|
||||
"tcgplayer_etched_product_id": identifiers.get("tcgplayerEtchedProductId"),
|
||||
"tnt_id": identifiers.get("tntId")
|
||||
}
|
||||
|
||||
for k, v in updates.items():
|
||||
if getattr(card, k) != v:
|
||||
setattr(card, k, v)
|
||||
|
||||
else:
|
||||
new_card = MTGJSONCard(
|
||||
mtgjson_uuid=card_data.get("uuid"),
|
||||
new_cards.append(MTGJSONCard(
|
||||
mtgjson_uuid=uuid,
|
||||
name=card_data.get("name"),
|
||||
set_code=card_data.get("setCode"),
|
||||
abu_id=card_data.get("identifiers", {}).get("abuId"),
|
||||
card_kingdom_etched_id=card_data.get("identifiers", {}).get("cardKingdomEtchedId"),
|
||||
card_kingdom_foil_id=card_data.get("identifiers", {}).get("cardKingdomFoilId"),
|
||||
card_kingdom_id=card_data.get("identifiers", {}).get("cardKingdomId"),
|
||||
cardsphere_id=card_data.get("identifiers", {}).get("cardsphereId"),
|
||||
cardsphere_foil_id=card_data.get("identifiers", {}).get("cardsphereFoilId"),
|
||||
cardtrader_id=card_data.get("identifiers", {}).get("cardtraderId"),
|
||||
csi_id=card_data.get("identifiers", {}).get("csiId"),
|
||||
mcm_id=card_data.get("identifiers", {}).get("mcmId"),
|
||||
mcm_meta_id=card_data.get("identifiers", {}).get("mcmMetaId"),
|
||||
miniaturemarket_id=card_data.get("identifiers", {}).get("miniaturemarketId"),
|
||||
mtg_arena_id=card_data.get("identifiers", {}).get("mtgArenaId"),
|
||||
mtgjson_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"),
|
||||
mtgjson_non_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"),
|
||||
mtgjson_v4_id=card_data.get("identifiers", {}).get("mtgjsonV4Id"),
|
||||
mtgo_foil_id=card_data.get("identifiers", {}).get("mtgoFoilId"),
|
||||
mtgo_id=card_data.get("identifiers", {}).get("mtgoId"),
|
||||
multiverse_id=card_data.get("identifiers", {}).get("multiverseId"),
|
||||
scg_id=card_data.get("identifiers", {}).get("scgId"),
|
||||
scryfall_id=card_data.get("identifiers", {}).get("scryfallId"),
|
||||
scryfall_card_back_id=card_data.get("identifiers", {}).get("scryfallCardBackId"),
|
||||
scryfall_oracle_id=card_data.get("identifiers", {}).get("scryfallOracleId"),
|
||||
scryfall_illustration_id=card_data.get("identifiers", {}).get("scryfallIllustrationId"),
|
||||
tcgplayer_product_id=card_data.get("identifiers", {}).get("tcgplayerProductId"),
|
||||
tcgplayer_etched_product_id=card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"),
|
||||
tnt_id=card_data.get("identifiers", {}).get("tntId")
|
||||
)
|
||||
db.add(new_card)
|
||||
count += 1
|
||||
abu_id=identifiers.get("abuId"),
|
||||
card_kingdom_etched_id=identifiers.get("cardKingdomEtchedId"),
|
||||
card_kingdom_foil_id=identifiers.get("cardKingdomFoilId"),
|
||||
card_kingdom_id=identifiers.get("cardKingdomId"),
|
||||
cardsphere_id=identifiers.get("cardsphereId"),
|
||||
cardsphere_foil_id=identifiers.get("cardsphereFoilId"),
|
||||
cardtrader_id=identifiers.get("cardtraderId"),
|
||||
csi_id=identifiers.get("csiId"),
|
||||
mcm_id=identifiers.get("mcmId"),
|
||||
mcm_meta_id=identifiers.get("mcmMetaId"),
|
||||
miniaturemarket_id=identifiers.get("miniaturemarketId"),
|
||||
mtg_arena_id=identifiers.get("mtgArenaId"),
|
||||
mtgjson_foil_version_id=identifiers.get("mtgjsonFoilVersionId"),
|
||||
mtgjson_non_foil_version_id=identifiers.get("mtgjsonNonFoilVersionId"),
|
||||
mtgjson_v4_id=identifiers.get("mtgjsonV4Id"),
|
||||
mtgo_foil_id=identifiers.get("mtgoFoilId"),
|
||||
mtgo_id=identifiers.get("mtgoId"),
|
||||
multiverse_id=identifiers.get("multiverseId"),
|
||||
scg_id=identifiers.get("scgId"),
|
||||
scryfall_id=identifiers.get("scryfallId"),
|
||||
scryfall_card_back_id=identifiers.get("scryfallCardBackId"),
|
||||
scryfall_oracle_id=identifiers.get("scryfallOracleId"),
|
||||
scryfall_illustration_id=identifiers.get("scryfallIllustrationId"),
|
||||
tcgplayer_product_id=identifiers.get("tcgplayerProductId"),
|
||||
tcgplayer_etched_product_id=identifiers.get("tcgplayerEtchedProductId"),
|
||||
tnt_id=identifiers.get("tntId")
|
||||
))
|
||||
|
||||
return count
|
||||
count += 1
|
||||
|
||||
if new_cards:
|
||||
db.bulk_save_objects(new_cards)
|
||||
|
||||
return count
|
||||
|
||||
async def sync_mtgjson_skus(self, db: Session, skus_data: dict) -> int:
|
||||
"""Sync MTGJSON SKUs data to the database"""
|
||||
|
||||
count = 0
|
||||
with db_transaction(db):
|
||||
for mtgjson_uuid, product_data in skus_data['data'].items():
|
||||
for sku_data in product_data:
|
||||
existing_record = db.query(MTGJSONSKU).filter(MTGJSONSKU.mtgjson_uuid == mtgjson_uuid).filter(MTGJSONSKU.tcgplayer_sku_id == sku_data.get("skuId")).first()
|
||||
if existing_record:
|
||||
# Update existing SKU
|
||||
for key, value in {
|
||||
"tcgplayer_product_id": sku_data.get("productId"),
|
||||
"condition": sku_data.get("condition"),
|
||||
"finish": sku_data.get("finish"),
|
||||
"language": sku_data.get("language"),
|
||||
"printing": sku_data.get("printing"),
|
||||
"normalized_printing": sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
|
||||
}.items():
|
||||
setattr(existing_record, key, value)
|
||||
else:
|
||||
new_sku = MTGJSONSKU(
|
||||
mtgjson_uuid=mtgjson_uuid,
|
||||
tcgplayer_sku_id=sku_data.get("skuId"),
|
||||
tcgplayer_product_id=sku_data.get("productId"),
|
||||
condition=sku_data.get("condition"),
|
||||
finish=sku_data.get("finish"),
|
||||
language=sku_data.get("language"),
|
||||
printing=sku_data.get("printing"),
|
||||
normalized_printing=sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
|
||||
)
|
||||
db.add(new_sku)
|
||||
count += 1
|
||||
sku_details_by_key = {}
|
||||
|
||||
for mtgjson_uuid, product_data in skus_data["data"].items():
|
||||
for sku_data in product_data:
|
||||
sku_id = sku_data.get("skuId")
|
||||
if sku_id is None or sku_id in sku_details_by_key:
|
||||
continue # Skip if missing or already added
|
||||
|
||||
sku_details_by_key[sku_id] = {
|
||||
"mtgjson_uuid": mtgjson_uuid,
|
||||
"tcgplayer_sku_id": sku_id,
|
||||
"tcgplayer_product_id": sku_data.get("productId"),
|
||||
"printing": sku_data.get("printing"),
|
||||
"normalized_printing": sku_data.get("printing", "").lower().replace(" ", "_").replace("non_foil", "normal") if sku_data.get("printing") else None,
|
||||
"condition": sku_data.get("condition"),
|
||||
"finish": sku_data.get("finish"),
|
||||
"language": sku_data.get("language"),
|
||||
}
|
||||
|
||||
with db_transaction(db):
|
||||
db.flush()
|
||||
|
||||
valid_uuids = {uuid for (uuid,) in db.query(MTGJSONCard.mtgjson_uuid).all()}
|
||||
valid_product_keys = {
|
||||
(product.tcgplayer_product_id, product.normalized_sub_type_name)
|
||||
for product in db.query(TCGPlayerProduct.tcgplayer_product_id, TCGPlayerProduct.normalized_sub_type_name)
|
||||
}
|
||||
|
||||
existing_sku_ids = {
|
||||
sku.tcgplayer_sku_id
|
||||
for sku in db.query(MTGJSONSKU.tcgplayer_sku_id).all()
|
||||
}
|
||||
|
||||
existing = {
|
||||
(sku.mtgjson_uuid, sku.tcgplayer_sku_id): sku
|
||||
for sku in db.query(MTGJSONSKU).all()
|
||||
}
|
||||
|
||||
new_skus = []
|
||||
|
||||
for data in sku_details_by_key.values():
|
||||
sku_id = data["tcgplayer_sku_id"]
|
||||
|
||||
if sku_id in existing_sku_ids:
|
||||
continue
|
||||
|
||||
mtgjson_uuid = data["mtgjson_uuid"]
|
||||
product_id = data["tcgplayer_product_id"]
|
||||
normalized_printing = data["normalized_printing"]
|
||||
|
||||
if mtgjson_uuid not in valid_uuids:
|
||||
continue
|
||||
|
||||
if (product_id, normalized_printing) not in valid_product_keys:
|
||||
continue
|
||||
|
||||
key = (mtgjson_uuid, sku_id)
|
||||
|
||||
if key in existing:
|
||||
record = existing[key]
|
||||
for field, value in data.items():
|
||||
if field not in ("mtgjson_uuid", "tcgplayer_sku_id") and getattr(record, field) != value:
|
||||
setattr(record, field, value)
|
||||
else:
|
||||
new_skus.append(MTGJSONSKU(**data))
|
||||
count += 1
|
||||
|
||||
if new_skus:
|
||||
db.bulk_save_objects(new_skus)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
|
||||
return count
|
||||
|
||||
async def initialize_data(
|
||||
self,
|
||||
@ -693,19 +752,17 @@ class DataInitializationService(BaseService):
|
||||
with db_transaction(db):
|
||||
logger.info("Initializing inventory data...")
|
||||
# set expected value
|
||||
product_id1 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562118").first().id
|
||||
expected_value_box = SealedExpectedValue(
|
||||
product_id=product_id1,
|
||||
expected_value=120.69
|
||||
tcgplayer_product_id=619645,
|
||||
expected_value=136.42
|
||||
)
|
||||
db.add(expected_value_box)
|
||||
db.flush()
|
||||
product_id2 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562119").first().id
|
||||
expected_value_case = SealedExpectedValue(
|
||||
product_id=product_id2,
|
||||
expected_value=820.69
|
||||
)
|
||||
db.add(expected_value_case)
|
||||
#db.flush()
|
||||
#expected_value_case = SealedExpectedValue(
|
||||
# tcgplayer_product_id=562119,
|
||||
# expected_value=820.69
|
||||
#)
|
||||
#db.add(expected_value_case)
|
||||
db.flush()
|
||||
|
||||
inventory_service = self.get_service("inventory")
|
||||
@ -715,32 +772,38 @@ class DataInitializationService(BaseService):
|
||||
transaction = await inventory_service.create_purchase_transaction(db, PurchaseTransactionCreate(
|
||||
vendor_id=vendor.id,
|
||||
transaction_date=datetime.now(),
|
||||
items=[PurchaseItem(product_id=product_id1, unit_price=100.69, quantity=1, is_case=False),
|
||||
PurchaseItem(product_id=product_id2, unit_price=800.01, quantity=2, is_case=True, num_boxes=6)],
|
||||
transaction_notes="Test Transaction: 1 case and 2 boxes of foundations"
|
||||
items=[PurchaseItem(product_id=619645, unit_price=100, quantity=1, is_case=False)],
|
||||
transaction_notes="tdm real box test"
|
||||
#PurchaseItem(product_id=562119, unit_price=800.01, quantity=2, is_case=True, num_boxes=6)],
|
||||
#transaction_notes="Test Transaction: 1 case and 2 boxes of foundations"
|
||||
))
|
||||
logger.info(f"Transaction created: {transaction}")
|
||||
case_num = 0
|
||||
for item in transaction.transaction_items:
|
||||
item = InventoryItemContextFactory(db).get_context(item.physical_item.inventory_item)
|
||||
logger.info(f"Item: {item}")
|
||||
if item.physical_item.item_type == "sealed_box":
|
||||
if item.inventory_item.physical_item.item_type == "box":
|
||||
manabox_service = self.get_service("manabox")
|
||||
file_path = 'app/data/test_data/manabox_test_file.csv'
|
||||
#file_path = 'app/data/test_data/manabox_test_file.csv'
|
||||
file_path = 'app/data/test_data/tdmtest.csv'
|
||||
file_bytes = open(file_path, 'rb').read()
|
||||
manabox_file = await manabox_service.process_manabox_csv(db, file_bytes, {"source": "test", "description": "test"}, wait=True)
|
||||
# Ensure manabox_file is a list before passing it
|
||||
if not isinstance(manabox_file, list):
|
||||
manabox_file = [manabox_file]
|
||||
sealed_box_service = self.get_service("sealed_box")
|
||||
sealed_box = sealed_box_service.get(db, item.physical_item.inventory_item.id)
|
||||
success = await inventory_service.process_manabox_import_staging(db, manabox_file, sealed_box)
|
||||
logger.info(f"sealed box opening success: {success}")
|
||||
elif item.physical_item.item_type == "sealed_case":
|
||||
box_service = self.get_service("box")
|
||||
open_event = await box_service.open_box(db, item.inventory_item.physical_item, manabox_file)
|
||||
# get all cards from box
|
||||
cards = open_event.resulting_items if open_event.resulting_items else []
|
||||
marketplace_listing_service = self.get_service("marketplace_listing")
|
||||
for card in cards:
|
||||
logger.info(f"card: {card}")
|
||||
# create marketplace listing
|
||||
await marketplace_listing_service.create_marketplace_listing(db, card.inventory_item, marketplace)
|
||||
elif item.inventory_item.physical_item.item_type == "case":
|
||||
if case_num == 0:
|
||||
logger.info(f"sealed case {case_num} opening...")
|
||||
sealed_case_service = self.get_service("sealed_case")
|
||||
success = await sealed_case_service.open_sealed_case(db, item.physical_item)
|
||||
case_service = self.get_service("case")
|
||||
success = await case_service.open_case(db, item.inventory_item.physical_item, 562119)
|
||||
logger.info(f"sealed case {case_num} opening success: {success}")
|
||||
case_num += 1
|
||||
|
||||
|
@ -2,6 +2,11 @@ from typing import Dict, List, Optional
|
||||
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.file import FileInDB
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory, UnmanagedTCGPlayerInventory
|
||||
import csv
|
||||
from app.db.database import transaction
|
||||
from app.models.inventory_management import MarketplaceListing, InventoryItem, Marketplace
|
||||
from sqlalchemy import func
|
||||
|
||||
class TCGPlayerInventoryService(BaseTCGPlayerService):
|
||||
def __init__(self):
|
||||
@ -24,10 +29,101 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
|
||||
raise ValueError(f"Invalid export type: {export_type}, must be 'staged', 'live', or 'pricing'")
|
||||
|
||||
file_bytes = await self._make_request("GET", endpoint, download_file=True)
|
||||
return await self.save_file(
|
||||
return await self.file_service.save_file(
|
||||
db=db,
|
||||
file_data=file_bytes,
|
||||
file_name=f"tcgplayer_{export_type}_export.csv",
|
||||
filename=f"tcgplayer_{export_type}_export.csv",
|
||||
subdir="tcgplayer/inventory",
|
||||
file_type=file_type
|
||||
)
|
||||
)
|
||||
|
||||
async def refresh_tcgplayer_inventory_table(self, db: Session):
|
||||
"""
|
||||
Refresh the TCGPlayer inventory table
|
||||
"""
|
||||
export = await self.get_tcgplayer_export(db, "live")
|
||||
csv_string = await self.file_service.file_in_db_to_csv(db, export)
|
||||
reader = csv.DictReader(csv_string.splitlines())
|
||||
|
||||
# Convert CSV rows to list of dictionaries for bulk insert
|
||||
inventory_data = []
|
||||
for row in reader:
|
||||
if row.get("TCGplayer Id") is None:
|
||||
continue
|
||||
inventory_data.append({
|
||||
"tcgplayer_sku_id": int(row.get("TCGplayer Id")),
|
||||
"product_line": row.get("Product Line") if row.get("Product Line") else None,
|
||||
"set_name": row.get("Set Name") if row.get("Set Name") else None,
|
||||
"product_name": row.get("Product Name") if row.get("Product Name") else None,
|
||||
"title": row.get("Title") if row.get("Title") else None,
|
||||
"number": row.get("Number") if row.get("Number") else None,
|
||||
"rarity": row.get("Rarity") if row.get("Rarity") else None,
|
||||
"condition": row.get("Condition") if row.get("Condition") else None,
|
||||
"tcg_market_price": float(row.get("TCG Market Price")) if row.get("TCG Market Price") else None,
|
||||
"tcg_direct_low": float(row.get("TCG Direct Low")) if row.get("TCG Direct Low") else None,
|
||||
"tcg_low_price_with_shipping": float(row.get("TCG Low Price With Shipping")) if row.get("TCG Low Price With Shipping") else None,
|
||||
"tcg_low_price": float(row.get("TCG Low Price")) if row.get("TCG Low Price") else None,
|
||||
"total_quantity": int(row.get("Total Quantity")) if row.get("Total Quantity") else None,
|
||||
"add_to_quantity": int(row.get("Add to Quantity")) if row.get("Add to Quantity") else None,
|
||||
"tcg_marketplace_price": float(row.get("TCG Marketplace Price")) if row.get("TCG Marketplace Price") else None,
|
||||
"photo_url": row.get("Photo URL") if row.get("Photo URL") else None
|
||||
})
|
||||
|
||||
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)
|
||||
|
@ -158,3 +158,8 @@ class FileService:
|
||||
if file_record:
|
||||
return FileInDB.model_validate(file_record)
|
||||
return None
|
||||
|
||||
async def file_in_db_to_csv(self, db: Session, file: FileInDB) -> str:
|
||||
"""Convert a file in the database to a CSV string"""
|
||||
with open(file.path, "r") as f:
|
||||
return f.read()
|
||||
|
@ -4,8 +4,8 @@ from app.services.base_service import BaseService
|
||||
from app.models.manabox_import_staging import ManaboxImportStaging
|
||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||
from app.models.inventory_management import (
|
||||
SealedBox, OpenEvent, OpenBox, OpenCard, InventoryItem, SealedCase,
|
||||
Transaction, TransactionItem, Customer, Vendor, Marketplace
|
||||
OpenEvent, Card, InventoryItem, Case,
|
||||
Transaction, TransactionItem, Customer, Vendor, Marketplace, Box, MarketplaceListing
|
||||
)
|
||||
from app.schemas.file import FileInDB
|
||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse
|
||||
@ -19,92 +19,6 @@ logger = logging.getLogger(__name__)
|
||||
class InventoryService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
async def process_manabox_import_staging(self, db: Session, manabox_file_uploads: List[FileInDB], sealed_box: SealedBox) -> bool:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Check if box is already opened
|
||||
existing_open_event = db.query(OpenEvent).filter(
|
||||
OpenEvent.sealed_box_id == sealed_box.id,
|
||||
OpenEvent.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_open_event:
|
||||
raise ValueError(f"Box {sealed_box.id} has already been opened")
|
||||
|
||||
# 1. Get the InventoryItemContext for the sealed box
|
||||
inventory_item_context = InventoryItemContextFactory(db).get_context(sealed_box.inventory_item)
|
||||
|
||||
# 2. Create the OpenEvent
|
||||
open_event = OpenEvent(
|
||||
sealed_box_id=sealed_box.id,
|
||||
open_date=datetime.now(),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(open_event)
|
||||
db.flush() # Get the ID for relationships
|
||||
|
||||
# 3. Create the OpenBox from the SealedBox
|
||||
open_box = OpenBox(
|
||||
open_event_id=open_event.id,
|
||||
product_id=sealed_box.product_id,
|
||||
sealed_box_id=sealed_box.id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(open_box)
|
||||
|
||||
# 4. Process each card from the CSV
|
||||
total_market_value = 0
|
||||
cards = []
|
||||
|
||||
manabox_file_upload_ids = [manabox_file_upload.id for manabox_file_upload in manabox_file_uploads]
|
||||
|
||||
staging_data = db.query(ManaboxImportStaging).filter(ManaboxImportStaging.file_id.in_(manabox_file_upload_ids)).all()
|
||||
|
||||
for record in staging_data:
|
||||
for i in range(record.quantity):
|
||||
# Create the OpenCard
|
||||
open_card = OpenCard(
|
||||
product_id=record.product_id,
|
||||
open_event_id=open_event.id,
|
||||
box_id=open_box.id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(open_card)
|
||||
|
||||
# Create the InventoryItem for the card
|
||||
card_inventory_item = InventoryItem(
|
||||
physical_item=open_card,
|
||||
cost_basis=0, # Will be calculated later
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(card_inventory_item)
|
||||
|
||||
# Get the market value for cost basis distribution
|
||||
card_context = InventoryItemContextFactory(db).get_context(card_inventory_item)
|
||||
market_value = card_context.market_price
|
||||
logger.debug(f"market_value: {market_value}")
|
||||
total_market_value += market_value
|
||||
|
||||
cards.append((open_card, card_inventory_item, market_value))
|
||||
|
||||
# 5. Distribute the cost basis
|
||||
original_cost_basis = inventory_item_context.cost_basis
|
||||
|
||||
for open_card, card_inventory_item, market_value in cards:
|
||||
# Calculate this card's share of the cost basis
|
||||
logger.debug(f"market_value: {market_value}, total_market_value: {total_market_value}, original_cost_basis: {original_cost_basis}")
|
||||
cost_basis_share = (market_value / total_market_value) * original_cost_basis
|
||||
card_inventory_item.cost_basis = cost_basis_share
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def create_purchase_transaction(
|
||||
self,
|
||||
@ -125,31 +39,31 @@ class InventoryService(BaseService):
|
||||
vendor_id=transaction_data.vendor_id,
|
||||
transaction_type='purchase',
|
||||
transaction_date=transaction_data.transaction_date,
|
||||
transaction_notes=transaction_data.transaction_notes,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
transaction_notes=transaction_data.transaction_notes
|
||||
)
|
||||
db.add(transaction)
|
||||
db.flush()
|
||||
|
||||
total_amount = 0
|
||||
physical_items = []
|
||||
case_service = self.get_service("case")
|
||||
box_service = self.get_service("box")
|
||||
for item in transaction_data.items:
|
||||
# Create the physical item based on type
|
||||
# TODO: remove is_case and num_boxes, should derive from product_id
|
||||
# TODO: add support for purchasing single cards
|
||||
if item.is_case:
|
||||
for i in range(item.quantity):
|
||||
physical_item = await SealedCaseService().create_sealed_case(
|
||||
physical_item = await case_service.create_case(
|
||||
db=db,
|
||||
product_id=item.product_id,
|
||||
cost_basis=item.unit_price,
|
||||
num_boxes=item.num_boxes or 1
|
||||
num_boxes=item.num_boxes
|
||||
)
|
||||
physical_items.append(physical_item)
|
||||
else:
|
||||
for i in range(item.quantity):
|
||||
physical_item = await SealedBoxService().create_sealed_box(
|
||||
physical_item = await box_service.create_box(
|
||||
db=db,
|
||||
product_id=item.product_id,
|
||||
cost_basis=item.unit_price
|
||||
@ -158,73 +72,10 @@ class InventoryService(BaseService):
|
||||
|
||||
for physical_item in physical_items:
|
||||
# Create transaction item
|
||||
transaction_item = TransactionItem(
|
||||
transaction_id=transaction.id,
|
||||
physical_item_id=physical_item.id,
|
||||
unit_price=item.unit_price,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(transaction_item)
|
||||
total_amount += item.unit_price
|
||||
|
||||
# Update transaction total
|
||||
transaction.transaction_total_amount = total_amount
|
||||
return transaction
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def create_sale_transaction(
|
||||
self,
|
||||
db: Session,
|
||||
transaction_data: SaleTransactionCreate
|
||||
) -> Transaction:
|
||||
"""
|
||||
this is basically psuedocode not implemented yet
|
||||
"""
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the transaction
|
||||
transaction = Transaction(
|
||||
customer_id=transaction_data.customer_id,
|
||||
marketplace_id=transaction_data.marketplace_id,
|
||||
transaction_type='sale',
|
||||
transaction_date=transaction_data.transaction_date,
|
||||
transaction_notes=transaction_data.transaction_notes,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(transaction)
|
||||
db.flush()
|
||||
|
||||
total_amount = 0
|
||||
for item in transaction_data.items:
|
||||
# Get the inventory item and validate
|
||||
inventory_item = db.query(InventoryItem).filter(
|
||||
InventoryItem.id == item.inventory_item_id,
|
||||
InventoryItem.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not inventory_item:
|
||||
raise ValueError(f"Inventory item {item.inventory_item_id} not found")
|
||||
|
||||
# Create transaction item
|
||||
transaction_item = TransactionItem(
|
||||
transaction_id=transaction.id,
|
||||
physical_item_id=inventory_item.physical_item_id,
|
||||
unit_price=item.unit_price,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(transaction_item)
|
||||
total_amount += item.unit_price
|
||||
|
||||
# Update marketplace listing if applicable
|
||||
if transaction_data.marketplace_id and inventory_item.marketplace_listings:
|
||||
listing = inventory_item.marketplace_listings
|
||||
listing.delisting_date = transaction_data.transaction_date
|
||||
listing.updated_at = datetime.now()
|
||||
transaction.transaction_items.append(TransactionItem(
|
||||
inventory_item_id=physical_item.inventory_item.id,
|
||||
unit_price=item.unit_price
|
||||
))
|
||||
|
||||
# Update transaction total
|
||||
transaction.transaction_total_amount = total_amount
|
||||
@ -246,9 +97,7 @@ class InventoryService(BaseService):
|
||||
|
||||
with db_transaction(db):
|
||||
customer = Customer(
|
||||
name=customer_name,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
name=customer_name
|
||||
)
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
@ -270,9 +119,7 @@ class InventoryService(BaseService):
|
||||
|
||||
with db_transaction(db):
|
||||
vendor = Vendor(
|
||||
name=vendor_name,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
name=vendor_name
|
||||
)
|
||||
db.add(vendor)
|
||||
db.flush()
|
||||
@ -294,9 +141,7 @@ class InventoryService(BaseService):
|
||||
|
||||
with db_transaction(db):
|
||||
marketplace = Marketplace(
|
||||
name=marketplace_name,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
name=marketplace_name
|
||||
)
|
||||
db.add(marketplace)
|
||||
db.flush()
|
||||
@ -305,110 +150,144 @@ class InventoryService(BaseService):
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class SealedBoxService(BaseService[SealedBox]):
|
||||
class BoxService(BaseService[Box]):
|
||||
def __init__(self):
|
||||
super().__init__(SealedBox)
|
||||
super().__init__(Box)
|
||||
|
||||
async def create_sealed_box(
|
||||
async def create_box(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: int,
|
||||
cost_basis: float,
|
||||
case_id: Optional[int] = None
|
||||
) -> SealedBox:
|
||||
cost_basis: float
|
||||
) -> Box:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the SealedBox
|
||||
sealed_box = SealedBox(
|
||||
product_id=product_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
box = Box(
|
||||
tcgplayer_product_id=product_id
|
||||
)
|
||||
db.add(sealed_box)
|
||||
db.add(box)
|
||||
db.flush() # Get the ID for relationships
|
||||
|
||||
# If this box is part of a case, link it
|
||||
if case_id:
|
||||
case = db.query(SealedCase).filter(SealedCase.id == case_id).first()
|
||||
if not case:
|
||||
raise ValueError(f"Case {case_id} not found")
|
||||
sealed_box.case_id = case_id
|
||||
expected_value = box.products.sealed_expected_value.expected_value
|
||||
box.expected_value = expected_value
|
||||
db.flush()
|
||||
|
||||
# Create the InventoryItem for the sealed box
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=sealed_box,
|
||||
cost_basis=cost_basis,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
physical_item=box,
|
||||
cost_basis=cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
return sealed_box
|
||||
return box
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class SealedCaseService(BaseService[SealedCase]):
|
||||
def __init__(self):
|
||||
super().__init__(SealedCase)
|
||||
async def calculate_cost_basis_for_opened_cards(self, db: Session, open_event: OpenEvent) -> float:
|
||||
box_cost_basis = open_event.source_item.inventory_item.cost_basis
|
||||
box_expected_value = open_event.source_item.products.sealed_expected_value.expected_value
|
||||
for resulting_card in open_event.resulting_items:
|
||||
# ensure card
|
||||
if resulting_card.item_type != "card":
|
||||
raise ValueError(f"Expected card, got {resulting_card.item_type}")
|
||||
resulting_card_market_value = resulting_card.products.most_recent_tcgplayer_price.market_price
|
||||
resulting_card_cost_basis = (resulting_card_market_value / box_expected_value) * box_cost_basis
|
||||
resulting_card.inventory_item.cost_basis = resulting_card_cost_basis
|
||||
db.flush()
|
||||
|
||||
async def open_box(self, db: Session, box: Box, manabox_file_uploads: List[FileInDB]) -> bool:
|
||||
with db_transaction(db):
|
||||
# create open event
|
||||
open_event = OpenEvent(
|
||||
source_item=box,
|
||||
open_date=datetime.now()
|
||||
)
|
||||
db.add(open_event)
|
||||
db.flush()
|
||||
|
||||
async def create_sealed_case(self, db: Session, product_id: int, cost_basis: float, num_boxes: int) -> SealedCase:
|
||||
manabox_upload_ids = [manabox_file_upload.id for manabox_file_upload in manabox_file_uploads]
|
||||
staging_data = db.query(ManaboxImportStaging).filter(ManaboxImportStaging.file_id.in_(manabox_upload_ids)).all()
|
||||
for record in staging_data:
|
||||
for i in range(record.quantity):
|
||||
open_card = Card(
|
||||
tcgplayer_product_id=record.tcgplayer_product_id,
|
||||
tcgplayer_sku_id=record.tcgplayer_sku_id
|
||||
)
|
||||
open_event.resulting_items.append(open_card)
|
||||
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=open_card,
|
||||
cost_basis=0
|
||||
)
|
||||
db.add(inventory_item)
|
||||
db.flush()
|
||||
|
||||
# calculate cost basis for opened cards
|
||||
await self.calculate_cost_basis_for_opened_cards(db, open_event)
|
||||
|
||||
|
||||
|
||||
return open_event
|
||||
|
||||
|
||||
|
||||
class CaseService(BaseService[Case]):
|
||||
def __init__(self):
|
||||
super().__init__(Case)
|
||||
|
||||
async def create_case(self, db: Session, product_id: int, cost_basis: float, num_boxes: int) -> Case:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the SealedCase
|
||||
sealed_case = SealedCase(
|
||||
product_id=product_id,
|
||||
num_boxes=num_boxes,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
case = Case(
|
||||
tcgplayer_product_id=product_id,
|
||||
num_boxes=num_boxes
|
||||
)
|
||||
db.add(sealed_case)
|
||||
db.add(case)
|
||||
db.flush() # Get the ID for relationships
|
||||
case.expected_value = case.products.sealed_expected_value.expected_value
|
||||
|
||||
# Create the InventoryItem for the sealed case
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=sealed_case,
|
||||
cost_basis=cost_basis,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
physical_item=case,
|
||||
cost_basis=cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
return sealed_case
|
||||
return case
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def open_sealed_case(self, db: Session, sealed_case: SealedCase) -> bool:
|
||||
async def open_case(self, db: Session, case: Case, child_product_id: int) -> bool:
|
||||
try:
|
||||
sealed_case_context = InventoryItemContextFactory(db).get_context(sealed_case.inventory_item)
|
||||
## TODO should be able to import a manabox file with a case
|
||||
## cost basis will be able to flow down to the card accurately
|
||||
with db_transaction(db):
|
||||
# Create the OpenEvent
|
||||
open_event = OpenEvent(
|
||||
sealed_case_id=sealed_case_context.physical_item.id,
|
||||
open_date=datetime.now(),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
source_item=case,
|
||||
open_date=datetime.now()
|
||||
)
|
||||
db.add(open_event)
|
||||
db.flush() # Get the ID for relationships
|
||||
|
||||
# Create num_boxes SealedBoxes
|
||||
for i in range(sealed_case.num_boxes):
|
||||
sealed_box = SealedBox(
|
||||
product_id=sealed_case_context.physical_item.product_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
for i in range(case.num_boxes):
|
||||
new_box = Box(
|
||||
tcgplayer_product_id=child_product_id
|
||||
)
|
||||
db.add(sealed_box)
|
||||
db.flush() # Get the ID for relationships
|
||||
open_event.resulting_items.append(new_box)
|
||||
db.flush()
|
||||
|
||||
per_box_cost_basis = case.inventory_item.cost_basis / case.num_boxes
|
||||
|
||||
# Create the InventoryItem for the sealed box
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=sealed_box,
|
||||
cost_basis=sealed_case_context.cost_basis,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
physical_item=new_box,
|
||||
cost_basis=per_box_cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
@ -416,3 +295,37 @@ class SealedCaseService(BaseService[SealedCase]):
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class MarketplaceListingService(BaseService[MarketplaceListing]):
|
||||
def __init__(self):
|
||||
super().__init__(MarketplaceListing)
|
||||
self.pricing_service = self.service_manager.get_service("pricing")
|
||||
|
||||
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)
|
||||
logger.info(f"recommended_price: {recommended_price.price}")
|
||||
marketplace_listing = MarketplaceListing(
|
||||
inventory_item=inventory_item,
|
||||
marketplace=marketplace,
|
||||
recommended_price=recommended_price,
|
||||
listing_date=None,
|
||||
delisting_date=None
|
||||
)
|
||||
db.add(marketplace_listing)
|
||||
db.flush()
|
||||
return marketplace_listing
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def update_marketplace_listing_price(self, db: Session, marketplace_listing: MarketplaceListing) -> MarketplaceListing:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
marketplace_listing.listed_price = self.pricing_service.set_price(marketplace_listing.inventory_item)
|
||||
db.flush()
|
||||
return marketplace_listing
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
@ -45,68 +45,89 @@ class ManaboxService(BaseService):
|
||||
# Read the CSV file
|
||||
with open(file.path, 'r') as csv_file:
|
||||
reader = csv.DictReader(csv_file)
|
||||
# skip header row
|
||||
next(reader)
|
||||
# Pre-fetch all MTGJSONCards for Scryfall IDs in the file
|
||||
scryfall_ids = {row['Scryfall ID'] for row in reader}
|
||||
mtg_json_map = {card.scryfall_id: card for card in db.query(MTGJSONCard).filter(MTGJSONCard.scryfall_id.in_(scryfall_ids)).all()}
|
||||
|
||||
# Re-read the file to process the rows
|
||||
csv_file.seek(0)
|
||||
next(reader) # Skip the header row
|
||||
|
||||
staging_entries = [] # To collect all staging entries for batch insert
|
||||
critical_errors = [] # To collect errors for logging
|
||||
|
||||
for row in reader:
|
||||
# match scryfall id to mtgjson scryfall id, make sure only one distinct tcgplayer id
|
||||
mtg_json = db.query(MTGJSONCard).filter(MTGJSONCard.scryfall_id == row['Scryfall ID']).all()
|
||||
# count distinct tcgplayer ids
|
||||
cd_tcgplayer_ids = db.query(MTGJSONCard.tcgplayer_sku_id).filter(MTGJSONCard.scryfall_id == row['Scryfall ID']).distinct().count()
|
||||
if cd_tcgplayer_ids != 1:
|
||||
logger.error(f"Error: multiple TCGplayer IDs found for scryfall id: {row['Scryfall ID']} found {cd_tcgplayer_ids} ids expected 1")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error: multiple TCGplayer IDs found for scryfall id: {row['Scryfall ID']} found {cd_tcgplayer_ids} ids expected 1"
|
||||
)
|
||||
db.add(critical_error_log)
|
||||
continue
|
||||
else:
|
||||
mtg_json = mtg_json[0]
|
||||
# get tcgplayer sku id from mtgjson skus
|
||||
language = 'ENGLISH' if row['Language'] == 'en' else 'JAPANESE' if row['Language'] == 'ja' else None
|
||||
if row['Foil'].lower() == 'etched':
|
||||
printing = 'FOIL'
|
||||
tcgplayer_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_sku_id == mtg_json.tcgplayer_etched_sku_id).filter(MTGJSONSKU.condition == row['Condition'].replace('_', ' ').upper()).filter(MTGJSONSKU.printing == printing).filter(MTGJSONSKU.language == language).distinct().all()
|
||||
else:
|
||||
printing = 'FOIL' if row['Foil'].lower() == 'foil' else 'NON FOIL'
|
||||
tcgplayer_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_sku_id == mtg_json.tcgplayer_sku_id).filter(MTGJSONSKU.condition == row['Condition'].replace('_', ' ').upper()).filter(MTGJSONSKU.printing == printing).filter(MTGJSONSKU.language == language).distinct().all()
|
||||
# count distinct tcgplayer skus
|
||||
if len(tcgplayer_sku) == 0:
|
||||
logger.error(f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
)
|
||||
db.add(critical_error_log)
|
||||
continue
|
||||
elif len(tcgplayer_sku) > 1:
|
||||
logger.error(f"Error: {len(tcgplayer_sku)} TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error: {len(tcgplayer_sku)} TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
)
|
||||
db.add(critical_error_log)
|
||||
continue
|
||||
else:
|
||||
tcgplayer_sku = tcgplayer_sku[0]
|
||||
# look up tcgplayer product data for sku
|
||||
tcgplayer_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_product_id == tcgplayer_sku.tcgplayer_product_id).filter(TCGPlayerProduct.condition == row['Condition'].replace('_', ' ').upper()).filter(TCGPlayerProduct.language == language).filter(TCGPlayerProduct.printing == printing).first()
|
||||
mtg_json = mtg_json_map.get(row['Scryfall ID'])
|
||||
|
||||
quantity = int(row['Quantity'])
|
||||
if not mtg_json:
|
||||
error_message = f"Error: No MTGJSONCard found for scryfall id: {row['Scryfall ID']}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
language = 'ENGLISH' if row['Language'] == 'en' else 'JAPANESE' if row['Language'] == 'ja' else None # manabox only needs en and jp for now
|
||||
printing = 'foil' if 'foil' in row['Foil'].lower() or 'etched' in row['Foil'].lower() else 'normal'
|
||||
condition = row['Condition'].replace('_', ' ').upper()
|
||||
|
||||
# Query the correct TCGPlayer SKU
|
||||
sku_query = db.query(MTGJSONSKU).filter(
|
||||
MTGJSONSKU.tcgplayer_product_id == (mtg_json.tcgplayer_etched_product_id if row['Foil'].lower() == 'etched' else mtg_json.tcgplayer_product_id)
|
||||
).filter(
|
||||
MTGJSONSKU.condition == condition,
|
||||
MTGJSONSKU.normalized_printing == printing,
|
||||
MTGJSONSKU.language == language
|
||||
).distinct()
|
||||
|
||||
if sku_query.count() != 1:
|
||||
error_message = f"Error: Multiple TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
tcgplayer_sku = sku_query.first()
|
||||
|
||||
if not tcgplayer_sku:
|
||||
error_message = f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
# Query TCGPlayer product data
|
||||
tcgplayer_product = db.query(TCGPlayerProduct).filter(
|
||||
TCGPlayerProduct.tcgplayer_product_id == tcgplayer_sku.tcgplayer_product_id,
|
||||
TCGPlayerProduct.normalized_sub_type_name == tcgplayer_sku.normalized_printing
|
||||
).distinct()
|
||||
|
||||
if tcgplayer_product.count() != 1:
|
||||
error_message = f"Error: Multiple TCGPlayer products found for SKU {tcgplayer_sku.tcgplayer_sku_id}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
tcgplayer_product = tcgplayer_product.first()
|
||||
|
||||
if not tcgplayer_product:
|
||||
error_message = f"Error: No TCGPlayer product found for SKU {tcgplayer_sku.tcgplayer_sku_id}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
# Prepare the staging entry
|
||||
quantity = int(row['Quantity'])
|
||||
staging_entries.append(ManaboxImportStaging(
|
||||
file_id=file.id,
|
||||
tcgplayer_product_id=tcgplayer_product.tcgplayer_product_id,
|
||||
tcgplayer_sku_id=tcgplayer_sku.tcgplayer_sku_id,
|
||||
quantity=quantity
|
||||
))
|
||||
|
||||
# Bulk insert all valid ManaboxImportStaging entries
|
||||
if staging_entries:
|
||||
db.bulk_save_objects(staging_entries)
|
||||
|
||||
# Log any critical errors that occurred
|
||||
for error_message in critical_errors:
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(error_message=error_message)
|
||||
db.add(critical_error_log)
|
||||
|
||||
with transaction(db):
|
||||
manabox_import_staging = ManaboxImportStaging(
|
||||
file_id=file.id,
|
||||
product_id=tcgplayer_product.id,
|
||||
quantity=quantity,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(manabox_import_staging)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing file: {str(e)}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error processing file: {str(e)}"
|
||||
)
|
||||
critical_error_log = CriticalErrorLog(error_message=f"Error processing file: {str(e)}")
|
||||
db.add(critical_error_log)
|
155
app/services/pricing_service.py
Normal file
155
app/services/pricing_service.py
Normal file
@ -0,0 +1,155 @@
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.inventory_management import InventoryItem
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
||||
from app.models.pricing import PricingEvent
|
||||
from app.db.database import transaction
|
||||
from decimal import Decimal
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PricingService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
|
||||
async def set_price(self, db: Session, inventory_item: InventoryItem) -> float:
|
||||
"""
|
||||
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
|
||||
|
||||
logger.info(f"listed_price: {listed_price}")
|
||||
logger.info(f"market_price: {market_price}")
|
||||
logger.info(f"tcg_low: {tcg_low}")
|
||||
logger.info(f"tcg_mid: {tcg_mid}")
|
||||
logger.info(f"cost_basis: {cost_basis}")
|
||||
|
||||
# TODO: Add logic to fetch lowest price for seller with same quantity in stock
|
||||
# NOT IMPLEMENTED YET
|
||||
lowest_price_for_quantity = Decimal('0.0')
|
||||
|
||||
# Hardcoded configuration values (should be parameterized later)
|
||||
shipping_cost = Decimal('1.0')
|
||||
tcgplayer_shipping_fee = Decimal('1.31')
|
||||
average_cards_per_order = Decimal('3.0')
|
||||
marketplace_fee_percentage = 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')
|
||||
# TODO add age of inventory price decrease multiplier
|
||||
age_of_inventory_multiplier = Decimal('0.0')
|
||||
|
||||
# card cost margin multiplier
|
||||
if market_price > 0 and market_price < 2:
|
||||
card_cost_margin_multiplier = Decimal('-0.075')
|
||||
elif market_price >= 2 and market_price < 10:
|
||||
card_cost_margin_multiplier = Decimal('-0.025')
|
||||
elif market_price >= 10 and market_price < 30:
|
||||
card_cost_margin_multiplier = Decimal('0.025')
|
||||
elif market_price >= 30 and market_price < 50:
|
||||
card_cost_margin_multiplier = Decimal('0.05')
|
||||
elif market_price >= 50 and market_price < 100:
|
||||
card_cost_margin_multiplier = Decimal('0.075')
|
||||
elif market_price >= 100 and market_price < 200:
|
||||
card_cost_margin_multiplier = Decimal('0.10')
|
||||
|
||||
# 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
|
||||
|
||||
# Determine quantity multiplier based on stock levels
|
||||
if quantity_in_stock < 4:
|
||||
quantity_multiplier = Decimal('0.0')
|
||||
elif quantity_in_stock == 4:
|
||||
quantity_multiplier = Decimal('0.1')
|
||||
elif 5 <= quantity_in_stock < 10:
|
||||
quantity_multiplier = Decimal('0.2')
|
||||
elif quantity_in_stock >= 10:
|
||||
quantity_multiplier = Decimal('0.3')
|
||||
else:
|
||||
quantity_multiplier = Decimal('0.0')
|
||||
|
||||
# Calculate adjusted target margin from base and global multipliers
|
||||
adjusted_target_margin = target_margin + global_margin_multiplier + card_cost_margin_multiplier
|
||||
|
||||
# limit shipping cost offset to 10% of market price
|
||||
shipping_cost_offset = min(shipping_cost / average_cards_per_order, market_price * Decimal('0.1'))
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Adjust base price by quantity and velocity multipliers, limit markup to amount of shipping fee
|
||||
adjusted_price = min(
|
||||
base_price * (Decimal('1.0') + quantity_multiplier + velocity_multiplier - age_of_inventory_multiplier),
|
||||
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')):
|
||||
adjusted_price = tcg_mid
|
||||
price_used = "tcg mid"
|
||||
price_reason = f"adjusted price below 80% of market price and tcg mid"
|
||||
else:
|
||||
price_used = "adjusted price"
|
||||
price_reason = "valid price assigned based on margin targets"
|
||||
|
||||
# TODO: Add logic to adjust price to beat competitor price with same quantity
|
||||
# NOT IMPLEMENTED YET
|
||||
if adjusted_price < lowest_price_for_quantity:
|
||||
adjusted_price = lowest_price_for_quantity - Decimal('0.01')
|
||||
price_used = "lowest price for quantity"
|
||||
price_reason = "adjusted price below lowest price for quantity"
|
||||
|
||||
# Fine-tune price to optimize for free shipping promotions
|
||||
free_shipping_adjustment = False
|
||||
for x in range(1, 5):
|
||||
quantity = Decimal(str(x))
|
||||
if Decimal('5.00') <= adjusted_price * quantity <= Decimal('5.05'):
|
||||
adjusted_price = Decimal('4.99') / quantity
|
||||
free_shipping_adjustment = True
|
||||
break
|
||||
|
||||
# prevent price drop over price drop threshold
|
||||
if listed_price and adjusted_price < (listed_price * (1 - price_drop_threshold)):
|
||||
adjusted_price = listed_price
|
||||
price_used = "listed price"
|
||||
price_reason = "adjusted price below price drop threshold"
|
||||
|
||||
# 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)
|
||||
|
||||
return pricing_event
|
||||
|
||||
def set_price_for_unmanaged_inventory(self, db: Session, tcgplayer_sku_id: int, quantity: int) -> float:
|
||||
pass
|
@ -3,7 +3,7 @@ from app.services.scheduler.base_scheduler import BaseScheduler
|
||||
from app.services.base_service import BaseService
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
|
||||
from app.models.tcgplayer_inventory import UnmanagedTCGPlayerInventory, TCGPlayerInventory
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerService(BaseService):
|
||||
@ -11,15 +11,6 @@ class SchedulerService(BaseService):
|
||||
# Initialize BaseService with None as model since this service doesn't have a specific model
|
||||
super().__init__(None)
|
||||
self.scheduler = BaseScheduler()
|
||||
|
||||
async def update_tcgplayer_price_history_daily(self, db):
|
||||
"""
|
||||
Update the TCGPlayer price history table
|
||||
"""
|
||||
with transaction(db):
|
||||
await db.execute(text("""REFRESH MATERIALIZED VIEW CONCURRENTLY most_recent_tcgplayer_price;"""))
|
||||
logger.info("TCGPlayer price history refreshed")
|
||||
|
||||
|
||||
async def update_open_orders_hourly(self, db):
|
||||
"""
|
||||
@ -64,6 +55,19 @@ class SchedulerService(BaseService):
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating all orders: {str(e)}")
|
||||
raise
|
||||
|
||||
async def refresh_tcgplayer_inventory_table(self, db):
|
||||
"""
|
||||
Refresh the TCGPlayer inventory table
|
||||
"""
|
||||
tcgplayer_inventory_service = self.service_manager.get_service('tcgplayer_inventory')
|
||||
with transaction(db):
|
||||
db.query(UnmanagedTCGPlayerInventory).delete()
|
||||
db.query(TCGPlayerInventory).delete()
|
||||
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"""
|
||||
@ -79,11 +83,11 @@ class SchedulerService(BaseService):
|
||||
func=lambda: self.update_all_orders_daily(db),
|
||||
cron_expression="0 3 * * *" # Run at 3:00 AM every day
|
||||
)
|
||||
# Schedule TCGPlayer price history update to run daily at 1 AM
|
||||
# Schedule TCGPlayer inventory refresh to run every 3 hours
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_tcgplayer_price_history_daily",
|
||||
func=lambda: self.update_tcgplayer_price_history_daily(db),
|
||||
cron_expression="0 1 * * *" # Run at 1:00 AM every day
|
||||
task_name="refresh_tcgplayer_inventory_table",
|
||||
func=lambda: self.refresh_tcgplayer_inventory_table(db),
|
||||
cron_expression="21 */3 * * *" # Run at minute 0 of every 3rd hour
|
||||
)
|
||||
|
||||
self.scheduler.start()
|
||||
|
@ -30,9 +30,11 @@ class ServiceManager:
|
||||
'tcgcsv': 'app.services.external_api.tcgcsv.tcgcsv_service.TCGCSVService',
|
||||
'mtgjson': 'app.services.external_api.mtgjson.mtgjson_service.MTGJSONService',
|
||||
'manabox': 'app.services.manabox_service.ManaboxService',
|
||||
'pricing': 'app.services.pricing_service.PricingService',
|
||||
'inventory': 'app.services.inventory_service.InventoryService',
|
||||
'sealed_box': 'app.services.inventory_service.SealedBoxService',
|
||||
'sealed_case': 'app.services.inventory_service.SealedCaseService'
|
||||
'box': 'app.services.inventory_service.BoxService',
|
||||
'case': 'app.services.inventory_service.CaseService',
|
||||
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService'
|
||||
|
||||
}
|
||||
self._service_configs = {
|
||||
|
Reference in New Issue
Block a user