Compare commits
10 Commits
d75e20ff2c
...
ui
Author | SHA1 | Date | |
---|---|---|---|
9c13118a05 | |||
82fd1cb2da | |||
77d6fd6e29 | |||
7bc64115f2 | |||
fa089adb53 | |||
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 ###
|
62
alembic/versions/420691c16f3c_asdf.py
Normal file
62
alembic/versions/420691c16f3c_asdf.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""asdf
|
||||
|
||||
Revision ID: 420691c16f3c
|
||||
Revises: 236605bcac6e
|
||||
Create Date: 2025-07-26 14:32:15.012286
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '420691c16f3c'
|
||||
down_revision: Union[str, None] = '236605bcac6e'
|
||||
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_table('inventory_labels',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('uuid', sa.String(), nullable=True),
|
||||
sa.Column('upc', 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), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_labels_id'), 'inventory_labels', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_labels_upc'), 'inventory_labels', ['upc'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_labels_uuid'), 'inventory_labels', ['uuid'], unique=False)
|
||||
op.create_table('inventory_label_metadata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('inventory_label_id', sa.Integer(), nullable=True),
|
||||
sa.Column('metadata_key', sa.String(), nullable=True),
|
||||
sa.Column('metadata_value', 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), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['inventory_label_id'], ['inventory_labels.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_label_metadata_id'), 'inventory_label_metadata', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_label_metadata_metadata_key'), 'inventory_label_metadata', ['metadata_key'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_inventory_label_metadata_metadata_key'), table_name='inventory_label_metadata')
|
||||
op.drop_index(op.f('ix_inventory_label_metadata_id'), table_name='inventory_label_metadata')
|
||||
op.drop_table('inventory_label_metadata')
|
||||
op.drop_index(op.f('ix_inventory_labels_uuid'), table_name='inventory_labels')
|
||||
op.drop_index(op.f('ix_inventory_labels_upc'), table_name='inventory_labels')
|
||||
op.drop_index(op.f('ix_inventory_labels_id'), table_name='inventory_labels')
|
||||
op.drop_table('inventory_labels')
|
||||
# ### 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:
|
||||
|
@@ -90,6 +90,12 @@ tr:hover {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.color-identity {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
width: 200px;
|
||||
}
|
||||
@@ -119,6 +125,7 @@ tbody tr:hover {
|
||||
<th class="set">Set</th>
|
||||
<th class="rarity">Rarity</th>
|
||||
<th class="card-number">Card #</th>
|
||||
<th class="color-identity">Colors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -129,6 +136,7 @@ tbody tr:hover {
|
||||
<td class="set">{{ item.set }}</td>
|
||||
<td class="rarity">{{ item.rarity }}</td>
|
||||
<td class="card-number">{{ item.card_number }}</td>
|
||||
<td class="color-identity">{{ item.color_identity }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
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
|
|
70
app/main.py
70
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")
|
||||
@@ -59,16 +59,18 @@ async def lifespan(app: FastAPI):
|
||||
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 = await data_init_service.initialize_data(db, game_ids=[1,62], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=True, archived_prices_start_date="2025-07-28", archived_prices_end_date="2025-07-30")
|
||||
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,62 @@ 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)
|
||||
|
||||
# serve inventory_labels.html
|
||||
@app.get("/inventory_labels.html")
|
||||
async def read_inventory_labels_html():
|
||||
html_path = Path('app/static/inventory_labels.html')
|
||||
if not html_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Inventory_labels.html file not found")
|
||||
return FileResponse(html_path)
|
||||
|
||||
# serve inventory_labels.js
|
||||
@app.get("/inventory_labels.js")
|
||||
async def read_inventory_labels_js():
|
||||
js_path = Path('app/static/inventory_labels.js')
|
||||
if not js_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Inventory_labels.js file not found")
|
||||
return FileResponse(js_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,51 @@ 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])
|
||||
|
||||
|
||||
class InventoryLabel(Base):
|
||||
__tablename__ = "inventory_labels"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String, index=True)
|
||||
upc = Column(String, index=True, nullable=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
|
||||
metadata_entries = relationship("InventoryLabelMetadata", back_populates="inventory_label")
|
||||
|
||||
class InventoryLabelMetadata(Base):
|
||||
__tablename__ = "inventory_label_metadata"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
inventory_label_id = Column(Integer, ForeignKey("inventory_labels.id"))
|
||||
metadata_key = Column(String, index=True)
|
||||
metadata_value = 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())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
inventory_label = relationship("InventoryLabel", back_populates="metadata_entries")
|
@@ -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()
|
46
app/routes/inventory_label_routes.py
Normal file
46
app/routes/inventory_label_routes.py
Normal file
@@ -0,0 +1,46 @@
|
||||
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.schemas.inventory_label import InventoryLabelCreate, InventoryLabelGet, InventoryLabelUpdate, InventoryLabelDelete, InventoryLabelResponse
|
||||
|
||||
router = APIRouter(prefix="/inventory-labels")
|
||||
|
||||
service_manager = ServiceManager()
|
||||
|
||||
# create
|
||||
@router.post("/")
|
||||
async def create_inventory_label(
|
||||
inventory_label: InventoryLabelCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_label_service = service_manager.get_service('inventory_label')
|
||||
return await inventory_label_service.create_inventory_label(db, inventory_label)
|
||||
|
||||
# get
|
||||
@router.get("/")
|
||||
async def get_inventory_label(
|
||||
inventory_label_get: InventoryLabelGet,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_label_service = service_manager.get_service('inventory_label')
|
||||
return await inventory_label_service.get_inventory_label(db, inventory_label_get)
|
||||
|
||||
# update
|
||||
@router.post("/{inventory_label_id}")
|
||||
async def update_inventory_label(
|
||||
inventory_label_id: int,
|
||||
inventory_label: InventoryLabelUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
pass
|
||||
|
||||
# delete
|
||||
@router.delete("/{inventory_label_id}")
|
||||
async def delete_inventory_label(
|
||||
inventory_label_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
pass
|
@@ -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,328 @@ 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))
|
||||
|
||||
@router.get("/tcgplayer/update-file")
|
||||
async def get_tcgplayer_update_file(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
csv_string = await marketplace_listing_service.create_tcgplayer_update_file(db, marketplace)
|
||||
return StreamingResponse(
|
||||
iter([csv_string]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=tcgplayer_update_file_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
|
||||
}
|
||||
)
|
@@ -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")
|
||||
|
@@ -32,7 +32,8 @@ router = APIRouter(prefix="/orders")
|
||||
@router.get("/", response_model=List[TCGPlayerAPIOrderSummary])
|
||||
async def get_orders(
|
||||
search_range: SearchRange = SearchRange.LAST_THREE_MONTHS,
|
||||
open_only: bool = False
|
||||
open_only: bool = False,
|
||||
db: Session = Depends(get_db)
|
||||
) -> List[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
Retrieve orders from TCGPlayer based on search criteria.
|
||||
@@ -47,6 +48,7 @@ async def get_orders(
|
||||
try:
|
||||
order_management = service_manager.get_service('order_management')
|
||||
orders = await order_management.get_orders(search_range, open_only)
|
||||
orders = await order_management.add_item_quantity(db, orders)
|
||||
return orders
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch orders: {str(e)}")
|
||||
@@ -220,6 +222,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(...),
|
||||
|
@@ -7,6 +7,8 @@ from app.routes.set_label_routes import router as set_label_router
|
||||
from app.routes.order_routes import router as order_router
|
||||
from app.routes.manabox_routes import router as manabox_router
|
||||
from app.routes.inventory_management_routes import router as inventory_management_router
|
||||
from app.routes.inventory_label_routes import router as inventory_label_router
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Include set label routes
|
||||
@@ -21,6 +23,9 @@ router.include_router(manabox_router)
|
||||
# Include inventory management routes
|
||||
router.include_router(inventory_management_router)
|
||||
|
||||
# Include inventory label routes
|
||||
router.include_router(inventory_label_router)
|
||||
|
||||
# ============================================================================
|
||||
# Health Check & Root Endpoints
|
||||
# ============================================================================
|
||||
|
0
app/schemas/inventory.py
Normal file
0
app/schemas/inventory.py
Normal file
45
app/schemas/inventory_label.py
Normal file
45
app/schemas/inventory_label.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# request
|
||||
# crud
|
||||
|
||||
class InventoryLabelMetadataCreate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
class InventoryLabelCreate(BaseModel):
|
||||
upc: Optional[str] = None
|
||||
metadata: Optional[List[InventoryLabelMetadataCreate]] = None
|
||||
print: Optional[bool] = True
|
||||
|
||||
class InventoryLabelGet(BaseModel):
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
inventory_label_id: Optional[int] = None
|
||||
input_data: Optional[str] = None
|
||||
|
||||
class InventoryLabelUpdate(BaseModel):
|
||||
inventory_label_id: int
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
input_data: Optional[str] = None
|
||||
metadata: Optional[List[InventoryLabelMetadataCreate]] = None
|
||||
|
||||
class InventoryLabelDelete(BaseModel):
|
||||
inventory_label_id: int
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
input_data: Optional[str] = None
|
||||
|
||||
# response
|
||||
|
||||
class InventoryLabelMetadataResponse(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
class InventoryLabelResponse(BaseModel):
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
metadata: Optional[List[InventoryLabelMetadataResponse]] = None
|
@@ -56,7 +56,7 @@ class TCGPlayerAPIOrder(BaseModel):
|
||||
orderFulfillment: str
|
||||
orderNumber: str
|
||||
sellerName: str
|
||||
buyerName: str
|
||||
buyerName: Optional[str] = None
|
||||
paymentType: str
|
||||
pickupStatus: str
|
||||
shippingType: str
|
||||
@@ -74,8 +74,9 @@ class TCGPlayerAPIOrderSummary(BaseModel):
|
||||
orderDate: datetime
|
||||
orderChannel: str
|
||||
orderStatus: str
|
||||
buyerName: str
|
||||
buyerName: Optional[str] = None
|
||||
shippingType: str
|
||||
itemQuantity: int
|
||||
productAmount: float
|
||||
shippingAmount: float
|
||||
totalAmount: float
|
||||
|
@@ -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: Optional[float] = None
|
||||
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,8 @@ __all__ = [
|
||||
'SetLabelService',
|
||||
'SchedulerService',
|
||||
'OrderManagementService',
|
||||
'TCGPlayerInventoryService'
|
||||
'TCGPlayerInventoryService',
|
||||
'PricingService',
|
||||
'MarketplaceListingService',
|
||||
'ScryfallService'
|
||||
]
|
@@ -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
|
||||
@@ -312,6 +313,9 @@ class DataInitializationService(BaseService):
|
||||
else:
|
||||
# Get CSV data from API
|
||||
csv_data = await tcgcsv_service.get_products_and_prices(game_id, group.group_id)
|
||||
if not csv_data:
|
||||
logger.warning(f"No products data found for group {group.group_id}")
|
||||
continue
|
||||
|
||||
# Save the CSV file
|
||||
await self.file_service.save_file(
|
||||
@@ -349,7 +353,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(
|
||||
@@ -427,6 +431,9 @@ class DataInitializationService(BaseService):
|
||||
logger.info(f"Downloading and processing archived prices for {date}")
|
||||
# Download and extract the archive
|
||||
archive_data = await tcgcsv_service.get_archived_prices_for_date(date)
|
||||
if not archive_data:
|
||||
logger.warning(f"No archive data found for {date}")
|
||||
continue
|
||||
|
||||
# Save the archive file
|
||||
file_record = await self.file_service.save_file(
|
||||
@@ -521,121 +528,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 +758,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 +778,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
|
||||
|
||||
|
@@ -67,7 +67,9 @@ class BaseExternalService:
|
||||
logger.error(f"Failed to parse JSON response: {e}")
|
||||
return raw_response
|
||||
return raw_response
|
||||
|
||||
except aiohttp.ClientResponseError as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise
|
||||
|
11
app/services/external_api/scryfall/scryfall_service.py
Normal file
11
app/services/external_api/scryfall/scryfall_service.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from app.services.external_api.base_external_service import BaseExternalService
|
||||
|
||||
class ScryfallService(BaseExternalService):
|
||||
def __init__(self):
|
||||
super().__init__(base_url="https://api.scryfall.com/")
|
||||
|
||||
async def get_color_identity(self, scryfall_id: str) -> str:
|
||||
"""Get the color identity of a card from Scryfall API"""
|
||||
endpoint = f"cards/{scryfall_id}"
|
||||
results = await self._make_request("GET", endpoint)
|
||||
return results['color_identity']
|
@@ -3,6 +3,9 @@ from datetime import datetime, timedelta
|
||||
import csv
|
||||
import io
|
||||
from app.services.external_api.base_external_service import BaseExternalService
|
||||
import aiohttp
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TCGCSVService(BaseExternalService):
|
||||
def __init__(self):
|
||||
@@ -16,7 +19,14 @@ class TCGCSVService(BaseExternalService):
|
||||
async def get_products_and_prices(self, game_id: str, group_id: int) -> str:
|
||||
"""Fetch products and prices for a specific group from TCGCSV API"""
|
||||
endpoint = f"tcgplayer/{game_id}/{group_id}/ProductsAndPrices.csv"
|
||||
try:
|
||||
return await self._make_request("GET", endpoint, headers={"Accept": "text/csv"})
|
||||
except aiohttp.ClientResponseError as e:
|
||||
if e.status == 403:
|
||||
logger.error(f"Request failed: {e}")
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
async def get_categories(self) -> Dict[str, Any]:
|
||||
"""Fetch all categories from TCGCSV API"""
|
||||
@@ -26,7 +36,14 @@ class TCGCSVService(BaseExternalService):
|
||||
async def get_archived_prices_for_date(self, date_str: str) -> bytes:
|
||||
"""Fetch archived prices from TCGCSV API"""
|
||||
endpoint = f"archive/tcgplayer/prices-{date_str}.ppmd.7z"
|
||||
try:
|
||||
return await self._make_request("GET", endpoint, binary=True)
|
||||
except aiohttp.ClientResponseError as e:
|
||||
if e.status == 403:
|
||||
logger.error(f"Request failed: {e}")
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
async def get_tcgcsv_date_range(self, start_date: datetime, end_date: datetime) -> List[datetime]:
|
||||
"""Get a date range for a given start and end date"""
|
||||
|
@@ -10,6 +10,8 @@ class BaseTCGPlayerService(BaseExternalService):
|
||||
STORE_BASE_URL = "https://store.tcgplayer.com"
|
||||
LOGIN_ENDPOINT = "/oauth/login"
|
||||
PRICING_ENDPOINT = "/Admin/Pricing"
|
||||
MP_SEARCH_URL = "https://mp-search-api.tcgplayer.com/v1"
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(base_url=self.STORE_BASE_URL)
|
||||
|
@@ -24,6 +24,7 @@ import csv
|
||||
import io
|
||||
from app.schemas.file import FileInDB
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OrderManagementService(BaseTCGPlayerService):
|
||||
@@ -40,6 +41,33 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
self.pull_sheet_endpoint = f"/pull-sheets/export{self.API_VERSION}"
|
||||
self.shipping_endpoint = f"/shipping/export{self.API_VERSION}"
|
||||
|
||||
async def add_item_quantity(self, db: Session, orders: list[TCGPlayerAPIOrderSummary]) -> list[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
Add item quantity to orders using SQL aggregation for better performance
|
||||
"""
|
||||
# Get order numbers from the input orders
|
||||
order_numbers = [order["orderNumber"] for order in orders]
|
||||
|
||||
# Use SQL aggregation to get the sum of quantities directly from the database
|
||||
quantity_sums = (
|
||||
db.query(
|
||||
TCGPlayerOrderProduct.order_number,
|
||||
func.sum(TCGPlayerOrderProduct.quantity).label('total_quantity')
|
||||
)
|
||||
.filter(TCGPlayerOrderProduct.order_number.in_(order_numbers))
|
||||
.group_by(TCGPlayerOrderProduct.order_number)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Create a lookup dictionary for faster access
|
||||
quantity_lookup = {order_number: total_quantity for order_number, total_quantity in quantity_sums}
|
||||
|
||||
# Update orders with quantities
|
||||
for order in orders:
|
||||
order["itemQuantity"] = quantity_lookup.get(order["orderNumber"], 0)
|
||||
|
||||
return orders
|
||||
|
||||
|
||||
async def get_orders(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []) -> list[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
@@ -79,6 +107,9 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
orders = [order for order in orders if order.get("orderNumber") not in filter_out]
|
||||
if filter_in:
|
||||
orders = [order for order in orders if order.get("orderNumber") in filter_in]
|
||||
# add item quantity to orders as none
|
||||
for order in orders:
|
||||
order["itemQuantity"] = 0
|
||||
return orders
|
||||
|
||||
async def get_order_ids(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []):
|
||||
@@ -376,8 +407,8 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
('extended_price', 'extendedPrice'),
|
||||
('quantity', 'quantity'),
|
||||
('url', 'url'),
|
||||
('tcgplayer_product_id', 'productId'),
|
||||
('tcgplayer_sku_id', 'skuId')
|
||||
('product_id', 'productId'),
|
||||
('sku_id', 'skuId')
|
||||
]
|
||||
|
||||
for db_field, api_field in product_fields_to_compare:
|
||||
|
@@ -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,46 @@ 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)
|
@@ -0,0 +1,54 @@
|
||||
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
||||
from sqlalchemy.orm import Session
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class TCGPlayerMarketDataInput:
|
||||
product_id: str
|
||||
mpfev: str
|
||||
condition: str
|
||||
language: str
|
||||
printing: str
|
||||
quantity: int
|
||||
|
||||
class TCGPlayerMarketDataService(BaseTCGPlayerService):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
async def get_active_listings(self, db: Session, input: TCGPlayerMarketDataInput):
|
||||
listings_endpoint = f"{self.MP_SEARCH_URL}/product/{input.product_id}/listings?mpfev={input.mpfev}"
|
||||
"""
|
||||
curl 'https://mp-search-api.tcgplayer.com/v1/product/631066/listings?mpfev=3816' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.5' \
|
||||
-H 'content-type: application/json' \
|
||||
-b 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; tcg-uuid=4321c3a3-bdc0-4a3f-952f-ada250ea91ab; product-display-settings=sort=price+shipping&size=10; setting=CD=US&M=1; brwsr=f9b5ab04-479f-11f0-9996-95b462f705de; TCG_VisitorKey=1cfd1431-fc5d-461b-9fb3-61de387f3342; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; OAuthLoginSessionId=766a573b-d2ba-4285-8eae-c79b5c8a877c; TCGAuthTicket_Production=3C334E06A27B20FDD326A6C20C7FFDEFECD7EDB73BBE7E2A072607D3417CFDC3B1A12EDA8F5B4F393380FA2CA8FCF596476F2BEC3B54FDE788D57A05745D8820DF0897F3B673BACD6487BDA6CC0780896CB382DCFAB9AFC90B747ED5561CE5B7B8E122D0815203F93DE6EDB73894CE9CD20D6090; BuyerRevalidationKey=; ASP.NET_SessionId=s04smsk3opzinl2tl31x042r; __RequestVerificationToken_L2FkbWlu0=TnVB3O7LFL0SbCOd2ULkhadaytHVM8uXJqi8b-27w6WdPQ3QU9P76z92HmVS-i4K0SjbPDbvGe8grkme7l4m6fgetX01; LastSeller=e576ed4c; StoreSaveForLater_PRODUCTION=SFLK=4db1ce3215c84eaca7439f889cd70b79&Ignore=false; SearchCriteria=M=1&WantVerifiedSellers=False&WantDirect=False&WantSellersInCart=False&WantWPNSellers=False; SearchSortSettings=M=1&ProductSortOption=Sales&ProductSortDesc=False&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg-segment-session=1749821401966%257C1749822225413' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Brave";v="137", "Chromium";v="137", "Not/A)Brand";v="24"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "Linux"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36' \
|
||||
--data-raw '{"filters":{"term":{"sellerStatus":"Live","channelId":0,"language":["English"],"condition":["Near Mint"],"printing":["Foil"]},"range":{"quantity":{"gte":2}},"exclude":{"channelExclusion":0}},"from":0,"size":10,"sort":{"field":"price+shipping","order":"asc"},"context":{"shippingCountry":"US","cart":{}},"aggregations":["listingType"]}'
|
||||
"""
|
||||
headers = {
|
||||
'accept': 'application/json, text/plain, */*',
|
||||
'accept-language': 'en-US,en;q=0.5',
|
||||
'content-type': 'application/json',
|
||||
'origin': 'https://www.tcgplayer.com',
|
||||
'priority': 'u=1, i',
|
||||
'referer': 'https://www.tcgplayer.com/',
|
||||
'sec-ch-ua': '"Brave";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': 'Linux',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-gpc': '1',
|
||||
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'
|
||||
}
|
@@ -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()
|
||||
|
438
app/services/inventory_label_service.py
Normal file
438
app/services/inventory_label_service.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.inventory_management import InventoryLabel, InventoryLabelMetadata
|
||||
from app.schemas.inventory_label import InventoryLabelCreate, InventoryLabelUpdate, InventoryLabelDelete, InventoryLabelGet, InventoryLabelMetadataCreate
|
||||
from app.db.database import transaction as db_transaction
|
||||
import uuid as uuid_lib
|
||||
import re
|
||||
from sqlalchemy.orm import Session
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import Optional
|
||||
|
||||
class InventoryLabelService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
def _convert_uuid_to_qr_code(self, uuid_string: str) -> bytes:
|
||||
"""
|
||||
Convert a UUID string to a QR code image as bytes.
|
||||
|
||||
Args:
|
||||
uuid_string: The UUID string to encode
|
||||
|
||||
Returns:
|
||||
bytes: The QR code image as bytes
|
||||
"""
|
||||
# Create QR code instance
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M, # Medium error correction for better reliability
|
||||
box_size=8, # Smaller box size for better fit on labels
|
||||
border=2, # Smaller border to maximize QR code size
|
||||
)
|
||||
|
||||
# Add the UUID data to the QR code
|
||||
qr.add_data(uuid_string)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Create the QR code image
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Convert to bytes
|
||||
img_buffer = BytesIO()
|
||||
img.save(img_buffer, format='PNG')
|
||||
img_buffer.seek(0)
|
||||
|
||||
return img_buffer.getvalue()
|
||||
|
||||
def _create_composite_label_image(self, qr_code_image: Image.Image, text: str, label_width: int = 991, label_height: int = 306) -> Image.Image:
|
||||
"""
|
||||
Create a composite image with QR code left-aligned and text right-aligned.
|
||||
|
||||
Args:
|
||||
qr_code_image: The QR code image to place on the left
|
||||
text: The text to place on the right
|
||||
label_width: Width of the label in pixels
|
||||
label_height: Height of the label in pixels
|
||||
|
||||
Returns:
|
||||
Image.Image: The composite label image
|
||||
"""
|
||||
# Create a new white canvas
|
||||
label_canvas = Image.new('RGB', (label_width, label_height), 'white')
|
||||
|
||||
# Calculate QR code size (square, fit within label height with margin)
|
||||
qr_margin = 20
|
||||
max_qr_size = label_height - (2 * qr_margin)
|
||||
qr_size = min(max_qr_size, label_width // 2) # QR takes up to half the width
|
||||
|
||||
# Resize QR code to fit
|
||||
resized_qr = qr_code_image.resize((qr_size, qr_size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Position QR code on the left with margin
|
||||
qr_x = qr_margin
|
||||
qr_y = (label_height - qr_size) // 2 # Center vertically
|
||||
|
||||
# Paste QR code onto canvas
|
||||
label_canvas.paste(resized_qr, (qr_x, qr_y))
|
||||
|
||||
# Add text on the right side
|
||||
draw = ImageDraw.Draw(label_canvas)
|
||||
|
||||
# Try to use a default font, fall back to basic font if not available
|
||||
font_size = 24
|
||||
font = None
|
||||
|
||||
# Try multiple font paths
|
||||
font_paths = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/System/Library/Fonts/Arial.ttf",
|
||||
"/usr/share/fonts/TTF/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"
|
||||
]
|
||||
|
||||
for font_path in font_paths:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
break
|
||||
except (OSError, IOError):
|
||||
continue
|
||||
|
||||
# Fall back to default font if no system font found
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Calculate text position (right-aligned with margin)
|
||||
text_margin = 20
|
||||
text_x = label_width - text_margin
|
||||
text_y = label_height // 2 # Center vertically
|
||||
|
||||
# Get text bounding box to position it properly
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Adjust position to right-align the text
|
||||
text_x = text_x - text_width
|
||||
text_y = text_y - (text_height // 2)
|
||||
|
||||
# Draw the text
|
||||
draw.text((text_x, text_y), text, fill='black', font=font)
|
||||
|
||||
return label_canvas
|
||||
|
||||
def _create_qr_code_with_text(self, uuid_string: str, text: str) -> bytes:
|
||||
"""
|
||||
Create a QR code image with text and return it as bytes.
|
||||
|
||||
Args:
|
||||
uuid_string: The UUID string to encode in QR code
|
||||
text: The text to display on the label
|
||||
|
||||
Returns:
|
||||
bytes: The composite image as bytes
|
||||
"""
|
||||
# Create QR code instance
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=8,
|
||||
border=2,
|
||||
)
|
||||
|
||||
# Add the UUID data to the QR code
|
||||
qr.add_data(uuid_string)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Create the QR code image
|
||||
qr_image = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Create composite image with text
|
||||
composite_image = self._create_composite_label_image(qr_image, text)
|
||||
|
||||
# Convert to bytes
|
||||
img_buffer = BytesIO()
|
||||
composite_image.save(img_buffer, format='PNG')
|
||||
img_buffer.seek(0)
|
||||
|
||||
return img_buffer.getvalue()
|
||||
|
||||
def create_qr_code_with_text_direct(self, uuid_string: str, text: str) -> bytes:
|
||||
"""
|
||||
Create a QR code image with text and return it as bytes directly.
|
||||
This method doesn't involve database operations and is useful for testing.
|
||||
|
||||
Args:
|
||||
uuid_string: The UUID string to encode in QR code
|
||||
text: The text to display on the label
|
||||
|
||||
Returns:
|
||||
bytes: The composite image as bytes
|
||||
"""
|
||||
return self._create_qr_code_with_text(uuid_string, text)
|
||||
|
||||
# create
|
||||
async def create_inventory_label(self, db: Session, inventory_label: InventoryLabelCreate, print: bool = True) -> InventoryLabel:
|
||||
file_service = self.get_service('file')
|
||||
# check if we have a upc
|
||||
if inventory_label.upc:
|
||||
# validate the upc
|
||||
if not self._is_valid_upc(inventory_label.upc):
|
||||
raise ValueError("Invalid UPC")
|
||||
# check if we have metadata
|
||||
if inventory_label.metadata:
|
||||
# validate the metadata
|
||||
for metadata in inventory_label.metadata:
|
||||
if not metadata.key or not metadata.value:
|
||||
raise ValueError("Invalid metadata")
|
||||
# generate a uuid
|
||||
label_uuid = str(uuid_lib.uuid4())
|
||||
with db_transaction(db):
|
||||
# create the inventory label
|
||||
inventory_label_model = InventoryLabel(uuid=label_uuid, upc=inventory_label.upc)
|
||||
db.add(inventory_label_model)
|
||||
db.flush()
|
||||
# add the metadata
|
||||
if inventory_label.metadata:
|
||||
for metadata in inventory_label.metadata:
|
||||
inventory_label_metadata_model = InventoryLabelMetadata(inventory_label_id=inventory_label_model.id, metadata_key=metadata.key, metadata_value=metadata.value)
|
||||
db.add(inventory_label_metadata_model)
|
||||
if print:
|
||||
# Create image with QR code and optional text
|
||||
if inventory_label.metadata and len(inventory_label.metadata) > 0:
|
||||
if inventory_label.upc:
|
||||
# add upc to metadata
|
||||
inventory_label.metadata.append(InventoryLabelMetadataCreate(key="upc", value=inventory_label.upc))
|
||||
# concat metadata key values separated by newlines and :
|
||||
text = "\n".join([f"{metadata.key}: {metadata.value}" for metadata in inventory_label.metadata])
|
||||
# Use composite image with QR code and text
|
||||
image_data = self._create_qr_code_with_text(label_uuid, text)
|
||||
else:
|
||||
# Use original QR code only
|
||||
image_data = self._convert_uuid_to_qr_code(label_uuid)
|
||||
|
||||
# save file
|
||||
filename = f"{label_uuid}.png"
|
||||
file_record = await file_service.save_file(
|
||||
db=db,
|
||||
file_data=image_data,
|
||||
filename=filename,
|
||||
subdir="inventory_labels",
|
||||
file_type="inventory_label",
|
||||
content_type="image/png",
|
||||
metadata={"uuid": label_uuid}
|
||||
)
|
||||
print_service = self.get_service('label_printer')
|
||||
await print_service.print_file(file_record.path, label_size="dk1201", label_type="inventory_label", copies=1)
|
||||
return inventory_label_model
|
||||
|
||||
# get
|
||||
|
||||
def classify_input_data(self, input_data: str) -> str:
|
||||
"""
|
||||
Classify input data as UPC, UUID, or other string with high accuracy.
|
||||
|
||||
Args:
|
||||
input_data: The string to classify
|
||||
|
||||
Returns:
|
||||
str: "upc", "uuid", or "other"
|
||||
"""
|
||||
if not input_data or not isinstance(input_data, str):
|
||||
return "other"
|
||||
|
||||
# Remove any whitespace
|
||||
input_data = input_data.strip()
|
||||
|
||||
# Check for UUID first (more specific pattern)
|
||||
if self._is_valid_uuid(input_data):
|
||||
return "uuid"
|
||||
|
||||
# Check for UPC code
|
||||
if self._is_valid_upc(input_data):
|
||||
return "upc"
|
||||
|
||||
return "other"
|
||||
|
||||
def _is_valid_uuid(self, uuid_string: str) -> bool:
|
||||
"""
|
||||
Validate if string is a proper UUID.
|
||||
|
||||
Args:
|
||||
uuid_string: String to validate
|
||||
|
||||
Returns:
|
||||
bool: True if valid UUID, False otherwise
|
||||
"""
|
||||
# UUID regex pattern for all versions
|
||||
uuid_pattern = re.compile(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
if not uuid_pattern.match(uuid_string):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Validate UUID structure and version
|
||||
uuid_obj = uuid_lib.UUID(uuid_string)
|
||||
# Accept all UUID versions (1, 3, 4, 5)
|
||||
return uuid_obj.version in [1, 3, 4, 5]
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _is_valid_upc(self, upc_string: str) -> bool:
|
||||
"""
|
||||
Validate if string is a proper UPC code.
|
||||
|
||||
Args:
|
||||
upc_string: String to validate
|
||||
|
||||
Returns:
|
||||
bool: True if valid UPC, False otherwise
|
||||
"""
|
||||
# Remove any non-digit characters
|
||||
digits_only = re.sub(r'[^0-9]', '', upc_string)
|
||||
|
||||
# UPC-A must be exactly 12 digits
|
||||
if len(digits_only) == 12:
|
||||
return self._validate_upc_a_checksum(digits_only)
|
||||
|
||||
# UPC-E must be exactly 8 digits
|
||||
if len(digits_only) == 8:
|
||||
return self._validate_upc_e_checksum(digits_only)
|
||||
|
||||
# EAN-13 must be exactly 13 digits
|
||||
if len(digits_only) == 13:
|
||||
return self._validate_ean_13_checksum(digits_only)
|
||||
|
||||
return False
|
||||
|
||||
def _validate_upc_a_checksum(self, upc: str) -> bool:
|
||||
"""
|
||||
Validate UPC-A checksum.
|
||||
|
||||
Args:
|
||||
upc: 12-digit UPC string
|
||||
|
||||
Returns:
|
||||
bool: True if checksum is valid
|
||||
"""
|
||||
if len(upc) != 12 or not upc.isdigit():
|
||||
return False
|
||||
|
||||
# Calculate checksum
|
||||
total = 0
|
||||
for i in range(11):
|
||||
digit = int(upc[i])
|
||||
if i % 2 == 0: # Odd positions (0-indexed)
|
||||
total += digit * 3
|
||||
else: # Even positions
|
||||
total += digit
|
||||
|
||||
checksum = (10 - (total % 10)) % 10
|
||||
return checksum == int(upc[11])
|
||||
|
||||
def _validate_upc_e_checksum(self, upc: str) -> bool:
|
||||
"""
|
||||
Validate UPC-E checksum.
|
||||
|
||||
Args:
|
||||
upc: 8-digit UPC-E string
|
||||
|
||||
Returns:
|
||||
bool: True if checksum is valid
|
||||
"""
|
||||
if len(upc) != 8 or not upc.isdigit():
|
||||
return False
|
||||
|
||||
# Calculate checksum
|
||||
total = 0
|
||||
for i in range(7):
|
||||
digit = int(upc[i])
|
||||
if i % 2 == 0: # Odd positions (0-indexed)
|
||||
total += digit * 3
|
||||
else: # Even positions
|
||||
total += digit
|
||||
|
||||
checksum = (10 - (total % 10)) % 10
|
||||
return checksum == int(upc[7])
|
||||
|
||||
def _validate_ean_13_checksum(self, ean: str) -> bool:
|
||||
"""
|
||||
Validate EAN-13 checksum.
|
||||
|
||||
Args:
|
||||
ean: 13-digit EAN string
|
||||
|
||||
Returns:
|
||||
bool: True if checksum is valid
|
||||
"""
|
||||
if len(ean) != 13 or not ean.isdigit():
|
||||
return False
|
||||
|
||||
# Calculate checksum
|
||||
total = 0
|
||||
for i in range(12):
|
||||
digit = int(ean[i])
|
||||
if i % 2 == 0: # Even positions (0-indexed)
|
||||
total += digit
|
||||
else: # Odd positions
|
||||
total += digit * 3
|
||||
|
||||
checksum = (10 - (total % 10)) % 10
|
||||
return checksum == int(ean[12])
|
||||
|
||||
async def get_inventory_label(self, db: Session, inventory_label_get: InventoryLabelGet) -> InventoryLabel:
|
||||
"""
|
||||
Get an inventory label by classifying the input data and querying the appropriate field.
|
||||
|
||||
Args:
|
||||
inventory_label_get: InventoryLabelGet object containing input_data
|
||||
|
||||
Returns:
|
||||
InventoryLabel: The found inventory label or None
|
||||
"""
|
||||
# check if we have a uuid or upc
|
||||
if inventory_label_get.uuid:
|
||||
return self._get_by_uuid(db, inventory_label_get.uuid)
|
||||
elif inventory_label_get.upc:
|
||||
return self._get_by_upc(db, inventory_label_get.upc)
|
||||
else:
|
||||
# check if we have input_data
|
||||
if inventory_label_get.input_data:
|
||||
# classify the input data
|
||||
input_type = self.classify_input_data(inventory_label_get.input_data)
|
||||
if input_type == "upc":
|
||||
return self._get_by_upc(db, inventory_label_get.input_data)
|
||||
elif input_type == "uuid":
|
||||
return self._get_by_uuid(db, inventory_label_get.input_data)
|
||||
else:
|
||||
raise ValueError("Invalid input data")
|
||||
else:
|
||||
raise ValueError("Invalid input data")
|
||||
|
||||
def _get_by_upc(self, db: Session, upc: str) -> InventoryLabel:
|
||||
"""
|
||||
Get inventory label by UPC.
|
||||
|
||||
Args:
|
||||
upc: The UPC code to search for
|
||||
|
||||
Returns:
|
||||
InventoryLabel: The found inventory label or None
|
||||
"""
|
||||
return db.query(InventoryLabel).filter(InventoryLabel.upc == upc).first()
|
||||
|
||||
def _get_by_uuid(self, db: Session, uuid: str) -> InventoryLabel:
|
||||
"""
|
||||
Get inventory label by UUID.
|
||||
|
||||
Args:
|
||||
uuid: The UUID to search for
|
||||
|
||||
Returns:
|
||||
InventoryLabel: The found inventory label or None
|
||||
"""
|
||||
return db.query(InventoryLabel).filter(InventoryLabel.uuid == uuid).first()
|
@@ -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 if resulting_card.products.most_recent_tcgplayer_price.market_price is not None else resulting_card.products.most_recent_tcgplayer_price.low_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,200 @@ 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_for_inventory_item(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:
|
||||
# idk if this was ever even finished so prob doesnt work idk
|
||||
try:
|
||||
with db_transaction(db):
|
||||
marketplace_listing.listed_price = self.pricing_service.set_price_for_inventory_item(db, 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
|
||||
|
||||
async def create_tcgplayer_update_file(self, db: Session, marketplace: Marketplace=None) -> 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"
|
||||
]
|
||||
unmanaged_inventory = await self.pricing_service.update_prices_for_unmanaged_inventory(db)
|
||||
managed_inventory = await self.pricing_service.update_prices_for_managed_inventory(db)
|
||||
# combine and convert to csv
|
||||
inventory = unmanaged_inventory + managed_inventory
|
||||
data = {}
|
||||
for inventory_item in inventory:
|
||||
data[inventory_item.tcgplayer_sku_id] = {
|
||||
"TCGplayer Id": inventory_item.tcgplayer_sku_id,
|
||||
"Product Line": inventory_item.product_line,
|
||||
"Set Name": inventory_item.set_name,
|
||||
"Product Name": inventory_item.product_name,
|
||||
"Title": inventory_item.title,
|
||||
"Number": inventory_item.number,
|
||||
"Rarity": inventory_item.rarity,
|
||||
"Condition": inventory_item.condition,
|
||||
"TCG Market Price": inventory_item.tcg_market_price,
|
||||
"TCG Direct Low": inventory_item.tcg_direct_low,
|
||||
"TCG Low Price With Shipping": inventory_item.tcg_low_price_with_shipping,
|
||||
"TCG Low Price": inventory_item.tcg_low_price,
|
||||
"Total Quantity": "",
|
||||
"Add to Quantity": "0",
|
||||
"TCG Marketplace Price": f"{Decimal(inventory_item.tcg_marketplace_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
|
@@ -142,7 +142,7 @@ class LabelPrinterService:
|
||||
logger.error(f"Unexpected error in _send_print_request: {e}")
|
||||
return False
|
||||
|
||||
async def print_file(self, file_path: Union[str, Path, FileInDB], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip", "set_label", "return_label", "pirate_ship_label"]] = None, copies: Optional[int] = None) -> bool:
|
||||
async def print_file(self, file_path: Union[str, Path, FileInDB], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip", "set_label", "return_label", "pirate_ship_label", "inventory_label"]] = None, copies: Optional[int] = None) -> bool:
|
||||
"""Print a PDF or PNG file to the label printer.
|
||||
|
||||
Args:
|
||||
@@ -207,7 +207,7 @@ class LabelPrinterService:
|
||||
resized_image = resized_image.resize((991, 306), Image.Resampling.LANCZOS)
|
||||
|
||||
# if file path contains address_label, rotate image 90 degrees
|
||||
if label_type == "address_label" or label_type == "set_label" or label_type == "return_label":
|
||||
if label_type == "address_label" or label_type == "set_label" or label_type == "return_label" or label_type == "inventory_label":
|
||||
rotate = "90"
|
||||
cut = False
|
||||
else:
|
||||
|
@@ -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)
|
341
app/services/pricing_service.py
Normal file
341
app/services/pricing_service.py
Normal file
@@ -0,0 +1,341 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.inventory_management import InventoryItem, MarketplaceListing, PhysicalItem
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
||||
from app.models.tcgplayer_products import TCGPlayerPriceHistory, TCGPlayerProduct, MTGJSONSKU
|
||||
from app.models.pricing import PricingEvent
|
||||
from app.db.database import transaction
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class PriceData:
|
||||
cost_basis: Optional[Decimal]
|
||||
market_price: Optional[Decimal]
|
||||
tcg_low: Optional[Decimal]
|
||||
tcg_mid: Optional[Decimal]
|
||||
direct_low: Optional[Decimal]
|
||||
listed_price: Optional[Decimal]
|
||||
quantity: int
|
||||
lowest_price_for_qty: Optional[Decimal]
|
||||
velocity: Optional[Decimal]
|
||||
age_of_inventory: Optional[int]
|
||||
|
||||
|
||||
class PricingService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
async def get_unmanaged_inventory(self, db: Session):
|
||||
unmanaged_inventory = db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_sku_id.notin_(
|
||||
db.query(PhysicalItem.tcgplayer_sku_id).join(
|
||||
InventoryItem, PhysicalItem.id == InventoryItem.physical_item_id
|
||||
).join(
|
||||
MarketplaceListing, InventoryItem.id == MarketplaceListing.inventory_item_id
|
||||
).filter(
|
||||
MarketplaceListing.delisting_date.is_(None),
|
||||
MarketplaceListing.deleted_at.is_(None),
|
||||
InventoryItem.deleted_at.is_(None),
|
||||
PhysicalItem.deleted_at.is_(None)
|
||||
)
|
||||
),
|
||||
TCGPlayerInventory.total_quantity >= 1
|
||||
).all()
|
||||
return unmanaged_inventory
|
||||
|
||||
async def get_managed_inventory(self, db: Session):
|
||||
# First get the TCGPlayerInventory IDs that are managed
|
||||
managed_ids = select(TCGPlayerInventory.id).join(
|
||||
PhysicalItem, TCGPlayerInventory.tcgplayer_sku_id == PhysicalItem.tcgplayer_sku_id
|
||||
).join(
|
||||
InventoryItem, PhysicalItem.id == InventoryItem.physical_item_id
|
||||
).join(
|
||||
MarketplaceListing, InventoryItem.id == MarketplaceListing.inventory_item_id
|
||||
).filter(
|
||||
MarketplaceListing.delisting_date.is_(None),
|
||||
MarketplaceListing.deleted_at.is_(None),
|
||||
InventoryItem.deleted_at.is_(None),
|
||||
PhysicalItem.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
# Then get just the TCGPlayerInventory data for those IDs
|
||||
managed_inventory = db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.id.in_(managed_ids)
|
||||
).all()
|
||||
return managed_inventory
|
||||
|
||||
async def get_pricing_data_for_unmanaged_inventory(self, db: Session) -> Dict[int, PriceData]:
|
||||
"""Gather all pricing data for unmanaged inventory in a single query."""
|
||||
unmanaged_inventory = await self.get_unmanaged_inventory(db)
|
||||
|
||||
# Get all SKU IDs
|
||||
sku_ids = [inv.tcgplayer_sku_id for inv in unmanaged_inventory]
|
||||
|
||||
# Fetch all MTGJSON SKUs and their products in one query
|
||||
mtgjson_skus = db.query(MTGJSONSKU).filter(
|
||||
MTGJSONSKU.tcgplayer_sku_id.in_(sku_ids)
|
||||
).all()
|
||||
|
||||
# Create a mapping of SKU ID to MTGJSON SKU
|
||||
sku_map = {sku.tcgplayer_sku_id: sku for sku in mtgjson_skus}
|
||||
|
||||
# Create price data for each inventory item
|
||||
price_data_map = {}
|
||||
for inventory in unmanaged_inventory:
|
||||
mtgjson_sku = sku_map.get(inventory.tcgplayer_sku_id)
|
||||
if mtgjson_sku and mtgjson_sku.product and mtgjson_sku.product.most_recent_tcgplayer_price:
|
||||
recent_price = mtgjson_sku.product.most_recent_tcgplayer_price
|
||||
price_data = PriceData(
|
||||
cost_basis=None,
|
||||
market_price=Decimal(str(recent_price.market_price)) if recent_price.market_price else None,
|
||||
tcg_low=Decimal(str(recent_price.low_price)) if recent_price.low_price else None,
|
||||
tcg_mid=Decimal(str(recent_price.mid_price)) if recent_price.mid_price else None,
|
||||
direct_low=Decimal(str(recent_price.direct_low_price)) if recent_price.direct_low_price else None,
|
||||
listed_price=Decimal(str(inventory.tcg_marketplace_price)) if inventory.tcg_marketplace_price else None,
|
||||
quantity=inventory.total_quantity,
|
||||
lowest_price_for_qty=None,
|
||||
velocity=None,
|
||||
age_of_inventory=None
|
||||
)
|
||||
price_data_map[inventory.tcgplayer_sku_id] = price_data
|
||||
|
||||
return price_data_map
|
||||
|
||||
async def update_prices_for_unmanaged_inventory(self, db: Session):
|
||||
# Get all pricing data upfront
|
||||
price_data_map = await self.get_pricing_data_for_unmanaged_inventory(db)
|
||||
|
||||
# Update prices using the pre-fetched data
|
||||
unmanaged_inventory = await self.get_unmanaged_inventory(db)
|
||||
for inventory in unmanaged_inventory:
|
||||
price_data = price_data_map.get(inventory.tcgplayer_sku_id)
|
||||
if price_data:
|
||||
inventory.tcg_marketplace_price = await self.set_price(db, price_data)
|
||||
|
||||
return unmanaged_inventory
|
||||
|
||||
async def update_prices_for_managed_inventory(self, db: Session):
|
||||
"""Update prices for managed inventory items and return updated TCGPlayerInventory data."""
|
||||
managed_inventory = await self.get_managed_inventory(db)
|
||||
|
||||
# Get all the inventory items we need in one query
|
||||
inventory_items = db.query(InventoryItem).join(
|
||||
PhysicalItem, InventoryItem.physical_item_id == PhysicalItem.id
|
||||
).filter(
|
||||
PhysicalItem.tcgplayer_sku_id.in_([inv.tcgplayer_sku_id for inv in managed_inventory]),
|
||||
InventoryItem.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
# Create a map of sku_id to inventory_item for easy lookup
|
||||
inventory_map = {item.physical_item.tcgplayer_sku_id: item for item in inventory_items}
|
||||
|
||||
for tcg_inventory in managed_inventory:
|
||||
inventory_item = inventory_map.get(tcg_inventory.tcgplayer_sku_id)
|
||||
if inventory_item:
|
||||
pricing_event = await self.set_price_for_inventory_item(db, inventory_item)
|
||||
if pricing_event:
|
||||
tcg_inventory.tcg_marketplace_price = pricing_event.price
|
||||
|
||||
return managed_inventory
|
||||
|
||||
async def set_price_for_inventory_item(self, db: Session, inventory_item: InventoryItem):
|
||||
recent_price = inventory_item.physical_item.sku.product.most_recent_tcgplayer_price
|
||||
|
||||
# Get the most recent active marketplace listing
|
||||
active_listing = None
|
||||
if inventory_item.marketplace_listing:
|
||||
active_listings = [listing for listing in inventory_item.marketplace_listing
|
||||
if listing.delisting_date is None and listing.deleted_at is None]
|
||||
if active_listings:
|
||||
active_listing = active_listings[0] # Get the first active listing
|
||||
|
||||
price_data = PriceData(
|
||||
cost_basis=Decimal(str(inventory_item.cost_basis)) if inventory_item.cost_basis is not None else None,
|
||||
market_price=Decimal(str(recent_price.market_price)) if recent_price and recent_price.market_price is not None else None,
|
||||
tcg_low=Decimal(str(recent_price.low_price)) if recent_price and recent_price.low_price is not None else None,
|
||||
tcg_mid=Decimal(str(recent_price.mid_price)) if recent_price and recent_price.mid_price is not None else None,
|
||||
direct_low=Decimal(str(recent_price.direct_low_price)) if recent_price and recent_price.direct_low_price is not None else None,
|
||||
listed_price=Decimal(str(active_listing.listed_price.price)) if active_listing and active_listing.listed_price and active_listing.listed_price.price is not None else None,
|
||||
quantity=db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
|
||||
).first().total_quantity if db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
|
||||
).first() else 0,
|
||||
lowest_price_for_qty=None,
|
||||
velocity=None,
|
||||
age_of_inventory=None
|
||||
)
|
||||
return await self.set_price(db, price_data, inventory_item)
|
||||
|
||||
async def set_price(self, db: Session, price_data: PriceData, inventory_item: InventoryItem=None):
|
||||
"""
|
||||
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 = price_data.cost_basis
|
||||
market_price = price_data.market_price if price_data.market_price is not None else price_data.tcg_mid
|
||||
tcg_low = price_data.tcg_low
|
||||
tcg_mid = price_data.tcg_mid
|
||||
listed_price = price_data.listed_price
|
||||
|
||||
if inventory_item:
|
||||
# average cost basis for all inventory items with the same tcgplayer_sku_id
|
||||
average_cost_basis = db.query(InventoryItem.cost_basis).filter(
|
||||
InventoryItem.physical_item_id == inventory_item.physical_item_id
|
||||
).all()
|
||||
cost_basis_values = [row[0] for row in average_cost_basis if row[0] is not None]
|
||||
if cost_basis_values:
|
||||
cost_basis = Decimal(str(sum(cost_basis_values) / len(cost_basis_values)))
|
||||
|
||||
logger.info(f"listed_price: {listed_price}")
|
||||
logger.info(f"market_price: {market_price}")
|
||||
logger.info(f"tcg_low: {tcg_low}")
|
||||
logger.info(f"tcg_mid: {tcg_mid}")
|
||||
logger.info(f"cost_basis: {cost_basis}")
|
||||
|
||||
# TODO: Add logic to fetch lowest price for seller with same quantity in stock
|
||||
# NOT IMPLEMENTED YET
|
||||
lowest_price_for_quantity = Decimal('0.0')
|
||||
|
||||
# Hardcoded configuration values (should be parameterized later)
|
||||
shipping_cost = Decimal('1.0')
|
||||
tcgplayer_shipping_fee = Decimal('1.31')
|
||||
average_cards_per_order = Decimal('3.0')
|
||||
marketplace_fee_percentage = Decimal('0.20')
|
||||
target_margin = Decimal('0.10')
|
||||
velocity_multiplier = Decimal('0.0')
|
||||
global_margin_multiplier = Decimal('0.00')
|
||||
min_floor_price = Decimal('0.25')
|
||||
price_drop_threshold = Decimal('0.50')
|
||||
# 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')
|
||||
else:
|
||||
card_cost_margin_multiplier = Decimal('0.0')
|
||||
|
||||
# Fetch current total quantity in stock for SKU
|
||||
quantity_in_stock = price_data.quantity
|
||||
|
||||
# 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'))
|
||||
|
||||
if cost_basis is None:
|
||||
cost_basis = tcg_mid * Decimal('0.65')
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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 < (tcg_mid * Decimal('0.85')):
|
||||
adjusted_price = tcg_mid
|
||||
price_used = "tcg mid"
|
||||
price_reason = f"adjusted price below 80% of tcg mid"
|
||||
elif adjusted_price > (tcg_mid * Decimal('1.1')):
|
||||
adjusted_price = max(tcg_mid, cost_basis)
|
||||
price_used = "max tcg mid/cost basis"
|
||||
price_reason = f"adjusted price above 110% of tcg mid, using max of tcg mid and cost basis"
|
||||
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"
|
||||
|
||||
# Enforce minimum floor price
|
||||
if adjusted_price < min_floor_price:
|
||||
adjusted_price = min_floor_price
|
||||
price_used = "min floor price"
|
||||
price_reason = "adjusted price below min floor price"
|
||||
|
||||
# Record pricing event in database transaction
|
||||
if inventory_item:
|
||||
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:
|
||||
for listing in inventory_item.marketplace_listing:
|
||||
if listing.listed_price:
|
||||
listing.listed_price.deleted_at = datetime.now()
|
||||
db.flush()
|
||||
listing.listed_price = pricing_event
|
||||
|
||||
return pricing_event
|
||||
else:
|
||||
return adjusted_price
|
||||
# BAD BAD BAD FIX PLS TODO
|
@@ -1,4 +1,5 @@
|
||||
from typing import List, Dict
|
||||
import json
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -9,7 +10,7 @@ import asyncio
|
||||
from app.schemas.file import FileInDB
|
||||
from app.services.base_service import BaseService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct, TCGPlayerGroup, MTGJSONSKU, MTGJSONCard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +49,7 @@ class PullSheetService(BaseService):
|
||||
"""
|
||||
try:
|
||||
# Read and process CSV data
|
||||
items = await self._read_and_process_csv(file.path)
|
||||
items = await self._read_and_process_csv(db, file.path)
|
||||
|
||||
# Prepare template data
|
||||
template_data = {
|
||||
@@ -80,7 +81,60 @@ class PullSheetService(BaseService):
|
||||
logger.error(f"Error generating pull sheet PDF: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _read_and_process_csv(self, csv_path: str) -> List[Dict]:
|
||||
async def _get_color_identity(self, db: Session, row: pd.Series) -> str:
|
||||
"""Get color identity from a row.
|
||||
|
||||
Args:
|
||||
row: pandas Series
|
||||
"""
|
||||
# if rarity is nan, return none
|
||||
if pd.isna(row['Rarity']):
|
||||
return '?'
|
||||
# get category id from set name
|
||||
group_id = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.name == row['Set']).first().group_id
|
||||
# format number - convert float to int if it's a pure number, otherwise keep as is
|
||||
number = str(int(float(row['Number']))) if 'Number' in row and pd.notna(row['Number']) and str(row['Number']).replace('.', '').isdigit() else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) else ''
|
||||
# get product info from category id
|
||||
product_id = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.group_id == group_id).filter(TCGPlayerProduct.name == row['Product Name']).filter(TCGPlayerProduct.ext_number == number).filter(TCGPlayerProduct.ext_rarity == row['Rarity']).first().tcgplayer_product_id
|
||||
# only do this block if mtg, to do fix this more betterer
|
||||
# get scryfall id from product id
|
||||
mtgjson_id = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_product_id == product_id).first()
|
||||
if mtgjson_id is not None:
|
||||
scryfall_id = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == mtgjson_id.mtgjson_uuid).first().scryfall_id
|
||||
else:
|
||||
scryfall_id = None
|
||||
if scryfall_id is not None:
|
||||
# get color identity from scryfall
|
||||
scryfall_service = self.get_service('scryfall')
|
||||
color_identity = await scryfall_service.get_color_identity(scryfall_id)
|
||||
if color_identity is None:
|
||||
return '?'
|
||||
# color identity is str of json array, convert to human readable string of list
|
||||
color_identity = [str(color) for color in color_identity]
|
||||
# if color identity is empty, return C for colorless
|
||||
if not color_identity:
|
||||
return 'C'
|
||||
# ensure order, W > U > B > R > G
|
||||
color_identity = sorted(color_identity, key=lambda x: ['W', 'U', 'B', 'R', 'G'].index(x))
|
||||
color_identity = ''.join(color_identity)
|
||||
else:
|
||||
color_identity = '?'
|
||||
return color_identity
|
||||
|
||||
async def _update_row_color_identity(self, db: Session, row: pd.Series) -> pd.Series:
|
||||
"""Update color identity from a row.
|
||||
|
||||
Args:
|
||||
row: pandas Series
|
||||
"""
|
||||
# get color identity from row
|
||||
color_identity = await self._get_color_identity(db, row)
|
||||
# update row with color identity
|
||||
row['Color Identity'] = color_identity
|
||||
return row
|
||||
|
||||
|
||||
async def _read_and_process_csv(self, db: Session, csv_path: str) -> List[Dict]:
|
||||
"""Read and process CSV data using pandas.
|
||||
|
||||
Args:
|
||||
@@ -104,6 +158,15 @@ class PullSheetService(BaseService):
|
||||
# Sort by Set Release Date (descending) and then Product Name (ascending)
|
||||
df = df.sort_values(['Set Release Date', 'Set', 'Product Name'], ascending=[False, True, True])
|
||||
|
||||
# Process color identities for all rows
|
||||
color_identities = []
|
||||
for _, row in df.iterrows():
|
||||
color_identity = await self._get_color_identity(db, row)
|
||||
color_identities.append(color_identity)
|
||||
|
||||
# Add color identity column to dataframe
|
||||
df['Color Identity'] = color_identities
|
||||
|
||||
# Convert to list of dictionaries
|
||||
items = []
|
||||
for _, row in df.iterrows():
|
||||
@@ -113,7 +176,8 @@ 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(float(row['Number']))) if 'Number' in row and pd.notna(row['Number']) and str(row['Number']).replace('.', '').isdigit() else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) else '',
|
||||
'color_identity': row['Color Identity']
|
||||
})
|
||||
|
||||
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,8 @@ 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
|
||||
from datetime import datetime
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerService(BaseService):
|
||||
@@ -12,15 +13,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,27 +57,48 @@ 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()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# Run initial inventory refresh on startup if inventory update was not run today
|
||||
# get last inventory update date
|
||||
last_inventory_update = db.query(TCGPlayerInventory).order_by(TCGPlayerInventory.created_at.desc()).first()
|
||||
if last_inventory_update is None or last_inventory_update.created_at.date() != datetime.now().date():
|
||||
await self.refresh_tcgplayer_inventory_table(db)
|
||||
|
||||
self.scheduler.start()
|
||||
logger.info("All scheduled tasks started")
|
||||
|
||||
|
@@ -30,9 +30,14 @@ 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',
|
||||
'scryfall': 'app.services.external_api.scryfall.scryfall_service.ScryfallService',
|
||||
'tcgplayer_market_data': 'app.services.external_api.tcgplayer.tcgplayer_market_data_service.TCGPlayerMarketDataService',
|
||||
'inventory_label': 'app.services.inventory_label_service.InventoryLabelService'
|
||||
|
||||
}
|
||||
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__)
|
||||
|
||||
|
@@ -67,16 +67,30 @@ function displayOrders(orders) {
|
||||
}
|
||||
|
||||
orders.forEach(order => {
|
||||
const hasHighQuantity = order.itemQuantity > 9;
|
||||
const hasHighAmount = order.productAmount > 40.00;
|
||||
|
||||
const orderCard = document.createElement('div');
|
||||
orderCard.className = `bg-gray-700 rounded-lg shadow-sm p-4 border border-gray-600 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selectedOrders.has(order.orderNumber) ? 'ring-2 ring-blue-500' : ''
|
||||
}`;
|
||||
} ${hasHighQuantity || hasHighAmount ? 'border-yellow-500' : ''}`;
|
||||
orderCard.dataset.orderId = order.orderNumber;
|
||||
orderCard.innerHTML = `
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-bold text-blue-400 truncate">#${order.orderNumber || 'N/A'}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-blue-400 truncate">
|
||||
<a href="https://sellerportal.tcgplayer.com/orders/${order.orderNumber}" target="_blank" rel="noopener noreferrer" class="hover:underline" onclick="event.stopPropagation()">${order.orderNumber || 'N/A'}</a>
|
||||
</h3>
|
||||
${(hasHighQuantity || hasHighAmount) ? `
|
||||
<span class="text-yellow-400" title="${hasHighQuantity ? 'High item quantity' : ''}${hasHighQuantity && hasHighAmount ? ' and ' : ''}${hasHighAmount ? 'High product amount' : ''}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">${order.buyerName || 'N/A'}</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs rounded-full ${
|
||||
@@ -86,9 +100,24 @@ function displayOrders(orders) {
|
||||
<div class="mt-auto">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-sm text-gray-400">${order.orderDate ? new Date(order.orderDate).toLocaleString() : 'N/A'}</p>
|
||||
<p class="text-lg font-bold text-white">$${order.totalAmount ? order.totalAmount.toFixed(2) : '0.00'}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
${hasHighAmount ? `
|
||||
<span class="text-yellow-400 text-sm">⚠️</span>
|
||||
` : ''}
|
||||
<p class="text-lg font-bold ${hasHighAmount ? 'text-yellow-400' : 'text-white'}">$${order.totalAmount ? order.totalAmount.toFixed(2) : '0.00'}</p>
|
||||
</div>
|
||||
</div>
|
||||
${hasHighQuantity ? `
|
||||
<div class="mt-2 text-sm text-yellow-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
High quantity: ${order.itemQuantity} items
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
ordersList.appendChild(orderCard);
|
||||
|
@@ -20,6 +20,23 @@
|
||||
</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>
|
||||
<a href="/inventory_labels.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Inventory Labels</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>
|
||||
|
118
app/static/inventory_labels.html
Normal file
118
app/static/inventory_labels.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Inventory Label Creator</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">
|
||||
<!-- 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>
|
||||
<a href="/inventory_labels.html" class="px-3 py-2 rounded-md text-sm font-medium text-white bg-blue-600 rounded-lg">Inventory Labels</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-4xl 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">Inventory Label Creator</h1>
|
||||
<p class="text-gray-400">Create QR code labels for inventory items with optional UPC codes and metadata</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Label Form -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-6">Create New Label</h2>
|
||||
|
||||
<form id="createLabelForm" class="space-y-6">
|
||||
<!-- UPC Code -->
|
||||
<div>
|
||||
<label for="upc" class="block text-sm font-medium text-gray-300 mb-2">UPC Code (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="upc"
|
||||
name="upc"
|
||||
placeholder="Enter UPC code..."
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<p class="text-sm text-gray-400 mt-1">Enter a valid UPC-A, UPC-E, or EAN-13 code</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="block text-sm font-medium text-gray-300">Metadata (Optional)</label>
|
||||
<button
|
||||
type="button"
|
||||
onclick="addMetadataField()"
|
||||
class="px-3 py-1 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 text-sm"
|
||||
>
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
<div id="metadataFields" class="space-y-3">
|
||||
<!-- Metadata fields will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print Option -->
|
||||
<div>
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="printLabel"
|
||||
name="print"
|
||||
checked
|
||||
class="rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500"
|
||||
>
|
||||
<span class="text-gray-300">Print label immediately</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-3 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 font-medium"
|
||||
>
|
||||
Create Label
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Recent Labels Section -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-6">Recent Labels</h2>
|
||||
<div id="recentLabels" class="space-y-4">
|
||||
<!-- Recent labels will be displayed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/inventory_labels.js"></script>
|
||||
</body>
|
||||
</html>
|
270
app/static/inventory_labels.js
Normal file
270
app/static/inventory_labels.js
Normal file
@@ -0,0 +1,270 @@
|
||||
// API base URL
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// 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 z-50`;
|
||||
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 submitButton = document.querySelector('#createLabelForm button[type="submit"]');
|
||||
if (isLoading) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
submitButton.textContent = 'Creating...';
|
||||
} else {
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
submitButton.textContent = 'Create Label';
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata field
|
||||
function addMetadataField() {
|
||||
const metadataFields = document.getElementById('metadataFields');
|
||||
const fieldId = Date.now(); // Simple unique ID
|
||||
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.className = 'flex space-x-3 items-end';
|
||||
fieldDiv.innerHTML = `
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
name="metadata_key_${fieldId}"
|
||||
placeholder="e.g., product_name, condition, location..."
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
name="metadata_value_${fieldId}"
|
||||
placeholder="e.g., Lightning Bolt, NM, Shelf A1..."
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick="removeMetadataField(this)"
|
||||
class="px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
`;
|
||||
|
||||
metadataFields.appendChild(fieldDiv);
|
||||
}
|
||||
|
||||
// Remove metadata field
|
||||
function removeMetadataField(button) {
|
||||
button.closest('div').remove();
|
||||
}
|
||||
|
||||
// Validate UPC code
|
||||
function validateUPC(upc) {
|
||||
if (!upc) return true; // Empty UPC is valid (optional field)
|
||||
|
||||
// Remove any non-digit characters
|
||||
const digitsOnly = upc.replace(/[^0-9]/g, '');
|
||||
|
||||
// Check for valid lengths
|
||||
if (digitsOnly.length === 12) {
|
||||
return validateUPCAChecksum(digitsOnly);
|
||||
} else if (digitsOnly.length === 8) {
|
||||
return validateUPCChecksum(digitsOnly);
|
||||
} else if (digitsOnly.length === 13) {
|
||||
return validateEAN13Checksum(digitsOnly);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate UPC-A checksum
|
||||
function validateUPCAChecksum(upc) {
|
||||
if (upc.length !== 12 || !/^\d+$/.test(upc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const digit = parseInt(upc[i]);
|
||||
if (i % 2 === 0) { // Odd positions (0-indexed)
|
||||
total += digit * 3;
|
||||
} else { // Even positions
|
||||
total += digit;
|
||||
}
|
||||
}
|
||||
|
||||
const checksum = (10 - (total % 10)) % 10;
|
||||
return checksum === parseInt(upc[11]);
|
||||
}
|
||||
|
||||
// Validate UPC-E checksum
|
||||
function validateUPCChecksum(upc) {
|
||||
if (upc.length !== 8 || !/^\d+$/.test(upc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const digit = parseInt(upc[i]);
|
||||
if (i % 2 === 0) { // Odd positions (0-indexed)
|
||||
total += digit * 3;
|
||||
} else { // Even positions
|
||||
total += digit;
|
||||
}
|
||||
}
|
||||
|
||||
const checksum = (10 - (total % 10)) % 10;
|
||||
return checksum === parseInt(upc[7]);
|
||||
}
|
||||
|
||||
// Validate EAN-13 checksum
|
||||
function validateEAN13Checksum(ean) {
|
||||
if (ean.length !== 13 || !/^\d+$/.test(ean)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const digit = parseInt(ean[i]);
|
||||
if (i % 2 === 0) { // Even positions (0-indexed)
|
||||
total += digit;
|
||||
} else { // Odd positions
|
||||
total += digit * 3;
|
||||
}
|
||||
}
|
||||
|
||||
const checksum = (10 - (total % 10)) % 10;
|
||||
return checksum === parseInt(ean[12]);
|
||||
}
|
||||
|
||||
// Collect form data
|
||||
function collectFormData() {
|
||||
const upc = document.getElementById('upc').value.trim();
|
||||
const print = document.getElementById('printLabel').checked;
|
||||
|
||||
// Collect metadata
|
||||
const metadata = [];
|
||||
const metadataFields = document.querySelectorAll('#metadataFields input[type="text"]');
|
||||
|
||||
for (let i = 0; i < metadataFields.length; i += 2) {
|
||||
const keyInput = metadataFields[i];
|
||||
const valueInput = metadataFields[i + 1];
|
||||
|
||||
if (keyInput && valueInput && keyInput.value.trim() && valueInput.value.trim()) {
|
||||
metadata.push({
|
||||
key: keyInput.value.trim(),
|
||||
value: valueInput.value.trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upc: upc || null,
|
||||
metadata: metadata.length > 0 ? metadata : null,
|
||||
print: print
|
||||
};
|
||||
}
|
||||
|
||||
// Create inventory label
|
||||
async function createInventoryLabel(formData) {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/inventory-labels/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to create inventory label');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
showToast('Inventory label created successfully!', 'success');
|
||||
|
||||
// Reset form
|
||||
document.getElementById('createLabelForm').reset();
|
||||
document.getElementById('metadataFields').innerHTML = '';
|
||||
|
||||
// Optionally refresh recent labels
|
||||
// loadRecentLabels();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
showToast('Error creating inventory label: ' + error.message, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
async function handleFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = collectFormData();
|
||||
|
||||
// Validate UPC if provided
|
||||
if (formData.upc && !validateUPC(formData.upc)) {
|
||||
showToast('Invalid UPC code. Please enter a valid UPC-A, UPC-E, or EAN-13 code.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate metadata if provided
|
||||
if (formData.metadata) {
|
||||
for (const item of formData.metadata) {
|
||||
if (!item.key || !item.value) {
|
||||
showToast('All metadata fields must have both key and value.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await createInventoryLabel(formData);
|
||||
} catch (error) {
|
||||
console.error('Error creating inventory label:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent labels (placeholder for future implementation)
|
||||
async function loadRecentLabels() {
|
||||
// This could be implemented to show recently created labels
|
||||
// For now, it's a placeholder
|
||||
const recentLabelsDiv = document.getElementById('recentLabels');
|
||||
recentLabelsDiv.innerHTML = '<p class="text-gray-400 text-center">No recent labels to display</p>';
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set up form submission handler
|
||||
document.getElementById('createLabelForm').addEventListener('submit', handleFormSubmit);
|
||||
|
||||
// Load recent labels
|
||||
loadRecentLabels();
|
||||
|
||||
// Add initial metadata field
|
||||
addMetadataField();
|
||||
});
|
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;
|
||||
}
|
184
app/static/transactions.html
Normal file
184
app/static/transactions.html
Normal file
@@ -0,0 +1,184 @@
|
||||
<!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">
|
||||
<div class="flex space-x-4">
|
||||
<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>
|
||||
<button id="downloadTcgplayerUpdateBtn" class="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">
|
||||
Download TCGPlayer Update File
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
1113
app/static/transactions.js
Normal file
1113
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