PRICING
This commit is contained in:
parent
56ba750aad
commit
c9bba8a26e
@ -28,13 +28,6 @@ target_metadata = Base.metadata
|
|||||||
# ... etc.
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
def include_object(object, name, type_, reflected, compare_to):
|
|
||||||
# Skip materialized views during migrations
|
|
||||||
if type_ == "table" and name == "most_recent_tcgplayer_price":
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
"""Run migrations in 'offline' mode.
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
@ -53,7 +46,6 @@ def run_migrations_offline() -> None:
|
|||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
include_object=include_object,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
@ -77,7 +69,6 @@ def run_migrations_online() -> None:
|
|||||||
context.configure(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
include_object=include_object,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
|
36
alembic/versions/0f534237fc90_i_literally_hate_sql.py
Normal file
36
alembic/versions/0f534237fc90_i_literally_hate_sql.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""i literally hate sql
|
||||||
|
|
||||||
|
Revision ID: 0f534237fc90
|
||||||
|
Revises: cf61f006db46
|
||||||
|
Create Date: 2025-04-25 16:59:07.177958
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0f534237fc90'
|
||||||
|
down_revision: Union[str, None] = 'cf61f006db46'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint('uq_sku_mtgjson_uuid', 'mtgjson_skus', type_='unique')
|
||||||
|
op.drop_index('ix_mtgjson_skus_mtgjson_uuid', table_name='mtgjson_skus')
|
||||||
|
op.create_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), 'mtgjson_skus', ['mtgjson_uuid'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), table_name='mtgjson_skus')
|
||||||
|
op.create_index('ix_mtgjson_skus_mtgjson_uuid', 'mtgjson_skus', ['mtgjson_uuid'], unique=True)
|
||||||
|
op.create_unique_constraint('uq_sku_mtgjson_uuid', 'mtgjson_skus', ['mtgjson_uuid'])
|
||||||
|
# ### end Alembic commands ###
|
40
alembic/versions/236605bcac6e_asdf.py
Normal file
40
alembic/versions/236605bcac6e_asdf.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""asdf
|
||||||
|
|
||||||
|
Revision ID: 236605bcac6e
|
||||||
|
Revises: d13600612a8f
|
||||||
|
Create Date: 2025-04-28 21:44:28.030202
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '236605bcac6e'
|
||||||
|
down_revision: Union[str, None] = 'd13600612a8f'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('marketplace_listings', sa.Column('recommended_price_id', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('marketplace_listings', sa.Column('listed_price_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(None, 'marketplace_listings', 'pricing_events', ['recommended_price_id'], ['id'])
|
||||||
|
op.create_foreign_key(None, 'marketplace_listings', 'pricing_events', ['listed_price_id'], ['id'])
|
||||||
|
op.drop_column('marketplace_listings', 'listed_price')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('marketplace_listings', sa.Column('listed_price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
|
||||||
|
op.drop_constraint(None, 'marketplace_listings', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'marketplace_listings', type_='foreignkey')
|
||||||
|
op.drop_column('marketplace_listings', 'listed_price_id')
|
||||||
|
op.drop_column('marketplace_listings', 'recommended_price_id')
|
||||||
|
# ### end Alembic commands ###
|
32
alembic/versions/62eee00bae8e_b.py
Normal file
32
alembic/versions/62eee00bae8e_b.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""b
|
||||||
|
|
||||||
|
Revision ID: 62eee00bae8e
|
||||||
|
Revises: 0f534237fc90
|
||||||
|
Create Date: 2025-04-28 11:01:28.564264
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '62eee00bae8e'
|
||||||
|
down_revision: Union[str, None] = '0f534237fc90'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index('idx_product_subtype', 'tcgplayer_products', ['tcgplayer_product_id', 'normalized_sub_type_name'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('idx_product_subtype', table_name='tcgplayer_products')
|
||||||
|
# ### end Alembic commands ###
|
@ -1,8 +1,8 @@
|
|||||||
"""why god
|
"""alembic is actually behaving so this message will be nice :)
|
||||||
|
|
||||||
Revision ID: d0792389ab20
|
Revision ID: cf61f006db46
|
||||||
Revises:
|
Revises:
|
||||||
Create Date: 2025-04-24 22:36:02.509040
|
Create Date: 2025-04-25 14:34:28.206737
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
@ -12,7 +12,7 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'd0792389ab20'
|
revision: str = 'cf61f006db46'
|
||||||
down_revision: Union[str, None] = None
|
down_revision: Union[str, None] = None
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
@ -63,6 +63,23 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
op.create_index(op.f('ix_marketplaces_id'), 'marketplaces', ['id'], unique=False)
|
op.create_index(op.f('ix_marketplaces_id'), 'marketplaces', ['id'], unique=False)
|
||||||
op.create_index(op.f('ix_marketplaces_name'), 'marketplaces', ['name'], unique=False)
|
op.create_index(op.f('ix_marketplaces_name'), 'marketplaces', ['name'], unique=False)
|
||||||
|
op.create_table('most_recent_tcgplayer_price',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('high_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('market_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_most_recent_price_product_subtype', 'most_recent_tcgplayer_price', ['product_id', 'sub_type_name'], unique=True)
|
||||||
|
op.create_index(op.f('ix_most_recent_tcgplayer_price_product_id'), 'most_recent_tcgplayer_price', ['product_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_most_recent_tcgplayer_price_sub_type_name'), 'most_recent_tcgplayer_price', ['sub_type_name'], unique=False)
|
||||||
op.create_table('mtgjson_cards',
|
op.create_table('mtgjson_cards',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('mtgjson_uuid', sa.String(), nullable=True),
|
sa.Column('mtgjson_uuid', sa.String(), nullable=True),
|
||||||
@ -91,12 +108,14 @@ def upgrade() -> None:
|
|||||||
sa.Column('scryfall_card_back_id', sa.String(), nullable=True),
|
sa.Column('scryfall_card_back_id', sa.String(), nullable=True),
|
||||||
sa.Column('scryfall_oracle_id', sa.String(), nullable=True),
|
sa.Column('scryfall_oracle_id', sa.String(), nullable=True),
|
||||||
sa.Column('scryfall_illustration_id', sa.String(), nullable=True),
|
sa.Column('scryfall_illustration_id', sa.String(), nullable=True),
|
||||||
sa.Column('tcgplayer_product_id', sa.String(), nullable=True),
|
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('tcgplayer_etched_product_id', sa.String(), nullable=True),
|
sa.Column('tcgplayer_etched_product_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('tnt_id', sa.String(), nullable=True),
|
sa.Column('tnt_id', sa.String(), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.ForeignKeyConstraint(['mtgjson_uuid'], ['mtgjson_cards.mtgjson_uuid'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('mtgjson_uuid', name='uq_card_mtgjson_uuid')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_mtgjson_cards_id'), 'mtgjson_cards', ['id'], unique=False)
|
op.create_index(op.f('ix_mtgjson_cards_id'), 'mtgjson_cards', ['id'], unique=False)
|
||||||
op.create_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), 'mtgjson_cards', ['mtgjson_uuid'], unique=True)
|
op.create_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), 'mtgjson_cards', ['mtgjson_uuid'], unique=True)
|
||||||
@ -109,6 +128,7 @@ def upgrade() -> None:
|
|||||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
|
op.create_index('idx_sealed_expected_value_product_id_deleted_at', 'sealed_expected_values', ['tcgplayer_product_id', 'deleted_at'], unique=True)
|
||||||
op.create_index(op.f('ix_sealed_expected_values_id'), 'sealed_expected_values', ['id'], unique=False)
|
op.create_index(op.f('ix_sealed_expected_values_id'), 'sealed_expected_values', ['id'], unique=False)
|
||||||
op.create_table('tcgplayer_categories',
|
op.create_table('tcgplayer_categories',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
@ -238,6 +258,7 @@ def upgrade() -> None:
|
|||||||
op.create_table('manabox_import_staging',
|
op.create_table('manabox_import_staging',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('file_id', sa.Integer(), nullable=True),
|
sa.Column('file_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('tcgplayer_sku_id', sa.Integer(), nullable=True),
|
sa.Column('tcgplayer_sku_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('quantity', sa.Integer(), nullable=True),
|
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
@ -325,10 +346,12 @@ def upgrade() -> None:
|
|||||||
sa.Column('printing', sa.String(), nullable=True),
|
sa.Column('printing', sa.String(), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['mtgjson_uuid'], ['mtgjson_cards.mtgjson_uuid'], ),
|
||||||
sa.ForeignKeyConstraint(['tcgplayer_product_id', 'normalized_printing'], ['tcgplayer_products.tcgplayer_product_id', 'tcgplayer_products.normalized_sub_type_name'], name='fk_sku_to_product_composite'),
|
sa.ForeignKeyConstraint(['tcgplayer_product_id', 'normalized_printing'], ['tcgplayer_products.tcgplayer_product_id', 'tcgplayer_products.normalized_sub_type_name'], name='fk_sku_to_product_composite'),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('mtgjson_uuid', name='uq_sku_mtgjson_uuid')
|
||||||
)
|
)
|
||||||
op.create_index('idx_sku_product_printing', 'mtgjson_skus', ['tcgplayer_product_id', 'normalized_printing'], unique=True)
|
op.create_index('idx_sku_product_printing', 'mtgjson_skus', ['tcgplayer_product_id', 'normalized_printing'], unique=False)
|
||||||
op.create_index(op.f('ix_mtgjson_skus_id'), 'mtgjson_skus', ['id'], unique=False)
|
op.create_index(op.f('ix_mtgjson_skus_id'), 'mtgjson_skus', ['id'], unique=False)
|
||||||
op.create_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), 'mtgjson_skus', ['mtgjson_uuid'], unique=True)
|
op.create_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), 'mtgjson_skus', ['mtgjson_uuid'], unique=True)
|
||||||
op.create_index(op.f('ix_mtgjson_skus_tcgplayer_sku_id'), 'mtgjson_skus', ['tcgplayer_sku_id'], unique=True)
|
op.create_index(op.f('ix_mtgjson_skus_tcgplayer_sku_id'), 'mtgjson_skus', ['tcgplayer_sku_id'], unique=True)
|
||||||
@ -369,6 +392,24 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
op.create_index(op.f('ix_tcgplayer_inventory_id'), 'tcgplayer_inventory', ['id'], unique=False)
|
op.create_index(op.f('ix_tcgplayer_inventory_id'), 'tcgplayer_inventory', ['id'], unique=False)
|
||||||
op.create_index(op.f('ix_tcgplayer_inventory_tcgplayer_sku_id'), 'tcgplayer_inventory', ['tcgplayer_sku_id'], unique=True)
|
op.create_index(op.f('ix_tcgplayer_inventory_tcgplayer_sku_id'), 'tcgplayer_inventory', ['tcgplayer_sku_id'], unique=True)
|
||||||
|
op.create_table('boxes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('expected_value', sa.Float(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('cards',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('cases',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('expected_value', sa.Float(), nullable=True),
|
||||||
|
sa.Column('num_boxes', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
op.create_table('inventory_items',
|
op.create_table('inventory_items',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('physical_item_id', sa.Integer(), nullable=True),
|
sa.Column('physical_item_id', sa.Integer(), nullable=True),
|
||||||
@ -383,26 +424,18 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint('physical_item_id')
|
sa.UniqueConstraint('physical_item_id')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False)
|
op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False)
|
||||||
op.create_table('sealed_cases',
|
op.create_table('open_events',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('expected_value', sa.Float(), nullable=True),
|
sa.Column('source_item_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('num_boxes', sa.Integer(), nullable=True),
|
sa.Column('open_date', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('transaction_items',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transaction_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('physical_item_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('unit_price', sa.Float(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.ForeignKeyConstraint(['physical_item_id'], ['physical_items.id'], ),
|
sa.ForeignKeyConstraint(['source_item_id'], ['physical_items.id'], ),
|
||||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.UniqueConstraint('source_item_id', name='uq_openevent_one_per_source')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_transaction_items_id'), 'transaction_items', ['id'], unique=False)
|
op.create_index(op.f('ix_open_events_id'), 'open_events', ['id'], unique=False)
|
||||||
op.create_table('marketplace_listings',
|
op.create_table('marketplace_listings',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('inventory_item_id', sa.Integer(), nullable=False),
|
sa.Column('inventory_item_id', sa.Integer(), nullable=False),
|
||||||
@ -418,63 +451,44 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_marketplace_listings_id'), 'marketplace_listings', ['id'], unique=False)
|
op.create_index(op.f('ix_marketplace_listings_id'), 'marketplace_listings', ['id'], unique=False)
|
||||||
op.create_table('sealed_boxes',
|
op.create_table('open_event_resulting_items',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||||
sa.Column('case_id', sa.Integer(), nullable=True),
|
sa.Column('item_id', sa.Integer(), nullable=False),
|
||||||
sa.Column('expected_value', sa.Float(), nullable=True),
|
sa.ForeignKeyConstraint(['event_id'], ['open_events.id'], ),
|
||||||
sa.ForeignKeyConstraint(['case_id'], ['sealed_cases.id'], ),
|
sa.ForeignKeyConstraint(['item_id'], ['physical_items.id'], ),
|
||||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
sa.PrimaryKeyConstraint('event_id', 'item_id')
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
)
|
||||||
op.create_table('open_events',
|
op.create_table('transaction_items',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('sealed_case_id', sa.Integer(), nullable=True),
|
sa.Column('transaction_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('sealed_box_id', sa.Integer(), nullable=True),
|
sa.Column('inventory_item_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('open_date', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('unit_price', sa.Float(), nullable=False),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.ForeignKeyConstraint(['sealed_box_id'], ['sealed_boxes.id'], ),
|
sa.ForeignKeyConstraint(['inventory_item_id'], ['inventory_items.id'], ),
|
||||||
sa.ForeignKeyConstraint(['sealed_case_id'], ['sealed_cases.id'], ),
|
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_open_events_id'), 'open_events', ['id'], unique=False)
|
|
||||||
op.create_table('open_boxes',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('open_event_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('sealed_box_id', sa.Integer(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['open_event_id'], ['open_events.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['sealed_box_id'], ['sealed_boxes.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('open_cards',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('open_event_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('box_id', sa.Integer(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['box_id'], ['open_boxes.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['open_event_id'], ['open_events.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
|
op.create_index(op.f('ix_transaction_items_id'), 'transaction_items', ['id'], unique=False)
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_table('open_cards')
|
|
||||||
op.drop_table('open_boxes')
|
|
||||||
op.drop_index(op.f('ix_open_events_id'), table_name='open_events')
|
|
||||||
op.drop_table('open_events')
|
|
||||||
op.drop_table('sealed_boxes')
|
|
||||||
op.drop_index(op.f('ix_marketplace_listings_id'), table_name='marketplace_listings')
|
|
||||||
op.drop_table('marketplace_listings')
|
|
||||||
op.drop_index(op.f('ix_transaction_items_id'), table_name='transaction_items')
|
op.drop_index(op.f('ix_transaction_items_id'), table_name='transaction_items')
|
||||||
op.drop_table('transaction_items')
|
op.drop_table('transaction_items')
|
||||||
op.drop_table('sealed_cases')
|
op.drop_table('open_event_resulting_items')
|
||||||
|
op.drop_index(op.f('ix_marketplace_listings_id'), table_name='marketplace_listings')
|
||||||
|
op.drop_table('marketplace_listings')
|
||||||
|
op.drop_index(op.f('ix_open_events_id'), table_name='open_events')
|
||||||
|
op.drop_table('open_events')
|
||||||
op.drop_index(op.f('ix_inventory_items_id'), table_name='inventory_items')
|
op.drop_index(op.f('ix_inventory_items_id'), table_name='inventory_items')
|
||||||
op.drop_table('inventory_items')
|
op.drop_table('inventory_items')
|
||||||
|
op.drop_table('cases')
|
||||||
|
op.drop_table('cards')
|
||||||
|
op.drop_table('boxes')
|
||||||
op.drop_index(op.f('ix_tcgplayer_inventory_tcgplayer_sku_id'), table_name='tcgplayer_inventory')
|
op.drop_index(op.f('ix_tcgplayer_inventory_tcgplayer_sku_id'), table_name='tcgplayer_inventory')
|
||||||
op.drop_index(op.f('ix_tcgplayer_inventory_id'), table_name='tcgplayer_inventory')
|
op.drop_index(op.f('ix_tcgplayer_inventory_id'), table_name='tcgplayer_inventory')
|
||||||
op.drop_table('tcgplayer_inventory')
|
op.drop_table('tcgplayer_inventory')
|
||||||
@ -517,10 +531,15 @@ def downgrade() -> None:
|
|||||||
op.drop_index(op.f('ix_tcgplayer_categories_category_id'), table_name='tcgplayer_categories')
|
op.drop_index(op.f('ix_tcgplayer_categories_category_id'), table_name='tcgplayer_categories')
|
||||||
op.drop_table('tcgplayer_categories')
|
op.drop_table('tcgplayer_categories')
|
||||||
op.drop_index(op.f('ix_sealed_expected_values_id'), table_name='sealed_expected_values')
|
op.drop_index(op.f('ix_sealed_expected_values_id'), table_name='sealed_expected_values')
|
||||||
|
op.drop_index('idx_sealed_expected_value_product_id_deleted_at', table_name='sealed_expected_values')
|
||||||
op.drop_table('sealed_expected_values')
|
op.drop_table('sealed_expected_values')
|
||||||
op.drop_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), table_name='mtgjson_cards')
|
op.drop_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), table_name='mtgjson_cards')
|
||||||
op.drop_index(op.f('ix_mtgjson_cards_id'), table_name='mtgjson_cards')
|
op.drop_index(op.f('ix_mtgjson_cards_id'), table_name='mtgjson_cards')
|
||||||
op.drop_table('mtgjson_cards')
|
op.drop_table('mtgjson_cards')
|
||||||
|
op.drop_index(op.f('ix_most_recent_tcgplayer_price_sub_type_name'), table_name='most_recent_tcgplayer_price')
|
||||||
|
op.drop_index(op.f('ix_most_recent_tcgplayer_price_product_id'), table_name='most_recent_tcgplayer_price')
|
||||||
|
op.drop_index('idx_most_recent_price_product_subtype', table_name='most_recent_tcgplayer_price')
|
||||||
|
op.drop_table('most_recent_tcgplayer_price')
|
||||||
op.drop_index(op.f('ix_marketplaces_name'), table_name='marketplaces')
|
op.drop_index(op.f('ix_marketplaces_name'), table_name='marketplaces')
|
||||||
op.drop_index(op.f('ix_marketplaces_id'), table_name='marketplaces')
|
op.drop_index(op.f('ix_marketplaces_id'), table_name='marketplaces')
|
||||||
op.drop_table('marketplaces')
|
op.drop_table('marketplaces')
|
@ -0,0 +1,34 @@
|
|||||||
|
"""there is literally no point to ever using foreign keys
|
||||||
|
|
||||||
|
Revision ID: d13600612a8f
|
||||||
|
Revises: 62eee00bae8e
|
||||||
|
Create Date: 2025-04-28 11:37:11.023788
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd13600612a8f'
|
||||||
|
down_revision: Union[str, None] = '62eee00bae8e'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint('tcgplayer_inventory_tcgplayer_sku_id_fkey', 'tcgplayer_inventory', type_='foreignkey')
|
||||||
|
op.drop_constraint('unmanaged_tcgplayer_inventory_tcgplayer_sku_id_fkey', 'unmanaged_tcgplayer_inventory', type_='foreignkey')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_foreign_key('unmanaged_tcgplayer_inventory_tcgplayer_sku_id_fkey', 'unmanaged_tcgplayer_inventory', 'mtgjson_skus', ['tcgplayer_sku_id'], ['tcgplayer_sku_id'])
|
||||||
|
op.create_foreign_key('tcgplayer_inventory_tcgplayer_sku_id_fkey', 'tcgplayer_inventory', 'mtgjson_skus', ['tcgplayer_sku_id'], ['tcgplayer_sku_id'])
|
||||||
|
# ### end Alembic commands ###
|
@ -8,10 +8,10 @@ class InventoryItemContext:
|
|||||||
def __init__(self, item: InventoryItem, db: Session):
|
def __init__(self, item: InventoryItem, db: Session):
|
||||||
self.item = item
|
self.item = item
|
||||||
self.physical_item = item.physical_item
|
self.physical_item = item.physical_item
|
||||||
self.marketplace_listings = item.marketplace_listings
|
self.marketplace_listing = item.marketplace_listing
|
||||||
self.parent = item.parent
|
self.parent = item.parent
|
||||||
self.children = item.children
|
self.children = item.children
|
||||||
self.product = item.product
|
self.product = item.products
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -44,9 +44,9 @@ class InventoryItemContext:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def listed_price(self) -> float:
|
def listed_price(self) -> float:
|
||||||
if not self.marketplace_listings:
|
if not self.marketplace_listing:
|
||||||
return 0.0
|
return 0.0
|
||||||
return self.marketplace_listings[0].listed_price if self.marketplace_listings else 0.0
|
return self.marketplace_listing[0].listed_price if self.marketplace_listing else 0.0
|
||||||
|
|
||||||
def top_level_parent(self) -> "InventoryItemContext":
|
def top_level_parent(self) -> "InventoryItemContext":
|
||||||
if self.parent:
|
if self.parent:
|
||||||
@ -71,9 +71,9 @@ class InventoryItemContext:
|
|||||||
raise ValueError("Cannot find transaction unit price for this item")
|
raise ValueError("Cannot find transaction unit price for this item")
|
||||||
|
|
||||||
def age_on_marketplace(self) -> int:
|
def age_on_marketplace(self) -> int:
|
||||||
if not self.marketplace_listings:
|
if not self.marketplace_listing:
|
||||||
return 0
|
return 0
|
||||||
return (datetime.now() - self.marketplace_listings[0].listing_date).days
|
return (datetime.now() - self.marketplace_listing[0].listing_date).days
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemContextFactory:
|
class InventoryItemContextFactory:
|
||||||
|
88
app/data/test_data/tdmtest.csv
Normal file
88
app/data/test_data/tdmtest.csv
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase price,Misprint,Altered,Condition,Language,Purchase price currency
|
||||||
|
Sunpearl Kirin,TDM,Tarkir: Dragonstorm,29,foil,uncommon,1,104470,18292b9c-0f42-4ce2-8b85-35d06cf45a63,1.24,false,false,near_mint,en,USD
|
||||||
|
Humbling Elder,TDM,Tarkir: Dragonstorm,48,foil,common,1,104562,3a84c3f8-0030-4653-880e-b2d19272f5fa,0.06,false,false,near_mint,en,USD
|
||||||
|
Fortress Kin-Guard,TDM,Tarkir: Dragonstorm,12,foil,common,1,104900,b647a018-1d70-43a1-a265-928bcd863689,0.04,false,false,near_mint,en,USD
|
||||||
|
Wild Ride,TDM,Tarkir: Dragonstorm,132,foil,common,1,105154,abc8c6f5-6135-428e-8476-1751f82623f9,0.14,false,false,near_mint,en,USD
|
||||||
|
Jade-Cast Sentinel,TDM,Tarkir: Dragonstorm,243,foil,common,1,104293,516ce5fa-bd00-429b-ba22-b38c7dd9306c,0.07,false,false,near_mint,en,USD
|
||||||
|
Sibsig Appraiser,TDM,Tarkir: Dragonstorm,56,foil,common,1,105135,670c5b96-bac6-449b-a2bd-cb43750d3911,0.04,false,false,near_mint,en,USD
|
||||||
|
Channeled Dragonfire,TDM,Tarkir: Dragonstorm,102,foil,uncommon,1,104499,24204881-690c-4043-8771-20cb93385072,0.07,false,false,near_mint,en,USD
|
||||||
|
Meticulous Artisan,TDM,Tarkir: Dragonstorm,112,foil,common,1,104912,baf4c9dd-0546-41ac-a7ba-0bc312fef31e,0.03,false,false,near_mint,en,USD
|
||||||
|
Kishla Trawlers,TDM,Tarkir: Dragonstorm,50,foil,uncommon,1,104472,190fbc55-e8e9-4077-9532-1de7406baabf,0.08,false,false,near_mint,en,USD
|
||||||
|
Rite of Renewal,TDM,Tarkir: Dragonstorm,153,foil,uncommon,1,104390,f737698a-d934-4851-b238-828959ef4835,0.07,false,false,near_mint,en,USD
|
||||||
|
Twin Bolt,TDM,Tarkir: Dragonstorm,128,foil,common,1,105137,688d8e93-d071-4089-9ef9-565ac4ae9ae0,0.04,false,false,near_mint,en,USD
|
||||||
|
Heritage Reclamation,TDM,Tarkir: Dragonstorm,145,foil,common,1,104636,4f8fee37-a050-4329-8b10-46d150e7a95e,0.33,false,false,near_mint,en,USD
|
||||||
|
Sarkhan's Resolve,TDM,Tarkir: Dragonstorm,158,foil,common,1,104952,cae56fef-b661-4bc5-b9a1-3871ae06e491,0.04,false,false,near_mint,en,USD
|
||||||
|
Delta Bloodflies,TDM,Tarkir: Dragonstorm,77,foil,common,1,104457,119bb72d-aed9-47dc-9285-7bc836cc3776,0.05,false,false,near_mint,en,USD
|
||||||
|
Dirgur Island Dragon // Skimming Strike,TDM,Tarkir: Dragonstorm,40,foil,common,1,104342,b1d21a9a-6b0c-4fbc-a427-81be885d326b,0.16,false,false,near_mint,en,USD
|
||||||
|
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,199,foil,uncommon,1,104798,8d11183a-57f5-4ddb-8a6e-15fff704b114,0.37,false,false,near_mint,en,USD
|
||||||
|
Rainveil Rejuvenator,TDM,Tarkir: Dragonstorm,152,foil,uncommon,1,105148,9bc5c316-6a41-48ba-864b-da3030dd3e0e,0.12,false,false,near_mint,en,USD
|
||||||
|
Rebellious Strike,TDM,Tarkir: Dragonstorm,20,foil,common,1,104949,c9bafe19-3bd6-4da0-b3e5-e0b89262504c,0.06,false,false,near_mint,en,USD
|
||||||
|
Jeskai Brushmaster,TDM,Tarkir: Dragonstorm,195,foil,uncommon,1,104526,2eb06c36-cf7e-47a9-819e-adfc54284153,0.09,false,false,near_mint,en,USD
|
||||||
|
Highspire Bell-Ringer,TDM,Tarkir: Dragonstorm,47,foil,common,1,105020,e75dccf7-2894-4c4a-b516-3eee73acddd3,0.06,false,false,near_mint,en,USD
|
||||||
|
Sagu Pummeler,TDM,Tarkir: Dragonstorm,156,foil,common,1,105169,def9cb5b-4062-481e-b682-3a30443c2e56,0.03,false,false,near_mint,en,USD
|
||||||
|
Roamer's Routine,TDM,Tarkir: Dragonstorm,154,foil,common,1,104396,fb8c2d5c-ba0c-4d50-8898-5c6574b1e974,0.13,false,false,near_mint,en,USD
|
||||||
|
Monastery Messenger,TDM,Tarkir: Dragonstorm,208,foil,common,1,104443,0c9eeced-6464-41f0-bbea-05b3af4cc005,0.04,false,false,near_mint,en,USD
|
||||||
|
Equilibrium Adept,TDM,Tarkir: Dragonstorm,106,foil,uncommon,1,104335,a4ba6d74-c6be-4a5e-8859-b791bb6b8f51,0.07,false,false,near_mint,en,USD
|
||||||
|
Dispelling Exhale,TDM,Tarkir: Dragonstorm,41,foil,common,1,104477,1c9af3f1-711e-42ae-803a-1100eba3fb13,0.21,false,false,near_mint,en,USD
|
||||||
|
Nightblade Brigade,TDM,Tarkir: Dragonstorm,85,foil,common,1,105134,648debd9-d4cf-4788-8882-f1601a3d87f5,0.08,false,false,near_mint,en,USD
|
||||||
|
Tranquil Cove,TDM,Tarkir: Dragonstorm,270,foil,common,1,104249,1c4efa6c-4f29-41cd-a728-bf0e479ace05,0.06,false,false,near_mint,en,USD
|
||||||
|
Rugged Highlands,TDM,Tarkir: Dragonstorm,265,foil,common,1,104267,31261eca-28ad-407c-84ef-0c124d0d7451,0.07,false,false,near_mint,en,USD
|
||||||
|
Swiftwater Cliffs,TDM,Tarkir: Dragonstorm,268,foil,common,1,104361,ca53fb19-b8ca-485b-af1a-5117ae54bfe3,0.11,false,false,near_mint,en,USD
|
||||||
|
Dismal Backwater,TDM,Tarkir: Dragonstorm,254,foil,common,1,104238,082b52c9-c46e-44d3-b723-546ba528e07b,0.06,false,false,near_mint,en,USD
|
||||||
|
Wind-Scarred Crag,TDM,Tarkir: Dragonstorm,271,foil,common,1,104286,4912e4d0-b16a-4aa6-a583-3430d26bd591,0.05,false,false,near_mint,en,USD
|
||||||
|
Jungle Hollow,TDM,Tarkir: Dragonstorm,258,foil,common,1,104375,ea13440b-3f7b-4182-9541-27c1fa3121e5,0.07,false,false,near_mint,en,USD
|
||||||
|
Plains,TDM,Tarkir: Dragonstorm,272,foil,common,1,104240,0d0f1dd6-9564-4adc-af7d-f83252e8581a,0.39,false,false,near_mint,en,USD
|
||||||
|
Purging Stormbrood // Absorb Essence,TDM,Tarkir: Dragonstorm,315,foil,uncommon,1,104395,fb293f4f-9ba2-48f5-a4fb-d902aa531bfc,0.18,false,false,near_mint,en,USD
|
||||||
|
Barrensteppe Siege,TDM,Tarkir: Dragonstorm,384,foil,rare,1,104002,c09d4015-f101-4529-a603-c66192dcfd92,1.68,false,false,near_mint,en,USD
|
||||||
|
United Battlefront,TDM,Tarkir: Dragonstorm,32,foil,rare,1,104370,dff398be-4ba4-4976-9acc-be99d2e07a61,0.8,false,false,near_mint,en,USD
|
||||||
|
Cori-Steel Cutter,TDM,Tarkir: Dragonstorm,103,foil,rare,1,104608,490eb213-9ae2-4b45-abec-6f1dfc83792a,15.2,false,false,near_mint,en,USD
|
||||||
|
Plains,TDM,Tarkir: Dragonstorm,272,normal,common,1,104240,0d0f1dd6-9564-4adc-af7d-f83252e8581a,0.52,false,false,near_mint,en,USD
|
||||||
|
Skirmish Rhino,TDM,Tarkir: Dragonstorm,224,normal,uncommon,1,103992,4a2e9ba1-c254-41e3-9845-4e81f9fec38d,0.18,false,false,near_mint,en,USD
|
||||||
|
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,199,normal,uncommon,1,104798,8d11183a-57f5-4ddb-8a6e-15fff704b114,0.17,false,false,near_mint,en,USD
|
||||||
|
Hardened Tactician,TDM,Tarkir: Dragonstorm,191,normal,uncommon,1,104780,86b225cb-5c45-4da1-a64e-b04091e483e8,0.19,false,false,near_mint,en,USD
|
||||||
|
Auroral Procession,TDM,Tarkir: Dragonstorm,169,normal,uncommon,1,104701,672f94ad-65d6-4c7d-925d-165ef264626f,0.2,false,false,near_mint,en,USD
|
||||||
|
Barrensteppe Siege,TDM,Tarkir: Dragonstorm,171,normal,rare,1,103989,2556a35b-2229-42c7-8cb3-c8c668403dd2,0.46,false,false,near_mint,en,USD
|
||||||
|
Heritage Reclamation,TDM,Tarkir: Dragonstorm,145,normal,common,1,104636,4f8fee37-a050-4329-8b10-46d150e7a95e,0.19,false,false,near_mint,en,USD
|
||||||
|
Windcrag Siege,TDM,Tarkir: Dragonstorm,235,normal,rare,1,104534,31a8329b-23a1-4c49-a579-a5da8d01435a,1.97,false,false,near_mint,en,USD
|
||||||
|
Ambling Stormshell,TDM,Tarkir: Dragonstorm,37,normal,rare,1,104942,c74d4a57-0f66-4965-9ed7-f88a08aa1d15,0.18,false,false,near_mint,en,USD
|
||||||
|
"Narset, Jeskai Waymaster",TDM,Tarkir: Dragonstorm,209,normal,rare,1,103995,6b77cbc1-dbc8-44d9-aa29-15cbb19afecd,0.21,false,false,near_mint,en,USD
|
||||||
|
Revival of the Ancestors,TDM,Tarkir: Dragonstorm,218,normal,rare,1,105074,fd742ff5-f0ea-4f4b-911e-4c09e2154dba,0.14,false,false,near_mint,en,USD
|
||||||
|
Dragonback Assault,TDM,Tarkir: Dragonstorm,179,normal,mythic,1,104985,d54cc838-d79d-433a-99fb-d6e4d1c1431d,3.49,false,false,near_mint,en,USD
|
||||||
|
Call the Spirit Dragons,TDM,Tarkir: Dragonstorm,174,normal,mythic,1,104888,b1ad91db-5f16-4392-baf1-f8400ec11e0a,3.99,false,false,near_mint,en,USD
|
||||||
|
Flamehold Grappler,TDM,Tarkir: Dragonstorm,185,normal,rare,1,104958,cc8443a6-282f-4218-9dc8-144b5570d891,0.26,false,false,near_mint,en,USD
|
||||||
|
New Way Forward,TDM,Tarkir: Dragonstorm,211,normal,rare,1,104996,d9d48f9e-79f0-478c-9db0-ff7ac4a8f401,0.2,false,false,near_mint,en,USD
|
||||||
|
Eshki Dragonclaw,TDM,Tarkir: Dragonstorm,182,normal,rare,1,104445,0d369c44-78ee-4f3c-bf2b-cddba7fe26d4,0.22,false,false,near_mint,en,USD
|
||||||
|
Warden of the Grove,TDM,Tarkir: Dragonstorm,166,normal,rare,1,104498,2414db96-0e2b-4f7c-9b97-41f8e310b752,0.94,false,false,near_mint,en,USD
|
||||||
|
United Battlefront,TDM,Tarkir: Dragonstorm,32,normal,rare,1,104370,dff398be-4ba4-4976-9acc-be99d2e07a61,0.58,false,false,near_mint,en,USD
|
||||||
|
Dalkovan Encampment,TDM,Tarkir: Dragonstorm,253,normal,rare,1,104822,98ad5f0c-8775-4e89-8e92-84a6ade93e35,0.3,false,false,near_mint,en,USD
|
||||||
|
Marang River Regent // Coil and Catch,TDM,Tarkir: Dragonstorm,51,normal,rare,1,104393,f890bdc7-32e6-4492-bac7-7cabf54a8bfd,2.87,false,false,near_mint,en,USD
|
||||||
|
Sage of the Skies,TDM,Tarkir: Dragonstorm,22,normal,rare,1,104710,6ade6918-6d1d-448d-ab56-93996051e9a9,0.28,false,false,near_mint,en,USD
|
||||||
|
Tersa Lightshatter,TDM,Tarkir: Dragonstorm,127,normal,rare,1,104825,99e96b34-b1c4-4647-a38e-2cf1aedaaace,1.92,false,false,near_mint,en,USD
|
||||||
|
Voice of Victory,TDM,Tarkir: Dragonstorm,33,normal,rare,1,104377,ec3de5f4-bb55-4ab9-995f-f3e0dc22c1bb,10.45,false,false,near_mint,en,USD
|
||||||
|
Yathan Roadwatcher,TDM,Tarkir: Dragonstorm,236,normal,rare,1,104800,8e77339b-dd82-481c-9ee2-4156ca69ad35,0.15,false,false,near_mint,en,USD
|
||||||
|
Great Arashin City,TDM,Tarkir: Dragonstorm,257,normal,rare,1,105033,ecba23b6-9f3a-431e-bc22-f1fb04d27b68,0.31,false,false,near_mint,en,USD
|
||||||
|
"Taigam, Master Opportunist",TDM,Tarkir: Dragonstorm,60,normal,mythic,1,104320,8693d631-05f6-414d-9e49-6385746e8960,1.69,false,false,near_mint,en,USD
|
||||||
|
Temur Battlecrier,TDM,Tarkir: Dragonstorm,228,normal,rare,1,104309,72184791-0767-4108-920c-763e92dae2d4,0.74,false,false,near_mint,en,USD
|
||||||
|
Fangkeeper's Familiar,TDM,Tarkir: Dragonstorm,183,normal,rare,1,104696,655fa2e1-3e1c-424c-b17a-daa7b8fface4,0.27,false,false,near_mint,en,USD
|
||||||
|
Nature's Rhythm,TDM,Tarkir: Dragonstorm,150,normal,rare,1,104460,1397d904-c51d-451e-8505-7f3118acc1f6,3.21,false,false,near_mint,en,USD
|
||||||
|
Stillness in Motion,TDM,Tarkir: Dragonstorm,59,normal,rare,1,104864,a6289251-17e4-4987-96b9-2fb1a8f90e2a,0.18,false,false,near_mint,en,USD
|
||||||
|
Stadium Headliner,TDM,Tarkir: Dragonstorm,122,normal,rare,1,104552,37d4ab2a-a06a-4768-b5e1-e1def957d7f4,0.46,false,false,near_mint,en,USD
|
||||||
|
Sinkhole Surveyor,TDM,Tarkir: Dragonstorm,93,normal,rare,1,104551,37cb5599-7d2c-48e9-978b-902a01a74bde,0.23,false,false,near_mint,en,USD
|
||||||
|
Naga Fleshcrafter,TDM,Tarkir: Dragonstorm,52,normal,rare,1,104675,5df17423-9fdd-4432-8660-1d267c685595,0.31,false,false,near_mint,en,USD
|
||||||
|
Herd Heirloom,TDM,Tarkir: Dragonstorm,144,normal,rare,1,104873,a88c7713-b3a9-4685-b1d3-623d35b62365,4.48,false,false,near_mint,en,USD
|
||||||
|
Severance Priest,TDM,Tarkir: Dragonstorm,222,normal,rare,1,104917,bc779a1b-128c-4c74-bebd-bdb687867f68,0.21,false,false,near_mint,en,USD
|
||||||
|
Dracogenesis,TDM,Tarkir: Dragonstorm,105,normal,mythic,1,104241,0d5674f9-22b2-45f9-902d-4fd245485c60,15.05,false,false,near_mint,en,USD
|
||||||
|
Kishla Village,TDM,Tarkir: Dragonstorm,259,normal,rare,1,104840,9f0ff90d-7312-44df-afc5-29c768fa7758,0.3,false,false,near_mint,en,USD
|
||||||
|
"Sarkhan, Dragon Ascendant",TDM,Tarkir: Dragonstorm,302,normal,rare,1,103994,57c03255-e3dc-44c2-982b-7efa188280df,0.49,false,false,near_mint,en,USD
|
||||||
|
Bloomvine Regent // Claim Territory,TDM,Tarkir: Dragonstorm,381,normal,rare,1,104237,081f2de5-251a-41c9-a62f-11487f54d355,1.97,false,false,near_mint,en,USD
|
||||||
|
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,313,normal,uncommon,1,105150,9d85ba44-8f29-4c49-b77f-8a6692d23c8c,0.45,false,false,near_mint,en,USD
|
||||||
|
Boulderborn Dragon,TDM,Tarkir: Dragonstorm,323,normal,common,1,104326,970e11f0-337a-46b5-9bff-4bcb7843ed3a,0.1,false,false,near_mint,en,USD
|
||||||
|
Sagu Wildling // Roost Seek,TDM,Tarkir: Dragonstorm,306,normal,common,1,104903,b72ee8f9-5e79-4f77-ae7e-e4c274f78187,0.11,false,false,near_mint,en,USD
|
||||||
|
Tempest Hawk,TDM,Tarkir: Dragonstorm,31,normal,common,1,104587,422f9453-ab12-4e3c-8c51-be87391395a1,0.66,false,false,near_mint,en,USD
|
||||||
|
Eshki Dragonclaw,TDM,Tarkir: Dragonstorm,356,normal,rare,1,104877,aafaa59e-87e1-4953-8c04-8e7a3a509827,0.44,false,false,near_mint,en,USD
|
||||||
|
Lotuslight Dancers,TDM,Tarkir: Dragonstorm,363,normal,rare,1,104751,79dc69dc-6245-43fc-95a2-85b2c2957182,0.32,false,false,near_mint,en,USD
|
||||||
|
Rakshasa's Bargain,TDM,Tarkir: Dragonstorm,214,normal,uncommon,1,104299,5c409f4f-3b2c-4c33-b850-55b2a46f51ca,0.32,false,false,near_mint,en,USD
|
||||||
|
Glacial Dragonhunt,TDM,Tarkir: Dragonstorm,188,normal,uncommon,1,104814,95994c88-e404-4a4f-8be6-b99d703d4609,0.1,false,false,near_mint,en,USD
|
||||||
|
"Elspeth, Storm Slayer",TDM,Tarkir: Dragonstorm,11,normal,mythic,1,104311,73a065e3-b530-4e62-ab3c-4f6f908184ec,39.69,false,false,near_mint,en,USD
|
||||||
|
Host of the Hereafter,TDM,Tarkir: Dragonstorm,193,normal,uncommon,2,104448,0f182957-8133-45a7-80a3-1944bead4d43,0.16,false,false,near_mint,en,USD
|
||||||
|
Sunset Strikemaster,TDM,Tarkir: Dragonstorm,126,normal,uncommon,2,104394,f8f1a2f2-526d-4b2c-985b-0acfdc21a2ee,0.17,false,false,near_mint,en,USD
|
|
16
app/main.py
16
app/main.py
@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
from app.routes import routes
|
from app.routes import routes
|
||||||
from app.db.database import init_db, SessionLocal
|
from app.db.database import init_db, SessionLocal
|
||||||
from app.services.service_manager import ServiceManager
|
from app.services.service_manager import ServiceManager
|
||||||
from app.models.tcgplayer_products import refresh_view
|
from app.models.tcgplayer_products import MostRecentTCGPlayerPrice
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
log_file = Path("app.log")
|
log_file = Path("app.log")
|
||||||
@ -59,16 +59,18 @@ async def lifespan(app: FastAPI):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
data_init_service = service_manager.get_service('data_initialization')
|
data_init_service = service_manager.get_service('data_initialization')
|
||||||
# data_init = await data_init_service.initialize_data(db, game_ids=[1, 3], use_cache=False, init_categories=False, init_products=True, init_groups=False, init_archived_prices=False, init_mtgjson=False, archived_prices_start_date="2024-03-05", archived_prices_end_date="2025-04-17")
|
#data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=True, archived_prices_start_date="2025-04-27", archived_prices_end_date="2025-04-28")
|
||||||
# logger.info(f"Data initialization results: {data_init}")
|
#logger.info(f"Data initialization results: {data_init}")
|
||||||
data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_groups=True, init_products=True, init_archived_prices=True, archived_prices_start_date="2025-04-22", archived_prices_end_date="2025-04-24", init_mtgjson=True)
|
# Update most recent prices
|
||||||
#refresh_view(db)
|
#MostRecentTCGPlayerPrice.update_most_recent_prices(db)
|
||||||
|
logger.info("Most recent prices updated successfully")
|
||||||
|
|
||||||
# Create default customer, vendor, and marketplace
|
# Create default customer, vendor, and marketplace
|
||||||
#inv_data_init = await data_init_service.initialize_inventory_data(db)
|
inv_data_init = await data_init_service.initialize_inventory_data(db)
|
||||||
|
logger.info(f"Inventory data initialization results: {inv_data_init}")
|
||||||
# Start the scheduler
|
# Start the scheduler
|
||||||
scheduler = service_manager.get_service('scheduler')
|
scheduler = service_manager.get_service('scheduler')
|
||||||
|
await scheduler.refresh_tcgplayer_inventory_table(db)
|
||||||
await scheduler.start_scheduled_tasks(db)
|
await scheduler.start_scheduled_tasks(db)
|
||||||
logger.info("Scheduler started successfully")
|
logger.info("Scheduler started successfully")
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ from app.models.tcgplayer_order import (
|
|||||||
)
|
)
|
||||||
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
||||||
from app.models.manabox_import_staging import ManaboxImportStaging
|
from app.models.manabox_import_staging import ManaboxImportStaging
|
||||||
|
from app.models.pricing import PricingEvent
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -56,5 +57,6 @@ __all__ = [
|
|||||||
'TCGPlayerOrderProduct',
|
'TCGPlayerOrderProduct',
|
||||||
'TCGPlayerOrderRefund',
|
'TCGPlayerOrderRefund',
|
||||||
'TCGPlayerPriceHistory',
|
'TCGPlayerPriceHistory',
|
||||||
'MostRecentTCGPlayerPrice'
|
'MostRecentTCGPlayerPrice',
|
||||||
|
'PricingEvent'
|
||||||
]
|
]
|
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, CheckConstraint
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, CheckConstraint, Index, Boolean, Table, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
@ -7,6 +7,17 @@ from sqlalchemy import func
|
|||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.models.critical_error_log import CriticalErrorLog
|
from app.models.critical_error_log import CriticalErrorLog
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
open_event_resulting_items = Table(
|
||||||
|
"open_event_resulting_items",
|
||||||
|
Base.metadata,
|
||||||
|
Column("event_id", Integer, ForeignKey("open_events.id"), primary_key=True),
|
||||||
|
Column("item_id", Integer, ForeignKey("physical_items.id"), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
class PhysicalItem(Base):
|
class PhysicalItem(Base):
|
||||||
__tablename__ = "physical_items"
|
__tablename__ = "physical_items"
|
||||||
@ -38,21 +49,32 @@ class PhysicalItem(Base):
|
|||||||
sku = relationship("MTGJSONSKU", back_populates="physical_items", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id")
|
sku = relationship("MTGJSONSKU", back_populates="physical_items", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id")
|
||||||
product_direct = relationship("TCGPlayerProduct",
|
product_direct = relationship("TCGPlayerProduct",
|
||||||
back_populates="physical_items_direct",
|
back_populates="physical_items_direct",
|
||||||
primaryjoin="PhysicalItem.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)")
|
primaryjoin="PhysicalItem.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)", uselist=False)
|
||||||
inventory_item = relationship("InventoryItem", uselist=False, back_populates="physical_item")
|
inventory_item = relationship("InventoryItem", uselist=False, back_populates="physical_item")
|
||||||
transaction_items = relationship("TransactionItem", back_populates="physical_item")
|
#transaction_items = relationship("TransactionItem", back_populates="physical_item")
|
||||||
|
source_open_events = relationship(
|
||||||
|
"OpenEvent",
|
||||||
|
back_populates="source_item",
|
||||||
|
foreign_keys="[OpenEvent.source_item_id]"
|
||||||
|
)
|
||||||
|
resulting_open_events = relationship(
|
||||||
|
"OpenEvent",
|
||||||
|
secondary=open_event_resulting_items,
|
||||||
|
back_populates="resulting_items"
|
||||||
|
)
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def is_sealed(self):
|
||||||
|
return not self.source_open_events
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def products(self):
|
def products(self):
|
||||||
"""
|
if self.sku and self.sku.product:
|
||||||
Dynamically resolve the associated TCGPlayerProduct(s):
|
return self.sku.product
|
||||||
- If the SKU is set, return all linked products.
|
elif self.product_direct:
|
||||||
- Else, return a list containing a single product from direct link.
|
return self.product_direct
|
||||||
"""
|
else:
|
||||||
# TODO IS THIS EVEN CORRECT OH GOD
|
return None
|
||||||
if self.sku and self.sku.products:
|
|
||||||
return self.sku.products # This is a list of TCGPlayerProduct
|
|
||||||
return [self.product_direct] if self.product_direct else []
|
|
||||||
|
|
||||||
class InventoryItem(Base):
|
class InventoryItem(Base):
|
||||||
__tablename__ = "inventory_items"
|
__tablename__ = "inventory_items"
|
||||||
@ -69,7 +91,8 @@ class InventoryItem(Base):
|
|||||||
physical_item = relationship("PhysicalItem", back_populates="inventory_item")
|
physical_item = relationship("PhysicalItem", back_populates="inventory_item")
|
||||||
parent = relationship("InventoryItem", remote_side=[id], back_populates="children")
|
parent = relationship("InventoryItem", remote_side=[id], back_populates="children")
|
||||||
children = relationship("InventoryItem", back_populates="parent", overlaps="parent")
|
children = relationship("InventoryItem", back_populates="parent", overlaps="parent")
|
||||||
marketplace_listings = relationship("MarketplaceListing", back_populates="inventory_item")
|
marketplace_listing = relationship("MarketplaceListing", back_populates="inventory_item")
|
||||||
|
transaction_items = relationship("TransactionItem", back_populates="inventory_item")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def products(self):
|
def products(self):
|
||||||
@ -78,7 +101,7 @@ class InventoryItem(Base):
|
|||||||
Returns:
|
Returns:
|
||||||
list[TCGPlayerProduct] or [] if no physical item or no linked products.
|
list[TCGPlayerProduct] or [] if no physical item or no linked products.
|
||||||
"""
|
"""
|
||||||
return self.physical_item.product if self.physical_item else []
|
return self.physical_item.products if self.physical_item else None
|
||||||
|
|
||||||
def soft_delete(self, timestamp=None):
|
def soft_delete(self, timestamp=None):
|
||||||
if not timestamp:
|
if not timestamp:
|
||||||
@ -87,92 +110,68 @@ class InventoryItem(Base):
|
|||||||
for child in self.children:
|
for child in self.children:
|
||||||
child.soft_delete(timestamp)
|
child.soft_delete(timestamp)
|
||||||
|
|
||||||
|
class Box(PhysicalItem):
|
||||||
|
__tablename__ = "boxes"
|
||||||
class SealedBox(PhysicalItem):
|
|
||||||
__tablename__ = "sealed_boxes"
|
|
||||||
|
|
||||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||||
case_id = Column(Integer, ForeignKey("sealed_cases.id"), nullable=True)
|
|
||||||
expected_value = Column(Float)
|
expected_value = Column(Float)
|
||||||
|
|
||||||
__mapper_args__ = {
|
__mapper_args__ = {
|
||||||
'polymorphic_identity': 'sealed_box'
|
'polymorphic_identity': 'box'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Relationships
|
class Case(PhysicalItem):
|
||||||
case = relationship("SealedCase", back_populates="boxes", foreign_keys=[case_id])
|
__tablename__ = "cases"
|
||||||
open_event = relationship("OpenEvent", uselist=False, back_populates="sealed_box")
|
|
||||||
|
|
||||||
class SealedCase(PhysicalItem):
|
|
||||||
__tablename__ = "sealed_cases"
|
|
||||||
|
|
||||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||||
expected_value = Column(Float)
|
expected_value = Column(Float)
|
||||||
num_boxes = Column(Integer)
|
num_boxes = Column(Integer)
|
||||||
|
|
||||||
__mapper_args__ = {
|
__mapper_args__ = {
|
||||||
'polymorphic_identity': 'sealed_case'
|
'polymorphic_identity': 'case'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Relationships
|
class Card(PhysicalItem):
|
||||||
boxes = relationship("SealedBox", back_populates="case", foreign_keys=[SealedBox.case_id])
|
__tablename__ = "cards"
|
||||||
open_event = relationship("OpenEvent", uselist=False, back_populates="sealed_case")
|
|
||||||
|
|
||||||
|
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||||
|
|
||||||
|
__mapper_args__ = {
|
||||||
|
'polymorphic_identity': 'card'
|
||||||
|
}
|
||||||
|
|
||||||
class OpenEvent(Base):
|
class OpenEvent(Base):
|
||||||
__tablename__ = "open_events"
|
__tablename__ = "open_events"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
sealed_case_id = Column(Integer, ForeignKey("sealed_cases.id"), nullable=True)
|
source_item_id = Column(Integer, ForeignKey("physical_items.id"))
|
||||||
sealed_box_id = Column(Integer, ForeignKey("sealed_boxes.id"), nullable=True)
|
|
||||||
open_date = Column(DateTime(timezone=True))
|
open_date = Column(DateTime(timezone=True))
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
__table_args__ = (
|
||||||
sealed_case = relationship("SealedCase", back_populates="open_event")
|
UniqueConstraint("source_item_id", name="uq_openevent_one_per_source"),
|
||||||
sealed_box = relationship("SealedBox", back_populates="open_event")
|
)
|
||||||
resulting_boxes = relationship("OpenBox", back_populates="open_event")
|
|
||||||
resulting_cards = relationship("OpenCard", back_populates="open_event")
|
|
||||||
|
|
||||||
class OpenCard(PhysicalItem):
|
|
||||||
__tablename__ = "open_cards"
|
|
||||||
|
|
||||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
|
||||||
open_event_id = Column(Integer, ForeignKey("open_events.id"))
|
|
||||||
box_id = Column(Integer, ForeignKey("open_boxes.id"), nullable=True)
|
|
||||||
|
|
||||||
__mapper_args__ = {
|
|
||||||
'polymorphic_identity': 'open_card'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
open_event = relationship("OpenEvent", back_populates="resulting_cards")
|
source_item = relationship(
|
||||||
box = relationship("OpenBox", back_populates="cards", foreign_keys=[box_id])
|
"PhysicalItem",
|
||||||
|
back_populates="source_open_events",
|
||||||
class OpenBox(PhysicalItem):
|
foreign_keys=[source_item_id]
|
||||||
__tablename__ = "open_boxes"
|
)
|
||||||
|
resulting_items = relationship(
|
||||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
"PhysicalItem",
|
||||||
open_event_id = Column(Integer, ForeignKey("open_events.id"))
|
secondary=open_event_resulting_items,
|
||||||
sealed_box_id = Column(Integer, ForeignKey("sealed_boxes.id"))
|
back_populates="resulting_open_events"
|
||||||
|
)
|
||||||
__mapper_args__ = {
|
|
||||||
'polymorphic_identity': 'open_box'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
open_event = relationship("OpenEvent", back_populates="resulting_boxes")
|
|
||||||
sealed_box = relationship("SealedBox", foreign_keys=[sealed_box_id])
|
|
||||||
cards = relationship("OpenCard", back_populates="box", foreign_keys=[OpenCard.box_id])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class SealedExpectedValue(Base):
|
class SealedExpectedValue(Base):
|
||||||
__tablename__ = "sealed_expected_values"
|
__tablename__ = "sealed_expected_values"
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_sealed_expected_value_product_id_deleted_at', 'tcgplayer_product_id', 'deleted_at', unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
tcgplayer_product_id = Column(Integer, nullable=False)
|
tcgplayer_product_id = Column(Integer, nullable=False)
|
||||||
@ -185,64 +184,56 @@ class SealedExpectedValue(Base):
|
|||||||
product = relationship(
|
product = relationship(
|
||||||
"TCGPlayerProduct",
|
"TCGPlayerProduct",
|
||||||
primaryjoin="SealedExpectedValue.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)",
|
primaryjoin="SealedExpectedValue.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)",
|
||||||
viewonly=True,
|
viewonly=True)
|
||||||
backref="sealed_expected_values"
|
|
||||||
)
|
|
||||||
|
|
||||||
# helper for ev
|
# helper for ev
|
||||||
def assign_expected_value(target, session):
|
#def assign_expected_value(target, session):
|
||||||
products = target.product # uses hybrid property
|
# products = target.products
|
||||||
if not products:
|
# if not products:
|
||||||
raise ValueError(f"No product found for item ID {target.id}")
|
# raise ValueError(f"No product found for item ID {target.id}")
|
||||||
|
|
||||||
if len(products) > 1:
|
# if len(products) > 1:
|
||||||
product_names = [p.name for p in products]
|
# product_names = [p.name for p in products]
|
||||||
critical_error = CriticalErrorLog(
|
# critical_error = CriticalErrorLog(
|
||||||
error_type="multiple_products_found",
|
# error_type="multiple_products_found",
|
||||||
error_message=f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}"
|
# error_message=f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}"
|
||||||
)
|
# )
|
||||||
session.add(critical_error)
|
# session.add(critical_error)
|
||||||
session.commit()
|
# session.commit()
|
||||||
raise ValueError(f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}")
|
# raise ValueError(f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}")
|
||||||
|
|
||||||
product_id = products[0].tcgplayer_product_id # reliable lookup key
|
# product_id = products[0].tcgplayer_product_id # reliable lookup key
|
||||||
|
|
||||||
expected_value_entry = session.query(SealedExpectedValue).filter(
|
# expected_value_entry = session.query(SealedExpectedValue).filter(
|
||||||
SealedExpectedValue.tcgplayer_product_id == product_id,
|
# SealedExpectedValue.tcgplayer_product_id == product_id,
|
||||||
SealedExpectedValue.deleted_at == None
|
# SealedExpectedValue.deleted_at == None
|
||||||
).order_by(SealedExpectedValue.created_at.desc()).first()
|
# ).order_by(SealedExpectedValue.created_at.desc()).first()
|
||||||
|
|
||||||
if expected_value_entry:
|
# if expected_value_entry:
|
||||||
target.expected_value = expected_value_entry.expected_value
|
# target.expected_value = expected_value_entry.expected_value
|
||||||
else:
|
# else:
|
||||||
critical_error = CriticalErrorLog(
|
# critical_error = CriticalErrorLog(
|
||||||
error_type="no_expected_value_found",
|
# error_type="no_expected_value_found",
|
||||||
error_message=f"No expected value found for product {products[0].name}"
|
# error_message=f"No expected value found for product {products[0].name}"
|
||||||
)
|
# )
|
||||||
session.add(critical_error)
|
# session.add(critical_error)
|
||||||
session.commit()
|
# session.commit()
|
||||||
raise ValueError(f"No expected value found for product {products[0].name}")
|
# raise ValueError(f"No expected value found for product {products[0].name}")
|
||||||
|
|
||||||
|
|
||||||
# event listeners
|
# event listeners
|
||||||
@event.listens_for(SealedBox, 'before_insert')
|
#@event.listens_for(InventoryItem, 'before_insert')
|
||||||
def sealed_box_before_insert(mapper, connection, target):
|
#def ev_before_insert(mapper, connection, target):
|
||||||
session = Session.object_session(target)
|
# session = Session.object_session(target)
|
||||||
if session:
|
# if session:
|
||||||
assign_expected_value(target, session)
|
# assign_expected_value(target, session)
|
||||||
|
|
||||||
@event.listens_for(SealedCase, 'before_insert')
|
|
||||||
def sealed_case_before_insert(mapper, connection, target):
|
|
||||||
session = Session.object_session(target)
|
|
||||||
if session:
|
|
||||||
assign_expected_value(target, session)
|
|
||||||
|
|
||||||
class TransactionItem(Base):
|
class TransactionItem(Base):
|
||||||
__tablename__ = "transaction_items"
|
__tablename__ = "transaction_items"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
transaction_id = Column(Integer, ForeignKey("transactions.id"))
|
transaction_id = Column(Integer, ForeignKey("transactions.id"))
|
||||||
physical_item_id = Column(Integer, ForeignKey("physical_items.id"))
|
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"))
|
||||||
unit_price = Column(Float, nullable=False)
|
unit_price = Column(Float, nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
@ -250,7 +241,7 @@ class TransactionItem(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
transaction = relationship("Transaction", back_populates="transaction_items")
|
transaction = relationship("Transaction", back_populates="transaction_items")
|
||||||
physical_item = relationship("PhysicalItem", back_populates="transaction_items")
|
inventory_item = relationship("InventoryItem", back_populates="transaction_items")
|
||||||
|
|
||||||
class Vendor(Base):
|
class Vendor(Base):
|
||||||
__tablename__ = "vendors"
|
__tablename__ = "vendors"
|
||||||
@ -305,13 +296,16 @@ class MarketplaceListing(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"), nullable=False)
|
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"), nullable=False)
|
||||||
marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=False)
|
marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=False)
|
||||||
listing_date = Column(DateTime(timezone=True))
|
recommended_price_id = Column(Integer, ForeignKey("pricing_events.id"), nullable=True)
|
||||||
|
listed_price_id = Column(Integer, ForeignKey("pricing_events.id"), nullable=True)
|
||||||
|
listing_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
delisting_date = Column(DateTime(timezone=True), nullable=True)
|
delisting_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
listed_price = Column(Float)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
inventory_item = relationship("InventoryItem", back_populates="marketplace_listings")
|
inventory_item = relationship("InventoryItem", back_populates="marketplace_listing")
|
||||||
marketplace = relationship("Marketplace", back_populates="listings")
|
marketplace = relationship("Marketplace", back_populates="listings")
|
||||||
|
recommended_price = relationship("PricingEvent", foreign_keys=[recommended_price_id])
|
||||||
|
listed_price = relationship("PricingEvent", foreign_keys=[listed_price_id])
|
@ -8,6 +8,7 @@ class ManaboxImportStaging(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
file_id = Column(Integer, ForeignKey("files.id"))
|
file_id = Column(Integer, ForeignKey("files.id"))
|
||||||
|
tcgplayer_product_id = Column(Integer)
|
||||||
tcgplayer_sku_id = Column(Integer)
|
tcgplayer_sku_id = Column(Integer)
|
||||||
quantity = Column(Integer)
|
quantity = Column(Integer)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
29
app/models/pricing.py
Normal file
29
app/models/pricing.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, CheckConstraint, Index, Boolean, Table, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.db.database import Base
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class PricingEvent(Base):
|
||||||
|
__tablename__ = "pricing_events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"))
|
||||||
|
price = Column(Float)
|
||||||
|
price_used = Column(String)
|
||||||
|
price_reason = Column(String)
|
||||||
|
free_shipping_adjustment = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
inventory_item = relationship(
|
||||||
|
"InventoryItem",
|
||||||
|
primaryjoin="PricingEvent.inventory_item_id == foreign(InventoryItem.id)",
|
||||||
|
viewonly=True
|
||||||
|
)
|
@ -1,12 +1,13 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
class TCGPlayerInventory(Base):
|
class TCGPlayerInventory(Base):
|
||||||
__tablename__ = "tcgplayer_inventory"
|
__tablename__ = "tcgplayer_inventory"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
tcgplayer_sku_id = Column(Integer, ForeignKey("mtgjson_skus.tcgplayer_sku_id"), unique=True, index=True)
|
tcgplayer_sku_id = Column(Integer, unique=True, index=True)
|
||||||
product_line = Column(String)
|
product_line = Column(String)
|
||||||
set_name = Column(String)
|
set_name = Column(String)
|
||||||
product_name = Column(String)
|
product_name = Column(String)
|
||||||
@ -23,4 +24,38 @@ class TCGPlayerInventory(Base):
|
|||||||
tcg_marketplace_price = Column(Float)
|
tcg_marketplace_price = Column(Float)
|
||||||
photo_url = Column(String)
|
photo_url = Column(String)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# relationships
|
||||||
|
unmanaged_inventory = relationship("UnmanagedTCGPlayerInventory", back_populates="tcgplayer_inventory")
|
||||||
|
sku = relationship("MTGJSONSKU", primaryjoin="foreign(MTGJSONSKU.tcgplayer_sku_id) == TCGPlayerInventory.tcgplayer_sku_id", viewonly=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UnmanagedTCGPlayerInventory(Base):
|
||||||
|
__tablename__ = "unmanaged_tcgplayer_inventory"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tcgplayer_inventory_id = Column(Integer, ForeignKey("tcgplayer_inventory.id"), unique=True, index=True)
|
||||||
|
tcgplayer_sku_id = Column(Integer, unique=True, index=True)
|
||||||
|
product_line = Column(String)
|
||||||
|
set_name = Column(String)
|
||||||
|
product_name = Column(String)
|
||||||
|
title = Column(String)
|
||||||
|
number = Column(String)
|
||||||
|
rarity = Column(String)
|
||||||
|
condition = Column(String)
|
||||||
|
tcg_market_price = Column(Float)
|
||||||
|
tcg_direct_low = Column(Float)
|
||||||
|
tcg_low_price_with_shipping = Column(Float)
|
||||||
|
tcg_low_price = Column(Float)
|
||||||
|
total_quantity = Column(Integer)
|
||||||
|
add_to_quantity = Column(Integer)
|
||||||
|
tcg_marketplace_price = Column(Float)
|
||||||
|
photo_url = Column(String)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# relationships
|
||||||
|
tcgplayer_inventory = relationship("TCGPlayerInventory", back_populates="unmanaged_inventory")
|
||||||
|
sku = relationship("MTGJSONSKU", primaryjoin="foreign(MTGJSONSKU.tcgplayer_sku_id) == UnmanagedTCGPlayerInventory.tcgplayer_sku_id", viewonly=True)
|
||||||
|
|
@ -2,43 +2,44 @@ from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Table, Flo
|
|||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Core Models
|
# Core Models
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class MTGJSONSKU(Base):
|
class MTGJSONSKU(Base):
|
||||||
"""Represents the most granular level of card identification.
|
|
||||||
THIS WORKS EVEN IF ITS A BOX SOMEHOW
|
|
||||||
"""
|
|
||||||
__tablename__ = "mtgjson_skus"
|
__tablename__ = "mtgjson_skus"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
mtgjson_uuid = Column(String, ForeignKey("mtgjson_cards.mtgjson_uuid"), unique=True, index=True)
|
mtgjson_uuid = Column(String, ForeignKey("mtgjson_cards.mtgjson_uuid"), index=True)
|
||||||
tcgplayer_sku_id = Column(Integer, index=True, unique=True)
|
tcgplayer_sku_id = Column(Integer, index=True, unique=True)
|
||||||
tcgplayer_product_id = Column(Integer, nullable=False)
|
tcgplayer_product_id = Column(Integer, nullable=False)
|
||||||
normalized_printing = Column(String, nullable=False) # normalized for FK
|
normalized_printing = Column(String, nullable=False)
|
||||||
condition = Column(String) # for boxes, condition = unopened
|
condition = Column(String)
|
||||||
finish = Column(String, nullable=True) # TODO MAKE THESE ENUMS
|
finish = Column(String, nullable=True)
|
||||||
language = Column(String)
|
language = Column(String)
|
||||||
printing = Column(String) # original unnormalized field ##### for boxes, printing = NON FOIL
|
printing = Column(String)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Foreign key to tcgplayer_products via composite key
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
ForeignKeyConstraint(
|
ForeignKeyConstraint(
|
||||||
["tcgplayer_product_id", "normalized_printing"],
|
["tcgplayer_product_id", "normalized_printing"],
|
||||||
["tcgplayer_products.tcgplayer_product_id", "tcgplayer_products.normalized_sub_type_name"],
|
["tcgplayer_products.tcgplayer_product_id", "tcgplayer_products.normalized_sub_type_name"],
|
||||||
name="fk_sku_to_product_composite"
|
name="fk_sku_to_product_composite"
|
||||||
),
|
),
|
||||||
Index('idx_sku_product_printing', 'tcgplayer_product_id', 'normalized_printing', unique=True),
|
Index('idx_sku_product_printing', 'tcgplayer_product_id', 'normalized_printing'),
|
||||||
)
|
)
|
||||||
|
|
||||||
product = relationship("TCGPlayerProduct", back_populates="skus")
|
product = relationship("TCGPlayerProduct", back_populates="skus")
|
||||||
physical_items = relationship("PhysicalItem", back_populates="sku", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id")
|
physical_items = relationship(
|
||||||
|
"PhysicalItem",
|
||||||
card = relationship("MTGJSONCard", back_populates="skus", primaryjoin="MTGJSONCard.mtgjson_uuid == MTGJSONSKU.mtgjson_uuid")
|
back_populates="sku",
|
||||||
|
primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id"
|
||||||
|
)
|
||||||
|
card = relationship("MTGJSONCard", back_populates="skus")
|
||||||
|
|
||||||
|
|
||||||
class MTGJSONCard(Base):
|
class MTGJSONCard(Base):
|
||||||
@ -72,13 +73,17 @@ class MTGJSONCard(Base):
|
|||||||
scryfall_card_back_id = Column(String, nullable=True)
|
scryfall_card_back_id = Column(String, nullable=True)
|
||||||
scryfall_oracle_id = Column(String, nullable=True)
|
scryfall_oracle_id = Column(String, nullable=True)
|
||||||
scryfall_illustration_id = Column(String, nullable=True)
|
scryfall_illustration_id = Column(String, nullable=True)
|
||||||
tcgplayer_product_id = Column(String, nullable=True)
|
tcgplayer_product_id = Column(Integer, nullable=True)
|
||||||
tcgplayer_etched_product_id = Column(String, nullable=True)
|
tcgplayer_etched_product_id = Column(Integer, nullable=True)
|
||||||
tnt_id = Column(String, nullable=True)
|
tnt_id = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
skus = relationship("MTGJSONSKU", back_populates="card", primaryjoin="MTGJSONSKU.mtgjson_uuid == MTGJSONCard.mtgjson_uuid")
|
__table_args__ = (
|
||||||
|
UniqueConstraint("mtgjson_uuid", name="uq_card_mtgjson_uuid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
skus = relationship("MTGJSONSKU", back_populates="card")
|
||||||
|
|
||||||
|
|
||||||
class TCGPlayerProduct(Base):
|
class TCGPlayerProduct(Base):
|
||||||
@ -117,13 +122,16 @@ class TCGPlayerProduct(Base):
|
|||||||
# Enforce uniqueness for composite key
|
# Enforce uniqueness for composite key
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("tcgplayer_product_id", "normalized_sub_type_name", name="uq_product_subtype"),
|
UniqueConstraint("tcgplayer_product_id", "normalized_sub_type_name", name="uq_product_subtype"),
|
||||||
|
Index('idx_product_subtype', 'tcgplayer_product_id', 'normalized_sub_type_name'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Backref to SKUs that link via composite FK
|
# Backref to SKUs that link via composite FK
|
||||||
skus = relationship("MTGJSONSKU", back_populates="product")
|
skus = relationship("MTGJSONSKU", back_populates="product")
|
||||||
physical_items_direct = relationship("PhysicalItem",
|
physical_items_direct = relationship("PhysicalItem",
|
||||||
back_populates="product_direct",
|
back_populates="product_direct",
|
||||||
primaryjoin="foreign(TCGPlayerProduct.tcgplayer_product_id) == PhysicalItem.tcgplayer_product_id")
|
primaryjoin="foreign(TCGPlayerProduct.tcgplayer_product_id) == PhysicalItem.tcgplayer_product_id",
|
||||||
|
viewonly=True,
|
||||||
|
uselist=False)
|
||||||
category = relationship("TCGPlayerCategory", back_populates="products")
|
category = relationship("TCGPlayerCategory", back_populates="products")
|
||||||
group = relationship("TCGPlayerGroup", back_populates="products")
|
group = relationship("TCGPlayerGroup", back_populates="products")
|
||||||
price_history = relationship("TCGPlayerPriceHistory",
|
price_history = relationship("TCGPlayerPriceHistory",
|
||||||
@ -136,6 +144,11 @@ class TCGPlayerProduct(Base):
|
|||||||
primaryjoin="and_(foreign(TCGPlayerProduct.tcgplayer_product_id) == MostRecentTCGPlayerPrice.product_id, "
|
primaryjoin="and_(foreign(TCGPlayerProduct.tcgplayer_product_id) == MostRecentTCGPlayerPrice.product_id, "
|
||||||
"foreign(TCGPlayerProduct.sub_type_name) == MostRecentTCGPlayerPrice.sub_type_name)")
|
"foreign(TCGPlayerProduct.sub_type_name) == MostRecentTCGPlayerPrice.sub_type_name)")
|
||||||
|
|
||||||
|
sealed_expected_value = relationship("SealedExpectedValue",
|
||||||
|
primaryjoin="and_(TCGPlayerProduct.tcgplayer_product_id == foreign(SealedExpectedValue.tcgplayer_product_id), "
|
||||||
|
"foreign(SealedExpectedValue.deleted_at) == None)",
|
||||||
|
viewonly=True,
|
||||||
|
uselist=False)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Supporting Models
|
# Supporting Models
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -213,9 +226,7 @@ class TCGPlayerPriceHistory(Base):
|
|||||||
|
|
||||||
class MostRecentTCGPlayerPrice(Base):
|
class MostRecentTCGPlayerPrice(Base):
|
||||||
"""Represents the most recent price for a product.
|
"""Represents the most recent price for a product.
|
||||||
|
THIS ISNT A MATERIALIZED VIEW ANYMORE FUCK IT
|
||||||
This is a materialized view that contains the latest price data for each product.
|
|
||||||
It is maintained through triggers on the price_history table.
|
|
||||||
"""
|
"""
|
||||||
__tablename__ = "most_recent_tcgplayer_price"
|
__tablename__ = "most_recent_tcgplayer_price"
|
||||||
|
|
||||||
@ -232,51 +243,53 @@ class MostRecentTCGPlayerPrice(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_most_recent_price_product_subtype', 'product_id', 'sub_type_name', unique=True),
|
Index('idx_most_recent_price_product_subtype', 'product_id', 'sub_type_name', unique=True),
|
||||||
{'info': {'is_view': True}}
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
product = relationship("TCGPlayerProduct",
|
product = relationship("TCGPlayerProduct",
|
||||||
back_populates="most_recent_tcgplayer_price",
|
back_populates="most_recent_tcgplayer_price",
|
||||||
primaryjoin="and_(MostRecentTCGPlayerPrice.product_id == foreign(TCGPlayerProduct.tcgplayer_product_id), "
|
primaryjoin="and_(MostRecentTCGPlayerPrice.product_id == foreign(TCGPlayerProduct.tcgplayer_product_id), "
|
||||||
"MostRecentTCGPlayerPrice.sub_type_name == foreign(TCGPlayerProduct.sub_type_name))")
|
"MostRecentTCGPlayerPrice.sub_type_name == foreign(TCGPlayerProduct.sub_type_name))")
|
||||||
|
|
||||||
def create_most_recent_price_view():
|
@classmethod
|
||||||
"""Creates the materialized view for most recent prices."""
|
def update_most_recent_prices(cls, db: Session) -> None:
|
||||||
return DDL("""
|
"""Update the most recent prices from the price history table."""
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS most_recent_tcgplayer_price AS
|
# Delete all existing records
|
||||||
SELECT DISTINCT ON (ph.product_id, ph.sub_type_name)
|
db.query(cls).delete()
|
||||||
ph.id,
|
|
||||||
ph.product_id,
|
|
||||||
ph.sub_type_name,
|
|
||||||
ph.date,
|
|
||||||
ph.low_price,
|
|
||||||
ph.mid_price,
|
|
||||||
ph.high_price,
|
|
||||||
ph.market_price,
|
|
||||||
ph.direct_low_price,
|
|
||||||
ph.created_at,
|
|
||||||
ph.updated_at
|
|
||||||
FROM tcgplayer_price_history ph
|
|
||||||
ORDER BY ph.product_id, ph.sub_type_name, ph.date DESC;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_most_recent_price_product_subtype
|
|
||||||
ON most_recent_tcgplayer_price (product_id, sub_type_name);
|
# Get the most recent price for each product and sub_type_name
|
||||||
""")
|
subquery = db.query(
|
||||||
|
TCGPlayerPriceHistory.product_id,
|
||||||
|
TCGPlayerPriceHistory.sub_type_name,
|
||||||
|
func.max(TCGPlayerPriceHistory.date).label('max_date')
|
||||||
|
).group_by(
|
||||||
|
TCGPlayerPriceHistory.product_id,
|
||||||
|
TCGPlayerPriceHistory.sub_type_name
|
||||||
|
).subquery()
|
||||||
|
|
||||||
# Register the view creation with SQLAlchemy
|
# Join with price history to get the full records
|
||||||
event.listen(
|
latest_prices = db.query(TCGPlayerPriceHistory).join(
|
||||||
MostRecentTCGPlayerPrice.__table__,
|
subquery,
|
||||||
'after_create',
|
and_(
|
||||||
create_most_recent_price_view()
|
TCGPlayerPriceHistory.product_id == subquery.c.product_id,
|
||||||
)
|
TCGPlayerPriceHistory.sub_type_name == subquery.c.sub_type_name,
|
||||||
|
TCGPlayerPriceHistory.date == subquery.c.max_date
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
# Add a method to refresh the view
|
# Create new MostRecentTCGPlayerPrice records
|
||||||
@classmethod
|
for price in latest_prices:
|
||||||
def refresh_view(cls, session):
|
most_recent = cls(
|
||||||
"""Refreshes the materialized view."""
|
product_id=price.product_id,
|
||||||
session.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY most_recent_tcgplayer_price"))
|
sub_type_name=price.sub_type_name,
|
||||||
session.commit()
|
date=price.date,
|
||||||
|
low_price=price.low_price,
|
||||||
|
mid_price=price.mid_price,
|
||||||
|
high_price=price.high_price,
|
||||||
|
market_price=price.market_price,
|
||||||
|
direct_low_price=price.direct_low_price
|
||||||
|
)
|
||||||
|
db.add(most_recent)
|
||||||
|
|
||||||
MostRecentTCGPlayerPrice.refresh_view = refresh_view
|
db.commit()
|
@ -14,6 +14,8 @@ from app.services.set_label_service import SetLabelService
|
|||||||
from app.services.scheduler.scheduler_service import SchedulerService
|
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.order_management_service import OrderManagementService
|
||||||
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
|
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__ = [
|
__all__ = [
|
||||||
'BaseService',
|
'BaseService',
|
||||||
@ -31,5 +33,7 @@ __all__ = [
|
|||||||
'SetLabelService',
|
'SetLabelService',
|
||||||
'SchedulerService',
|
'SchedulerService',
|
||||||
'OrderManagementService',
|
'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.contexts.inventory_item import InventoryItemContextFactory
|
||||||
from app.models.tcgplayer_products import MTGJSONSKU, MTGJSONCard
|
from app.models.tcgplayer_products import MTGJSONSKU, MTGJSONCard
|
||||||
from app.models.tcgplayer_products import TCGPlayerPriceHistory
|
from app.models.tcgplayer_products import TCGPlayerPriceHistory
|
||||||
|
from app.models.critical_error_log import CriticalErrorLog
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
@ -349,7 +350,7 @@ class DataInitializationService(BaseService):
|
|||||||
for price_data in archived_prices_data.get("results", []):
|
for price_data in archived_prices_data.get("results", []):
|
||||||
try:
|
try:
|
||||||
# Get the subtype name from the price data
|
# 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
|
# First try to find product with the requested subtype
|
||||||
product = db.query(TCGPlayerProduct).filter(
|
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:
|
async def sync_mtgjson_identifiers(self, db: Session, identifiers_data: List[dict]) -> int:
|
||||||
"""Sync MTGJSON identifiers data to the database"""
|
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
with db_transaction(db):
|
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:
|
for card_data in identifiers_data:
|
||||||
if not isinstance(card_data, dict):
|
if not isinstance(card_data, dict):
|
||||||
logger.debug(f"Skipping non-dict item: {card_data}")
|
logger.debug(f"Skipping non-dict item: {card_data}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == card_data.get("uuid")).first()
|
uuid = card_data.get("uuid")
|
||||||
if existing_card:
|
identifiers = card_data.get("identifiers", {})
|
||||||
# Update existing card
|
|
||||||
for key, value in {
|
if uuid in existing_cards:
|
||||||
|
card = existing_cards[uuid]
|
||||||
|
updates = {
|
||||||
"name": card_data.get("name"),
|
"name": card_data.get("name"),
|
||||||
"set_code": card_data.get("setCode"),
|
"set_code": card_data.get("setCode"),
|
||||||
"abu_id": card_data.get("identifiers", {}).get("abuId"),
|
"abu_id": identifiers.get("abuId"),
|
||||||
"card_kingdom_etched_id": card_data.get("identifiers", {}).get("cardKingdomEtchedId"),
|
"card_kingdom_etched_id": identifiers.get("cardKingdomEtchedId"),
|
||||||
"card_kingdom_foil_id": card_data.get("identifiers", {}).get("cardKingdomFoilId"),
|
"card_kingdom_foil_id": identifiers.get("cardKingdomFoilId"),
|
||||||
"card_kingdom_id": card_data.get("identifiers", {}).get("cardKingdomId"),
|
"card_kingdom_id": identifiers.get("cardKingdomId"),
|
||||||
"cardsphere_id": card_data.get("identifiers", {}).get("cardsphereId"),
|
"cardsphere_id": identifiers.get("cardsphereId"),
|
||||||
"cardsphere_foil_id": card_data.get("identifiers", {}).get("cardsphereFoilId"),
|
"cardsphere_foil_id": identifiers.get("cardsphereFoilId"),
|
||||||
"cardtrader_id": card_data.get("identifiers", {}).get("cardtraderId"),
|
"cardtrader_id": identifiers.get("cardtraderId"),
|
||||||
"csi_id": card_data.get("identifiers", {}).get("csiId"),
|
"csi_id": identifiers.get("csiId"),
|
||||||
"mcm_id": card_data.get("identifiers", {}).get("mcmId"),
|
"mcm_id": identifiers.get("mcmId"),
|
||||||
"mcm_meta_id": card_data.get("identifiers", {}).get("mcmMetaId"),
|
"mcm_meta_id": identifiers.get("mcmMetaId"),
|
||||||
"miniaturemarket_id": card_data.get("identifiers", {}).get("miniaturemarketId"),
|
"miniaturemarket_id": identifiers.get("miniaturemarketId"),
|
||||||
"mtg_arena_id": card_data.get("identifiers", {}).get("mtgArenaId"),
|
"mtg_arena_id": identifiers.get("mtgArenaId"),
|
||||||
"mtgjson_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"),
|
"mtgjson_foil_version_id": identifiers.get("mtgjsonFoilVersionId"),
|
||||||
"mtgjson_non_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"),
|
"mtgjson_non_foil_version_id": identifiers.get("mtgjsonNonFoilVersionId"),
|
||||||
"mtgjson_v4_id": card_data.get("identifiers", {}).get("mtgjsonV4Id"),
|
"mtgjson_v4_id": identifiers.get("mtgjsonV4Id"),
|
||||||
"mtgo_foil_id": card_data.get("identifiers", {}).get("mtgoFoilId"),
|
"mtgo_foil_id": identifiers.get("mtgoFoilId"),
|
||||||
"mtgo_id": card_data.get("identifiers", {}).get("mtgoId"),
|
"mtgo_id": identifiers.get("mtgoId"),
|
||||||
"multiverse_id": card_data.get("identifiers", {}).get("multiverseId"),
|
"multiverse_id": identifiers.get("multiverseId"),
|
||||||
"scg_id": card_data.get("identifiers", {}).get("scgId"),
|
"scg_id": identifiers.get("scgId"),
|
||||||
"scryfall_id": card_data.get("identifiers", {}).get("scryfallId"),
|
"scryfall_id": identifiers.get("scryfallId"),
|
||||||
"scryfall_card_back_id": card_data.get("identifiers", {}).get("scryfallCardBackId"),
|
"scryfall_card_back_id": identifiers.get("scryfallCardBackId"),
|
||||||
"scryfall_oracle_id": card_data.get("identifiers", {}).get("scryfallOracleId"),
|
"scryfall_oracle_id": identifiers.get("scryfallOracleId"),
|
||||||
"scryfall_illustration_id": card_data.get("identifiers", {}).get("scryfallIllustrationId"),
|
"scryfall_illustration_id": identifiers.get("scryfallIllustrationId"),
|
||||||
"tcgplayer_product_id": card_data.get("identifiers", {}).get("tcgplayerProductId"),
|
"tcgplayer_product_id": identifiers.get("tcgplayerProductId"),
|
||||||
"tcgplayer_etched_product_id": card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"),
|
"tcgplayer_etched_product_id": identifiers.get("tcgplayerEtchedProductId"),
|
||||||
"tnt_id": card_data.get("identifiers", {}).get("tntId")
|
"tnt_id": identifiers.get("tntId")
|
||||||
}.items():
|
}
|
||||||
setattr(existing_card, key, value)
|
|
||||||
|
for k, v in updates.items():
|
||||||
|
if getattr(card, k) != v:
|
||||||
|
setattr(card, k, v)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
new_card = MTGJSONCard(
|
new_cards.append(MTGJSONCard(
|
||||||
mtgjson_uuid=card_data.get("uuid"),
|
mtgjson_uuid=uuid,
|
||||||
name=card_data.get("name"),
|
name=card_data.get("name"),
|
||||||
set_code=card_data.get("setCode"),
|
set_code=card_data.get("setCode"),
|
||||||
abu_id=card_data.get("identifiers", {}).get("abuId"),
|
abu_id=identifiers.get("abuId"),
|
||||||
card_kingdom_etched_id=card_data.get("identifiers", {}).get("cardKingdomEtchedId"),
|
card_kingdom_etched_id=identifiers.get("cardKingdomEtchedId"),
|
||||||
card_kingdom_foil_id=card_data.get("identifiers", {}).get("cardKingdomFoilId"),
|
card_kingdom_foil_id=identifiers.get("cardKingdomFoilId"),
|
||||||
card_kingdom_id=card_data.get("identifiers", {}).get("cardKingdomId"),
|
card_kingdom_id=identifiers.get("cardKingdomId"),
|
||||||
cardsphere_id=card_data.get("identifiers", {}).get("cardsphereId"),
|
cardsphere_id=identifiers.get("cardsphereId"),
|
||||||
cardsphere_foil_id=card_data.get("identifiers", {}).get("cardsphereFoilId"),
|
cardsphere_foil_id=identifiers.get("cardsphereFoilId"),
|
||||||
cardtrader_id=card_data.get("identifiers", {}).get("cardtraderId"),
|
cardtrader_id=identifiers.get("cardtraderId"),
|
||||||
csi_id=card_data.get("identifiers", {}).get("csiId"),
|
csi_id=identifiers.get("csiId"),
|
||||||
mcm_id=card_data.get("identifiers", {}).get("mcmId"),
|
mcm_id=identifiers.get("mcmId"),
|
||||||
mcm_meta_id=card_data.get("identifiers", {}).get("mcmMetaId"),
|
mcm_meta_id=identifiers.get("mcmMetaId"),
|
||||||
miniaturemarket_id=card_data.get("identifiers", {}).get("miniaturemarketId"),
|
miniaturemarket_id=identifiers.get("miniaturemarketId"),
|
||||||
mtg_arena_id=card_data.get("identifiers", {}).get("mtgArenaId"),
|
mtg_arena_id=identifiers.get("mtgArenaId"),
|
||||||
mtgjson_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"),
|
mtgjson_foil_version_id=identifiers.get("mtgjsonFoilVersionId"),
|
||||||
mtgjson_non_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"),
|
mtgjson_non_foil_version_id=identifiers.get("mtgjsonNonFoilVersionId"),
|
||||||
mtgjson_v4_id=card_data.get("identifiers", {}).get("mtgjsonV4Id"),
|
mtgjson_v4_id=identifiers.get("mtgjsonV4Id"),
|
||||||
mtgo_foil_id=card_data.get("identifiers", {}).get("mtgoFoilId"),
|
mtgo_foil_id=identifiers.get("mtgoFoilId"),
|
||||||
mtgo_id=card_data.get("identifiers", {}).get("mtgoId"),
|
mtgo_id=identifiers.get("mtgoId"),
|
||||||
multiverse_id=card_data.get("identifiers", {}).get("multiverseId"),
|
multiverse_id=identifiers.get("multiverseId"),
|
||||||
scg_id=card_data.get("identifiers", {}).get("scgId"),
|
scg_id=identifiers.get("scgId"),
|
||||||
scryfall_id=card_data.get("identifiers", {}).get("scryfallId"),
|
scryfall_id=identifiers.get("scryfallId"),
|
||||||
scryfall_card_back_id=card_data.get("identifiers", {}).get("scryfallCardBackId"),
|
scryfall_card_back_id=identifiers.get("scryfallCardBackId"),
|
||||||
scryfall_oracle_id=card_data.get("identifiers", {}).get("scryfallOracleId"),
|
scryfall_oracle_id=identifiers.get("scryfallOracleId"),
|
||||||
scryfall_illustration_id=card_data.get("identifiers", {}).get("scryfallIllustrationId"),
|
scryfall_illustration_id=identifiers.get("scryfallIllustrationId"),
|
||||||
tcgplayer_product_id=card_data.get("identifiers", {}).get("tcgplayerProductId"),
|
tcgplayer_product_id=identifiers.get("tcgplayerProductId"),
|
||||||
tcgplayer_etched_product_id=card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"),
|
tcgplayer_etched_product_id=identifiers.get("tcgplayerEtchedProductId"),
|
||||||
tnt_id=card_data.get("identifiers", {}).get("tntId")
|
tnt_id=identifiers.get("tntId")
|
||||||
)
|
))
|
||||||
db.add(new_card)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
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:
|
async def sync_mtgjson_skus(self, db: Session, skus_data: dict) -> int:
|
||||||
"""Sync MTGJSON SKUs data to the database"""
|
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
with db_transaction(db):
|
sku_details_by_key = {}
|
||||||
for mtgjson_uuid, product_data in skus_data['data'].items():
|
|
||||||
for sku_data in product_data:
|
for mtgjson_uuid, product_data in skus_data["data"].items():
|
||||||
existing_record = db.query(MTGJSONSKU).filter(MTGJSONSKU.mtgjson_uuid == mtgjson_uuid).filter(MTGJSONSKU.tcgplayer_sku_id == sku_data.get("skuId")).first()
|
for sku_data in product_data:
|
||||||
if existing_record:
|
sku_id = sku_data.get("skuId")
|
||||||
# Update existing SKU
|
if sku_id is None or sku_id in sku_details_by_key:
|
||||||
for key, value in {
|
continue # Skip if missing or already added
|
||||||
"tcgplayer_product_id": sku_data.get("productId"),
|
|
||||||
"condition": sku_data.get("condition"),
|
sku_details_by_key[sku_id] = {
|
||||||
"finish": sku_data.get("finish"),
|
"mtgjson_uuid": mtgjson_uuid,
|
||||||
"language": sku_data.get("language"),
|
"tcgplayer_sku_id": sku_id,
|
||||||
"printing": sku_data.get("printing"),
|
"tcgplayer_product_id": sku_data.get("productId"),
|
||||||
"normalized_printing": sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
|
"printing": sku_data.get("printing"),
|
||||||
}.items():
|
"normalized_printing": sku_data.get("printing", "").lower().replace(" ", "_").replace("non_foil", "normal") if sku_data.get("printing") else None,
|
||||||
setattr(existing_record, key, value)
|
"condition": sku_data.get("condition"),
|
||||||
else:
|
"finish": sku_data.get("finish"),
|
||||||
new_sku = MTGJSONSKU(
|
"language": sku_data.get("language"),
|
||||||
mtgjson_uuid=mtgjson_uuid,
|
}
|
||||||
tcgplayer_sku_id=sku_data.get("skuId"),
|
|
||||||
tcgplayer_product_id=sku_data.get("productId"),
|
with db_transaction(db):
|
||||||
condition=sku_data.get("condition"),
|
db.flush()
|
||||||
finish=sku_data.get("finish"),
|
|
||||||
language=sku_data.get("language"),
|
valid_uuids = {uuid for (uuid,) in db.query(MTGJSONCard.mtgjson_uuid).all()}
|
||||||
printing=sku_data.get("printing"),
|
valid_product_keys = {
|
||||||
normalized_printing=sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
|
(product.tcgplayer_product_id, product.normalized_sub_type_name)
|
||||||
)
|
for product in db.query(TCGPlayerProduct.tcgplayer_product_id, TCGPlayerProduct.normalized_sub_type_name)
|
||||||
db.add(new_sku)
|
}
|
||||||
count += 1
|
|
||||||
|
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(
|
async def initialize_data(
|
||||||
self,
|
self,
|
||||||
@ -693,19 +752,17 @@ class DataInitializationService(BaseService):
|
|||||||
with db_transaction(db):
|
with db_transaction(db):
|
||||||
logger.info("Initializing inventory data...")
|
logger.info("Initializing inventory data...")
|
||||||
# set expected value
|
# set expected value
|
||||||
product_id1 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562118").first().id
|
|
||||||
expected_value_box = SealedExpectedValue(
|
expected_value_box = SealedExpectedValue(
|
||||||
product_id=product_id1,
|
tcgplayer_product_id=619645,
|
||||||
expected_value=120.69
|
expected_value=136.42
|
||||||
)
|
)
|
||||||
db.add(expected_value_box)
|
db.add(expected_value_box)
|
||||||
db.flush()
|
#db.flush()
|
||||||
product_id2 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562119").first().id
|
#expected_value_case = SealedExpectedValue(
|
||||||
expected_value_case = SealedExpectedValue(
|
# tcgplayer_product_id=562119,
|
||||||
product_id=product_id2,
|
# expected_value=820.69
|
||||||
expected_value=820.69
|
#)
|
||||||
)
|
#db.add(expected_value_case)
|
||||||
db.add(expected_value_case)
|
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
inventory_service = self.get_service("inventory")
|
inventory_service = self.get_service("inventory")
|
||||||
@ -715,32 +772,38 @@ class DataInitializationService(BaseService):
|
|||||||
transaction = await inventory_service.create_purchase_transaction(db, PurchaseTransactionCreate(
|
transaction = await inventory_service.create_purchase_transaction(db, PurchaseTransactionCreate(
|
||||||
vendor_id=vendor.id,
|
vendor_id=vendor.id,
|
||||||
transaction_date=datetime.now(),
|
transaction_date=datetime.now(),
|
||||||
items=[PurchaseItem(product_id=product_id1, unit_price=100.69, quantity=1, is_case=False),
|
items=[PurchaseItem(product_id=619645, unit_price=100, quantity=1, is_case=False)],
|
||||||
PurchaseItem(product_id=product_id2, unit_price=800.01, quantity=2, is_case=True, num_boxes=6)],
|
transaction_notes="tdm real box test"
|
||||||
transaction_notes="Test Transaction: 1 case and 2 boxes of foundations"
|
#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}")
|
logger.info(f"Transaction created: {transaction}")
|
||||||
case_num = 0
|
case_num = 0
|
||||||
for item in transaction.transaction_items:
|
for item in transaction.transaction_items:
|
||||||
item = InventoryItemContextFactory(db).get_context(item.physical_item.inventory_item)
|
|
||||||
logger.info(f"Item: {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")
|
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()
|
file_bytes = open(file_path, 'rb').read()
|
||||||
manabox_file = await manabox_service.process_manabox_csv(db, file_bytes, {"source": "test", "description": "test"}, wait=True)
|
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
|
# Ensure manabox_file is a list before passing it
|
||||||
if not isinstance(manabox_file, list):
|
if not isinstance(manabox_file, list):
|
||||||
manabox_file = [manabox_file]
|
manabox_file = [manabox_file]
|
||||||
sealed_box_service = self.get_service("sealed_box")
|
box_service = self.get_service("box")
|
||||||
sealed_box = sealed_box_service.get(db, item.physical_item.inventory_item.id)
|
open_event = await box_service.open_box(db, item.inventory_item.physical_item, manabox_file)
|
||||||
success = await inventory_service.process_manabox_import_staging(db, manabox_file, sealed_box)
|
# get all cards from box
|
||||||
logger.info(f"sealed box opening success: {success}")
|
cards = open_event.resulting_items if open_event.resulting_items else []
|
||||||
elif item.physical_item.item_type == "sealed_case":
|
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:
|
if case_num == 0:
|
||||||
logger.info(f"sealed case {case_num} opening...")
|
logger.info(f"sealed case {case_num} opening...")
|
||||||
sealed_case_service = self.get_service("sealed_case")
|
case_service = self.get_service("case")
|
||||||
success = await sealed_case_service.open_sealed_case(db, item.physical_item)
|
success = await case_service.open_case(db, item.inventory_item.physical_item, 562119)
|
||||||
logger.info(f"sealed case {case_num} opening success: {success}")
|
logger.info(f"sealed case {case_num} opening success: {success}")
|
||||||
case_num += 1
|
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 app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.schemas.file import FileInDB
|
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):
|
class TCGPlayerInventoryService(BaseTCGPlayerService):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -24,10 +29,101 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
|
|||||||
raise ValueError(f"Invalid export type: {export_type}, must be 'staged', 'live', or 'pricing'")
|
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)
|
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,
|
db=db,
|
||||||
file_data=file_bytes,
|
file_data=file_bytes,
|
||||||
file_name=f"tcgplayer_{export_type}_export.csv",
|
filename=f"tcgplayer_{export_type}_export.csv",
|
||||||
subdir="tcgplayer/inventory",
|
subdir="tcgplayer/inventory",
|
||||||
file_type=file_type
|
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:
|
if file_record:
|
||||||
return FileInDB.model_validate(file_record)
|
return FileInDB.model_validate(file_record)
|
||||||
return None
|
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.models.manabox_import_staging import ManaboxImportStaging
|
||||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||||
from app.models.inventory_management import (
|
from app.models.inventory_management import (
|
||||||
SealedBox, OpenEvent, OpenBox, OpenCard, InventoryItem, SealedCase,
|
OpenEvent, Card, InventoryItem, Case,
|
||||||
Transaction, TransactionItem, Customer, Vendor, Marketplace
|
Transaction, TransactionItem, Customer, Vendor, Marketplace, Box, MarketplaceListing
|
||||||
)
|
)
|
||||||
from app.schemas.file import FileInDB
|
from app.schemas.file import FileInDB
|
||||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse
|
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse
|
||||||
@ -19,92 +19,6 @@ logger = logging.getLogger(__name__)
|
|||||||
class InventoryService(BaseService):
|
class InventoryService(BaseService):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(None)
|
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(
|
async def create_purchase_transaction(
|
||||||
self,
|
self,
|
||||||
@ -125,31 +39,31 @@ class InventoryService(BaseService):
|
|||||||
vendor_id=transaction_data.vendor_id,
|
vendor_id=transaction_data.vendor_id,
|
||||||
transaction_type='purchase',
|
transaction_type='purchase',
|
||||||
transaction_date=transaction_data.transaction_date,
|
transaction_date=transaction_data.transaction_date,
|
||||||
transaction_notes=transaction_data.transaction_notes,
|
transaction_notes=transaction_data.transaction_notes
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(transaction)
|
db.add(transaction)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
total_amount = 0
|
total_amount = 0
|
||||||
physical_items = []
|
physical_items = []
|
||||||
|
case_service = self.get_service("case")
|
||||||
|
box_service = self.get_service("box")
|
||||||
for item in transaction_data.items:
|
for item in transaction_data.items:
|
||||||
# Create the physical item based on type
|
# Create the physical item based on type
|
||||||
# TODO: remove is_case and num_boxes, should derive from product_id
|
# TODO: remove is_case and num_boxes, should derive from product_id
|
||||||
# TODO: add support for purchasing single cards
|
# TODO: add support for purchasing single cards
|
||||||
if item.is_case:
|
if item.is_case:
|
||||||
for i in range(item.quantity):
|
for i in range(item.quantity):
|
||||||
physical_item = await SealedCaseService().create_sealed_case(
|
physical_item = await case_service.create_case(
|
||||||
db=db,
|
db=db,
|
||||||
product_id=item.product_id,
|
product_id=item.product_id,
|
||||||
cost_basis=item.unit_price,
|
cost_basis=item.unit_price,
|
||||||
num_boxes=item.num_boxes or 1
|
num_boxes=item.num_boxes
|
||||||
)
|
)
|
||||||
physical_items.append(physical_item)
|
physical_items.append(physical_item)
|
||||||
else:
|
else:
|
||||||
for i in range(item.quantity):
|
for i in range(item.quantity):
|
||||||
physical_item = await SealedBoxService().create_sealed_box(
|
physical_item = await box_service.create_box(
|
||||||
db=db,
|
db=db,
|
||||||
product_id=item.product_id,
|
product_id=item.product_id,
|
||||||
cost_basis=item.unit_price
|
cost_basis=item.unit_price
|
||||||
@ -158,73 +72,10 @@ class InventoryService(BaseService):
|
|||||||
|
|
||||||
for physical_item in physical_items:
|
for physical_item in physical_items:
|
||||||
# Create transaction item
|
# Create transaction item
|
||||||
transaction_item = TransactionItem(
|
transaction.transaction_items.append(TransactionItem(
|
||||||
transaction_id=transaction.id,
|
inventory_item_id=physical_item.inventory_item.id,
|
||||||
physical_item_id=physical_item.id,
|
unit_price=item.unit_price
|
||||||
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()
|
|
||||||
|
|
||||||
# Update transaction total
|
# Update transaction total
|
||||||
transaction.transaction_total_amount = total_amount
|
transaction.transaction_total_amount = total_amount
|
||||||
@ -246,9 +97,7 @@ class InventoryService(BaseService):
|
|||||||
|
|
||||||
with db_transaction(db):
|
with db_transaction(db):
|
||||||
customer = Customer(
|
customer = Customer(
|
||||||
name=customer_name,
|
name=customer_name
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(customer)
|
db.add(customer)
|
||||||
db.flush()
|
db.flush()
|
||||||
@ -270,9 +119,7 @@ class InventoryService(BaseService):
|
|||||||
|
|
||||||
with db_transaction(db):
|
with db_transaction(db):
|
||||||
vendor = Vendor(
|
vendor = Vendor(
|
||||||
name=vendor_name,
|
name=vendor_name
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(vendor)
|
db.add(vendor)
|
||||||
db.flush()
|
db.flush()
|
||||||
@ -294,9 +141,7 @@ class InventoryService(BaseService):
|
|||||||
|
|
||||||
with db_transaction(db):
|
with db_transaction(db):
|
||||||
marketplace = Marketplace(
|
marketplace = Marketplace(
|
||||||
name=marketplace_name,
|
name=marketplace_name
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(marketplace)
|
db.add(marketplace)
|
||||||
db.flush()
|
db.flush()
|
||||||
@ -305,110 +150,144 @@ class InventoryService(BaseService):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
class SealedBoxService(BaseService[SealedBox]):
|
class BoxService(BaseService[Box]):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(SealedBox)
|
super().__init__(Box)
|
||||||
|
|
||||||
async def create_sealed_box(
|
async def create_box(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
product_id: int,
|
product_id: int,
|
||||||
cost_basis: float,
|
cost_basis: float
|
||||||
case_id: Optional[int] = None
|
) -> Box:
|
||||||
) -> SealedBox:
|
|
||||||
try:
|
try:
|
||||||
with db_transaction(db):
|
with db_transaction(db):
|
||||||
# Create the SealedBox
|
# Create the SealedBox
|
||||||
sealed_box = SealedBox(
|
box = Box(
|
||||||
product_id=product_id,
|
tcgplayer_product_id=product_id
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(sealed_box)
|
db.add(box)
|
||||||
db.flush() # Get the ID for relationships
|
db.flush() # Get the ID for relationships
|
||||||
|
|
||||||
# If this box is part of a case, link it
|
expected_value = box.products.sealed_expected_value.expected_value
|
||||||
if case_id:
|
box.expected_value = expected_value
|
||||||
case = db.query(SealedCase).filter(SealedCase.id == case_id).first()
|
db.flush()
|
||||||
if not case:
|
|
||||||
raise ValueError(f"Case {case_id} not found")
|
|
||||||
sealed_box.case_id = case_id
|
|
||||||
|
|
||||||
# Create the InventoryItem for the sealed box
|
# Create the InventoryItem for the sealed box
|
||||||
inventory_item = InventoryItem(
|
inventory_item = InventoryItem(
|
||||||
physical_item=sealed_box,
|
physical_item=box,
|
||||||
cost_basis=cost_basis,
|
cost_basis=cost_basis
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(inventory_item)
|
db.add(inventory_item)
|
||||||
|
|
||||||
return sealed_box
|
return box
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
class SealedCaseService(BaseService[SealedCase]):
|
async def calculate_cost_basis_for_opened_cards(self, db: Session, open_event: OpenEvent) -> float:
|
||||||
def __init__(self):
|
box_cost_basis = open_event.source_item.inventory_item.cost_basis
|
||||||
super().__init__(SealedCase)
|
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:
|
try:
|
||||||
with db_transaction(db):
|
with db_transaction(db):
|
||||||
# Create the SealedCase
|
# Create the SealedCase
|
||||||
sealed_case = SealedCase(
|
case = Case(
|
||||||
product_id=product_id,
|
tcgplayer_product_id=product_id,
|
||||||
num_boxes=num_boxes,
|
num_boxes=num_boxes
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(sealed_case)
|
db.add(case)
|
||||||
db.flush() # Get the ID for relationships
|
db.flush() # Get the ID for relationships
|
||||||
|
case.expected_value = case.products.sealed_expected_value.expected_value
|
||||||
|
|
||||||
# Create the InventoryItem for the sealed case
|
# Create the InventoryItem for the sealed case
|
||||||
inventory_item = InventoryItem(
|
inventory_item = InventoryItem(
|
||||||
physical_item=sealed_case,
|
physical_item=case,
|
||||||
cost_basis=cost_basis,
|
cost_basis=cost_basis
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(inventory_item)
|
db.add(inventory_item)
|
||||||
|
|
||||||
return sealed_case
|
return case
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise 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:
|
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):
|
with db_transaction(db):
|
||||||
# Create the OpenEvent
|
# Create the OpenEvent
|
||||||
open_event = OpenEvent(
|
open_event = OpenEvent(
|
||||||
sealed_case_id=sealed_case_context.physical_item.id,
|
source_item=case,
|
||||||
open_date=datetime.now(),
|
open_date=datetime.now()
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(open_event)
|
db.add(open_event)
|
||||||
db.flush() # Get the ID for relationships
|
db.flush() # Get the ID for relationships
|
||||||
|
|
||||||
# Create num_boxes SealedBoxes
|
# Create num_boxes SealedBoxes
|
||||||
for i in range(sealed_case.num_boxes):
|
for i in range(case.num_boxes):
|
||||||
sealed_box = SealedBox(
|
new_box = Box(
|
||||||
product_id=sealed_case_context.physical_item.product_id,
|
tcgplayer_product_id=child_product_id
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(sealed_box)
|
open_event.resulting_items.append(new_box)
|
||||||
db.flush() # Get the ID for relationships
|
db.flush()
|
||||||
|
|
||||||
|
per_box_cost_basis = case.inventory_item.cost_basis / case.num_boxes
|
||||||
|
|
||||||
# Create the InventoryItem for the sealed box
|
# Create the InventoryItem for the sealed box
|
||||||
inventory_item = InventoryItem(
|
inventory_item = InventoryItem(
|
||||||
physical_item=sealed_box,
|
physical_item=new_box,
|
||||||
cost_basis=sealed_case_context.cost_basis,
|
cost_basis=per_box_cost_basis
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now()
|
|
||||||
)
|
)
|
||||||
db.add(inventory_item)
|
db.add(inventory_item)
|
||||||
|
|
||||||
@ -416,3 +295,37 @@ class SealedCaseService(BaseService[SealedCase]):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise 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
|
# Read the CSV file
|
||||||
with open(file.path, 'r') as csv_file:
|
with open(file.path, 'r') as csv_file:
|
||||||
reader = csv.DictReader(csv_file)
|
reader = csv.DictReader(csv_file)
|
||||||
# skip header row
|
# Pre-fetch all MTGJSONCards for Scryfall IDs in the file
|
||||||
next(reader)
|
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:
|
for row in reader:
|
||||||
# match scryfall id to mtgjson scryfall id, make sure only one distinct tcgplayer id
|
mtg_json = mtg_json_map.get(row['Scryfall 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()
|
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error processing file: {str(e)}")
|
logger.error(f"Error processing file: {str(e)}")
|
||||||
with transaction(db):
|
with transaction(db):
|
||||||
critical_error_log = CriticalErrorLog(
|
critical_error_log = CriticalErrorLog(error_message=f"Error processing file: {str(e)}")
|
||||||
error_message=f"Error processing file: {str(e)}"
|
|
||||||
)
|
|
||||||
db.add(critical_error_log)
|
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 app.services.base_service import BaseService
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
import logging
|
import logging
|
||||||
|
from app.models.tcgplayer_inventory import UnmanagedTCGPlayerInventory, TCGPlayerInventory
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SchedulerService(BaseService):
|
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
|
# Initialize BaseService with None as model since this service doesn't have a specific model
|
||||||
super().__init__(None)
|
super().__init__(None)
|
||||||
self.scheduler = BaseScheduler()
|
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):
|
async def update_open_orders_hourly(self, db):
|
||||||
"""
|
"""
|
||||||
@ -64,6 +55,19 @@ class SchedulerService(BaseService):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating all orders: {str(e)}")
|
logger.error(f"Error updating all orders: {str(e)}")
|
||||||
raise
|
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):
|
async def start_scheduled_tasks(self, db):
|
||||||
"""Start all scheduled tasks"""
|
"""Start all scheduled tasks"""
|
||||||
@ -79,11 +83,11 @@ class SchedulerService(BaseService):
|
|||||||
func=lambda: self.update_all_orders_daily(db),
|
func=lambda: self.update_all_orders_daily(db),
|
||||||
cron_expression="0 3 * * *" # Run at 3:00 AM every day
|
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(
|
await self.scheduler.schedule_task(
|
||||||
task_name="update_tcgplayer_price_history_daily",
|
task_name="refresh_tcgplayer_inventory_table",
|
||||||
func=lambda: self.update_tcgplayer_price_history_daily(db),
|
func=lambda: self.refresh_tcgplayer_inventory_table(db),
|
||||||
cron_expression="0 1 * * *" # Run at 1:00 AM every day
|
cron_expression="21 */3 * * *" # Run at minute 0 of every 3rd hour
|
||||||
)
|
)
|
||||||
|
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
|
@ -30,9 +30,11 @@ class ServiceManager:
|
|||||||
'tcgcsv': 'app.services.external_api.tcgcsv.tcgcsv_service.TCGCSVService',
|
'tcgcsv': 'app.services.external_api.tcgcsv.tcgcsv_service.TCGCSVService',
|
||||||
'mtgjson': 'app.services.external_api.mtgjson.mtgjson_service.MTGJSONService',
|
'mtgjson': 'app.services.external_api.mtgjson.mtgjson_service.MTGJSONService',
|
||||||
'manabox': 'app.services.manabox_service.ManaboxService',
|
'manabox': 'app.services.manabox_service.ManaboxService',
|
||||||
|
'pricing': 'app.services.pricing_service.PricingService',
|
||||||
'inventory': 'app.services.inventory_service.InventoryService',
|
'inventory': 'app.services.inventory_service.InventoryService',
|
||||||
'sealed_box': 'app.services.inventory_service.SealedBoxService',
|
'box': 'app.services.inventory_service.BoxService',
|
||||||
'sealed_case': 'app.services.inventory_service.SealedCaseService'
|
'case': 'app.services.inventory_service.CaseService',
|
||||||
|
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService'
|
||||||
|
|
||||||
}
|
}
|
||||||
self._service_configs = {
|
self._service_configs = {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user