Compare commits

..

8 Commits

Author SHA1 Message Date
9c13118a05 lots 2025-08-01 10:33:50 -04:00
82fd1cb2da update pricing i guess 2025-06-09 12:28:15 -04:00
77d6fd6e29 part of pricing idk i dont remember 2025-06-09 08:28:14 -04:00
7bc64115f2 color identity in pull sheet 2025-05-31 12:51:25 -04:00
fa089adb53 flag special orders in ui 2025-05-31 12:00:28 -04:00
dca11b0ede asdf 2025-05-30 17:34:40 -04:00
f2c2b69d63 asdf 2025-05-30 17:31:59 -04:00
5c85411c69 we are so back 2025-05-05 14:05:12 -04:00
44 changed files with 4100 additions and 181 deletions

View 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 ###

View File

@@ -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>

View 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
1 Name Set code Set name Collector number Foil Rarity Quantity ManaBox ID Scryfall ID Purchase price Misprint Altered Condition Language Purchase price currency
2 Undergrowth Leopard TDM Tarkir: Dragonstorm 165 foil common 1 104307 67ab8f9a-b17c-452f-b4ef-a3f91909e3de 0.08 false false near_mint en USD
3 Gurmag Nightwatch TDM Tarkir: Dragonstorm 190 foil common 1 104369 de731430-6bbf-4782-953e-b69c46353959 0.03 false false near_mint en USD
4 Mystic Monastery TDM Tarkir: Dragonstorm 262 foil uncommon 1 104945 c7b8a01c-c400-47c7-8270-78902efe850e 0.21 false false near_mint en USD
5 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
6 Reigning Victor TDM Tarkir: Dragonstorm 216 foil common 1 104334 a394112a-032b-4047-887a-6522cf7b83d5 0.02 false false near_mint en USD
7 Dragonbroods' Relic TDM Tarkir: Dragonstorm 140 foil uncommon 1 104569 3d634087-77ba-4543-aa7a-8a3774d69cd7 0.13 false false near_mint en USD
8 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
9 Sibsig Appraiser TDM Tarkir: Dragonstorm 56 foil common 1 105135 670c5b96-bac6-449b-a2bd-cb43750d3911 0.05 false false near_mint en USD
10 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
11 Snowmelt Stag TDM Tarkir: Dragonstorm 57 foil common 1 104869 a6b3b131-704a-4586-84f8-db465cd4a277 0.04 false false near_mint en USD
12 Tranquil Cove TDM Tarkir: Dragonstorm 270 foil common 1 104249 1c4efa6c-4f29-41cd-a728-bf0e479ace05 0.07 false false near_mint en USD
13 Rally the Monastery TDM Tarkir: Dragonstorm 19 foil uncommon 1 104136 b56e0037-8143-4c13-83e1-0c3f44e685ea 0.22 false false near_mint en USD
14 Dragon's Prey TDM Tarkir: Dragonstorm 79 foil common 1 104754 7a6004ff-4180-4332-8b51-960f8c7521d9 0.03 false false near_mint en USD
15 Ambling Stormshell TDM Tarkir: Dragonstorm 37 foil rare 1 104942 c74d4a57-0f66-4965-9ed7-f88a08aa1d15 0.45 false false near_mint en USD
16 Mountain TDM Tarkir: Dragonstorm 275 foil common 1 104397 fe0865ba-47c0-40bc-b0c6-e1ea5ae08a98 1.83 false false near_mint en USD
17 Mardu Devotee TDM Tarkir: Dragonstorm 16 foil common 1 104366 da45e9b0-a4f6-413b-9e62-666c511eb5b0 0.09 false false near_mint en USD
18 Swiftwater Cliffs TDM Tarkir: Dragonstorm 268 foil common 1 104361 ca53fb19-b8ca-485b-af1a-5117ae54bfe3 0.11 false false near_mint en USD
19 Adorned Crocodile TDM Tarkir: Dragonstorm 69 foil common 1 105159 bb13a34b-6ac8-47cb-9e91-47106a585fc1 0.05 false false near_mint en USD
20 Dusyut Earthcarver TDM Tarkir: Dragonstorm 141 foil common 1 104352 b98ecc96-f557-479a-8685-2b5487d5b407 0.02 false false near_mint en USD
21 Knockout Maneuver TDM Tarkir: Dragonstorm 147 foil uncommon 1 105149 9d218831-2a41-46a3-8e9d-93462cae5cab 0.07 false false near_mint en USD
22 Roiling Dragonstorm TDM Tarkir: Dragonstorm 55 foil uncommon 1 104280 455f4c96-684b-4b14-bd21-6799da2e1fa7 0.22 false false near_mint en USD
23 Dragonclaw Strike TDM Tarkir: Dragonstorm 180 foil uncommon 1 105161 bc7692ef-7091-4365-85a8-1edbd374f279 0.12 false false near_mint en USD
24 Seize Opportunity TDM Tarkir: Dragonstorm 119 foil common 1 104391 f7818d28-b9a5-4341-9adc-666070b8878d 0.03 false false near_mint en USD
25 Shock Brigade TDM Tarkir: Dragonstorm 120 foil common 1 104700 66940466-8e9d-4a85-bfb0-e92189b7a121 0.11 false false near_mint en USD
26 Hardened Tactician TDM Tarkir: Dragonstorm 191 foil uncommon 1 104780 86b225cb-5c45-4da1-a64e-b04091e483e8 0.43 false false near_mint en USD
27 Stormplain Detainment TDM Tarkir: Dragonstorm 28 foil common 1 104135 39f3aab5-7b54-4b55-8114-c6f9f79c255d 0.04 false false near_mint en USD
28 Formation Breaker TDM Tarkir: Dragonstorm 143 foil uncommon 1 105136 67ab8e8f-3ef6-4339-8c66-68c5aca4867a 0.08 false false near_mint en USD
29 Duty Beyond Death TDM Tarkir: Dragonstorm 10 foil uncommon 1 104265 2e92640d-768b-4357-905f-bea017d351cc 1.11 false false near_mint en USD
30 Piercing Exhale TDM Tarkir: Dragonstorm 151 foil common 1 104891 b2a0deb9-5bc3-42d5-9e1e-5f463d176aef 0.04 false false near_mint en USD
31 Trade Route Envoy TDM Tarkir: Dragonstorm 163 foil common 1 105174 f0c89d95-d697-4cfa-9dfa-52d7adb96176 0.05 false false near_mint en USD
32 Thornwood Falls TDM Tarkir: Dragonstorm 269 foil common 1 104376 ebb502c2-5fd0-46a9-b77d-010f4a942056 0.07 false false near_mint en USD
33 Kin-Tree Nurturer TDM Tarkir: Dragonstorm 83 foil common 1 105124 2177ef64-28bf-4acf-b1f1-c1408f03c411 0.03 false false near_mint en USD
34 Rebellious Strike TDM Tarkir: Dragonstorm 20 foil common 1 104949 c9bafe19-3bd6-4da0-b3e5-e0b89262504c 0.06 false false near_mint en USD
35 Scoured Barrens TDM Tarkir: Dragonstorm 267 foil common 1 104346 b4b47b80-69ed-44b0-afa0-ca90206dc16d 0.06 false false near_mint en USD
36 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
37 Dismal Backwater TDM Tarkir: Dragonstorm 254 foil common 1 104238 082b52c9-c46e-44d3-b723-546ba528e07b 0.07 false false near_mint en USD
38 Rediscover the Way TDM Tarkir: Dragonstorm 215 normal rare 1 104313 79d6decf-afd5-4e96-b87e-fd7ab7e3c068 0.19 false false near_mint en USD
39 Auroral Procession TDM Tarkir: Dragonstorm 169 normal uncommon 1 104701 672f94ad-65d6-4c7d-925d-165ef264626f 0.22 false false near_mint en USD
40 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
41 Stadium Headliner TDM Tarkir: Dragonstorm 122 normal rare 1 104552 37d4ab2a-a06a-4768-b5e1-e1def957d7f4 0.44 false false near_mint en USD
42 Nature's Rhythm TDM Tarkir: Dragonstorm 150 normal rare 1 104460 1397d904-c51d-451e-8505-7f3118acc1f6 3.08 false false near_mint en USD
43 Karakyk Guardian TDM Tarkir: Dragonstorm 198 normal uncommon 1 104859 a4c77b08-c3f6-4458-8636-f226f9843b6d 0.08 false false near_mint en USD
44 Anafenza, Unyielding Lineage TDM Tarkir: Dragonstorm 2 normal rare 1 104258 29957f49-9a6b-42f6-b2fb-b48f653ab725 0.22 false false near_mint en USD
45 Narset, Jeskai Waymaster TDM Tarkir: Dragonstorm 209 normal rare 1 103995 6b77cbc1-dbc8-44d9-aa29-15cbb19afecd 0.22 false false near_mint en USD
46 Stillness in Motion TDM Tarkir: Dragonstorm 59 normal rare 1 104864 a6289251-17e4-4987-96b9-2fb1a8f90e2a 0.17 false false near_mint en USD
47 Thunder of Unity TDM Tarkir: Dragonstorm 231 normal rare 1 104671 5c953b36-f5e4-4258-91cb-f07e799321f7 0.14 false false near_mint en USD
48 The Sibsig Ceremony TDM Tarkir: Dragonstorm 340 normal rare 1 104719 6daa156c-478f-47dd-9284-b95e82ccfd68 0.67 false false near_mint en USD
49 Tersa Lightshatter TDM Tarkir: Dragonstorm 127 normal rare 1 104825 99e96b34-b1c4-4647-a38e-2cf1aedaaace 2.31 false false near_mint en USD
50 Forest TDM Tarkir: Dragonstorm 286 normal common 1 104324 8e3e83d2-96ba-4d5c-a1ed-6c08a90b339c 0.07 false false near_mint en USD
51 Windcrag Siege TDM Tarkir: Dragonstorm 235 normal rare 1 104534 31a8329b-23a1-4c49-a579-a5da8d01435a 1.68 false false near_mint en USD
52 Mountain TDM Tarkir: Dragonstorm 284 normal common 1 104274 3df7c206-97b6-49d7-ba01-7a35fd8c61d9 0.05 false false near_mint en USD
53 Inevitable Defeat TDM Tarkir: Dragonstorm 194 normal rare 1 103997 9d677980-b608-407e-9f17-790a81263f15 0.28 false false near_mint en USD
54 Dalkovan Encampment TDM Tarkir: Dragonstorm 394 normal rare 1 104670 5af006f6-135e-4ea0-8ce4-7824934e87da 0.72 false false near_mint en USD
55 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
56 Sarkhan, Dragon Ascendant TDM Tarkir: Dragonstorm 118 normal rare 1 104003 c2200646-7b7c-489d-bbae-16b03e1d7fb2 0.32 false false near_mint en USD
57 Stormscale Scion TDM Tarkir: Dragonstorm 123 normal mythic 1 103987 0ac43386-bd32-425c-8776-cec00b064cbc 6.78 false false near_mint en USD
58 Dragon Sniper TDM Tarkir: Dragonstorm 139 normal uncommon 1 105120 074b1e00-45bb-4436-8f5e-058512b2d08a 0.25 false false near_mint en USD
59 Island TDM Tarkir: Dragonstorm 273 normal common 1 104276 4208e66c-8c98-4c48-ab07-8523c0b26ca4 1.02 false false near_mint en USD
60 Yathan Roadwatcher TDM Tarkir: Dragonstorm 236 normal rare 1 104800 8e77339b-dd82-481c-9ee2-4156ca69ad35 0.14 false false near_mint en USD
61 Nomad Outpost TDM Tarkir: Dragonstorm 263 normal uncommon 1 104868 a68fbeaa-941f-4d53-becd-f93ed22b9a54 0.12 false false near_mint en USD
62 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
63 Kheru Goldkeeper TDM Tarkir: Dragonstorm 199 normal uncommon 1 104798 8d11183a-57f5-4ddb-8a6e-15fff704b114 0.18 false false near_mint en USD
64 All-Out Assault TDM Tarkir: Dragonstorm 167 normal mythic 1 104348 b74876d8-f6a6-4b47-b960-b01a331bab01 4.11 false false near_mint en USD
65 Winternight Stories TDM Tarkir: Dragonstorm 67 normal rare 1 104693 64d9367c-f50c-4568-aa63-6760c44ecaeb 0.44 false false near_mint en USD
66 New Way Forward TDM Tarkir: Dragonstorm 211 normal rare 1 104996 d9d48f9e-79f0-478c-9db0-ff7ac4a8f401 0.17 false false near_mint en USD
67 Strategic Betrayal TDM Tarkir: Dragonstorm 94 normal uncommon 1 105145 95617742-548d-464a-bb89-a858ffa9018f 0.18 false false near_mint en USD
68 Opulent Palace TDM Tarkir: Dragonstorm 264 normal uncommon 1 104491 21cb3b3b-0738-4c2e-a3fc-927fd6b9d3fb 0.14 false false near_mint en USD
69 United Battlefront TDM Tarkir: Dragonstorm 32 normal rare 1 104370 dff398be-4ba4-4976-9acc-be99d2e07a61 0.51 false false near_mint en USD
70 Temur Battlecrier TDM Tarkir: Dragonstorm 228 normal rare 1 104309 72184791-0767-4108-920c-763e92dae2d4 0.68 false false near_mint en USD
71 Kotis, the Fangkeeper TDM Tarkir: Dragonstorm 362 normal rare 1 104388 f70098f2-e5a8-4056-b5b3-1229fc290c51 0.48 false false near_mint en USD
72 Forest TDM Tarkir: Dragonstorm 285 normal common 1 104317 8100bceb-ffba-487a-bb45-4fe2a156a8dc 0.06 false false near_mint en USD
73 Dragonfire Blade TDM Tarkir: Dragonstorm 240 normal rare 1 104427 031afea3-fbfb-4663-a8cc-9b7eb7b16020 0.64 false false near_mint en USD
74 Great Arashin City TDM Tarkir: Dragonstorm 257 normal rare 1 105033 ecba23b6-9f3a-431e-bc22-f1fb04d27b68 0.33 false false near_mint en USD
75 Smile at Death TDM Tarkir: Dragonstorm 24 normal mythic 1 104000 ae2da18f-0d7d-446c-b463-8bf170ed95da 3.51 false false near_mint en USD
76 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
77 Eshki Dragonclaw TDM Tarkir: Dragonstorm 182 normal rare 1 104445 0d369c44-78ee-4f3c-bf2b-cddba7fe26d4 0.19 false false near_mint en USD
78 Skirmish Rhino TDM Tarkir: Dragonstorm 224 normal uncommon 1 103992 4a2e9ba1-c254-41e3-9845-4e81f9fec38d 0.15 false false near_mint en USD
79 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
80 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
81 Hardened Tactician TDM Tarkir: Dragonstorm 191 normal uncommon 1 104780 86b225cb-5c45-4da1-a64e-b04091e483e8 1.0 false false near_mint en USD
82 Sandsteppe Citadel TDM Tarkir: Dragonstorm 266 normal uncommon 1 104603 47f47e7f-39ba-4807-8e32-7262a61dfbba 0.13 false false near_mint en USD
83 Kotis, the Fangkeeper TDM Tarkir: Dragonstorm 202 normal rare 1 104364 d3736f17-f80b-4b2c-b919-2c963bc14682 0.28 false false near_mint en USD
84 Magmatic Hellkite TDM Tarkir: Dragonstorm 111 normal rare 1 104895 b3b3aec8-d931-4c7f-86b5-1e7dfb717b59 0.56 false false near_mint en USD
85 Dalkovan Encampment TDM Tarkir: Dragonstorm 253 normal rare 1 104822 98ad5f0c-8775-4e89-8e92-84a6ade93e35 0.38 false false near_mint en USD
86 Ambling Stormshell TDM Tarkir: Dragonstorm 37 normal rare 1 104942 c74d4a57-0f66-4965-9ed7-f88a08aa1d15 0.18 false false near_mint en USD
87 Hollowmurk Siege TDM Tarkir: Dragonstorm 192 normal rare 1 104668 5ac0e136-8877-4bfc-a831-2bf7b7b5ad1e 0.53 false false near_mint en USD
88 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
89 Mystic Monastery TDM Tarkir: Dragonstorm 262 normal uncommon 1 104945 c7b8a01c-c400-47c7-8270-78902efe850e 0.11 false false near_mint en USD
90 Songcrafter Mage TDM Tarkir: Dragonstorm 225 normal rare 1 104813 9523bc07-49e5-409c-ae6b-b28e305eef36 0.35 false false near_mint en USD
91 Misty Rainforest SPG Special Guests 111 normal mythic 1 104321 894105c4-d3ce-4d38-855b-24aa47b112c1 32.31 false false near_mint en USD
92 Tempest Hawk TDM Tarkir: Dragonstorm 31 normal common 3 104587 422f9453-ab12-4e3c-8c51-be87391395a1 0.92 false false near_mint en USD
93 Duty Beyond Death TDM Tarkir: Dragonstorm 10 normal uncommon 2 104265 2e92640d-768b-4357-905f-bea017d351cc 0.33 false false near_mint en USD
94 Heritage Reclamation TDM Tarkir: Dragonstorm 145 normal common 2 104636 4f8fee37-a050-4329-8b10-46d150e7a95e 0.2 false false near_mint en USD

View File

@@ -59,18 +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], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=True, archived_prices_start_date="2025-04-27", archived_prices_end_date="2025-04-28")
#logger.info(f"Data initialization results: {data_init}")
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)
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}")
#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.refresh_tcgplayer_inventory_table(db)
await scheduler.start_scheduled_tasks(db)
logger.info("Scheduler started successfully")
@@ -115,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,

View File

@@ -252,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"
@@ -260,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"
@@ -277,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"
@@ -289,7 +299,7 @@ class Marketplace(Base):
# Relationships
listings = relationship("MarketplaceListing", back_populates="marketplace")
transactions = relationship("Transaction", back_populates="marketplaces")
class MarketplaceListing(Base):
__tablename__ = "marketplace_listings"
@@ -308,4 +318,32 @@ class MarketplaceListing(Base):
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])
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")

View 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

View File

@@ -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,
@@ -162,4 +176,329 @@ async def create_marketplace(
):
inventory_service = service_manager.get_service("inventory")
marketplace = await inventory_service.create_marketplace(db, marketplace_name)
return marketplace
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"
}
)

View File

@@ -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")

View 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(...),

View 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
View File

View 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

View File

@@ -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

View File

@@ -1,12 +1,12 @@
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
from app.models.tcgplayer_products import TCGPlayerProduct
class PurchaseItem(BaseModel):
product_id: int
unit_price: float
quantity: int
is_case: bool
item_type: str
num_boxes: Optional[int] = None
# TODO: remove is_case and num_boxes, should derive from product_id
@@ -30,11 +30,11 @@ class SaleTransactionCreate(BaseModel):
class TransactionItemResponse(BaseModel):
id: int
transaction_id: int
physical_item_id: int
inventory_item_id: int
unit_price: float
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
class TransactionResponse(BaseModel):
id: int
vendor_id: Optional[int] = None
@@ -46,4 +46,64 @@ class TransactionResponse(BaseModel):
transaction_notes: Optional[str] = None
created_at: datetime
updated_at: datetime
transaction_items: List[TransactionItemResponse]
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]

View File

@@ -35,5 +35,6 @@ __all__ = [
'OrderManagementService',
'TCGPlayerInventoryService',
'PricingService',
'MarketplaceListingService'
'MarketplaceListingService',
'ScryfallService'
]

View File

@@ -313,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(
@@ -428,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(
@@ -772,7 +778,7 @@ class DataInitializationService(BaseService):
transaction = await inventory_service.create_purchase_transaction(db, PurchaseTransactionCreate(
vendor_id=vendor.id,
transaction_date=datetime.now(),
items=[PurchaseItem(product_id=619645, unit_price=100, quantity=1, is_case=False)],
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"
@@ -784,7 +790,7 @@ class DataInitializationService(BaseService):
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/tdmtest.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

View File

@@ -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

View 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']

View File

@@ -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"
return await self._make_request("GET", endpoint, headers={"Accept": "text/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"
return await self._make_request("GET", endpoint, binary=True)
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"""

View File

@@ -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)

View File

@@ -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,7 +41,34 @@ 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]:
"""
search range options:
@@ -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:

View File

@@ -71,59 +71,4 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
with transaction(db):
# Bulk insert new data
db.bulk_insert_mappings(TCGPlayerInventory, inventory_data)
async def refresh_unmanaged_tcgplayer_inventory_table(self, db: Session):
"""
Refresh the TCGPlayer unmanaged inventory table
unmanaged inventory is any inventory that cannot be mapped to a card with a marketplace listing
"""
with transaction(db):
# Get active marketplace listings with their physical items in a single query
listed_cards = (
db.query(MarketplaceListing)
.join(MarketplaceListing.inventory_item)
.join(InventoryItem.physical_item)
.filter(
func.lower(Marketplace.name) == func.lower("tcgplayer"),
MarketplaceListing.delisting_date == None,
MarketplaceListing.deleted_at == None,
MarketplaceListing.listing_date != None
)
.all()
)
# Get current inventory and create lookup dict
current_inventory = db.query(TCGPlayerInventory).all()
# Create a set of SKUs that have active listings
listed_skus = {
card.inventory_item.physical_item.tcgplayer_sku_id
for card in listed_cards
}
unmanaged_inventory = []
for inventory in current_inventory:
# Only include SKUs that have no active listings
if inventory.tcgplayer_sku_id not in listed_skus:
unmanaged_inventory.append({
"tcgplayer_inventory_id": inventory.id,
"tcgplayer_sku_id": inventory.tcgplayer_sku_id,
"product_line": inventory.product_line,
"set_name": inventory.set_name,
"product_name": inventory.product_name,
"title": inventory.title,
"number": inventory.number,
"rarity": inventory.rarity,
"condition": inventory.condition,
"tcg_market_price": inventory.tcg_market_price,
"tcg_direct_low": inventory.tcg_direct_low,
"tcg_low_price_with_shipping": inventory.tcg_low_price_with_shipping,
"tcg_low_price": inventory.tcg_low_price,
"total_quantity": inventory.total_quantity,
"add_to_quantity": inventory.add_to_quantity,
"tcg_marketplace_price": inventory.tcg_marketplace_price,
"photo_url": inventory.photo_url
})
db.bulk_insert_mappings(UnmanagedTCGPlayerInventory, unmanaged_inventory)
db.bulk_insert_mappings(TCGPlayerInventory, inventory_data)

View File

@@ -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'
}

View File

@@ -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]

View 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()

View File

@@ -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 (
OpenEvent, Card, InventoryItem, Case,
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,6 +22,60 @@ class InventoryService(BaseService):
def __init__(self):
super().__init__(None)
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):
expected_value = SealedExpectedValue(
tcgplayer_product_id=expected_value_data.tcgplayer_product_id,
expected_value=expected_value_data.expected_value
)
db.add(expected_value)
db.flush()
return expected_value
async def create_purchase_transaction(
self,
db: Session,
@@ -52,7 +108,7 @@ class InventoryService(BaseService):
# 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 case_service.create_case(
db=db,
@@ -61,7 +117,7 @@ class InventoryService(BaseService):
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 box_service.create_box(
db=db,
@@ -69,6 +125,9 @@ class InventoryService(BaseService):
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
@@ -128,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,
@@ -149,6 +214,12 @@ class InventoryService(BaseService):
except Exception as e:
raise e
async def get_marketplaces(
self,
db: Session
) -> List[Marketplace]:
return db.query(Marketplace).all()
class BoxService(BaseService[Box]):
def __init__(self):
@@ -192,7 +263,7 @@ class BoxService(BaseService[Box]):
# ensure card
if resulting_card.item_type != "card":
raise ValueError(f"Expected card, got {resulting_card.item_type}")
resulting_card_market_value = resulting_card.products.most_recent_tcgplayer_price.market_price
resulting_card_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()
@@ -304,7 +375,7 @@ class MarketplaceListingService(BaseService[MarketplaceListing]):
async def create_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
try:
with db_transaction(db):
recommended_price = await self.pricing_service.set_price(db, inventory_item)
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,
@@ -321,11 +392,174 @@ class MarketplaceListingService(BaseService[MarketplaceListing]):
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(marketplace_listing.inventory_item)
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

View File

@@ -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:

View File

@@ -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,34 +29,36 @@ 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 []
return True
else:
background_tasks.add_task(self._process_file_background, db, file)
return True
async def _process_file_background(self, db: Session, file: FileInDB):
try:
# Read the CSV file
with open(file.path, 'r') as csv_file:
reader = csv.DictReader(csv_file)
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:
logger.debug(f"Processing row: {row}")
mtg_json = mtg_json_map.get(row['Scryfall ID'])
if not mtg_json:
@@ -109,6 +111,7 @@ class ManaboxService(BaseService):
# Prepare the staging entry
quantity = int(row['Quantity'])
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,
tcgplayer_product_id=tcgplayer_product.tcgplayer_product_id,
@@ -118,10 +121,13 @@ class ManaboxService(BaseService):
# Bulk insert all valid ManaboxImportStaging entries
if staging_entries:
db.bulk_save_objects(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)

View File

@@ -1,31 +1,201 @@
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
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)
async def set_price(self, db: Session, inventory_item: InventoryItem) -> float:
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 = Decimal(str(inventory_item.cost_basis))
market_price = Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.market_price))
tcg_low = Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.low_price))
tcg_mid = Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.mid_price))
listed_price = Decimal(str(inventory_item.marketplace_listing.listed_price)) if inventory_item.marketplace_listing else None
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}")
@@ -46,39 +216,38 @@ class PricingService(BaseService):
velocity_multiplier = Decimal('0.0')
global_margin_multiplier = Decimal('0.00')
min_floor_price = Decimal('0.25')
price_drop_threshold = Decimal('0.20')
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.075')
card_cost_margin_multiplier = Decimal('-0.033')
elif market_price >= 2 and market_price < 10:
card_cost_margin_multiplier = Decimal('-0.025')
card_cost_margin_multiplier = Decimal('0.0')
elif market_price >= 10 and market_price < 30:
card_cost_margin_multiplier = Decimal('0.025')
card_cost_margin_multiplier = Decimal('0.0125')
elif market_price >= 30 and market_price < 50:
card_cost_margin_multiplier = Decimal('0.05')
card_cost_margin_multiplier = Decimal('0.025')
elif market_price >= 50 and market_price < 100:
card_cost_margin_multiplier = Decimal('0.075')
card_cost_margin_multiplier = Decimal('0.033')
elif market_price >= 100 and market_price < 200:
card_cost_margin_multiplier = Decimal('0.10')
card_cost_margin_multiplier = Decimal('0.05')
else:
card_cost_margin_multiplier = Decimal('0.0')
# Fetch current total quantity in stock for SKU
quantity_record = db.query(TCGPlayerInventory).filter(
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
).first()
quantity_in_stock = quantity_record.total_quantity if quantity_record else 0
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.1')
elif 5 <= quantity_in_stock < 10:
quantity_multiplier = Decimal('0.2')
elif quantity_in_stock >= 10:
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')
@@ -88,6 +257,8 @@ class PricingService(BaseService):
# 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
@@ -99,19 +270,19 @@ class PricingService(BaseService):
base_price + tcgplayer_shipping_fee
)
# Enforce minimum floor price to ensure profitability
if adjusted_price < min_floor_price:
adjusted_price = min_floor_price
# Adjust price based on market prices (TCG low and TCG mid)
if adjusted_price < tcg_low:
adjusted_price = tcg_mid
price_used = "tcg mid"
price_reason = "adjusted price below tcg low"
elif adjusted_price > tcg_low and adjusted_price < (market_price * Decimal('0.8')) and adjusted_price < (tcg_mid * Decimal('0.8')):
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 market price and 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"
@@ -127,7 +298,7 @@ class PricingService(BaseService):
free_shipping_adjustment = False
for x in range(1, 5):
quantity = Decimal(str(x))
if Decimal('5.00') <= adjusted_price * quantity <= Decimal('5.05'):
if Decimal('5.00') <= adjusted_price * quantity <= Decimal('5.15'):
adjusted_price = Decimal('4.99') / quantity
free_shipping_adjustment = True
break
@@ -137,19 +308,34 @@ class PricingService(BaseService):
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
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)
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
def set_price_for_unmanaged_inventory(self, db: Session, tcgplayer_sku_id: int, quantity: int) -> float:
pass
return pricing_event
else:
return adjusted_price
# BAD BAD BAD FIX PLS TODO

View File

@@ -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 = {
@@ -79,8 +80,61 @@ class PullSheetService(BaseService):
except Exception as e:
logger.error(f"Error generating pull sheet PDF: {str(e)}")
raise
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, csv_path: str) -> List[Dict]:
async def _read_and_process_csv(self, db: Session, csv_path: str) -> List[Dict]:
"""Read and process CSV data using pandas.
Args:
@@ -103,6 +157,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 = []
@@ -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

View File

@@ -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,20 +37,22 @@ class BaseScheduler:
trigger = CronTrigger.from_crontab(cron_expression)
job = self.scheduler.add_job(
func,
func=func,
trigger=trigger,
args=args,
kwargs=kwargs,
id=task_name,
replace_existing=True
)
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:

View File

@@ -4,6 +4,7 @@ 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):
@@ -67,29 +68,37 @@ class SchedulerService(BaseService):
db.flush()
await tcgplayer_inventory_service.refresh_tcgplayer_inventory_table(db)
db.flush()
await tcgplayer_inventory_service.refresh_unmanaged_tcgplayer_inventory_table(db)
async def start_scheduled_tasks(self, db):
"""Start all scheduled tasks"""
# Schedule open orders update to run hourly at 00 minutes
await self.scheduler.schedule_task(
task_name="update_open_orders_hourly",
func=lambda: self.update_open_orders_hourly(db),
cron_expression="0 * * * *" # Run at minute 0 of every hour
func=self.update_open_orders_hourly,
cron_expression="*/10 * * * *", # Run at minute 10 of every hour
db=db
)
# Schedule all orders update to run daily at 3 AM
await self.scheduler.schedule_task(
task_name="update_all_orders_daily",
func=lambda: self.update_all_orders_daily(db),
cron_expression="0 3 * * *" # Run at 3:00 AM every day
func=self.update_all_orders_daily,
cron_expression="0 3 * * *", # Run at 3:00 AM every day
db=db
)
# Schedule TCGPlayer inventory refresh to run every 3 hours
await self.scheduler.schedule_task(
task_name="refresh_tcgplayer_inventory_table",
func=lambda: self.refresh_tcgplayer_inventory_table(db),
cron_expression="21 */3 * * *" # Run at minute 0 of every 3rd hour
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")

View File

@@ -34,7 +34,10 @@ class ServiceManager:
'inventory': 'app.services.inventory_service.InventoryService',
'box': 'app.services.inventory_service.BoxService',
'case': 'app.services.inventory_service.CaseService',
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService'
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService',
'scryfall': 'app.services.external_api.scryfall.scryfall_service.ScryfallService',
'tcgplayer_market_data': 'app.services.external_api.tcgplayer.tcgplayer_market_data_service.TCGPlayerMarketDataService',
'inventory_label': 'app.services.inventory_label_service.InventoryLabelService'
}
self._service_configs = {

View File

@@ -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__)

View File

@@ -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,8 +100,23 @@ 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>
`;

View File

@@ -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>

View 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>

View 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
View 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
View 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);
}
});
});
});

View File

@@ -105,4 +105,126 @@ button:hover {
select, button {
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;
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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