Compare commits
5 Commits
d75e20ff2c
...
main
Author | SHA1 | Date | |
---|---|---|---|
dca11b0ede | |||
f2c2b69d63 | |||
5c85411c69 | |||
11aa4cda16 | |||
c9bba8a26e |
@@ -28,13 +28,6 @@ target_metadata = Base.metadata
|
||||
# ... 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:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
@@ -53,7 +46,6 @@ def run_migrations_offline() -> None:
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
include_object=include_object,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
@@ -77,7 +69,6 @@ def run_migrations_online() -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
)
|
||||
|
||||
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:
|
||||
Create Date: 2025-04-24 22:36:02.509040
|
||||
Create Date: 2025-04-25 14:34:28.206737
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd0792389ab20'
|
||||
revision: str = 'cf61f006db46'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: 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_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',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
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_oracle_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_etched_product_id', sa.String(), nullable=True),
|
||||
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('tcgplayer_etched_product_id', sa.Integer(), 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('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_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.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_table('tcgplayer_categories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
@@ -238,6 +258,7 @@ def upgrade() -> None:
|
||||
op.create_table('manabox_import_staging',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
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('quantity', sa.Integer(), 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('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), 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.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_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)
|
||||
@@ -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_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',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('physical_item_id', sa.Integer(), nullable=True),
|
||||
@@ -383,26 +424,18 @@ def upgrade() -> None:
|
||||
sa.UniqueConstraint('physical_item_id')
|
||||
)
|
||||
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('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('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('source_item_id', sa.Integer(), nullable=True),
|
||||
sa.Column('open_date', sa.DateTime(timezone=True), 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('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['physical_item_id'], ['physical_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
sa.ForeignKeyConstraint(['source_item_id'], ['physical_items.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',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('inventory_item_id', sa.Integer(), nullable=False),
|
||||
@@ -418,63 +451,44 @@ def upgrade() -> None:
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_marketplace_listings_id'), 'marketplace_listings', ['id'], unique=False)
|
||||
op.create_table('sealed_boxes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('case_id', sa.Integer(), nullable=True),
|
||||
sa.Column('expected_value', sa.Float(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['case_id'], ['sealed_cases.id'], ),
|
||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table('open_event_resulting_items',
|
||||
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||
sa.Column('item_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['event_id'], ['open_events.id'], ),
|
||||
sa.ForeignKeyConstraint(['item_id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('event_id', 'item_id')
|
||||
)
|
||||
op.create_table('open_events',
|
||||
op.create_table('transaction_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('sealed_case_id', sa.Integer(), nullable=True),
|
||||
sa.Column('sealed_box_id', sa.Integer(), nullable=True),
|
||||
sa.Column('open_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('transaction_id', sa.Integer(), nullable=True),
|
||||
sa.Column('inventory_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('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['sealed_box_id'], ['sealed_boxes.id'], ),
|
||||
sa.ForeignKeyConstraint(['sealed_case_id'], ['sealed_cases.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.ForeignKeyConstraint(['inventory_item_id'], ['inventory_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transaction_items_id'), 'transaction_items', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### 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_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_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_id'), table_name='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_table('tcgplayer_categories')
|
||||
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_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_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_id'), table_name='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):
|
||||
self.item = item
|
||||
self.physical_item = item.physical_item
|
||||
self.marketplace_listings = item.marketplace_listings
|
||||
self.marketplace_listing = item.marketplace_listing
|
||||
self.parent = item.parent
|
||||
self.children = item.children
|
||||
self.product = item.product
|
||||
self.product = item.products
|
||||
self.db = db
|
||||
|
||||
@property
|
||||
@@ -44,9 +44,9 @@ class InventoryItemContext:
|
||||
|
||||
@property
|
||||
def listed_price(self) -> float:
|
||||
if not self.marketplace_listings:
|
||||
if not self.marketplace_listing:
|
||||
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":
|
||||
if self.parent:
|
||||
@@ -71,9 +71,9 @@ class InventoryItemContext:
|
||||
raise ValueError("Cannot find transaction unit price for this item")
|
||||
|
||||
def age_on_marketplace(self) -> int:
|
||||
if not self.marketplace_listings:
|
||||
if not self.marketplace_listing:
|
||||
return 0
|
||||
return (datetime.now() - self.marketplace_listings[0].listing_date).days
|
||||
return (datetime.now() - self.marketplace_listing[0].listing_date).days
|
||||
|
||||
|
||||
class InventoryItemContextFactory:
|
||||
|
94
app/data/test_data/dragon.csv
Normal file
94
app/data/test_data/dragon.csv
Normal file
@@ -0,0 +1,94 @@
|
||||
Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase price,Misprint,Altered,Condition,Language,Purchase price currency
|
||||
Undergrowth Leopard,TDM,Tarkir: Dragonstorm,165,foil,common,1,104307,67ab8f9a-b17c-452f-b4ef-a3f91909e3de,0.08,false,false,near_mint,en,USD
|
||||
Gurmag Nightwatch,TDM,Tarkir: Dragonstorm,190,foil,common,1,104369,de731430-6bbf-4782-953e-b69c46353959,0.03,false,false,near_mint,en,USD
|
||||
Mystic Monastery,TDM,Tarkir: Dragonstorm,262,foil,uncommon,1,104945,c7b8a01c-c400-47c7-8270-78902efe850e,0.21,false,false,near_mint,en,USD
|
||||
Stormshriek Feral // Flush Out,TDM,Tarkir: Dragonstorm,124,foil,common,1,104447,0ec92c44-7cf0-48a5-a3ca-bc633496d887,0.1,false,false,near_mint,en,USD
|
||||
Reigning Victor,TDM,Tarkir: Dragonstorm,216,foil,common,1,104334,a394112a-032b-4047-887a-6522cf7b83d5,0.02,false,false,near_mint,en,USD
|
||||
Dragonbroods' Relic,TDM,Tarkir: Dragonstorm,140,foil,uncommon,1,104569,3d634087-77ba-4543-aa7a-8a3774d69cd7,0.13,false,false,near_mint,en,USD
|
||||
Sagu Wildling // Roost Seek,TDM,Tarkir: Dragonstorm,306,foil,common,1,104903,b72ee8f9-5e79-4f77-ae7e-e4c274f78187,0.13,false,false,near_mint,en,USD
|
||||
Sibsig Appraiser,TDM,Tarkir: Dragonstorm,56,foil,common,1,105135,670c5b96-bac6-449b-a2bd-cb43750d3911,0.05,false,false,near_mint,en,USD
|
||||
Sage of the Fang,TDM,Tarkir: Dragonstorm,155,foil,uncommon,1,105123,1ebf4a9d-d90c-4017-9f00-fca89899f301,0.09,false,false,near_mint,en,USD
|
||||
Snowmelt Stag,TDM,Tarkir: Dragonstorm,57,foil,common,1,104869,a6b3b131-704a-4586-84f8-db465cd4a277,0.04,false,false,near_mint,en,USD
|
||||
Tranquil Cove,TDM,Tarkir: Dragonstorm,270,foil,common,1,104249,1c4efa6c-4f29-41cd-a728-bf0e479ace05,0.07,false,false,near_mint,en,USD
|
||||
Rally the Monastery,TDM,Tarkir: Dragonstorm,19,foil,uncommon,1,104136,b56e0037-8143-4c13-83e1-0c3f44e685ea,0.22,false,false,near_mint,en,USD
|
||||
Dragon's Prey,TDM,Tarkir: Dragonstorm,79,foil,common,1,104754,7a6004ff-4180-4332-8b51-960f8c7521d9,0.03,false,false,near_mint,en,USD
|
||||
Ambling Stormshell,TDM,Tarkir: Dragonstorm,37,foil,rare,1,104942,c74d4a57-0f66-4965-9ed7-f88a08aa1d15,0.45,false,false,near_mint,en,USD
|
||||
Mountain,TDM,Tarkir: Dragonstorm,275,foil,common,1,104397,fe0865ba-47c0-40bc-b0c6-e1ea5ae08a98,1.83,false,false,near_mint,en,USD
|
||||
Mardu Devotee,TDM,Tarkir: Dragonstorm,16,foil,common,1,104366,da45e9b0-a4f6-413b-9e62-666c511eb5b0,0.09,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
|
||||
Adorned Crocodile,TDM,Tarkir: Dragonstorm,69,foil,common,1,105159,bb13a34b-6ac8-47cb-9e91-47106a585fc1,0.05,false,false,near_mint,en,USD
|
||||
Dusyut Earthcarver,TDM,Tarkir: Dragonstorm,141,foil,common,1,104352,b98ecc96-f557-479a-8685-2b5487d5b407,0.02,false,false,near_mint,en,USD
|
||||
Knockout Maneuver,TDM,Tarkir: Dragonstorm,147,foil,uncommon,1,105149,9d218831-2a41-46a3-8e9d-93462cae5cab,0.07,false,false,near_mint,en,USD
|
||||
Roiling Dragonstorm,TDM,Tarkir: Dragonstorm,55,foil,uncommon,1,104280,455f4c96-684b-4b14-bd21-6799da2e1fa7,0.22,false,false,near_mint,en,USD
|
||||
Dragonclaw Strike,TDM,Tarkir: Dragonstorm,180,foil,uncommon,1,105161,bc7692ef-7091-4365-85a8-1edbd374f279,0.12,false,false,near_mint,en,USD
|
||||
Seize Opportunity,TDM,Tarkir: Dragonstorm,119,foil,common,1,104391,f7818d28-b9a5-4341-9adc-666070b8878d,0.03,false,false,near_mint,en,USD
|
||||
Shock Brigade,TDM,Tarkir: Dragonstorm,120,foil,common,1,104700,66940466-8e9d-4a85-bfb0-e92189b7a121,0.11,false,false,near_mint,en,USD
|
||||
Hardened Tactician,TDM,Tarkir: Dragonstorm,191,foil,uncommon,1,104780,86b225cb-5c45-4da1-a64e-b04091e483e8,0.43,false,false,near_mint,en,USD
|
||||
Stormplain Detainment,TDM,Tarkir: Dragonstorm,28,foil,common,1,104135,39f3aab5-7b54-4b55-8114-c6f9f79c255d,0.04,false,false,near_mint,en,USD
|
||||
Formation Breaker,TDM,Tarkir: Dragonstorm,143,foil,uncommon,1,105136,67ab8e8f-3ef6-4339-8c66-68c5aca4867a,0.08,false,false,near_mint,en,USD
|
||||
Duty Beyond Death,TDM,Tarkir: Dragonstorm,10,foil,uncommon,1,104265,2e92640d-768b-4357-905f-bea017d351cc,1.11,false,false,near_mint,en,USD
|
||||
Piercing Exhale,TDM,Tarkir: Dragonstorm,151,foil,common,1,104891,b2a0deb9-5bc3-42d5-9e1e-5f463d176aef,0.04,false,false,near_mint,en,USD
|
||||
Trade Route Envoy,TDM,Tarkir: Dragonstorm,163,foil,common,1,105174,f0c89d95-d697-4cfa-9dfa-52d7adb96176,0.05,false,false,near_mint,en,USD
|
||||
Thornwood Falls,TDM,Tarkir: Dragonstorm,269,foil,common,1,104376,ebb502c2-5fd0-46a9-b77d-010f4a942056,0.07,false,false,near_mint,en,USD
|
||||
Kin-Tree Nurturer,TDM,Tarkir: Dragonstorm,83,foil,common,1,105124,2177ef64-28bf-4acf-b1f1-c1408f03c411,0.03,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
|
||||
Scoured Barrens,TDM,Tarkir: Dragonstorm,267,foil,common,1,104346,b4b47b80-69ed-44b0-afa0-ca90206dc16d,0.06,false,false,near_mint,en,USD
|
||||
Avenger of the Fallen,TDM,Tarkir: Dragonstorm,73,foil,rare,1,104984,d5397366-151f-46b0-b9b2-fa4d5bd892d8,0.68,false,false,near_mint,en,USD
|
||||
Dismal Backwater,TDM,Tarkir: Dragonstorm,254,foil,common,1,104238,082b52c9-c46e-44d3-b723-546ba528e07b,0.07,false,false,near_mint,en,USD
|
||||
Rediscover the Way,TDM,Tarkir: Dragonstorm,215,normal,rare,1,104313,79d6decf-afd5-4e96-b87e-fd7ab7e3c068,0.19,false,false,near_mint,en,USD
|
||||
Auroral Procession,TDM,Tarkir: Dragonstorm,169,normal,uncommon,1,104701,672f94ad-65d6-4c7d-925d-165ef264626f,0.22,false,false,near_mint,en,USD
|
||||
Runescale Stormbrood // Chilling Screech,TDM,Tarkir: Dragonstorm,316,normal,uncommon,1,104733,72e8f916-5a01-4918-bcb5-7fd69fe32785,0.31,false,false,near_mint,en,USD
|
||||
Stadium Headliner,TDM,Tarkir: Dragonstorm,122,normal,rare,1,104552,37d4ab2a-a06a-4768-b5e1-e1def957d7f4,0.44,false,false,near_mint,en,USD
|
||||
Nature's Rhythm,TDM,Tarkir: Dragonstorm,150,normal,rare,1,104460,1397d904-c51d-451e-8505-7f3118acc1f6,3.08,false,false,near_mint,en,USD
|
||||
Karakyk Guardian,TDM,Tarkir: Dragonstorm,198,normal,uncommon,1,104859,a4c77b08-c3f6-4458-8636-f226f9843b6d,0.08,false,false,near_mint,en,USD
|
||||
"Anafenza, Unyielding Lineage",TDM,Tarkir: Dragonstorm,2,normal,rare,1,104258,29957f49-9a6b-42f6-b2fb-b48f653ab725,0.22,false,false,near_mint,en,USD
|
||||
"Narset, Jeskai Waymaster",TDM,Tarkir: Dragonstorm,209,normal,rare,1,103995,6b77cbc1-dbc8-44d9-aa29-15cbb19afecd,0.22,false,false,near_mint,en,USD
|
||||
Stillness in Motion,TDM,Tarkir: Dragonstorm,59,normal,rare,1,104864,a6289251-17e4-4987-96b9-2fb1a8f90e2a,0.17,false,false,near_mint,en,USD
|
||||
Thunder of Unity,TDM,Tarkir: Dragonstorm,231,normal,rare,1,104671,5c953b36-f5e4-4258-91cb-f07e799321f7,0.14,false,false,near_mint,en,USD
|
||||
The Sibsig Ceremony,TDM,Tarkir: Dragonstorm,340,normal,rare,1,104719,6daa156c-478f-47dd-9284-b95e82ccfd68,0.67,false,false,near_mint,en,USD
|
||||
Tersa Lightshatter,TDM,Tarkir: Dragonstorm,127,normal,rare,1,104825,99e96b34-b1c4-4647-a38e-2cf1aedaaace,2.31,false,false,near_mint,en,USD
|
||||
Forest,TDM,Tarkir: Dragonstorm,286,normal,common,1,104324,8e3e83d2-96ba-4d5c-a1ed-6c08a90b339c,0.07,false,false,near_mint,en,USD
|
||||
Windcrag Siege,TDM,Tarkir: Dragonstorm,235,normal,rare,1,104534,31a8329b-23a1-4c49-a579-a5da8d01435a,1.68,false,false,near_mint,en,USD
|
||||
Mountain,TDM,Tarkir: Dragonstorm,284,normal,common,1,104274,3df7c206-97b6-49d7-ba01-7a35fd8c61d9,0.05,false,false,near_mint,en,USD
|
||||
Inevitable Defeat,TDM,Tarkir: Dragonstorm,194,normal,rare,1,103997,9d677980-b608-407e-9f17-790a81263f15,0.28,false,false,near_mint,en,USD
|
||||
Dalkovan Encampment,TDM,Tarkir: Dragonstorm,394,normal,rare,1,104670,5af006f6-135e-4ea0-8ce4-7824934e87da,0.72,false,false,near_mint,en,USD
|
||||
Host of the Hereafter,TDM,Tarkir: Dragonstorm,193,normal,uncommon,1,104448,0f182957-8133-45a7-80a3-1944bead4d43,0.14,false,false,near_mint,en,USD
|
||||
"Sarkhan, Dragon Ascendant",TDM,Tarkir: Dragonstorm,118,normal,rare,1,104003,c2200646-7b7c-489d-bbae-16b03e1d7fb2,0.32,false,false,near_mint,en,USD
|
||||
Stormscale Scion,TDM,Tarkir: Dragonstorm,123,normal,mythic,1,103987,0ac43386-bd32-425c-8776-cec00b064cbc,6.78,false,false,near_mint,en,USD
|
||||
Dragon Sniper,TDM,Tarkir: Dragonstorm,139,normal,uncommon,1,105120,074b1e00-45bb-4436-8f5e-058512b2d08a,0.25,false,false,near_mint,en,USD
|
||||
Island,TDM,Tarkir: Dragonstorm,273,normal,common,1,104276,4208e66c-8c98-4c48-ab07-8523c0b26ca4,1.02,false,false,near_mint,en,USD
|
||||
Yathan Roadwatcher,TDM,Tarkir: Dragonstorm,236,normal,rare,1,104800,8e77339b-dd82-481c-9ee2-4156ca69ad35,0.14,false,false,near_mint,en,USD
|
||||
Nomad Outpost,TDM,Tarkir: Dragonstorm,263,normal,uncommon,1,104868,a68fbeaa-941f-4d53-becd-f93ed22b9a54,0.12,false,false,near_mint,en,USD
|
||||
Sage of the Skies,TDM,Tarkir: Dragonstorm,22,normal,rare,1,104710,6ade6918-6d1d-448d-ab56-93996051e9a9,0.21,false,false,near_mint,en,USD
|
||||
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,199,normal,uncommon,1,104798,8d11183a-57f5-4ddb-8a6e-15fff704b114,0.18,false,false,near_mint,en,USD
|
||||
All-Out Assault,TDM,Tarkir: Dragonstorm,167,normal,mythic,1,104348,b74876d8-f6a6-4b47-b960-b01a331bab01,4.11,false,false,near_mint,en,USD
|
||||
Winternight Stories,TDM,Tarkir: Dragonstorm,67,normal,rare,1,104693,64d9367c-f50c-4568-aa63-6760c44ecaeb,0.44,false,false,near_mint,en,USD
|
||||
New Way Forward,TDM,Tarkir: Dragonstorm,211,normal,rare,1,104996,d9d48f9e-79f0-478c-9db0-ff7ac4a8f401,0.17,false,false,near_mint,en,USD
|
||||
Strategic Betrayal,TDM,Tarkir: Dragonstorm,94,normal,uncommon,1,105145,95617742-548d-464a-bb89-a858ffa9018f,0.18,false,false,near_mint,en,USD
|
||||
Opulent Palace,TDM,Tarkir: Dragonstorm,264,normal,uncommon,1,104491,21cb3b3b-0738-4c2e-a3fc-927fd6b9d3fb,0.14,false,false,near_mint,en,USD
|
||||
United Battlefront,TDM,Tarkir: Dragonstorm,32,normal,rare,1,104370,dff398be-4ba4-4976-9acc-be99d2e07a61,0.51,false,false,near_mint,en,USD
|
||||
Temur Battlecrier,TDM,Tarkir: Dragonstorm,228,normal,rare,1,104309,72184791-0767-4108-920c-763e92dae2d4,0.68,false,false,near_mint,en,USD
|
||||
"Kotis, the Fangkeeper",TDM,Tarkir: Dragonstorm,362,normal,rare,1,104388,f70098f2-e5a8-4056-b5b3-1229fc290c51,0.48,false,false,near_mint,en,USD
|
||||
Forest,TDM,Tarkir: Dragonstorm,285,normal,common,1,104317,8100bceb-ffba-487a-bb45-4fe2a156a8dc,0.06,false,false,near_mint,en,USD
|
||||
Dragonfire Blade,TDM,Tarkir: Dragonstorm,240,normal,rare,1,104427,031afea3-fbfb-4663-a8cc-9b7eb7b16020,0.64,false,false,near_mint,en,USD
|
||||
Great Arashin City,TDM,Tarkir: Dragonstorm,257,normal,rare,1,105033,ecba23b6-9f3a-431e-bc22-f1fb04d27b68,0.33,false,false,near_mint,en,USD
|
||||
Smile at Death,TDM,Tarkir: Dragonstorm,24,normal,mythic,1,104000,ae2da18f-0d7d-446c-b463-8bf170ed95da,3.51,false,false,near_mint,en,USD
|
||||
Maelstrom of the Spirit Dragon,TDM,Tarkir: Dragonstorm,260,normal,rare,1,104359,c4e90bfb-d9a5-48a9-9ff9-b0f50a813eee,1.31,false,false,near_mint,en,USD
|
||||
Eshki Dragonclaw,TDM,Tarkir: Dragonstorm,182,normal,rare,1,104445,0d369c44-78ee-4f3c-bf2b-cddba7fe26d4,0.19,false,false,near_mint,en,USD
|
||||
Skirmish Rhino,TDM,Tarkir: Dragonstorm,224,normal,uncommon,1,103992,4a2e9ba1-c254-41e3-9845-4e81f9fec38d,0.15,false,false,near_mint,en,USD
|
||||
"Teval, Arbiter of Virtue",TDM,Tarkir: Dragonstorm,373,normal,mythic,1,104332,a19c38bc-946c-438a-ac8b-f59ff0b4c613,7.06,false,false,near_mint,en,USD
|
||||
"Ureni, the Song Unending",TDM,Tarkir: Dragonstorm,233,normal,mythic,1,104253,227802c0-4ff6-43a8-a850-ed0f546dc5ac,3.79,false,false,near_mint,en,USD
|
||||
Hardened Tactician,TDM,Tarkir: Dragonstorm,191,normal,uncommon,1,104780,86b225cb-5c45-4da1-a64e-b04091e483e8,1.0,false,false,near_mint,en,USD
|
||||
Sandsteppe Citadel,TDM,Tarkir: Dragonstorm,266,normal,uncommon,1,104603,47f47e7f-39ba-4807-8e32-7262a61dfbba,0.13,false,false,near_mint,en,USD
|
||||
"Kotis, the Fangkeeper",TDM,Tarkir: Dragonstorm,202,normal,rare,1,104364,d3736f17-f80b-4b2c-b919-2c963bc14682,0.28,false,false,near_mint,en,USD
|
||||
Magmatic Hellkite,TDM,Tarkir: Dragonstorm,111,normal,rare,1,104895,b3b3aec8-d931-4c7f-86b5-1e7dfb717b59,0.56,false,false,near_mint,en,USD
|
||||
Dalkovan Encampment,TDM,Tarkir: Dragonstorm,253,normal,rare,1,104822,98ad5f0c-8775-4e89-8e92-84a6ade93e35,0.38,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
|
||||
Hollowmurk Siege,TDM,Tarkir: Dragonstorm,192,normal,rare,1,104668,5ac0e136-8877-4bfc-a831-2bf7b7b5ad1e,0.53,false,false,near_mint,en,USD
|
||||
Avenger of the Fallen,TDM,Tarkir: Dragonstorm,73,normal,rare,1,104984,d5397366-151f-46b0-b9b2-fa4d5bd892d8,0.34,false,false,near_mint,en,USD
|
||||
Mystic Monastery,TDM,Tarkir: Dragonstorm,262,normal,uncommon,1,104945,c7b8a01c-c400-47c7-8270-78902efe850e,0.11,false,false,near_mint,en,USD
|
||||
Songcrafter Mage,TDM,Tarkir: Dragonstorm,225,normal,rare,1,104813,9523bc07-49e5-409c-ae6b-b28e305eef36,0.35,false,false,near_mint,en,USD
|
||||
Misty Rainforest,SPG,Special Guests,111,normal,mythic,1,104321,894105c4-d3ce-4d38-855b-24aa47b112c1,32.31,false,false,near_mint,en,USD
|
||||
Tempest Hawk,TDM,Tarkir: Dragonstorm,31,normal,common,3,104587,422f9453-ab12-4e3c-8c51-be87391395a1,0.92,false,false,near_mint,en,USD
|
||||
Duty Beyond Death,TDM,Tarkir: Dragonstorm,10,normal,uncommon,2,104265,2e92640d-768b-4357-905f-bea017d351cc,0.33,false,false,near_mint,en,USD
|
||||
Heritage Reclamation,TDM,Tarkir: Dragonstorm,145,normal,common,2,104636,4f8fee37-a050-4329-8b10-46d150e7a95e,0.2,false,false,near_mint,en,USD
|
|
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
|
|
56
app/main.py
56
app/main.py
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from app.routes import routes
|
||||
from app.db.database import init_db, SessionLocal
|
||||
from app.services.service_manager import ServiceManager
|
||||
from app.models.tcgplayer_products import refresh_view
|
||||
from app.models.tcgplayer_products import MostRecentTCGPlayerPrice
|
||||
|
||||
# Configure logging
|
||||
log_file = Path("app.log")
|
||||
@@ -58,17 +58,19 @@ async def lifespan(app: FastAPI):
|
||||
# Get a database session
|
||||
db = SessionLocal()
|
||||
try:
|
||||
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")
|
||||
# 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)
|
||||
#refresh_view(db)
|
||||
#data_init_service = service_manager.get_service('data_initialization')
|
||||
#data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=False, archived_prices_start_date="2025-05-22", archived_prices_end_date="2025-05-23")
|
||||
#logger.info(f"Data initialization results: {data_init}")
|
||||
# Update most recent prices
|
||||
#MostRecentTCGPlayerPrice.update_most_recent_prices(db)
|
||||
logger.info("Most recent prices updated successfully")
|
||||
|
||||
# Create default customer, vendor, and marketplace
|
||||
#inv_data_init = await data_init_service.initialize_inventory_data(db)
|
||||
|
||||
#logger.info(f"Inventory data initialization results: {inv_data_init}")
|
||||
# Start the scheduler
|
||||
scheduler = service_manager.get_service('scheduler')
|
||||
#await scheduler.refresh_tcgplayer_inventory_table(db)
|
||||
await scheduler.start_scheduled_tasks(db)
|
||||
logger.info("Scheduler started successfully")
|
||||
|
||||
@@ -113,6 +115,46 @@ async def read_app_js():
|
||||
raise HTTPException(status_code=404, detail="App.js file not found")
|
||||
return FileResponse(js_path)
|
||||
|
||||
# Serve manabox.html
|
||||
@app.get("/manabox.html")
|
||||
async def read_manabox_html():
|
||||
html_path = Path('app/static/manabox.html')
|
||||
if not html_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Manabox.html file not found")
|
||||
return FileResponse(html_path)
|
||||
|
||||
# Serve manabox.js
|
||||
@app.get("/manabox.js")
|
||||
async def read_manabox_js():
|
||||
js_path = Path('app/static/manabox.js')
|
||||
if not js_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Manabox.js file not found")
|
||||
return FileResponse(js_path)
|
||||
|
||||
# serve transactions.html
|
||||
@app.get("/transactions.html")
|
||||
async def read_transactions_html():
|
||||
html_path = Path('app/static/transactions.html')
|
||||
if not html_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Transaction.html file not found")
|
||||
return FileResponse(html_path)
|
||||
|
||||
# serve transactions.js
|
||||
@app.get("/transactions.js")
|
||||
async def read_transactions_js():
|
||||
js_path = Path('app/static/transactions.js')
|
||||
if not js_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Transaction.js file not found")
|
||||
return FileResponse(js_path)
|
||||
|
||||
# serve styles.css
|
||||
@app.get("/styles.css")
|
||||
async def read_styles_css():
|
||||
css_path = Path('app/static/styles.css')
|
||||
if not css_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Styles.css file not found")
|
||||
return FileResponse(css_path)
|
||||
|
||||
# Configure CORS with specific origins in production
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
@@ -29,6 +29,7 @@ from app.models.tcgplayer_order import (
|
||||
)
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
||||
from app.models.manabox_import_staging import ManaboxImportStaging
|
||||
from app.models.pricing import PricingEvent
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -56,5 +57,6 @@ __all__ = [
|
||||
'TCGPlayerOrderProduct',
|
||||
'TCGPlayerOrderRefund',
|
||||
'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 app.db.database import Base
|
||||
from sqlalchemy import event
|
||||
@@ -7,6 +7,17 @@ from sqlalchemy import func
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from datetime import datetime
|
||||
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):
|
||||
__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")
|
||||
product_direct = relationship("TCGPlayerProduct",
|
||||
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")
|
||||
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
|
||||
def products(self):
|
||||
"""
|
||||
Dynamically resolve the associated TCGPlayerProduct(s):
|
||||
- If the SKU is set, return all linked products.
|
||||
- Else, return a list containing a single product from direct link.
|
||||
"""
|
||||
# TODO IS THIS EVEN CORRECT OH GOD
|
||||
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 []
|
||||
if self.sku and self.sku.product:
|
||||
return self.sku.product
|
||||
elif self.product_direct:
|
||||
return self.product_direct
|
||||
else:
|
||||
return None
|
||||
|
||||
class InventoryItem(Base):
|
||||
__tablename__ = "inventory_items"
|
||||
@@ -69,7 +91,8 @@ class InventoryItem(Base):
|
||||
physical_item = relationship("PhysicalItem", back_populates="inventory_item")
|
||||
parent = relationship("InventoryItem", remote_side=[id], back_populates="children")
|
||||
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
|
||||
def products(self):
|
||||
@@ -78,7 +101,7 @@ class InventoryItem(Base):
|
||||
Returns:
|
||||
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):
|
||||
if not timestamp:
|
||||
@@ -87,92 +110,68 @@ class InventoryItem(Base):
|
||||
for child in self.children:
|
||||
child.soft_delete(timestamp)
|
||||
|
||||
|
||||
|
||||
class SealedBox(PhysicalItem):
|
||||
__tablename__ = "sealed_boxes"
|
||||
class Box(PhysicalItem):
|
||||
__tablename__ = "boxes"
|
||||
|
||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||
case_id = Column(Integer, ForeignKey("sealed_cases.id"), nullable=True)
|
||||
expected_value = Column(Float)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'sealed_box'
|
||||
'polymorphic_identity': 'box'
|
||||
}
|
||||
|
||||
# Relationships
|
||||
case = relationship("SealedCase", back_populates="boxes", foreign_keys=[case_id])
|
||||
open_event = relationship("OpenEvent", uselist=False, back_populates="sealed_box")
|
||||
|
||||
class SealedCase(PhysicalItem):
|
||||
__tablename__ = "sealed_cases"
|
||||
class Case(PhysicalItem):
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||
expected_value = Column(Float)
|
||||
num_boxes = Column(Integer)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'sealed_case'
|
||||
'polymorphic_identity': 'case'
|
||||
}
|
||||
|
||||
# Relationships
|
||||
boxes = relationship("SealedBox", back_populates="case", foreign_keys=[SealedBox.case_id])
|
||||
open_event = relationship("OpenEvent", uselist=False, back_populates="sealed_case")
|
||||
class Card(PhysicalItem):
|
||||
__tablename__ = "cards"
|
||||
|
||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'card'
|
||||
}
|
||||
|
||||
class OpenEvent(Base):
|
||||
__tablename__ = "open_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sealed_case_id = Column(Integer, ForeignKey("sealed_cases.id"), nullable=True)
|
||||
sealed_box_id = Column(Integer, ForeignKey("sealed_boxes.id"), nullable=True)
|
||||
source_item_id = Column(Integer, ForeignKey("physical_items.id"))
|
||||
open_date = Column(DateTime(timezone=True))
|
||||
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
|
||||
sealed_case = relationship("SealedCase", back_populates="open_event")
|
||||
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'
|
||||
}
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_item_id", name="uq_openevent_one_per_source"),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
open_event = relationship("OpenEvent", back_populates="resulting_cards")
|
||||
box = relationship("OpenBox", back_populates="cards", foreign_keys=[box_id])
|
||||
|
||||
class OpenBox(PhysicalItem):
|
||||
__tablename__ = "open_boxes"
|
||||
|
||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||
open_event_id = Column(Integer, ForeignKey("open_events.id"))
|
||||
sealed_box_id = Column(Integer, ForeignKey("sealed_boxes.id"))
|
||||
|
||||
__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])
|
||||
|
||||
|
||||
source_item = relationship(
|
||||
"PhysicalItem",
|
||||
back_populates="source_open_events",
|
||||
foreign_keys=[source_item_id]
|
||||
)
|
||||
resulting_items = relationship(
|
||||
"PhysicalItem",
|
||||
secondary=open_event_resulting_items,
|
||||
back_populates="resulting_open_events"
|
||||
)
|
||||
|
||||
|
||||
class SealedExpectedValue(Base):
|
||||
__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)
|
||||
tcgplayer_product_id = Column(Integer, nullable=False)
|
||||
@@ -185,64 +184,56 @@ class SealedExpectedValue(Base):
|
||||
product = relationship(
|
||||
"TCGPlayerProduct",
|
||||
primaryjoin="SealedExpectedValue.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)",
|
||||
viewonly=True,
|
||||
backref="sealed_expected_values"
|
||||
)
|
||||
viewonly=True)
|
||||
|
||||
# helper for ev
|
||||
def assign_expected_value(target, session):
|
||||
products = target.product # uses hybrid property
|
||||
if not products:
|
||||
raise ValueError(f"No product found for item ID {target.id}")
|
||||
#def assign_expected_value(target, session):
|
||||
# products = target.products
|
||||
# if not products:
|
||||
# raise ValueError(f"No product found for item ID {target.id}")
|
||||
|
||||
if len(products) > 1:
|
||||
product_names = [p.name for p in products]
|
||||
critical_error = CriticalErrorLog(
|
||||
error_type="multiple_products_found",
|
||||
error_message=f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}"
|
||||
)
|
||||
session.add(critical_error)
|
||||
session.commit()
|
||||
raise ValueError(f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}")
|
||||
# if len(products) > 1:
|
||||
# product_names = [p.name for p in products]
|
||||
# critical_error = CriticalErrorLog(
|
||||
# error_type="multiple_products_found",
|
||||
# error_message=f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}"
|
||||
# )
|
||||
# session.add(critical_error)
|
||||
# session.commit()
|
||||
# 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(
|
||||
SealedExpectedValue.tcgplayer_product_id == product_id,
|
||||
SealedExpectedValue.deleted_at == None
|
||||
).order_by(SealedExpectedValue.created_at.desc()).first()
|
||||
# expected_value_entry = session.query(SealedExpectedValue).filter(
|
||||
# SealedExpectedValue.tcgplayer_product_id == product_id,
|
||||
# SealedExpectedValue.deleted_at == None
|
||||
# ).order_by(SealedExpectedValue.created_at.desc()).first()
|
||||
|
||||
if expected_value_entry:
|
||||
target.expected_value = expected_value_entry.expected_value
|
||||
else:
|
||||
critical_error = CriticalErrorLog(
|
||||
error_type="no_expected_value_found",
|
||||
error_message=f"No expected value found for product {products[0].name}"
|
||||
)
|
||||
session.add(critical_error)
|
||||
session.commit()
|
||||
raise ValueError(f"No expected value found for product {products[0].name}")
|
||||
# if expected_value_entry:
|
||||
# target.expected_value = expected_value_entry.expected_value
|
||||
# else:
|
||||
# critical_error = CriticalErrorLog(
|
||||
# error_type="no_expected_value_found",
|
||||
# error_message=f"No expected value found for product {products[0].name}"
|
||||
# )
|
||||
# session.add(critical_error)
|
||||
# session.commit()
|
||||
# raise ValueError(f"No expected value found for product {products[0].name}")
|
||||
|
||||
|
||||
# event listeners
|
||||
@event.listens_for(SealedBox, 'before_insert')
|
||||
def sealed_box_before_insert(mapper, connection, target):
|
||||
session = Session.object_session(target)
|
||||
if 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)
|
||||
#@event.listens_for(InventoryItem, 'before_insert')
|
||||
#def ev_before_insert(mapper, connection, target):
|
||||
# session = Session.object_session(target)
|
||||
# if session:
|
||||
# assign_expected_value(target, session)
|
||||
|
||||
class TransactionItem(Base):
|
||||
__tablename__ = "transaction_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
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)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -250,7 +241,7 @@ class TransactionItem(Base):
|
||||
|
||||
# Relationships
|
||||
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):
|
||||
__tablename__ = "vendors"
|
||||
@@ -261,6 +252,9 @@ class Vendor(Base):
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
transactions = relationship("Transaction", back_populates="vendors")
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
@@ -269,6 +263,10 @@ class Customer(Base):
|
||||
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
|
||||
transactions = relationship("Transaction", back_populates="customers")
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
@@ -286,6 +284,9 @@ class Transaction(Base):
|
||||
|
||||
# Relationships
|
||||
transaction_items = relationship("TransactionItem", back_populates="transaction")
|
||||
vendors = relationship("Vendor", back_populates="transactions")
|
||||
customers = relationship("Customer", back_populates="transactions")
|
||||
marketplaces = relationship("Marketplace", back_populates="transactions")
|
||||
|
||||
class Marketplace(Base):
|
||||
__tablename__ = "marketplaces"
|
||||
@@ -298,20 +299,23 @@ class Marketplace(Base):
|
||||
|
||||
# Relationships
|
||||
listings = relationship("MarketplaceListing", back_populates="marketplace")
|
||||
|
||||
transactions = relationship("Transaction", back_populates="marketplaces")
|
||||
class MarketplaceListing(Base):
|
||||
__tablename__ = "marketplace_listings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
inventory_item_id = Column(Integer, ForeignKey("inventory_items.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)
|
||||
listed_price = Column(Float)
|
||||
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", back_populates="marketplace_listings")
|
||||
inventory_item = relationship("InventoryItem", back_populates="marketplace_listing")
|
||||
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)
|
||||
file_id = Column(Integer, ForeignKey("files.id"))
|
||||
tcgplayer_product_id = Column(Integer)
|
||||
tcgplayer_sku_id = Column(Integer)
|
||||
quantity = Column(Integer)
|
||||
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.sql import func
|
||||
from app.db.database import Base
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
class TCGPlayerInventory(Base):
|
||||
__tablename__ = "tcgplayer_inventory"
|
||||
|
||||
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)
|
||||
set_name = Column(String)
|
||||
product_name = Column(String)
|
||||
@@ -24,3 +25,37 @@ class TCGPlayerInventory(Base):
|
||||
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
|
||||
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 app.db.database import Base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
# =============================================================================
|
||||
# Core Models
|
||||
# =============================================================================
|
||||
|
||||
class MTGJSONSKU(Base):
|
||||
"""Represents the most granular level of card identification.
|
||||
THIS WORKS EVEN IF ITS A BOX SOMEHOW
|
||||
"""
|
||||
__tablename__ = "mtgjson_skus"
|
||||
|
||||
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_product_id = Column(Integer, nullable=False)
|
||||
normalized_printing = Column(String, nullable=False) # normalized for FK
|
||||
condition = Column(String) # for boxes, condition = unopened
|
||||
finish = Column(String, nullable=True) # TODO MAKE THESE ENUMS
|
||||
normalized_printing = Column(String, nullable=False)
|
||||
condition = Column(String)
|
||||
finish = Column(String, nullable=True)
|
||||
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())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Foreign key to tcgplayer_products via composite key
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["tcgplayer_product_id", "normalized_printing"],
|
||||
["tcgplayer_products.tcgplayer_product_id", "tcgplayer_products.normalized_sub_type_name"],
|
||||
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")
|
||||
physical_items = relationship("PhysicalItem", back_populates="sku", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id")
|
||||
|
||||
card = relationship("MTGJSONCard", back_populates="skus", primaryjoin="MTGJSONCard.mtgjson_uuid == MTGJSONSKU.mtgjson_uuid")
|
||||
physical_items = relationship(
|
||||
"PhysicalItem",
|
||||
back_populates="sku",
|
||||
primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id"
|
||||
)
|
||||
card = relationship("MTGJSONCard", back_populates="skus")
|
||||
|
||||
|
||||
class MTGJSONCard(Base):
|
||||
@@ -72,13 +73,17 @@ class MTGJSONCard(Base):
|
||||
scryfall_card_back_id = Column(String, nullable=True)
|
||||
scryfall_oracle_id = Column(String, nullable=True)
|
||||
scryfall_illustration_id = Column(String, nullable=True)
|
||||
tcgplayer_product_id = Column(String, nullable=True)
|
||||
tcgplayer_etched_product_id = Column(String, nullable=True)
|
||||
tcgplayer_product_id = Column(Integer, nullable=True)
|
||||
tcgplayer_etched_product_id = Column(Integer, nullable=True)
|
||||
tnt_id = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=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):
|
||||
@@ -117,13 +122,16 @@ class TCGPlayerProduct(Base):
|
||||
# Enforce uniqueness for composite key
|
||||
__table_args__ = (
|
||||
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
|
||||
skus = relationship("MTGJSONSKU", back_populates="product")
|
||||
physical_items_direct = relationship("PhysicalItem",
|
||||
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")
|
||||
group = relationship("TCGPlayerGroup", back_populates="products")
|
||||
price_history = relationship("TCGPlayerPriceHistory",
|
||||
@@ -136,6 +144,11 @@ class TCGPlayerProduct(Base):
|
||||
primaryjoin="and_(foreign(TCGPlayerProduct.tcgplayer_product_id) == MostRecentTCGPlayerPrice.product_id, "
|
||||
"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
|
||||
# =============================================================================
|
||||
@@ -213,9 +226,7 @@ class TCGPlayerPriceHistory(Base):
|
||||
|
||||
class MostRecentTCGPlayerPrice(Base):
|
||||
"""Represents the most recent price for a product.
|
||||
|
||||
This is a materialized view that contains the latest price data for each product.
|
||||
It is maintained through triggers on the price_history table.
|
||||
THIS ISNT A MATERIALIZED VIEW ANYMORE FUCK IT
|
||||
"""
|
||||
__tablename__ = "most_recent_tcgplayer_price"
|
||||
|
||||
@@ -233,50 +244,52 @@ class MostRecentTCGPlayerPrice(Base):
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_most_recent_price_product_subtype', 'product_id', 'sub_type_name', unique=True),
|
||||
{'info': {'is_view': True}}
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
product = relationship("TCGPlayerProduct",
|
||||
back_populates="most_recent_tcgplayer_price",
|
||||
primaryjoin="and_(MostRecentTCGPlayerPrice.product_id == foreign(TCGPlayerProduct.tcgplayer_product_id), "
|
||||
"MostRecentTCGPlayerPrice.sub_type_name == foreign(TCGPlayerProduct.sub_type_name))")
|
||||
|
||||
def create_most_recent_price_view():
|
||||
"""Creates the materialized view for most recent prices."""
|
||||
return DDL("""
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS most_recent_tcgplayer_price AS
|
||||
SELECT DISTINCT ON (ph.product_id, ph.sub_type_name)
|
||||
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;
|
||||
@classmethod
|
||||
def update_most_recent_prices(cls, db: Session) -> None:
|
||||
"""Update the most recent prices from the price history table."""
|
||||
# Delete all existing records
|
||||
db.query(cls).delete()
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_most_recent_price_product_subtype
|
||||
ON most_recent_tcgplayer_price (product_id, sub_type_name);
|
||||
""")
|
||||
|
||||
# Register the view creation with SQLAlchemy
|
||||
event.listen(
|
||||
MostRecentTCGPlayerPrice.__table__,
|
||||
'after_create',
|
||||
create_most_recent_price_view()
|
||||
)
|
||||
# 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()
|
||||
|
||||
# Add a method to refresh the view
|
||||
@classmethod
|
||||
def refresh_view(cls, session):
|
||||
"""Refreshes the materialized view."""
|
||||
session.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY most_recent_tcgplayer_price"))
|
||||
session.commit()
|
||||
# Join with price history to get the full records
|
||||
latest_prices = db.query(TCGPlayerPriceHistory).join(
|
||||
subquery,
|
||||
and_(
|
||||
TCGPlayerPriceHistory.product_id == subquery.c.product_id,
|
||||
TCGPlayerPriceHistory.sub_type_name == subquery.c.sub_type_name,
|
||||
TCGPlayerPriceHistory.date == subquery.c.max_date
|
||||
)
|
||||
).all()
|
||||
|
||||
MostRecentTCGPlayerPrice.refresh_view = refresh_view
|
||||
# Create new MostRecentTCGPlayerPrice records
|
||||
for price in latest_prices:
|
||||
most_recent = cls(
|
||||
product_id=price.product_id,
|
||||
sub_type_name=price.sub_type_name,
|
||||
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)
|
||||
|
||||
db.commit()
|
@@ -1,10 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
from app.db.database import get_db
|
||||
from app.services.service_manager import ServiceManager
|
||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse
|
||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, SealedExpectedValueCreate, GetAllTransactionsResponse, TransactionResponse, TransactionItemResponse, InventoryItemResponse, TCGPlayerProductResponse, OpenEventResponse, OpenEventCreate, OpenEventResultingItemsResponse, OpenEventsForInventoryItemResponse
|
||||
from app.models.inventory_management import Transaction
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
from typing import List
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
router = APIRouter(prefix="/inventory")
|
||||
|
||||
service_manager = ServiceManager()
|
||||
@@ -155,6 +161,14 @@ async def create_vendor(
|
||||
vendor = await inventory_service.create_vendor(db, vendor_name)
|
||||
return vendor
|
||||
|
||||
@router.get("/vendors")
|
||||
async def get_vendors(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
vendors = await inventory_service.get_vendors(db)
|
||||
return vendors
|
||||
|
||||
@router.post("/marketplaces")
|
||||
async def create_marketplace(
|
||||
marketplace_name: str,
|
||||
@@ -163,3 +177,312 @@ async def create_marketplace(
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
marketplace = await inventory_service.create_marketplace(db, marketplace_name)
|
||||
return marketplace
|
||||
|
||||
@router.get("/marketplaces")
|
||||
async def get_marketplaces(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
marketplaces = await inventory_service.get_marketplaces(db)
|
||||
return marketplaces
|
||||
|
||||
@router.get("/products/search")
|
||||
async def get_products(q: str, db: Session = Depends(get_db)):
|
||||
query = ' & '.join(q.lower().split()) # This ensures all terms must match
|
||||
|
||||
products = db.query(TCGPlayerProduct).filter(
|
||||
func.to_tsvector('english', TCGPlayerProduct.name)
|
||||
.op('@@')(func.to_tsquery('english', query))
|
||||
).all()
|
||||
|
||||
return products
|
||||
|
||||
@router.get("/products/{product_id}/expected-value")
|
||||
async def get_expected_value(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
expected_value = await inventory_service.get_expected_value(db, product_id)
|
||||
return expected_value
|
||||
|
||||
|
||||
@router.post("/products/expected-value")
|
||||
async def create_expected_value(
|
||||
expected_value_data: SealedExpectedValueCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
expected_value = await inventory_service.create_expected_value(db, expected_value_data)
|
||||
return expected_value
|
||||
|
||||
@router.post("/transactions/purchase")
|
||||
async def create_purchase_transaction(
|
||||
transaction_data: PurchaseTransactionCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
transaction = await inventory_service.create_purchase_transaction(db, transaction_data)
|
||||
return transaction
|
||||
|
||||
@router.get("/transactions")
|
||||
async def get_transactions(
|
||||
db: Session = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
total = db.query(func.count(Transaction.id)).filter(Transaction.deleted_at == None).scalar()
|
||||
transactions = await inventory_service.get_transactions(db, skip, limit)
|
||||
return GetAllTransactionsResponse(
|
||||
total=total,
|
||||
transactions=[TransactionResponse(
|
||||
id=transaction.id,
|
||||
vendor_id=transaction.vendor_id,
|
||||
customer_id=transaction.customer_id,
|
||||
marketplace_id=transaction.marketplace_id,
|
||||
transaction_type=transaction.transaction_type,
|
||||
transaction_date=transaction.transaction_date,
|
||||
transaction_total_amount=transaction.transaction_total_amount,
|
||||
transaction_notes=transaction.transaction_notes,
|
||||
created_at=transaction.created_at,
|
||||
updated_at=transaction.updated_at,
|
||||
transaction_items=[TransactionItemResponse(
|
||||
id=transaction_item.id,
|
||||
transaction_id=transaction_item.transaction_id,
|
||||
inventory_item_id=transaction_item.inventory_item_id,
|
||||
unit_price=transaction_item.unit_price,
|
||||
created_at=transaction_item.created_at,
|
||||
updated_at=transaction_item.updated_at,
|
||||
deleted_at=transaction_item.deleted_at
|
||||
) for transaction_item in transaction.transaction_items]
|
||||
) for transaction in transactions]
|
||||
)
|
||||
|
||||
@router.get("/transactions/{transaction_id}")
|
||||
async def get_transaction(
|
||||
transaction_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
transaction = await inventory_service.get_transaction(db, transaction_id)
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
return TransactionResponse(
|
||||
id=transaction.id,
|
||||
vendor_id=transaction.vendor_id,
|
||||
customer_id=transaction.customer_id,
|
||||
marketplace_id=transaction.marketplace_id,
|
||||
transaction_type=transaction.transaction_type,
|
||||
transaction_date=transaction.transaction_date,
|
||||
transaction_total_amount=transaction.transaction_total_amount,
|
||||
transaction_notes=transaction.transaction_notes,
|
||||
created_at=transaction.created_at,
|
||||
updated_at=transaction.updated_at,
|
||||
transaction_items=[TransactionItemResponse(
|
||||
id=transaction_item.id,
|
||||
transaction_id=transaction_item.transaction_id,
|
||||
inventory_item_id=transaction_item.inventory_item_id,
|
||||
unit_price=transaction_item.unit_price,
|
||||
created_at=transaction_item.created_at,
|
||||
updated_at=transaction_item.updated_at,
|
||||
deleted_at=transaction_item.deleted_at
|
||||
) for transaction_item in transaction.transaction_items]
|
||||
)
|
||||
|
||||
@router.get("/items/{inventory_item_id}")
|
||||
async def get_inventory_item(
|
||||
inventory_item_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
marketplace_listing = await marketplace_listing_service.get_marketplace_listing(db, inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
listed_price = None
|
||||
recommended_price = None
|
||||
marketplace_listing_id = None
|
||||
else:
|
||||
if marketplace_listing.listed_price is not None:
|
||||
listed_price = marketplace_listing.listed_price.price if marketplace_listing.listed_price.price is not None else None
|
||||
else:
|
||||
listed_price = None
|
||||
if marketplace_listing.recommended_price is not None:
|
||||
recommended_price = marketplace_listing.recommended_price.price if marketplace_listing.recommended_price.price is not None else None
|
||||
else:
|
||||
recommended_price = None
|
||||
marketplace_listing_id = marketplace_listing.id
|
||||
return InventoryItemResponse(
|
||||
id=inventory_item.id,
|
||||
physical_item_id=inventory_item.physical_item_id,
|
||||
cost_basis=inventory_item.cost_basis,
|
||||
parent_id=inventory_item.parent_id,
|
||||
created_at=inventory_item.created_at,
|
||||
updated_at=inventory_item.updated_at,
|
||||
item_type=inventory_item.physical_item.item_type,
|
||||
listed_price=listed_price,
|
||||
recommended_price=recommended_price,
|
||||
marketplace_listing_id=marketplace_listing_id,
|
||||
product=TCGPlayerProductResponse(
|
||||
id=inventory_item.physical_item.product_direct.id,
|
||||
tcgplayer_product_id=inventory_item.physical_item.product_direct.tcgplayer_product_id,
|
||||
name=inventory_item.physical_item.product_direct.name,
|
||||
image_url=inventory_item.physical_item.product_direct.image_url,
|
||||
category_id=inventory_item.physical_item.product_direct.category_id,
|
||||
group_id=inventory_item.physical_item.product_direct.group_id,
|
||||
url=inventory_item.physical_item.product_direct.url,
|
||||
market_price=inventory_item.physical_item.product_direct.most_recent_tcgplayer_price.market_price,
|
||||
category_name=inventory_item.physical_item.product_direct.category.name,
|
||||
group_name=inventory_item.physical_item.product_direct.group.name
|
||||
)
|
||||
)
|
||||
|
||||
@router.post("/items/{inventory_item_id}/open")
|
||||
async def open_box_or_case(
|
||||
open_event_data: OpenEventCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, open_event_data.inventory_item_id)
|
||||
file_service = service_manager.get_service("file")
|
||||
files = [await file_service.get_file(db, file_id) for file_id in open_event_data.manabox_file_upload_ids]
|
||||
if inventory_item.physical_item.item_type == "box":
|
||||
box_service = service_manager.get_service("box")
|
||||
open_event = await box_service.open_box(db, inventory_item.physical_item, files)
|
||||
return OpenEventResponse(
|
||||
id=open_event.id,
|
||||
source_item_id=open_event.source_item_id,
|
||||
created_at=open_event.created_at,
|
||||
updated_at=open_event.updated_at
|
||||
)
|
||||
elif inventory_item.physical_item.item_type == "case":
|
||||
case_service = service_manager.get_service("case")
|
||||
open_event = await case_service.open_case(db, inventory_item.physical_item, files)
|
||||
return OpenEventResponse(
|
||||
id=open_event.id,
|
||||
source_item_id=open_event.source_item_id,
|
||||
created_at=open_event.created_at,
|
||||
updated_at=open_event.updated_at
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid item type")
|
||||
|
||||
@router.get("/items/{inventory_item_id}/open-events")
|
||||
async def get_open_events(
|
||||
inventory_item_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
|
||||
# Don't return open events for cards
|
||||
if inventory_item.physical_item.item_type == 'card':
|
||||
return OpenEventsForInventoryItemResponse(open_events=[])
|
||||
|
||||
open_events = await inventory_service.get_open_events_for_inventory_item(db, inventory_item)
|
||||
return OpenEventsForInventoryItemResponse(
|
||||
open_events=[OpenEventResponse(
|
||||
id=open_event.id,
|
||||
source_item_id=open_event.source_item_id,
|
||||
created_at=open_event.created_at,
|
||||
updated_at=open_event.updated_at
|
||||
) for open_event in open_events]
|
||||
)
|
||||
|
||||
@router.get("/items/{inventory_item_id}/open-events/{open_event_id}/resulting-items", response_model=List[InventoryItemResponse])
|
||||
async def get_resulting_items(
|
||||
inventory_item_id: int,
|
||||
open_event_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
open_event = await inventory_service.get_open_event(db, inventory_item, open_event_id)
|
||||
resulting_items = await inventory_service.get_resulting_items_for_open_event(db, open_event)
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
marketplace_listing = await marketplace_listing_service.get_marketplace_listing(db, inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
listed_price = None
|
||||
recommended_price = None
|
||||
marketplace_listing_id = None
|
||||
else:
|
||||
if marketplace_listing.listed_price is not None:
|
||||
listed_price = marketplace_listing.listed_price.price if marketplace_listing.listed_price.price is not None else None
|
||||
else:
|
||||
listed_price = None
|
||||
if marketplace_listing.recommended_price is not None:
|
||||
recommended_price = marketplace_listing.recommended_price.price if marketplace_listing.recommended_price.price is not None else None
|
||||
else:
|
||||
recommended_price = None
|
||||
marketplace_listing_id = marketplace_listing.id
|
||||
return [InventoryItemResponse(
|
||||
id=resulting_item.id,
|
||||
physical_item_id=resulting_item.physical_item_id,
|
||||
cost_basis=resulting_item.cost_basis,
|
||||
parent_id=resulting_item.parent_id,
|
||||
product=TCGPlayerProductResponse(
|
||||
id=resulting_item.physical_item.product_direct.id,
|
||||
tcgplayer_product_id=resulting_item.physical_item.product_direct.tcgplayer_product_id,
|
||||
name=resulting_item.physical_item.product_direct.name,
|
||||
image_url=resulting_item.physical_item.product_direct.image_url,
|
||||
category_id=resulting_item.physical_item.product_direct.category_id,
|
||||
group_id=resulting_item.physical_item.product_direct.group_id,
|
||||
url=resulting_item.physical_item.product_direct.url,
|
||||
market_price=resulting_item.physical_item.product_direct.most_recent_tcgplayer_price.market_price,
|
||||
category_name=resulting_item.physical_item.product_direct.category.name,
|
||||
group_name=resulting_item.physical_item.product_direct.group.name
|
||||
),
|
||||
item_type=resulting_item.physical_item.item_type,
|
||||
marketplace_listing_id=marketplace_listing_id,
|
||||
listed_price=listed_price,
|
||||
recommended_price=recommended_price,
|
||||
created_at=resulting_item.created_at,
|
||||
updated_at=resulting_item.updated_at) for resulting_item in resulting_items]
|
||||
|
||||
@router.post("/items/{inventory_item_id}/open-events/{open_event_id}/create-listings")
|
||||
async def create_marketplace_listings(
|
||||
inventory_item_id: int,
|
||||
open_event_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
open_event = await inventory_service.get_open_event(db, inventory_item, open_event_id)
|
||||
resulting_items = await inventory_service.get_resulting_items_for_open_event(db, open_event)
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
for resulting_item in resulting_items:
|
||||
await marketplace_listing_service.create_marketplace_listing(db, resulting_item, marketplace)
|
||||
return {"message": f"{len(resulting_items)} marketplace listings created successfully"}
|
||||
|
||||
@router.post("/items/{inventory_item_id}/open-events/{open_event_id}/confirm-listings")
|
||||
async def confirm_listings(
|
||||
inventory_item_id: int,
|
||||
open_event_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
open_event = await inventory_service.get_open_event(db, inventory_item, open_event_id)
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
try:
|
||||
csv_string = await marketplace_listing_service.confirm_listings(db, open_event, marketplace)
|
||||
if not csv_string:
|
||||
raise ValueError("No CSV data generated")
|
||||
|
||||
# Create a streaming response with the CSV data
|
||||
return StreamingResponse(
|
||||
iter([csv_string]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=tcgplayer_add_file_{open_event.id}_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.service_manager import ServiceManager
|
||||
@@ -50,6 +50,7 @@ def is_valid_csv(file: UploadFile) -> tuple[bool, str]:
|
||||
|
||||
@router.post("/process-csv")
|
||||
async def process_manabox_csv(
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
source: str = Form(...),
|
||||
description: str = Form(...),
|
||||
@@ -72,7 +73,7 @@ async def process_manabox_csv(
|
||||
|
||||
manabox_service = service_manager.get_service("manabox")
|
||||
|
||||
success = await manabox_service.process_manabox_csv(db, content, metadata)
|
||||
success = await manabox_service.process_manabox_csv(db, content, metadata, background_tasks)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to process CSV file")
|
||||
|
@@ -220,6 +220,7 @@ async def print_pirate_ship_label(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to print Pirate Ship label: {str(e)}")
|
||||
|
||||
# what even is this TODO delete
|
||||
@router.post("/process-manabox-csv")
|
||||
async def process_manabox_csv(
|
||||
file: UploadFile = File(...),
|
||||
|
0
app/schemas/inventory.py
Normal file
0
app/schemas/inventory.py
Normal file
@@ -1,12 +1,12 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
class PurchaseItem(BaseModel):
|
||||
product_id: int
|
||||
unit_price: float
|
||||
quantity: int
|
||||
is_case: bool
|
||||
item_type: str
|
||||
num_boxes: Optional[int] = None
|
||||
# TODO: remove is_case and num_boxes, should derive from product_id
|
||||
|
||||
@@ -30,11 +30,11 @@ class SaleTransactionCreate(BaseModel):
|
||||
class TransactionItemResponse(BaseModel):
|
||||
id: int
|
||||
transaction_id: int
|
||||
physical_item_id: int
|
||||
inventory_item_id: int
|
||||
unit_price: float
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
deleted_at: Optional[datetime] = None
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
vendor_id: Optional[int] = None
|
||||
@@ -46,4 +46,64 @@ class TransactionResponse(BaseModel):
|
||||
transaction_notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
transaction_items: List[TransactionItemResponse]
|
||||
|
||||
|
||||
class GetAllTransactionsResponse(BaseModel):
|
||||
total: int
|
||||
transactions: List[TransactionResponse]
|
||||
|
||||
class SealedExpectedValueResponse(BaseModel):
|
||||
id: int
|
||||
tcgplayer_product_id: int
|
||||
expected_value: float
|
||||
|
||||
class SealedExpectedValueCreate(BaseModel):
|
||||
tcgplayer_product_id: int
|
||||
expected_value: float
|
||||
|
||||
class TCGPlayerProductResponse(BaseModel):
|
||||
id: int
|
||||
tcgplayer_product_id: int
|
||||
name: str
|
||||
image_url: str
|
||||
category_id: int
|
||||
group_id: int
|
||||
url: str
|
||||
market_price: float
|
||||
category_name: str
|
||||
group_name: str
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
id: int
|
||||
physical_item_id: int
|
||||
cost_basis: float
|
||||
item_type: str
|
||||
listed_price: Optional[float] = None
|
||||
marketplace_listing_id: Optional[int] = None
|
||||
recommended_price: Optional[float] = None
|
||||
parent_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
product: Optional[TCGPlayerProductResponse] = None
|
||||
|
||||
class OpenEventResponse(BaseModel):
|
||||
id: int
|
||||
source_item_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class OpenEventCreate(BaseModel):
|
||||
inventory_item_id: int
|
||||
manabox_file_upload_ids: List[int]
|
||||
|
||||
class OpenEventResultingItemsResponse(BaseModel):
|
||||
id: int
|
||||
source_item_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
resulting_items: List[InventoryItemResponse]
|
||||
|
||||
class OpenEventsForInventoryItemResponse(BaseModel):
|
||||
open_events: List[OpenEventResponse]
|
||||
|
@@ -14,6 +14,8 @@ from app.services.set_label_service import SetLabelService
|
||||
from app.services.scheduler.scheduler_service import SchedulerService
|
||||
from app.services.external_api.tcgplayer.order_management_service import OrderManagementService
|
||||
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
|
||||
from app.services.pricing_service import PricingService
|
||||
from app.services.inventory_service import MarketplaceListingService
|
||||
|
||||
__all__ = [
|
||||
'BaseService',
|
||||
@@ -31,5 +33,7 @@ __all__ = [
|
||||
'SetLabelService',
|
||||
'SchedulerService',
|
||||
'OrderManagementService',
|
||||
'TCGPlayerInventoryService'
|
||||
'TCGPlayerInventoryService',
|
||||
'PricingService',
|
||||
'MarketplaceListingService'
|
||||
]
|
@@ -12,6 +12,7 @@ from app.schemas.transaction import PurchaseTransactionCreate, PurchaseItem
|
||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||
from app.models.tcgplayer_products import MTGJSONSKU, MTGJSONCard
|
||||
from app.models.tcgplayer_products import TCGPlayerPriceHistory
|
||||
from app.models.critical_error_log import CriticalErrorLog
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
@@ -349,7 +350,7 @@ class DataInitializationService(BaseService):
|
||||
for price_data in archived_prices_data.get("results", []):
|
||||
try:
|
||||
# Get the subtype name from the price data
|
||||
sub_type_name = price_data.get("subTypeName", "other")
|
||||
sub_type_name = price_data.get("subTypeName", "None")
|
||||
|
||||
# First try to find product with the requested subtype
|
||||
product = db.query(TCGPlayerProduct).filter(
|
||||
@@ -521,121 +522,179 @@ class DataInitializationService(BaseService):
|
||||
}
|
||||
|
||||
async def sync_mtgjson_identifiers(self, db: Session, identifiers_data: List[dict]) -> int:
|
||||
"""Sync MTGJSON identifiers data to the database"""
|
||||
|
||||
count = 0
|
||||
with db_transaction(db):
|
||||
# Load all existing UUIDs once
|
||||
existing_cards = {
|
||||
card.mtgjson_uuid: card
|
||||
for card in db.query(MTGJSONCard).all()
|
||||
}
|
||||
|
||||
new_cards = []
|
||||
|
||||
for card_data in identifiers_data:
|
||||
if not isinstance(card_data, dict):
|
||||
logger.debug(f"Skipping non-dict item: {card_data}")
|
||||
continue
|
||||
|
||||
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == card_data.get("uuid")).first()
|
||||
if existing_card:
|
||||
# Update existing card
|
||||
for key, value in {
|
||||
uuid = card_data.get("uuid")
|
||||
identifiers = card_data.get("identifiers", {})
|
||||
|
||||
if uuid in existing_cards:
|
||||
card = existing_cards[uuid]
|
||||
updates = {
|
||||
"name": card_data.get("name"),
|
||||
"set_code": card_data.get("setCode"),
|
||||
"abu_id": card_data.get("identifiers", {}).get("abuId"),
|
||||
"card_kingdom_etched_id": card_data.get("identifiers", {}).get("cardKingdomEtchedId"),
|
||||
"card_kingdom_foil_id": card_data.get("identifiers", {}).get("cardKingdomFoilId"),
|
||||
"card_kingdom_id": card_data.get("identifiers", {}).get("cardKingdomId"),
|
||||
"cardsphere_id": card_data.get("identifiers", {}).get("cardsphereId"),
|
||||
"cardsphere_foil_id": card_data.get("identifiers", {}).get("cardsphereFoilId"),
|
||||
"cardtrader_id": card_data.get("identifiers", {}).get("cardtraderId"),
|
||||
"csi_id": card_data.get("identifiers", {}).get("csiId"),
|
||||
"mcm_id": card_data.get("identifiers", {}).get("mcmId"),
|
||||
"mcm_meta_id": card_data.get("identifiers", {}).get("mcmMetaId"),
|
||||
"miniaturemarket_id": card_data.get("identifiers", {}).get("miniaturemarketId"),
|
||||
"mtg_arena_id": card_data.get("identifiers", {}).get("mtgArenaId"),
|
||||
"mtgjson_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"),
|
||||
"mtgjson_non_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"),
|
||||
"mtgjson_v4_id": card_data.get("identifiers", {}).get("mtgjsonV4Id"),
|
||||
"mtgo_foil_id": card_data.get("identifiers", {}).get("mtgoFoilId"),
|
||||
"mtgo_id": card_data.get("identifiers", {}).get("mtgoId"),
|
||||
"multiverse_id": card_data.get("identifiers", {}).get("multiverseId"),
|
||||
"scg_id": card_data.get("identifiers", {}).get("scgId"),
|
||||
"scryfall_id": card_data.get("identifiers", {}).get("scryfallId"),
|
||||
"scryfall_card_back_id": card_data.get("identifiers", {}).get("scryfallCardBackId"),
|
||||
"scryfall_oracle_id": card_data.get("identifiers", {}).get("scryfallOracleId"),
|
||||
"scryfall_illustration_id": card_data.get("identifiers", {}).get("scryfallIllustrationId"),
|
||||
"tcgplayer_product_id": card_data.get("identifiers", {}).get("tcgplayerProductId"),
|
||||
"tcgplayer_etched_product_id": card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"),
|
||||
"tnt_id": card_data.get("identifiers", {}).get("tntId")
|
||||
}.items():
|
||||
setattr(existing_card, key, value)
|
||||
"abu_id": identifiers.get("abuId"),
|
||||
"card_kingdom_etched_id": identifiers.get("cardKingdomEtchedId"),
|
||||
"card_kingdom_foil_id": identifiers.get("cardKingdomFoilId"),
|
||||
"card_kingdom_id": identifiers.get("cardKingdomId"),
|
||||
"cardsphere_id": identifiers.get("cardsphereId"),
|
||||
"cardsphere_foil_id": identifiers.get("cardsphereFoilId"),
|
||||
"cardtrader_id": identifiers.get("cardtraderId"),
|
||||
"csi_id": identifiers.get("csiId"),
|
||||
"mcm_id": identifiers.get("mcmId"),
|
||||
"mcm_meta_id": identifiers.get("mcmMetaId"),
|
||||
"miniaturemarket_id": identifiers.get("miniaturemarketId"),
|
||||
"mtg_arena_id": identifiers.get("mtgArenaId"),
|
||||
"mtgjson_foil_version_id": identifiers.get("mtgjsonFoilVersionId"),
|
||||
"mtgjson_non_foil_version_id": identifiers.get("mtgjsonNonFoilVersionId"),
|
||||
"mtgjson_v4_id": identifiers.get("mtgjsonV4Id"),
|
||||
"mtgo_foil_id": identifiers.get("mtgoFoilId"),
|
||||
"mtgo_id": identifiers.get("mtgoId"),
|
||||
"multiverse_id": identifiers.get("multiverseId"),
|
||||
"scg_id": identifiers.get("scgId"),
|
||||
"scryfall_id": identifiers.get("scryfallId"),
|
||||
"scryfall_card_back_id": identifiers.get("scryfallCardBackId"),
|
||||
"scryfall_oracle_id": identifiers.get("scryfallOracleId"),
|
||||
"scryfall_illustration_id": identifiers.get("scryfallIllustrationId"),
|
||||
"tcgplayer_product_id": identifiers.get("tcgplayerProductId"),
|
||||
"tcgplayer_etched_product_id": identifiers.get("tcgplayerEtchedProductId"),
|
||||
"tnt_id": identifiers.get("tntId")
|
||||
}
|
||||
|
||||
for k, v in updates.items():
|
||||
if getattr(card, k) != v:
|
||||
setattr(card, k, v)
|
||||
|
||||
else:
|
||||
new_card = MTGJSONCard(
|
||||
mtgjson_uuid=card_data.get("uuid"),
|
||||
new_cards.append(MTGJSONCard(
|
||||
mtgjson_uuid=uuid,
|
||||
name=card_data.get("name"),
|
||||
set_code=card_data.get("setCode"),
|
||||
abu_id=card_data.get("identifiers", {}).get("abuId"),
|
||||
card_kingdom_etched_id=card_data.get("identifiers", {}).get("cardKingdomEtchedId"),
|
||||
card_kingdom_foil_id=card_data.get("identifiers", {}).get("cardKingdomFoilId"),
|
||||
card_kingdom_id=card_data.get("identifiers", {}).get("cardKingdomId"),
|
||||
cardsphere_id=card_data.get("identifiers", {}).get("cardsphereId"),
|
||||
cardsphere_foil_id=card_data.get("identifiers", {}).get("cardsphereFoilId"),
|
||||
cardtrader_id=card_data.get("identifiers", {}).get("cardtraderId"),
|
||||
csi_id=card_data.get("identifiers", {}).get("csiId"),
|
||||
mcm_id=card_data.get("identifiers", {}).get("mcmId"),
|
||||
mcm_meta_id=card_data.get("identifiers", {}).get("mcmMetaId"),
|
||||
miniaturemarket_id=card_data.get("identifiers", {}).get("miniaturemarketId"),
|
||||
mtg_arena_id=card_data.get("identifiers", {}).get("mtgArenaId"),
|
||||
mtgjson_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"),
|
||||
mtgjson_non_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"),
|
||||
mtgjson_v4_id=card_data.get("identifiers", {}).get("mtgjsonV4Id"),
|
||||
mtgo_foil_id=card_data.get("identifiers", {}).get("mtgoFoilId"),
|
||||
mtgo_id=card_data.get("identifiers", {}).get("mtgoId"),
|
||||
multiverse_id=card_data.get("identifiers", {}).get("multiverseId"),
|
||||
scg_id=card_data.get("identifiers", {}).get("scgId"),
|
||||
scryfall_id=card_data.get("identifiers", {}).get("scryfallId"),
|
||||
scryfall_card_back_id=card_data.get("identifiers", {}).get("scryfallCardBackId"),
|
||||
scryfall_oracle_id=card_data.get("identifiers", {}).get("scryfallOracleId"),
|
||||
scryfall_illustration_id=card_data.get("identifiers", {}).get("scryfallIllustrationId"),
|
||||
tcgplayer_product_id=card_data.get("identifiers", {}).get("tcgplayerProductId"),
|
||||
tcgplayer_etched_product_id=card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"),
|
||||
tnt_id=card_data.get("identifiers", {}).get("tntId")
|
||||
)
|
||||
db.add(new_card)
|
||||
abu_id=identifiers.get("abuId"),
|
||||
card_kingdom_etched_id=identifiers.get("cardKingdomEtchedId"),
|
||||
card_kingdom_foil_id=identifiers.get("cardKingdomFoilId"),
|
||||
card_kingdom_id=identifiers.get("cardKingdomId"),
|
||||
cardsphere_id=identifiers.get("cardsphereId"),
|
||||
cardsphere_foil_id=identifiers.get("cardsphereFoilId"),
|
||||
cardtrader_id=identifiers.get("cardtraderId"),
|
||||
csi_id=identifiers.get("csiId"),
|
||||
mcm_id=identifiers.get("mcmId"),
|
||||
mcm_meta_id=identifiers.get("mcmMetaId"),
|
||||
miniaturemarket_id=identifiers.get("miniaturemarketId"),
|
||||
mtg_arena_id=identifiers.get("mtgArenaId"),
|
||||
mtgjson_foil_version_id=identifiers.get("mtgjsonFoilVersionId"),
|
||||
mtgjson_non_foil_version_id=identifiers.get("mtgjsonNonFoilVersionId"),
|
||||
mtgjson_v4_id=identifiers.get("mtgjsonV4Id"),
|
||||
mtgo_foil_id=identifiers.get("mtgoFoilId"),
|
||||
mtgo_id=identifiers.get("mtgoId"),
|
||||
multiverse_id=identifiers.get("multiverseId"),
|
||||
scg_id=identifiers.get("scgId"),
|
||||
scryfall_id=identifiers.get("scryfallId"),
|
||||
scryfall_card_back_id=identifiers.get("scryfallCardBackId"),
|
||||
scryfall_oracle_id=identifiers.get("scryfallOracleId"),
|
||||
scryfall_illustration_id=identifiers.get("scryfallIllustrationId"),
|
||||
tcgplayer_product_id=identifiers.get("tcgplayerProductId"),
|
||||
tcgplayer_etched_product_id=identifiers.get("tcgplayerEtchedProductId"),
|
||||
tnt_id=identifiers.get("tntId")
|
||||
))
|
||||
|
||||
count += 1
|
||||
|
||||
if new_cards:
|
||||
db.bulk_save_objects(new_cards)
|
||||
|
||||
return count
|
||||
|
||||
async def sync_mtgjson_skus(self, db: Session, skus_data: dict) -> int:
|
||||
"""Sync MTGJSON SKUs data to the database"""
|
||||
|
||||
count = 0
|
||||
with db_transaction(db):
|
||||
for mtgjson_uuid, product_data in skus_data['data'].items():
|
||||
sku_details_by_key = {}
|
||||
|
||||
for mtgjson_uuid, product_data in skus_data["data"].items():
|
||||
for sku_data in product_data:
|
||||
existing_record = db.query(MTGJSONSKU).filter(MTGJSONSKU.mtgjson_uuid == mtgjson_uuid).filter(MTGJSONSKU.tcgplayer_sku_id == sku_data.get("skuId")).first()
|
||||
if existing_record:
|
||||
# Update existing SKU
|
||||
for key, value in {
|
||||
sku_id = sku_data.get("skuId")
|
||||
if sku_id is None or sku_id in sku_details_by_key:
|
||||
continue # Skip if missing or already added
|
||||
|
||||
sku_details_by_key[sku_id] = {
|
||||
"mtgjson_uuid": mtgjson_uuid,
|
||||
"tcgplayer_sku_id": sku_id,
|
||||
"tcgplayer_product_id": sku_data.get("productId"),
|
||||
"printing": sku_data.get("printing"),
|
||||
"normalized_printing": sku_data.get("printing", "").lower().replace(" ", "_").replace("non_foil", "normal") if sku_data.get("printing") else None,
|
||||
"condition": sku_data.get("condition"),
|
||||
"finish": sku_data.get("finish"),
|
||||
"language": sku_data.get("language"),
|
||||
"printing": sku_data.get("printing"),
|
||||
"normalized_printing": sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
|
||||
}.items():
|
||||
setattr(existing_record, key, value)
|
||||
}
|
||||
|
||||
with db_transaction(db):
|
||||
db.flush()
|
||||
|
||||
valid_uuids = {uuid for (uuid,) in db.query(MTGJSONCard.mtgjson_uuid).all()}
|
||||
valid_product_keys = {
|
||||
(product.tcgplayer_product_id, product.normalized_sub_type_name)
|
||||
for product in db.query(TCGPlayerProduct.tcgplayer_product_id, TCGPlayerProduct.normalized_sub_type_name)
|
||||
}
|
||||
|
||||
existing_sku_ids = {
|
||||
sku.tcgplayer_sku_id
|
||||
for sku in db.query(MTGJSONSKU.tcgplayer_sku_id).all()
|
||||
}
|
||||
|
||||
existing = {
|
||||
(sku.mtgjson_uuid, sku.tcgplayer_sku_id): sku
|
||||
for sku in db.query(MTGJSONSKU).all()
|
||||
}
|
||||
|
||||
new_skus = []
|
||||
|
||||
for data in sku_details_by_key.values():
|
||||
sku_id = data["tcgplayer_sku_id"]
|
||||
|
||||
if sku_id in existing_sku_ids:
|
||||
continue
|
||||
|
||||
mtgjson_uuid = data["mtgjson_uuid"]
|
||||
product_id = data["tcgplayer_product_id"]
|
||||
normalized_printing = data["normalized_printing"]
|
||||
|
||||
if mtgjson_uuid not in valid_uuids:
|
||||
continue
|
||||
|
||||
if (product_id, normalized_printing) not in valid_product_keys:
|
||||
continue
|
||||
|
||||
key = (mtgjson_uuid, sku_id)
|
||||
|
||||
if key in existing:
|
||||
record = existing[key]
|
||||
for field, value in data.items():
|
||||
if field not in ("mtgjson_uuid", "tcgplayer_sku_id") and getattr(record, field) != value:
|
||||
setattr(record, field, value)
|
||||
else:
|
||||
new_sku = MTGJSONSKU(
|
||||
mtgjson_uuid=mtgjson_uuid,
|
||||
tcgplayer_sku_id=sku_data.get("skuId"),
|
||||
tcgplayer_product_id=sku_data.get("productId"),
|
||||
condition=sku_data.get("condition"),
|
||||
finish=sku_data.get("finish"),
|
||||
language=sku_data.get("language"),
|
||||
printing=sku_data.get("printing"),
|
||||
normalized_printing=sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
|
||||
)
|
||||
db.add(new_sku)
|
||||
new_skus.append(MTGJSONSKU(**data))
|
||||
count += 1
|
||||
|
||||
if new_skus:
|
||||
db.bulk_save_objects(new_skus)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
|
||||
|
||||
async def initialize_data(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -693,19 +752,17 @@ class DataInitializationService(BaseService):
|
||||
with db_transaction(db):
|
||||
logger.info("Initializing inventory data...")
|
||||
# set expected value
|
||||
product_id1 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562118").first().id
|
||||
expected_value_box = SealedExpectedValue(
|
||||
product_id=product_id1,
|
||||
expected_value=120.69
|
||||
tcgplayer_product_id=619645,
|
||||
expected_value=136.42
|
||||
)
|
||||
db.add(expected_value_box)
|
||||
db.flush()
|
||||
product_id2 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562119").first().id
|
||||
expected_value_case = SealedExpectedValue(
|
||||
product_id=product_id2,
|
||||
expected_value=820.69
|
||||
)
|
||||
db.add(expected_value_case)
|
||||
#db.flush()
|
||||
#expected_value_case = SealedExpectedValue(
|
||||
# tcgplayer_product_id=562119,
|
||||
# expected_value=820.69
|
||||
#)
|
||||
#db.add(expected_value_case)
|
||||
db.flush()
|
||||
|
||||
inventory_service = self.get_service("inventory")
|
||||
@@ -715,32 +772,38 @@ class DataInitializationService(BaseService):
|
||||
transaction = await inventory_service.create_purchase_transaction(db, PurchaseTransactionCreate(
|
||||
vendor_id=vendor.id,
|
||||
transaction_date=datetime.now(),
|
||||
items=[PurchaseItem(product_id=product_id1, unit_price=100.69, quantity=1, is_case=False),
|
||||
PurchaseItem(product_id=product_id2, unit_price=800.01, quantity=2, is_case=True, num_boxes=6)],
|
||||
transaction_notes="Test Transaction: 1 case and 2 boxes of foundations"
|
||||
items=[PurchaseItem(product_id=619645, unit_price=100, quantity=1, item_type="box")],
|
||||
transaction_notes="tdm real box test"
|
||||
#PurchaseItem(product_id=562119, unit_price=800.01, quantity=2, is_case=True, num_boxes=6)],
|
||||
#transaction_notes="Test Transaction: 1 case and 2 boxes of foundations"
|
||||
))
|
||||
logger.info(f"Transaction created: {transaction}")
|
||||
case_num = 0
|
||||
for item in transaction.transaction_items:
|
||||
item = InventoryItemContextFactory(db).get_context(item.physical_item.inventory_item)
|
||||
logger.info(f"Item: {item}")
|
||||
if item.physical_item.item_type == "sealed_box":
|
||||
if item.inventory_item.physical_item.item_type == "box":
|
||||
manabox_service = self.get_service("manabox")
|
||||
file_path = 'app/data/test_data/manabox_test_file.csv'
|
||||
#file_path = 'app/data/test_data/manabox_test_file.csv'
|
||||
file_path = 'app/data/test_data/dragon.csv'
|
||||
file_bytes = open(file_path, 'rb').read()
|
||||
manabox_file = await manabox_service.process_manabox_csv(db, file_bytes, {"source": "test", "description": "test"}, wait=True)
|
||||
# Ensure manabox_file is a list before passing it
|
||||
if not isinstance(manabox_file, list):
|
||||
manabox_file = [manabox_file]
|
||||
sealed_box_service = self.get_service("sealed_box")
|
||||
sealed_box = sealed_box_service.get(db, item.physical_item.inventory_item.id)
|
||||
success = await inventory_service.process_manabox_import_staging(db, manabox_file, sealed_box)
|
||||
logger.info(f"sealed box opening success: {success}")
|
||||
elif item.physical_item.item_type == "sealed_case":
|
||||
box_service = self.get_service("box")
|
||||
open_event = await box_service.open_box(db, item.inventory_item.physical_item, manabox_file)
|
||||
# get all cards from box
|
||||
cards = open_event.resulting_items if open_event.resulting_items else []
|
||||
marketplace_listing_service = self.get_service("marketplace_listing")
|
||||
for card in cards:
|
||||
logger.info(f"card: {card}")
|
||||
# create marketplace listing
|
||||
await marketplace_listing_service.create_marketplace_listing(db, card.inventory_item, marketplace)
|
||||
elif item.inventory_item.physical_item.item_type == "case":
|
||||
if case_num == 0:
|
||||
logger.info(f"sealed case {case_num} opening...")
|
||||
sealed_case_service = self.get_service("sealed_case")
|
||||
success = await sealed_case_service.open_sealed_case(db, item.physical_item)
|
||||
case_service = self.get_service("case")
|
||||
success = await case_service.open_case(db, item.inventory_item.physical_item, 562119)
|
||||
logger.info(f"sealed case {case_num} opening success: {success}")
|
||||
case_num += 1
|
||||
|
||||
|
@@ -2,6 +2,11 @@ from typing import Dict, List, Optional
|
||||
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.file import FileInDB
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory, UnmanagedTCGPlayerInventory
|
||||
import csv
|
||||
from app.db.database import transaction
|
||||
from app.models.inventory_management import MarketplaceListing, InventoryItem, Marketplace
|
||||
from sqlalchemy import func
|
||||
|
||||
class TCGPlayerInventoryService(BaseTCGPlayerService):
|
||||
def __init__(self):
|
||||
@@ -24,10 +29,101 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
|
||||
raise ValueError(f"Invalid export type: {export_type}, must be 'staged', 'live', or 'pricing'")
|
||||
|
||||
file_bytes = await self._make_request("GET", endpoint, download_file=True)
|
||||
return await self.save_file(
|
||||
return await self.file_service.save_file(
|
||||
db=db,
|
||||
file_data=file_bytes,
|
||||
file_name=f"tcgplayer_{export_type}_export.csv",
|
||||
filename=f"tcgplayer_{export_type}_export.csv",
|
||||
subdir="tcgplayer/inventory",
|
||||
file_type=file_type
|
||||
)
|
||||
|
||||
async def refresh_tcgplayer_inventory_table(self, db: Session):
|
||||
"""
|
||||
Refresh the TCGPlayer inventory table
|
||||
"""
|
||||
export = await self.get_tcgplayer_export(db, "live")
|
||||
csv_string = await self.file_service.file_in_db_to_csv(db, export)
|
||||
reader = csv.DictReader(csv_string.splitlines())
|
||||
|
||||
# Convert CSV rows to list of dictionaries for bulk insert
|
||||
inventory_data = []
|
||||
for row in reader:
|
||||
if row.get("TCGplayer Id") is None:
|
||||
continue
|
||||
inventory_data.append({
|
||||
"tcgplayer_sku_id": int(row.get("TCGplayer Id")),
|
||||
"product_line": row.get("Product Line") if row.get("Product Line") else None,
|
||||
"set_name": row.get("Set Name") if row.get("Set Name") else None,
|
||||
"product_name": row.get("Product Name") if row.get("Product Name") else None,
|
||||
"title": row.get("Title") if row.get("Title") else None,
|
||||
"number": row.get("Number") if row.get("Number") else None,
|
||||
"rarity": row.get("Rarity") if row.get("Rarity") else None,
|
||||
"condition": row.get("Condition") if row.get("Condition") else None,
|
||||
"tcg_market_price": float(row.get("TCG Market Price")) if row.get("TCG Market Price") else None,
|
||||
"tcg_direct_low": float(row.get("TCG Direct Low")) if row.get("TCG Direct Low") else None,
|
||||
"tcg_low_price_with_shipping": float(row.get("TCG Low Price With Shipping")) if row.get("TCG Low Price With Shipping") else None,
|
||||
"tcg_low_price": float(row.get("TCG Low Price")) if row.get("TCG Low Price") else None,
|
||||
"total_quantity": int(row.get("Total Quantity")) if row.get("Total Quantity") else None,
|
||||
"add_to_quantity": int(row.get("Add to Quantity")) if row.get("Add to Quantity") else None,
|
||||
"tcg_marketplace_price": float(row.get("TCG Marketplace Price")) if row.get("TCG Marketplace Price") else None,
|
||||
"photo_url": row.get("Photo URL") if row.get("Photo URL") else None
|
||||
})
|
||||
|
||||
with transaction(db):
|
||||
# Bulk insert new data
|
||||
db.bulk_insert_mappings(TCGPlayerInventory, inventory_data)
|
||||
|
||||
async def refresh_unmanaged_tcgplayer_inventory_table(self, db: Session):
|
||||
"""
|
||||
Refresh the TCGPlayer unmanaged inventory table
|
||||
unmanaged inventory is any inventory that cannot be mapped to a card with a marketplace listing
|
||||
"""
|
||||
with transaction(db):
|
||||
# Get active marketplace listings with their physical items in a single query
|
||||
listed_cards = (
|
||||
db.query(MarketplaceListing)
|
||||
.join(MarketplaceListing.inventory_item)
|
||||
.join(InventoryItem.physical_item)
|
||||
.filter(
|
||||
func.lower(Marketplace.name) == func.lower("tcgplayer"),
|
||||
MarketplaceListing.delisting_date == None,
|
||||
MarketplaceListing.deleted_at == None,
|
||||
MarketplaceListing.listing_date != None
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get current inventory and create lookup dict
|
||||
current_inventory = db.query(TCGPlayerInventory).all()
|
||||
|
||||
# Create a set of SKUs that have active listings
|
||||
listed_skus = {
|
||||
card.inventory_item.physical_item.tcgplayer_sku_id
|
||||
for card in listed_cards
|
||||
}
|
||||
|
||||
unmanaged_inventory = []
|
||||
for inventory in current_inventory:
|
||||
# Only include SKUs that have no active listings
|
||||
if inventory.tcgplayer_sku_id not in listed_skus:
|
||||
unmanaged_inventory.append({
|
||||
"tcgplayer_inventory_id": inventory.id,
|
||||
"tcgplayer_sku_id": inventory.tcgplayer_sku_id,
|
||||
"product_line": inventory.product_line,
|
||||
"set_name": inventory.set_name,
|
||||
"product_name": inventory.product_name,
|
||||
"title": inventory.title,
|
||||
"number": inventory.number,
|
||||
"rarity": inventory.rarity,
|
||||
"condition": inventory.condition,
|
||||
"tcg_market_price": inventory.tcg_market_price,
|
||||
"tcg_direct_low": inventory.tcg_direct_low,
|
||||
"tcg_low_price_with_shipping": inventory.tcg_low_price_with_shipping,
|
||||
"tcg_low_price": inventory.tcg_low_price,
|
||||
"total_quantity": inventory.total_quantity,
|
||||
"add_to_quantity": inventory.add_to_quantity,
|
||||
"tcg_marketplace_price": inventory.tcg_marketplace_price,
|
||||
"photo_url": inventory.photo_url
|
||||
})
|
||||
|
||||
db.bulk_insert_mappings(UnmanagedTCGPlayerInventory, unmanaged_inventory)
|
||||
|
@@ -120,7 +120,7 @@ class FileService:
|
||||
"""List files with optional filtering"""
|
||||
query = db.query(File)
|
||||
if file_type:
|
||||
query = query.filter(File.type == file_type).order_by(File.created_at.desc())
|
||||
query = query.filter(File.file_type == file_type).filter(File.deleted_at == None).order_by(File.created_at.desc())
|
||||
files = query.offset(skip).limit(limit).all()
|
||||
return [FileInDB.model_validate(file) for file in files]
|
||||
|
||||
@@ -158,3 +158,8 @@ class FileService:
|
||||
if file_record:
|
||||
return FileInDB.model_validate(file_record)
|
||||
return None
|
||||
|
||||
async def file_in_db_to_csv(self, db: Session, file: FileInDB) -> str:
|
||||
"""Convert a file in the database to a CSV string"""
|
||||
with open(file.path, "r") as f:
|
||||
return f.read()
|
||||
|
@@ -1,14 +1,16 @@
|
||||
from typing import List, Optional, Dict, TypedDict
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from decimal import Decimal
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.manabox_import_staging import ManaboxImportStaging
|
||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||
from app.models.inventory_management import (
|
||||
SealedBox, OpenEvent, OpenBox, OpenCard, InventoryItem, SealedCase,
|
||||
Transaction, TransactionItem, Customer, Vendor, Marketplace
|
||||
OpenEvent, Card, InventoryItem, Case, SealedExpectedValue,
|
||||
Transaction, TransactionItem, Customer, Vendor, Marketplace, Box, MarketplaceListing
|
||||
)
|
||||
from app.schemas.file import FileInDB
|
||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse
|
||||
from app.models.inventory_management import PhysicalItem
|
||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse, SealedExpectedValueCreate
|
||||
from app.db.database import transaction as db_transaction
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -20,91 +22,59 @@ class InventoryService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
async def process_manabox_import_staging(self, db: Session, manabox_file_uploads: List[FileInDB], sealed_box: SealedBox) -> bool:
|
||||
try:
|
||||
async def get_resulting_items_for_open_event(self, db: Session, open_event: OpenEvent) -> List[InventoryItem]:
|
||||
# Get the IDs of resulting items
|
||||
resulting_item_ids = [item.id for item in open_event.resulting_items]
|
||||
# Query using the IDs
|
||||
return db.query(InventoryItem).filter(InventoryItem.physical_item_id.in_(resulting_item_ids)).filter(InventoryItem.deleted_at == None).all()
|
||||
|
||||
async def get_open_event(self, db: Session, inventory_item: InventoryItem, open_event_id: int) -> OpenEvent:
|
||||
return db.query(OpenEvent).filter(OpenEvent.source_item == inventory_item.physical_item).filter(OpenEvent.id == open_event_id).filter(OpenEvent.deleted_at == None).first()
|
||||
|
||||
async def get_open_events_for_inventory_item(self, db: Session, inventory_item: InventoryItem) -> List[OpenEvent]:
|
||||
return db.query(OpenEvent).filter(OpenEvent.source_item == inventory_item.physical_item).filter(OpenEvent.deleted_at == None).all()
|
||||
|
||||
async def get_inventory_item(self, db: Session, inventory_item_id: int) -> InventoryItem:
|
||||
return db.query(InventoryItem)\
|
||||
.options(
|
||||
joinedload(InventoryItem.physical_item).joinedload(PhysicalItem.product_direct)
|
||||
)\
|
||||
.filter(InventoryItem.id == inventory_item_id)\
|
||||
.first()
|
||||
|
||||
async def get_expected_value(self, db: Session, product_id: int) -> float:
|
||||
expected_value = db.query(SealedExpectedValue).filter(SealedExpectedValue.tcgplayer_product_id == product_id).filter(SealedExpectedValue.deleted_at == None).first()
|
||||
return expected_value.expected_value if expected_value else None
|
||||
|
||||
async def get_transactions(self, db: Session, skip: int, limit: int) -> List[Transaction]:
|
||||
return db.query(Transaction)\
|
||||
.filter(Transaction.deleted_at == None)\
|
||||
.order_by(Transaction.transaction_date.desc())\
|
||||
.offset(skip)\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
async def get_transaction(self, db: Session, transaction_id: int) -> Transaction:
|
||||
return db.query(Transaction)\
|
||||
.options(
|
||||
joinedload(Transaction.transaction_items).joinedload(TransactionItem.inventory_item).joinedload(InventoryItem.physical_item).joinedload(PhysicalItem.product_direct),
|
||||
joinedload(Transaction.vendors),
|
||||
joinedload(Transaction.customers),
|
||||
joinedload(Transaction.marketplaces)
|
||||
)\
|
||||
.filter(Transaction.id == transaction_id)\
|
||||
.filter(Transaction.deleted_at == None)\
|
||||
.first()
|
||||
|
||||
async def create_expected_value(self, db: Session, expected_value_data: SealedExpectedValueCreate) -> SealedExpectedValue:
|
||||
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()
|
||||
expected_value = SealedExpectedValue(
|
||||
tcgplayer_product_id=expected_value_data.tcgplayer_product_id,
|
||||
expected_value=expected_value_data.expected_value
|
||||
)
|
||||
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
|
||||
db.add(expected_value)
|
||||
db.flush()
|
||||
return expected_value
|
||||
|
||||
async def create_purchase_transaction(
|
||||
self,
|
||||
@@ -125,106 +95,46 @@ class InventoryService(BaseService):
|
||||
vendor_id=transaction_data.vendor_id,
|
||||
transaction_type='purchase',
|
||||
transaction_date=transaction_data.transaction_date,
|
||||
transaction_notes=transaction_data.transaction_notes,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
transaction_notes=transaction_data.transaction_notes
|
||||
)
|
||||
db.add(transaction)
|
||||
db.flush()
|
||||
|
||||
total_amount = 0
|
||||
physical_items = []
|
||||
case_service = self.get_service("case")
|
||||
box_service = self.get_service("box")
|
||||
for item in transaction_data.items:
|
||||
# Create the physical item based on type
|
||||
# TODO: remove is_case and num_boxes, should derive from product_id
|
||||
# TODO: add support for purchasing single cards
|
||||
if item.is_case:
|
||||
if item.item_type == "case":
|
||||
for i in range(item.quantity):
|
||||
physical_item = await SealedCaseService().create_sealed_case(
|
||||
physical_item = await case_service.create_case(
|
||||
db=db,
|
||||
product_id=item.product_id,
|
||||
cost_basis=item.unit_price,
|
||||
num_boxes=item.num_boxes or 1
|
||||
num_boxes=item.num_boxes
|
||||
)
|
||||
physical_items.append(physical_item)
|
||||
else:
|
||||
elif item.item_type == "box":
|
||||
for i in range(item.quantity):
|
||||
physical_item = await SealedBoxService().create_sealed_box(
|
||||
physical_item = await box_service.create_box(
|
||||
db=db,
|
||||
product_id=item.product_id,
|
||||
cost_basis=item.unit_price
|
||||
)
|
||||
physical_items.append(physical_item)
|
||||
else:
|
||||
raise ValueError(f"Invalid item type: {item.item_type}")
|
||||
# TODO: add support for purchasing single cards
|
||||
|
||||
for physical_item in physical_items:
|
||||
# Create transaction item
|
||||
transaction_item = TransactionItem(
|
||||
transaction_id=transaction.id,
|
||||
physical_item_id=physical_item.id,
|
||||
unit_price=item.unit_price,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(transaction_item)
|
||||
total_amount += item.unit_price
|
||||
|
||||
# Update transaction total
|
||||
transaction.transaction_total_amount = total_amount
|
||||
return transaction
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def create_sale_transaction(
|
||||
self,
|
||||
db: Session,
|
||||
transaction_data: SaleTransactionCreate
|
||||
) -> Transaction:
|
||||
"""
|
||||
this is basically psuedocode not implemented yet
|
||||
"""
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the transaction
|
||||
transaction = Transaction(
|
||||
customer_id=transaction_data.customer_id,
|
||||
marketplace_id=transaction_data.marketplace_id,
|
||||
transaction_type='sale',
|
||||
transaction_date=transaction_data.transaction_date,
|
||||
transaction_notes=transaction_data.transaction_notes,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(transaction)
|
||||
db.flush()
|
||||
|
||||
total_amount = 0
|
||||
for item in transaction_data.items:
|
||||
# Get the inventory item and validate
|
||||
inventory_item = db.query(InventoryItem).filter(
|
||||
InventoryItem.id == item.inventory_item_id,
|
||||
InventoryItem.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not inventory_item:
|
||||
raise ValueError(f"Inventory item {item.inventory_item_id} not found")
|
||||
|
||||
# Create transaction item
|
||||
transaction_item = TransactionItem(
|
||||
transaction_id=transaction.id,
|
||||
physical_item_id=inventory_item.physical_item_id,
|
||||
unit_price=item.unit_price,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(transaction_item)
|
||||
total_amount += item.unit_price
|
||||
|
||||
# Update marketplace listing if applicable
|
||||
if transaction_data.marketplace_id and inventory_item.marketplace_listings:
|
||||
listing = inventory_item.marketplace_listings
|
||||
listing.delisting_date = transaction_data.transaction_date
|
||||
listing.updated_at = datetime.now()
|
||||
transaction.transaction_items.append(TransactionItem(
|
||||
inventory_item_id=physical_item.inventory_item.id,
|
||||
unit_price=item.unit_price
|
||||
))
|
||||
|
||||
# Update transaction total
|
||||
transaction.transaction_total_amount = total_amount
|
||||
@@ -246,9 +156,7 @@ class InventoryService(BaseService):
|
||||
|
||||
with db_transaction(db):
|
||||
customer = Customer(
|
||||
name=customer_name,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
name=customer_name
|
||||
)
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
@@ -270,9 +178,7 @@ class InventoryService(BaseService):
|
||||
|
||||
with db_transaction(db):
|
||||
vendor = Vendor(
|
||||
name=vendor_name,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
name=vendor_name
|
||||
)
|
||||
db.add(vendor)
|
||||
db.flush()
|
||||
@@ -281,6 +187,12 @@ class InventoryService(BaseService):
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def get_vendors(
|
||||
self,
|
||||
db: Session
|
||||
) -> List[Vendor]:
|
||||
return db.query(Vendor).all()
|
||||
|
||||
async def create_marketplace(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -294,9 +206,7 @@ class InventoryService(BaseService):
|
||||
|
||||
with db_transaction(db):
|
||||
marketplace = Marketplace(
|
||||
name=marketplace_name,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
name=marketplace_name
|
||||
)
|
||||
db.add(marketplace)
|
||||
db.flush()
|
||||
@@ -305,110 +215,150 @@ class InventoryService(BaseService):
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class SealedBoxService(BaseService[SealedBox]):
|
||||
def __init__(self):
|
||||
super().__init__(SealedBox)
|
||||
async def get_marketplaces(
|
||||
self,
|
||||
db: Session
|
||||
) -> List[Marketplace]:
|
||||
return db.query(Marketplace).all()
|
||||
|
||||
async def create_sealed_box(
|
||||
class BoxService(BaseService[Box]):
|
||||
def __init__(self):
|
||||
super().__init__(Box)
|
||||
|
||||
async def create_box(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: int,
|
||||
cost_basis: float,
|
||||
case_id: Optional[int] = None
|
||||
) -> SealedBox:
|
||||
cost_basis: float
|
||||
) -> Box:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the SealedBox
|
||||
sealed_box = SealedBox(
|
||||
product_id=product_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
box = Box(
|
||||
tcgplayer_product_id=product_id
|
||||
)
|
||||
db.add(sealed_box)
|
||||
db.add(box)
|
||||
db.flush() # Get the ID for relationships
|
||||
|
||||
# If this box is part of a case, link it
|
||||
if case_id:
|
||||
case = db.query(SealedCase).filter(SealedCase.id == case_id).first()
|
||||
if not case:
|
||||
raise ValueError(f"Case {case_id} not found")
|
||||
sealed_box.case_id = case_id
|
||||
expected_value = box.products.sealed_expected_value.expected_value
|
||||
box.expected_value = expected_value
|
||||
db.flush()
|
||||
|
||||
# Create the InventoryItem for the sealed box
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=sealed_box,
|
||||
cost_basis=cost_basis,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
physical_item=box,
|
||||
cost_basis=cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
return sealed_box
|
||||
return box
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class SealedCaseService(BaseService[SealedCase]):
|
||||
def __init__(self):
|
||||
super().__init__(SealedCase)
|
||||
async def calculate_cost_basis_for_opened_cards(self, db: Session, open_event: OpenEvent) -> float:
|
||||
box_cost_basis = open_event.source_item.inventory_item.cost_basis
|
||||
box_expected_value = open_event.source_item.products.sealed_expected_value.expected_value
|
||||
for resulting_card in open_event.resulting_items:
|
||||
# ensure card
|
||||
if resulting_card.item_type != "card":
|
||||
raise ValueError(f"Expected card, got {resulting_card.item_type}")
|
||||
resulting_card_market_value = resulting_card.products.most_recent_tcgplayer_price.market_price
|
||||
resulting_card_cost_basis = (resulting_card_market_value / box_expected_value) * box_cost_basis
|
||||
resulting_card.inventory_item.cost_basis = resulting_card_cost_basis
|
||||
db.flush()
|
||||
|
||||
async def create_sealed_case(self, db: Session, product_id: int, cost_basis: float, num_boxes: int) -> SealedCase:
|
||||
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()
|
||||
|
||||
manabox_upload_ids = [manabox_file_upload.id for manabox_file_upload in manabox_file_uploads]
|
||||
staging_data = db.query(ManaboxImportStaging).filter(ManaboxImportStaging.file_id.in_(manabox_upload_ids)).all()
|
||||
for record in staging_data:
|
||||
for i in range(record.quantity):
|
||||
open_card = Card(
|
||||
tcgplayer_product_id=record.tcgplayer_product_id,
|
||||
tcgplayer_sku_id=record.tcgplayer_sku_id
|
||||
)
|
||||
open_event.resulting_items.append(open_card)
|
||||
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=open_card,
|
||||
cost_basis=0
|
||||
)
|
||||
db.add(inventory_item)
|
||||
db.flush()
|
||||
|
||||
# calculate cost basis for opened cards
|
||||
await self.calculate_cost_basis_for_opened_cards(db, open_event)
|
||||
|
||||
|
||||
|
||||
return open_event
|
||||
|
||||
|
||||
|
||||
class CaseService(BaseService[Case]):
|
||||
def __init__(self):
|
||||
super().__init__(Case)
|
||||
|
||||
async def create_case(self, db: Session, product_id: int, cost_basis: float, num_boxes: int) -> Case:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the SealedCase
|
||||
sealed_case = SealedCase(
|
||||
product_id=product_id,
|
||||
num_boxes=num_boxes,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
case = Case(
|
||||
tcgplayer_product_id=product_id,
|
||||
num_boxes=num_boxes
|
||||
)
|
||||
db.add(sealed_case)
|
||||
db.add(case)
|
||||
db.flush() # Get the ID for relationships
|
||||
case.expected_value = case.products.sealed_expected_value.expected_value
|
||||
|
||||
# Create the InventoryItem for the sealed case
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=sealed_case,
|
||||
cost_basis=cost_basis,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
physical_item=case,
|
||||
cost_basis=cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
return sealed_case
|
||||
return case
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def open_sealed_case(self, db: Session, sealed_case: SealedCase) -> bool:
|
||||
async def open_case(self, db: Session, case: Case, child_product_id: int) -> bool:
|
||||
try:
|
||||
sealed_case_context = InventoryItemContextFactory(db).get_context(sealed_case.inventory_item)
|
||||
## TODO should be able to import a manabox file with a case
|
||||
## cost basis will be able to flow down to the card accurately
|
||||
with db_transaction(db):
|
||||
# Create the OpenEvent
|
||||
open_event = OpenEvent(
|
||||
sealed_case_id=sealed_case_context.physical_item.id,
|
||||
open_date=datetime.now(),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
source_item=case,
|
||||
open_date=datetime.now()
|
||||
)
|
||||
db.add(open_event)
|
||||
db.flush() # Get the ID for relationships
|
||||
|
||||
# Create num_boxes SealedBoxes
|
||||
for i in range(sealed_case.num_boxes):
|
||||
sealed_box = SealedBox(
|
||||
product_id=sealed_case_context.physical_item.product_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
for i in range(case.num_boxes):
|
||||
new_box = Box(
|
||||
tcgplayer_product_id=child_product_id
|
||||
)
|
||||
db.add(sealed_box)
|
||||
db.flush() # Get the ID for relationships
|
||||
open_event.resulting_items.append(new_box)
|
||||
db.flush()
|
||||
|
||||
per_box_cost_basis = case.inventory_item.cost_basis / case.num_boxes
|
||||
|
||||
# Create the InventoryItem for the sealed box
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=sealed_box,
|
||||
cost_basis=sealed_case_context.cost_basis,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
physical_item=new_box,
|
||||
cost_basis=per_box_cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
@@ -416,3 +366,140 @@ class SealedCaseService(BaseService[SealedCase]):
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class MarketplaceListingService(BaseService[MarketplaceListing]):
|
||||
def __init__(self):
|
||||
super().__init__(MarketplaceListing)
|
||||
self.pricing_service = self.service_manager.get_service("pricing")
|
||||
|
||||
async def create_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
recommended_price = await self.pricing_service.set_price(db, inventory_item)
|
||||
logger.info(f"recommended_price: {recommended_price.price}")
|
||||
marketplace_listing = MarketplaceListing(
|
||||
inventory_item=inventory_item,
|
||||
marketplace=marketplace,
|
||||
recommended_price=recommended_price,
|
||||
listing_date=None,
|
||||
delisting_date=None
|
||||
)
|
||||
db.add(marketplace_listing)
|
||||
db.flush()
|
||||
return marketplace_listing
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def update_marketplace_listing_price(self, db: Session, marketplace_listing: MarketplaceListing) -> MarketplaceListing:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
marketplace_listing.listed_price = self.pricing_service.set_price(marketplace_listing.inventory_item)
|
||||
db.flush()
|
||||
return marketplace_listing
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def get_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
|
||||
return db.query(MarketplaceListing).filter(MarketplaceListing.inventory_item == inventory_item).filter(MarketplaceListing.delisting_date == None).filter(MarketplaceListing.deleted_at == None).filter(MarketplaceListing.marketplace == marketplace).order_by(MarketplaceListing.created_at.desc()).first()
|
||||
|
||||
async def get_active_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
|
||||
return db.query(MarketplaceListing).filter(MarketplaceListing.inventory_item == inventory_item).filter(MarketplaceListing.delisting_date == None).filter(MarketplaceListing.deleted_at == None).filter(MarketplaceListing.marketplace == marketplace).filter(MarketplaceListing.listing_date != None).order_by(MarketplaceListing.created_at.desc()).first()
|
||||
|
||||
async def confirm_listings(self, db: Session, open_event: OpenEvent, marketplace: Marketplace) -> str:
|
||||
tcgplayer_add_file = await self.create_tcgplayer_add_file(db, open_event, marketplace)
|
||||
if not tcgplayer_add_file:
|
||||
raise ValueError("No TCGplayer add file created")
|
||||
with db_transaction(db):
|
||||
for resulting_item in open_event.resulting_items:
|
||||
marketplace_listing = await self.get_marketplace_listing(db, resulting_item.inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
raise ValueError(f"No active marketplace listing found for inventory item id {resulting_item.inventory_item.id} in {marketplace.name}")
|
||||
marketplace_listing.listing_date = datetime.now()
|
||||
db.flush()
|
||||
return tcgplayer_add_file
|
||||
|
||||
async def create_tcgplayer_add_file(self, db: Session, open_event: OpenEvent, marketplace: Marketplace) -> str:
|
||||
# TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low Price With Shipping,TCG Low Price,Total Quantity,Add to Quantity,TCG Marketplace Price,Photo URL
|
||||
headers = [
|
||||
"TCGplayer Id",
|
||||
"Product Line",
|
||||
"Set Name",
|
||||
"Product Name",
|
||||
"Title",
|
||||
"Number",
|
||||
"Rarity",
|
||||
"Condition",
|
||||
"TCG Market Price",
|
||||
"TCG Direct Low",
|
||||
"TCG Low Price With Shipping",
|
||||
"TCG Low Price",
|
||||
"Total Quantity",
|
||||
"Add to Quantity",
|
||||
"TCG Marketplace Price",
|
||||
"Photo URL"
|
||||
]
|
||||
data = {}
|
||||
for resulting_item in open_event.resulting_items:
|
||||
marketplace_listing = await self.get_marketplace_listing(db, resulting_item.inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
raise ValueError(f"No active marketplace listing found for inventory item id {resulting_item.inventory_item.id} in {marketplace.name}")
|
||||
tcgplayer_sku_id = resulting_item.tcgplayer_sku_id
|
||||
if tcgplayer_sku_id in data:
|
||||
data[tcgplayer_sku_id]["Add to Quantity"] += 1
|
||||
continue
|
||||
product_line = resulting_item.products.category.name
|
||||
set_name = resulting_item.products.group.name
|
||||
product_name = resulting_item.products.name
|
||||
title = ""
|
||||
number = resulting_item.products.ext_number
|
||||
rarity = resulting_item.products.ext_rarity
|
||||
condition = " ".join([condition.capitalize() for condition in resulting_item.sku.condition.split(" ")]) + (" " + resulting_item.products.sub_type_name if resulting_item.products.sub_type_name == "Foil" else "")
|
||||
tcg_market_price = resulting_item.products.most_recent_tcgplayer_price.market_price
|
||||
tcg_direct_low = resulting_item.products.most_recent_tcgplayer_price.direct_low_price
|
||||
tcg_low_price_with_shipping = resulting_item.products.most_recent_tcgplayer_price.low_price
|
||||
tcg_low_price = resulting_item.products.most_recent_tcgplayer_price.low_price
|
||||
total_quantity = ""
|
||||
add_to_quantity = 1
|
||||
# get average recommended price of product
|
||||
# get inventory items with same tcgplayer_product_id
|
||||
inventory_items = db.query(InventoryItem).filter(InventoryItem.physical_item.has(tcgplayer_sku_id=tcgplayer_sku_id)).all()
|
||||
inventory_item_ids = [inventory_item.id for inventory_item in inventory_items]
|
||||
logger.debug(f"inventory_item_ids: {inventory_item_ids}")
|
||||
valid_listings = db.query(MarketplaceListing).filter(MarketplaceListing.inventory_item_id.in_(inventory_item_ids)).filter(MarketplaceListing.delisting_date == None).filter(MarketplaceListing.deleted_at == None).filter(MarketplaceListing.listing_date == None).all()
|
||||
logger.debug(f"valid_listings: {valid_listings}")
|
||||
avg_recommended_price = sum([listing.recommended_price.price for listing in valid_listings]) / len(valid_listings)
|
||||
data[tcgplayer_sku_id] = {
|
||||
"TCGplayer Id": tcgplayer_sku_id,
|
||||
"Product Line": product_line,
|
||||
"Set Name": set_name,
|
||||
"Product Name": product_name,
|
||||
"Title": title,
|
||||
"Number": number,
|
||||
"Rarity": rarity,
|
||||
"Condition": condition,
|
||||
"TCG Market Price": tcg_market_price,
|
||||
"TCG Direct Low": tcg_direct_low,
|
||||
"TCG Low Price With Shipping": tcg_low_price_with_shipping,
|
||||
"TCG Low Price": tcg_low_price,
|
||||
"Total Quantity": total_quantity,
|
||||
"Add to Quantity": add_to_quantity,
|
||||
"TCG Marketplace Price": f"{Decimal(avg_recommended_price):.2f}",
|
||||
"Photo URL": ""
|
||||
}
|
||||
# format data into csv
|
||||
# header
|
||||
header_row = ",".join(headers)
|
||||
# data
|
||||
def escape_csv_value(value):
|
||||
if value is None:
|
||||
return ""
|
||||
value = str(value)
|
||||
if any(c in value for c in [',', '"', '\n']):
|
||||
return f'"{value.replace('"', '""')}"'
|
||||
return value
|
||||
|
||||
data_rows = [",".join([escape_csv_value(data[tcgplayer_id][header]) for header in headers]) for tcgplayer_id in data]
|
||||
csv_data = "\n".join([header_row] + data_rows)
|
||||
return csv_data
|
||||
|
@@ -9,7 +9,7 @@ from typing import Dict, Any, Union, List
|
||||
import csv
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,7 +17,7 @@ class ManaboxService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
async def process_manabox_csv(self, db: Session, bytes: bytes, metadata: Dict[str, Any], wait: bool = False) -> Union[bool, List[FileInDB]]:
|
||||
async def process_manabox_csv(self, db: Session, bytes: bytes, metadata: Dict[str, Any], background_tasks: BackgroundTasks, wait: bool = False) -> Union[bool, List[FileInDB]]:
|
||||
# save file
|
||||
file = await self.file_service.save_file(
|
||||
db=db,
|
||||
@@ -29,15 +29,12 @@ class ManaboxService(BaseService):
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# Create the background task
|
||||
task = asyncio.create_task(self._process_file_background(db, file))
|
||||
|
||||
# If wait is True, wait for the task to complete and return the file
|
||||
if wait:
|
||||
await task
|
||||
await self._process_file_background(db, file)
|
||||
return_value = await self.file_service.get_file(db, file.id)
|
||||
return [return_value] if return_value else []
|
||||
|
||||
else:
|
||||
background_tasks.add_task(self._process_file_background, db, file)
|
||||
return True
|
||||
|
||||
async def _process_file_background(self, db: Session, file: FileInDB):
|
||||
@@ -45,68 +42,98 @@ class ManaboxService(BaseService):
|
||||
# Read the CSV file
|
||||
with open(file.path, 'r') as csv_file:
|
||||
reader = csv.DictReader(csv_file)
|
||||
# skip header row
|
||||
next(reader)
|
||||
logger.debug(f"Processing file: {file.path}")
|
||||
# Pre-fetch all MTGJSONCards for Scryfall IDs in the file
|
||||
scryfall_ids = {row['Scryfall ID'] for row in reader}
|
||||
mtg_json_map = {card.scryfall_id: card for card in db.query(MTGJSONCard).filter(MTGJSONCard.scryfall_id.in_(scryfall_ids)).all()}
|
||||
logger.debug(f"len ids: {len(scryfall_ids)}")
|
||||
|
||||
# Re-read the file to process the rows
|
||||
csv_file.seek(0)
|
||||
logger.debug(f"header: {reader.fieldnames}")
|
||||
next(reader) # Skip the header row
|
||||
|
||||
staging_entries = [] # To collect all staging entries for batch insert
|
||||
critical_errors = [] # To collect errors for logging
|
||||
|
||||
for row in reader:
|
||||
# match scryfall id to mtgjson scryfall id, make sure only one distinct tcgplayer id
|
||||
mtg_json = db.query(MTGJSONCard).filter(MTGJSONCard.scryfall_id == row['Scryfall ID']).all()
|
||||
# count distinct tcgplayer ids
|
||||
cd_tcgplayer_ids = db.query(MTGJSONCard.tcgplayer_sku_id).filter(MTGJSONCard.scryfall_id == row['Scryfall ID']).distinct().count()
|
||||
if cd_tcgplayer_ids != 1:
|
||||
logger.error(f"Error: multiple TCGplayer IDs found for scryfall id: {row['Scryfall ID']} found {cd_tcgplayer_ids} ids expected 1")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error: multiple TCGplayer IDs found for scryfall id: {row['Scryfall ID']} found {cd_tcgplayer_ids} ids expected 1"
|
||||
)
|
||||
db.add(critical_error_log)
|
||||
continue
|
||||
else:
|
||||
mtg_json = mtg_json[0]
|
||||
# get tcgplayer sku id from mtgjson skus
|
||||
language = 'ENGLISH' if row['Language'] == 'en' else 'JAPANESE' if row['Language'] == 'ja' else None
|
||||
if row['Foil'].lower() == 'etched':
|
||||
printing = 'FOIL'
|
||||
tcgplayer_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_sku_id == mtg_json.tcgplayer_etched_sku_id).filter(MTGJSONSKU.condition == row['Condition'].replace('_', ' ').upper()).filter(MTGJSONSKU.printing == printing).filter(MTGJSONSKU.language == language).distinct().all()
|
||||
else:
|
||||
printing = 'FOIL' if row['Foil'].lower() == 'foil' else 'NON FOIL'
|
||||
tcgplayer_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_sku_id == mtg_json.tcgplayer_sku_id).filter(MTGJSONSKU.condition == row['Condition'].replace('_', ' ').upper()).filter(MTGJSONSKU.printing == printing).filter(MTGJSONSKU.language == language).distinct().all()
|
||||
# count distinct tcgplayer skus
|
||||
if len(tcgplayer_sku) == 0:
|
||||
logger.error(f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
)
|
||||
db.add(critical_error_log)
|
||||
continue
|
||||
elif len(tcgplayer_sku) > 1:
|
||||
logger.error(f"Error: {len(tcgplayer_sku)} TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error: {len(tcgplayer_sku)} TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
)
|
||||
db.add(critical_error_log)
|
||||
continue
|
||||
else:
|
||||
tcgplayer_sku = tcgplayer_sku[0]
|
||||
# look up tcgplayer product data for sku
|
||||
tcgplayer_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_product_id == tcgplayer_sku.tcgplayer_product_id).filter(TCGPlayerProduct.condition == row['Condition'].replace('_', ' ').upper()).filter(TCGPlayerProduct.language == language).filter(TCGPlayerProduct.printing == printing).first()
|
||||
|
||||
logger.debug(f"Processing row: {row}")
|
||||
mtg_json = mtg_json_map.get(row['Scryfall ID'])
|
||||
|
||||
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'])
|
||||
|
||||
with transaction(db):
|
||||
manabox_import_staging = ManaboxImportStaging(
|
||||
logger.debug(f"inserting row file id: {file.id} tcgplayer_product_id: {tcgplayer_product.tcgplayer_product_id} tcgplayer_sku_id: {tcgplayer_sku.tcgplayer_sku_id} quantity: {quantity}")
|
||||
staging_entries.append(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)
|
||||
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:
|
||||
logger.debug(f"inserting {len(staging_entries)} rows")
|
||||
with transaction(db):
|
||||
db.bulk_save_objects(staging_entries)
|
||||
|
||||
# Log any critical errors that occurred
|
||||
for error_message in critical_errors:
|
||||
logger.debug(f"logging critical error: {error_message}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(error_message=error_message)
|
||||
db.add(critical_error_log)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing file: {str(e)}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(
|
||||
error_message=f"Error processing file: {str(e)}"
|
||||
)
|
||||
critical_error_log = CriticalErrorLog(error_message=f"Error processing file: {str(e)}")
|
||||
db.add(critical_error_log)
|
182
app/services/pricing_service.py
Normal file
182
app/services/pricing_service.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.inventory_management import InventoryItem, MarketplaceListing
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
||||
from app.models.pricing import PricingEvent
|
||||
from app.db.database import transaction
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
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.20')
|
||||
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.033')
|
||||
elif market_price >= 2 and market_price < 10:
|
||||
card_cost_margin_multiplier = Decimal('0.0')
|
||||
elif market_price >= 10 and market_price < 30:
|
||||
card_cost_margin_multiplier = Decimal('0.0125')
|
||||
elif market_price >= 30 and market_price < 50:
|
||||
card_cost_margin_multiplier = Decimal('0.025')
|
||||
elif market_price >= 50 and market_price < 100:
|
||||
card_cost_margin_multiplier = Decimal('0.033')
|
||||
elif market_price >= 100 and market_price < 200:
|
||||
card_cost_margin_multiplier = Decimal('0.05')
|
||||
|
||||
# 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.2')
|
||||
elif 5 <= quantity_in_stock < 10:
|
||||
quantity_multiplier = Decimal('0.3')
|
||||
elif quantity_in_stock >= 10:
|
||||
quantity_multiplier = Decimal('0.4')
|
||||
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.15'):
|
||||
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)
|
||||
|
||||
# delete previous pricing events for inventory item
|
||||
if inventory_item.marketplace_listing and inventory_item.marketplace_listing.listed_price:
|
||||
inventory_item.marketplace_listing.listed_price.deleted_at = datetime.now()
|
||||
|
||||
return pricing_event
|
||||
|
||||
def set_price_for_unmanaged_inventory(self, db: Session, tcgplayer_sku_id: int, quantity: int) -> float:
|
||||
pass
|
||||
|
||||
def update_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> list[PricingEvent]:
|
||||
# get inventory items for sku
|
||||
updated_prices = []
|
||||
inventory_items = db.query(InventoryItem).filter(
|
||||
InventoryItem.physical_item.tcgplayer_sku_id == tcgplayer_sku_id
|
||||
).all()
|
||||
for inventory_item in inventory_items:
|
||||
pricing_event = self.set_price(db, inventory_item)
|
||||
updated_prices.append(pricing_event)
|
||||
return updated_prices
|
||||
|
||||
def set_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> float:
|
||||
# update price for all inventory items for sku
|
||||
prices = self.update_price_for_product(db, tcgplayer_sku_id)
|
||||
sum_prices = sum(price.price for price in prices)
|
||||
average_price = sum_prices / len(prices)
|
||||
return average_price
|
||||
|
||||
|
@@ -113,7 +113,7 @@ class PullSheetService(BaseService):
|
||||
'quantity': str(int(row['Quantity'])), # Convert to string for template
|
||||
'set': row['Set'],
|
||||
'rarity': row['Rarity'],
|
||||
'card_number': str(int(row['Number'])) if 'Number' in row else ''
|
||||
'card_number': str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else ''
|
||||
})
|
||||
|
||||
return items
|
@@ -22,16 +22,6 @@ class BaseScheduler:
|
||||
*args,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""Schedule a task to run at regular intervals or at specific times using APScheduler
|
||||
|
||||
Args:
|
||||
task_name: Name of the task
|
||||
func: Function to execute
|
||||
interval_seconds: Interval in seconds for periodic execution (mutually exclusive with cron_expression)
|
||||
cron_expression: Cron expression for time-based scheduling (mutually exclusive with interval_seconds)
|
||||
*args: Additional positional arguments for the function
|
||||
**kwargs: Additional keyword arguments for the function
|
||||
"""
|
||||
if task_name in self.jobs:
|
||||
logger.warning(f"Task {task_name} already exists. Removing existing job.")
|
||||
self.jobs[task_name].remove()
|
||||
@@ -47,7 +37,7 @@ class BaseScheduler:
|
||||
trigger = CronTrigger.from_crontab(cron_expression)
|
||||
|
||||
job = self.scheduler.add_job(
|
||||
func,
|
||||
func=func,
|
||||
trigger=trigger,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
@@ -56,11 +46,13 @@ class BaseScheduler:
|
||||
)
|
||||
|
||||
self.jobs[task_name] = job
|
||||
|
||||
if interval_seconds:
|
||||
logger.info(f"Scheduled task {task_name} to run every {interval_seconds} seconds")
|
||||
else:
|
||||
logger.info(f"Scheduled task {task_name} with cron expression: {cron_expression}")
|
||||
|
||||
|
||||
def remove_task(self, task_name: str) -> None:
|
||||
"""Remove a scheduled task"""
|
||||
if task_name in self.jobs:
|
||||
|
@@ -3,7 +3,7 @@ from app.services.scheduler.base_scheduler import BaseScheduler
|
||||
from app.services.base_service import BaseService
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
|
||||
from app.models.tcgplayer_inventory import UnmanagedTCGPlayerInventory, TCGPlayerInventory
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerService(BaseService):
|
||||
@@ -12,15 +12,6 @@ class SchedulerService(BaseService):
|
||||
super().__init__(None)
|
||||
self.scheduler = BaseScheduler()
|
||||
|
||||
async def update_tcgplayer_price_history_daily(self, db):
|
||||
"""
|
||||
Update the TCGPlayer price history table
|
||||
"""
|
||||
with transaction(db):
|
||||
await db.execute(text("""REFRESH MATERIALIZED VIEW CONCURRENTLY most_recent_tcgplayer_price;"""))
|
||||
logger.info("TCGPlayer price history refreshed")
|
||||
|
||||
|
||||
async def update_open_orders_hourly(self, db):
|
||||
"""
|
||||
Hourly update of orders from TCGPlayer API to database
|
||||
@@ -65,25 +56,41 @@ class SchedulerService(BaseService):
|
||||
logger.error(f"Error updating all orders: {str(e)}")
|
||||
raise
|
||||
|
||||
async def refresh_tcgplayer_inventory_table(self, db):
|
||||
"""
|
||||
Refresh the TCGPlayer inventory table
|
||||
"""
|
||||
tcgplayer_inventory_service = self.service_manager.get_service('tcgplayer_inventory')
|
||||
with transaction(db):
|
||||
db.query(UnmanagedTCGPlayerInventory).delete()
|
||||
db.query(TCGPlayerInventory).delete()
|
||||
db.flush()
|
||||
await tcgplayer_inventory_service.refresh_tcgplayer_inventory_table(db)
|
||||
db.flush()
|
||||
await tcgplayer_inventory_service.refresh_unmanaged_tcgplayer_inventory_table(db)
|
||||
|
||||
async def start_scheduled_tasks(self, db):
|
||||
"""Start all scheduled tasks"""
|
||||
# Schedule open orders update to run hourly at 00 minutes
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_open_orders_hourly",
|
||||
func=lambda: self.update_open_orders_hourly(db),
|
||||
cron_expression="0 * * * *" # Run at minute 0 of every hour
|
||||
func=self.update_open_orders_hourly,
|
||||
cron_expression="10 * * * *", # Run at minute 10 of every hour
|
||||
db=db
|
||||
)
|
||||
# Schedule all orders update to run daily at 3 AM
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_all_orders_daily",
|
||||
func=lambda: self.update_all_orders_daily(db),
|
||||
cron_expression="0 3 * * *" # Run at 3:00 AM every day
|
||||
func=self.update_all_orders_daily,
|
||||
cron_expression="0 3 * * *", # Run at 3:00 AM every day
|
||||
db=db
|
||||
)
|
||||
# Schedule TCGPlayer price history update to run daily at 1 AM
|
||||
# Schedule TCGPlayer inventory refresh to run every 3 hours
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_tcgplayer_price_history_daily",
|
||||
func=lambda: self.update_tcgplayer_price_history_daily(db),
|
||||
cron_expression="0 1 * * *" # Run at 1:00 AM every day
|
||||
task_name="refresh_tcgplayer_inventory_table",
|
||||
func=self.refresh_tcgplayer_inventory_table,
|
||||
cron_expression="28 */3 * * *", # Run at minute 28 of every 3rd hour
|
||||
db=db
|
||||
)
|
||||
|
||||
self.scheduler.start()
|
||||
|
@@ -30,9 +30,11 @@ class ServiceManager:
|
||||
'tcgcsv': 'app.services.external_api.tcgcsv.tcgcsv_service.TCGCSVService',
|
||||
'mtgjson': 'app.services.external_api.mtgjson.mtgjson_service.MTGJSONService',
|
||||
'manabox': 'app.services.manabox_service.ManaboxService',
|
||||
'pricing': 'app.services.pricing_service.PricingService',
|
||||
'inventory': 'app.services.inventory_service.InventoryService',
|
||||
'sealed_box': 'app.services.inventory_service.SealedBoxService',
|
||||
'sealed_case': 'app.services.inventory_service.SealedCaseService'
|
||||
'box': 'app.services.inventory_service.BoxService',
|
||||
'case': 'app.services.inventory_service.CaseService',
|
||||
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService'
|
||||
|
||||
}
|
||||
self._service_configs = {
|
||||
|
@@ -10,7 +10,7 @@ import aiohttp
|
||||
import jinja2
|
||||
from weasyprint import HTML
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct, TCGPlayerGroup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -20,6 +20,22 @@
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 min-h-screen text-gray-100">
|
||||
<!-- Navigation Menu -->
|
||||
<nav class="bg-gray-800 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="text-xl font-bold text-gray-100">TCGPlayer Manager</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Orders</a>
|
||||
<a href="/manabox.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Manabox</a>
|
||||
<a href="/transactions.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Transactions</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-2">TCGPlayer Order Management</h1>
|
||||
|
86
app/static/manabox.html
Normal file
86
app/static/manabox.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manabox Inventory Management</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 min-h-screen text-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-2">Manabox Inventory Management</h1>
|
||||
<p class="text-gray-400">Upload and manage your Manabox inventory</p>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Section -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-6">Upload Manabox CSV</h2>
|
||||
<form id="uploadForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="source" class="block text-sm font-medium text-gray-300 mb-2">Source</label>
|
||||
<input type="text" id="source" name="source" required
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||||
<textarea id="description" name="description" rows="3" required
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="csvFile" class="block text-sm font-medium text-gray-300 mb-2">CSV File</label>
|
||||
<input type="file" id="csvFile" name="file" accept=".csv" required
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Upload CSV
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- File Uploads List Section -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-100">Recent Uploads</h2>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="selectAllUploads()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Select All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
<input type="checkbox" id="selectAll" class="rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Source</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Upload Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="uploadsList" class="divide-y divide-gray-700">
|
||||
<!-- Uploads will be populated here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/manabox.js"></script>
|
||||
</body>
|
||||
</html>
|
170
app/static/manabox.js
Normal file
170
app/static/manabox.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// API base URL
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// Selected uploads for actions
|
||||
let selectedUploads = new Set();
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white ${
|
||||
type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||
} transform translate-y-0 opacity-100 transition-all duration-300`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateY(100%)';
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
function setLoading(isLoading) {
|
||||
const buttons = document.querySelectorAll('button');
|
||||
buttons.forEach(button => {
|
||||
if (isLoading) {
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', document.getElementById('csvFile').files[0]);
|
||||
formData.append('source', document.getElementById('source').value);
|
||||
formData.append('description', document.getElementById('description').value);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/manabox/process-csv`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to upload CSV');
|
||||
}
|
||||
|
||||
showToast('CSV uploaded successfully');
|
||||
document.getElementById('uploadForm').reset();
|
||||
fetchUploads(); // Refresh the uploads list
|
||||
} catch (error) {
|
||||
showToast('Error uploading CSV: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch uploads from the API
|
||||
async function fetchUploads() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/manabox/manabox-file-uploads`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch uploads');
|
||||
}
|
||||
|
||||
const uploads = await response.json();
|
||||
displayUploads(uploads);
|
||||
} catch (error) {
|
||||
showToast('Error fetching uploads: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Display uploads in the UI
|
||||
function displayUploads(uploads) {
|
||||
const uploadsList = document.getElementById('uploadsList');
|
||||
uploadsList.innerHTML = '';
|
||||
|
||||
if (!uploads || uploads.length === 0) {
|
||||
uploadsList.innerHTML = '<tr><td colspan="5" class="px-6 py-4 text-center text-gray-400">No uploads found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
uploads.forEach(upload => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-700';
|
||||
row.dataset.uploadId = upload.id;
|
||||
row.innerHTML = `
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input type="checkbox" class="upload-checkbox rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">${upload.name || 'N/A'}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-300">${upload.file_metadata?.description || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">${formatDate(upload.created_at)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-900/50 text-green-300">Processed</span>
|
||||
</td>
|
||||
`;
|
||||
uploadsList.appendChild(row);
|
||||
|
||||
// Add click event listener to the checkbox
|
||||
const checkbox = row.querySelector('.upload-checkbox');
|
||||
checkbox.addEventListener('change', () => {
|
||||
const uploadId = row.dataset.uploadId;
|
||||
if (checkbox.checked) {
|
||||
selectedUploads.add(uploadId);
|
||||
} else {
|
||||
selectedUploads.delete(uploadId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to format date
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Select all uploads
|
||||
function selectAllUploads() {
|
||||
const checkboxes = document.querySelectorAll('.upload-checkbox');
|
||||
const allSelected = checkboxes.length > 0 && Array.from(checkboxes).every(checkbox => checkbox.checked);
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = !allSelected;
|
||||
const row = checkbox.closest('tr');
|
||||
const uploadId = row.dataset.uploadId;
|
||||
if (!allSelected) {
|
||||
selectedUploads.add(uploadId);
|
||||
} else {
|
||||
selectedUploads.delete(uploadId);
|
||||
}
|
||||
});
|
||||
|
||||
showToast(allSelected ? 'All uploads deselected' : 'All uploads selected');
|
||||
}
|
||||
|
||||
// Initialize the page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchUploads();
|
||||
|
||||
// Add event listener for the select all checkbox
|
||||
document.getElementById('selectAll').addEventListener('change', (e) => {
|
||||
const checkboxes = document.querySelectorAll('.upload-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = e.target.checked;
|
||||
const row = checkbox.closest('tr');
|
||||
const uploadId = row.dataset.uploadId;
|
||||
if (e.target.checked) {
|
||||
selectedUploads.add(uploadId);
|
||||
} else {
|
||||
selectedUploads.delete(uploadId);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@@ -106,3 +106,125 @@ button:hover {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transaction Page Styles */
|
||||
.transaction-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.transaction-form .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.transaction-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.transaction-form .form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.transaction-form .btn-add {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.transaction-form .items-section {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.transaction-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.transaction-card {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.transaction-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.transaction-card p {
|
||||
margin-bottom: 0.25rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-content {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
body.dark-mode {
|
||||
background-color: #1a1a1a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .container {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
body.dark-mode .transaction-card {
|
||||
background-color: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
body.dark-mode .transaction-card h3 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .transaction-card p {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-content {
|
||||
background-color: #2d2d2d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-header,
|
||||
body.dark-mode .modal-footer {
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background-color: #404040;
|
||||
border-color: #505050;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
background-color: #404040;
|
||||
border-color: #007bff;
|
||||
color: #ffffff;
|
||||
}
|
179
app/static/transactions.html
Normal file
179
app/static/transactions.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Transactions - AI Giga TCG</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 min-h-screen text-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-2">Transactions</h1>
|
||||
<p class="text-gray-400">Manage your transactions</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Transaction Button -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<button id="createTransactionBtn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Create New Transaction
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Transaction List -->
|
||||
<div id="transactionList" class="bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-100">Recent Transactions</h2>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="limitSelect" class="text-sm text-gray-300">Show:</label>
|
||||
<select id="limitSelect" class="rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="transactionsTable" class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Vendor/Customer</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Total</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700" id="transactionsBody">
|
||||
<!-- Transactions will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination Controls -->
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<button id="prevPageBtn" class="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Previous
|
||||
</button>
|
||||
<span id="pageInfo" class="text-gray-300">Page 1</span>
|
||||
<button id="nextPageBtn" class="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Transaction Modal -->
|
||||
<div id="createTransactionModal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center">
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-100">Create Transaction</h3>
|
||||
<button onclick="closeTransactionModal()" class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="transactionForm" class="space-y-4">
|
||||
<!-- Transaction Type -->
|
||||
<div>
|
||||
<label for="transactionType" class="block text-sm font-medium text-gray-300 mb-2">Transaction Type</label>
|
||||
<select id="transactionType" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="purchase" selected>Purchase</option>
|
||||
<option value="sale">Sale</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Vendor/Customer Selection -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="vendorSelect" class="block text-sm font-medium text-gray-300">Vendor</label>
|
||||
<button type="button" id="addVendorBtn" class="px-2 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Add New
|
||||
</button>
|
||||
</div>
|
||||
<select id="vendorSelect" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Select a vendor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Selection (for sales) -->
|
||||
<div id="marketplaceSection" class="hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="marketplaceSelect" class="block text-sm font-medium text-gray-300">Marketplace</label>
|
||||
<button type="button" id="addMarketplaceBtn" class="px-2 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Add New
|
||||
</button>
|
||||
</div>
|
||||
<select id="marketplaceSelect" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Select a marketplace</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Date -->
|
||||
<div>
|
||||
<label for="transactionDate" class="block text-sm font-medium text-gray-300 mb-2">Transaction Date</label>
|
||||
<input type="datetime-local" id="transactionDate" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500" required>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Notes -->
|
||||
<div>
|
||||
<label for="transactionNotes" class="block text-sm font-medium text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="transactionNotes" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Items Section -->
|
||||
<div id="itemsSection" class="border border-gray-700 rounded-lg p-4">
|
||||
<h5 class="text-lg font-medium text-gray-100 mb-4">Items</h5>
|
||||
<div id="itemsContainer" class="space-y-4">
|
||||
<!-- Items will be added here -->
|
||||
</div>
|
||||
<button type="button" id="addItemBtn" class="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors">
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeTransactionModal()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="saveTransactionBtn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Save Transaction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Details Modal -->
|
||||
<div id="transactionDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center">
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-100">Transaction Details</h3>
|
||||
<button onclick="closeTransactionDetailsModal()" class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="transactionDetails" class="space-y-4">
|
||||
<!-- Transaction details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="transactions.js"></script>
|
||||
</body>
|
||||
</html>
|
1077
app/static/transactions.js
Normal file
1077
app/static/transactions.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,88 @@
|
||||
aiofiles==24.1.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.11.16
|
||||
aiojson==0.3.1612466996
|
||||
aiosignal==1.3.2
|
||||
alembic==1.13.1
|
||||
alembic==1.15.2
|
||||
annotated-types==0.7.0
|
||||
anyio==4.9.0
|
||||
APScheduler==3.10.4
|
||||
APScheduler==3.11.0
|
||||
attrs==25.3.0
|
||||
blinker==1.9.0
|
||||
brother_ql_next==0.11.3
|
||||
Brotli==1.1.0
|
||||
cairocffi==1.7.1
|
||||
certifi==2025.1.31
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
cssselect2==0.8.0
|
||||
defusedxml==0.7.1
|
||||
dotenv==0.9.9
|
||||
fastapi==0.115.12
|
||||
Flask==3.1.0
|
||||
fonttools==4.57.0
|
||||
frozenlist==1.5.0
|
||||
future==1.0.0
|
||||
greenlet==3.1.1
|
||||
h11==0.14.0
|
||||
httpcore==1.0.8
|
||||
httpx==0.28.1
|
||||
idna==3.10
|
||||
ijson==3.3.0
|
||||
inflate64==1.0.1
|
||||
iniconfig==2.1.0
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
jsons==1.6.3
|
||||
Mako==1.3.9
|
||||
MarkupSafe==3.0.2
|
||||
multidict==6.4.2
|
||||
multidict==6.2.0
|
||||
multivolumefile==0.2.3
|
||||
numpy==2.2.4
|
||||
packaging==24.2
|
||||
packbits==0.6
|
||||
pandas==2.2.3
|
||||
pdf2image==1.17.0
|
||||
pillow==11.2.1
|
||||
pluggy==1.5.0
|
||||
propcache==0.3.1
|
||||
psutil==7.0.0
|
||||
psycopg2-binary==2.9.10
|
||||
py7zr==0.22.0
|
||||
pybcj==1.0.3
|
||||
pycparser==2.22
|
||||
pycryptodomex==3.22.0
|
||||
pycups==2.0.4
|
||||
pydantic==2.11.3
|
||||
pydantic_core==2.33.1
|
||||
pydyf==0.11.0
|
||||
pyphen==0.17.2
|
||||
pyppmd==1.1.1
|
||||
pytest==8.3.5
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.0
|
||||
python-multipart==0.0.20
|
||||
pytz==2025.2
|
||||
pyusb==1.3.1
|
||||
pyzstd==0.16.2
|
||||
requests==2.32.3
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.40
|
||||
starlette==0.46.1
|
||||
texttable==1.7.0
|
||||
tinycss2==1.4.0
|
||||
tinyhtml5==2.0.0
|
||||
typing-inspection==0.4.0
|
||||
typing_extensions==4.13.1
|
||||
typish==1.9.3
|
||||
tzdata==2025.2
|
||||
tzlocal==5.3.1
|
||||
urllib3==2.4.0
|
||||
uvicorn==0.34.0
|
||||
weasyprint==65.0
|
||||
webencodings==0.5.1
|
||||
Werkzeug==3.1.3
|
||||
yarl==1.19.0
|
||||
py7zr>=0.20.8
|
||||
psycopg2-binary==2.9.9
|
||||
python-dotenv==1.0.1
|
||||
zopfli==0.2.3.post1
|
||||
|
Reference in New Issue
Block a user