From 5c85411c69fd5caa349c50c358a465986ad7d7a6 Mon Sep 17 00:00:00 2001 From: zman Date: Mon, 5 May 2025 14:05:12 -0400 Subject: [PATCH] we are so back --- app/data/test_data/dragon.csv | 94 ++ app/main.py | 44 +- app/models/inventory_management.py | 12 +- app/routes/inventory_management_routes.py | 327 ++++++- app/routes/manabox_routes.py | 5 +- app/routes/order_routes.py | 1 + app/schemas/inventory.py | 0 app/schemas/transaction.py | 70 +- app/services/data_initialization.py | 4 +- app/services/file_service.py | 2 +- app/services/inventory_service.py | 182 +++- app/services/manabox_service.py | 26 +- app/services/pricing_service.py | 31 +- app/services/set_label_service.py | 2 +- app/static/index.html | 16 + app/static/manabox.html | 86 ++ app/static/manabox.js | 170 ++++ app/static/styles.css | 122 +++ app/static/transactions.html | 179 ++++ app/static/transactions.js | 1077 +++++++++++++++++++++ 20 files changed, 2417 insertions(+), 33 deletions(-) create mode 100644 app/data/test_data/dragon.csv create mode 100644 app/schemas/inventory.py create mode 100644 app/static/manabox.html create mode 100644 app/static/manabox.js create mode 100644 app/static/transactions.html create mode 100644 app/static/transactions.js diff --git a/app/data/test_data/dragon.csv b/app/data/test_data/dragon.csv new file mode 100644 index 0000000..703026e --- /dev/null +++ b/app/data/test_data/dragon.csv @@ -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 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 74a9ea1..9d33722 100644 --- a/app/main.py +++ b/app/main.py @@ -66,8 +66,8 @@ async def lifespan(app: FastAPI): 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) @@ -115,6 +115,46 @@ async def read_app_js(): raise HTTPException(status_code=404, detail="App.js file not found") return FileResponse(js_path) +# Serve manabox.html +@app.get("/manabox.html") +async def read_manabox_html(): + html_path = Path('app/static/manabox.html') + if not html_path.exists(): + raise HTTPException(status_code=404, detail="Manabox.html file not found") + return FileResponse(html_path) + +# Serve manabox.js +@app.get("/manabox.js") +async def read_manabox_js(): + js_path = Path('app/static/manabox.js') + if not js_path.exists(): + raise HTTPException(status_code=404, detail="Manabox.js file not found") + return FileResponse(js_path) + +# serve transactions.html +@app.get("/transactions.html") +async def read_transactions_html(): + html_path = Path('app/static/transactions.html') + if not html_path.exists(): + raise HTTPException(status_code=404, detail="Transaction.html file not found") + return FileResponse(html_path) + +# serve transactions.js +@app.get("/transactions.js") +async def read_transactions_js(): + js_path = Path('app/static/transactions.js') + if not js_path.exists(): + raise HTTPException(status_code=404, detail="Transaction.js file not found") + return FileResponse(js_path) + +# serve styles.css +@app.get("/styles.css") +async def read_styles_css(): + css_path = Path('app/static/styles.css') + if not css_path.exists(): + raise HTTPException(status_code=404, detail="Styles.css file not found") + return FileResponse(css_path) + # Configure CORS with specific origins in production app.add_middleware( CORSMiddleware, diff --git a/app/models/inventory_management.py b/app/models/inventory_management.py index 2f73287..1c3d817 100644 --- a/app/models/inventory_management.py +++ b/app/models/inventory_management.py @@ -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" diff --git a/app/routes/inventory_management_routes.py b/app/routes/inventory_management_routes.py index 14cc25f..6e3a762 100644 --- a/app/routes/inventory_management_routes.py +++ b/app/routes/inventory_management_routes.py @@ -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,313 @@ async def create_marketplace( ): inventory_service = service_manager.get_service("inventory") marketplace = await inventory_service.create_marketplace(db, marketplace_name) - return marketplace \ No newline at end of file + 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)).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)) \ No newline at end of file diff --git a/app/routes/manabox_routes.py b/app/routes/manabox_routes.py index 5f2e604..4f53df9 100644 --- a/app/routes/manabox_routes.py +++ b/app/routes/manabox_routes.py @@ -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") diff --git a/app/routes/order_routes.py b/app/routes/order_routes.py index c116354..1507f85 100644 --- a/app/routes/order_routes.py +++ b/app/routes/order_routes.py @@ -220,6 +220,7 @@ async def print_pirate_ship_label( except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to print Pirate Ship label: {str(e)}") +# what even is this TODO delete @router.post("/process-manabox-csv") async def process_manabox_csv( file: UploadFile = File(...), diff --git a/app/schemas/inventory.py b/app/schemas/inventory.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/transaction.py b/app/schemas/transaction.py index 3cd0ffa..6e2fd0c 100644 --- a/app/schemas/transaction.py +++ b/app/schemas/transaction.py @@ -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] \ No newline at end of file + deleted_at: Optional[datetime] = None + transaction_items: List[TransactionItemResponse] + + +class GetAllTransactionsResponse(BaseModel): + total: int + transactions: List[TransactionResponse] + +class SealedExpectedValueResponse(BaseModel): + id: int + tcgplayer_product_id: int + expected_value: float + +class SealedExpectedValueCreate(BaseModel): + tcgplayer_product_id: int + expected_value: float + +class TCGPlayerProductResponse(BaseModel): + id: int + tcgplayer_product_id: int + name: str + image_url: str + category_id: int + group_id: int + url: str + market_price: float + category_name: str + group_name: str + +class InventoryItemResponse(BaseModel): + id: int + physical_item_id: int + cost_basis: float + item_type: str + listed_price: Optional[float] = None + marketplace_listing_id: Optional[int] = None + recommended_price: Optional[float] = None + parent_id: Optional[int] = None + created_at: datetime + updated_at: datetime + product: Optional[TCGPlayerProductResponse] = None + +class OpenEventResponse(BaseModel): + id: int + source_item_id: int + created_at: datetime + updated_at: datetime + +class OpenEventCreate(BaseModel): + inventory_item_id: int + manabox_file_upload_ids: List[int] + +class OpenEventResultingItemsResponse(BaseModel): + id: int + source_item_id: int + created_at: datetime + updated_at: datetime + resulting_items: List[InventoryItemResponse] + +class OpenEventsForInventoryItemResponse(BaseModel): + open_events: List[OpenEventResponse] diff --git a/app/services/data_initialization.py b/app/services/data_initialization.py index 24f7f5b..03bc04b 100644 --- a/app/services/data_initialization.py +++ b/app/services/data_initialization.py @@ -772,7 +772,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 +784,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 diff --git a/app/services/file_service.py b/app/services/file_service.py index 123d529..3ebe2a4 100644 --- a/app/services/file_service.py +++ b/app/services/file_service.py @@ -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).order_by(File.created_at.desc()) files = query.offset(skip).limit(limit).all() return [FileInDB.model_validate(file) for file in files] diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py index a6efa45..2e7284e 100644 --- a/app/services/inventory_service.py +++ b/app/services/inventory_service.py @@ -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,58 @@ 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)).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).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).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).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)\ + .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)\ + .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 +106,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 +115,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 +123,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 +185,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 +212,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): @@ -329,3 +398,106 @@ class MarketplaceListingService(BaseService[MarketplaceListing]): 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 diff --git a/app/services/manabox_service.py b/app/services/manabox_service.py index df026b1..690c284 100644 --- a/app/services/manabox_service.py +++ b/app/services/manabox_service.py @@ -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) diff --git a/app/services/pricing_service.py b/app/services/pricing_service.py index 1399305..e16eace 100644 --- a/app/services/pricing_service.py +++ b/app/services/pricing_service.py @@ -1,11 +1,14 @@ import logging from sqlalchemy.orm import Session from app.services.base_service import BaseService -from app.models.inventory_management import InventoryItem +from app.models.inventory_management import InventoryItem, MarketplaceListing from app.models.tcgplayer_inventory import TCGPlayerInventory from app.models.pricing import PricingEvent from app.db.database import transaction from decimal import Decimal +from datetime import datetime + + logger = logging.getLogger(__name__) @@ -148,8 +151,32 @@ class PricingService(BaseService): free_shipping_adjustment=free_shipping_adjustment ) db.add(pricing_event) + + # delete previous pricing events for inventory item + if inventory_item.marketplace_listing and inventory_item.marketplace_listing.listed_price: + inventory_item.marketplace_listing.listed_price.deleted_at = datetime.now() return pricing_event def set_price_for_unmanaged_inventory(self, db: Session, tcgplayer_sku_id: int, quantity: int) -> float: - pass \ No newline at end of file + pass + + def update_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> list[PricingEvent]: + # get inventory items for sku + updated_prices = [] + inventory_items = db.query(InventoryItem).filter( + InventoryItem.physical_item.tcgplayer_sku_id == tcgplayer_sku_id + ).all() + for inventory_item in inventory_items: + pricing_event = self.set_price(db, inventory_item) + updated_prices.append(pricing_event) + return updated_prices + + def set_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> float: + # update price for all inventory items for sku + prices = self.update_price_for_product(db, tcgplayer_sku_id) + sum_prices = sum(price.price for price in prices) + average_price = sum_prices / len(prices) + return average_price + + \ No newline at end of file diff --git a/app/services/set_label_service.py b/app/services/set_label_service.py index 5814026..b8b803f 100644 --- a/app/services/set_label_service.py +++ b/app/services/set_label_service.py @@ -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__) diff --git a/app/static/index.html b/app/static/index.html index 099fd4b..3c7a746 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -20,6 +20,22 @@ + + +

TCGPlayer Order Management

diff --git a/app/static/manabox.html b/app/static/manabox.html new file mode 100644 index 0000000..c26a307 --- /dev/null +++ b/app/static/manabox.html @@ -0,0 +1,86 @@ + + + + + + Manabox Inventory Management + + + + + +
+
+

Manabox Inventory Management

+

Upload and manage your Manabox inventory

+
+ + +
+

Upload Manabox CSV

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+

Recent Uploads

+
+ +
+
+
+ + + + + + + + + + + + + +
+ + SourceDescriptionUpload DateStatus
+
+
+
+ + + \ No newline at end of file diff --git a/app/static/manabox.js b/app/static/manabox.js new file mode 100644 index 0000000..1b8a22e --- /dev/null +++ b/app/static/manabox.js @@ -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 = 'No uploads found'; + return; + } + + uploads.forEach(upload => { + const row = document.createElement('tr'); + row.className = 'hover:bg-gray-700'; + row.dataset.uploadId = upload.id; + row.innerHTML = ` + + + + ${upload.name || 'N/A'} + ${upload.file_metadata?.description || 'N/A'} + ${formatDate(upload.created_at)} + + Processed + + `; + 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); + } + }); + }); +}); \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index 1145fe9..96aa58c 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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; } \ No newline at end of file diff --git a/app/static/transactions.html b/app/static/transactions.html new file mode 100644 index 0000000..94ac6fc --- /dev/null +++ b/app/static/transactions.html @@ -0,0 +1,179 @@ + + + + + + Transactions - AI Giga TCG + + + + + +
+
+

Transactions

+

Manage your transactions

+
+ + +
+ +
+ + +
+
+

Recent Transactions

+
+
+ + +
+
+
+
+ + + + + + + + + + + + + +
DateTypeVendor/CustomerTotalNotes
+
+ +
+ + Page 1 + +
+
+ + + + + + +
+ + + \ No newline at end of file diff --git a/app/static/transactions.js b/app/static/transactions.js new file mode 100644 index 0000000..db11f22 --- /dev/null +++ b/app/static/transactions.js @@ -0,0 +1,1077 @@ +document.addEventListener('DOMContentLoaded', function() { + // Event Listeners + document.getElementById('createTransactionBtn').addEventListener('click', () => { + showTransactionModal(); + }); + + document.getElementById('addVendorBtn').addEventListener('click', () => { + const vendorName = prompt('Enter vendor name:'); + if (vendorName) { + createVendor(vendorName); + } + }); + + document.getElementById('addMarketplaceBtn').addEventListener('click', () => { + const marketplaceName = prompt('Enter marketplace name:'); + if (marketplaceName) { + createMarketplace(marketplaceName); + } + }); + + document.getElementById('addItemBtn').addEventListener('click', addItem); + + document.getElementById('transactionType').addEventListener('change', (e) => { + const marketplaceSection = document.getElementById('marketplaceSection'); + if (e.target.value === 'sale') { + marketplaceSection.classList.remove('hidden'); + } else { + marketplaceSection.classList.add('hidden'); + } + }); + + document.getElementById('saveTransactionBtn').addEventListener('click', saveTransaction); + + // Load initial data + loadVendors(); + loadMarketplaces(); + loadTransactions(); + addItem(); // Add first item by default +}); + +// Modal Functions +function showTransactionModal() { + document.getElementById('createTransactionModal').classList.remove('hidden'); +} + +function closeTransactionModal() { + document.getElementById('createTransactionModal').classList.add('hidden'); +} + +// Item Management Functions +function addItem() { + const itemsContainer = document.getElementById('itemsContainer'); + const itemIndex = itemsContainer.children.length; + + const itemDiv = document.createElement('div'); + itemDiv.className = 'item border border-gray-700 rounded-lg p-4'; + itemDiv.innerHTML = ` +
+
Item ${itemIndex + 1}
+ ${itemIndex > 0 ? '' : ''} +
+
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + itemsContainer.appendChild(itemDiv); + + // Add event listeners for the new item + const productSearch = itemDiv.querySelector('.product-search'); + const setEvBtn = itemDiv.querySelector('.set-ev-btn'); + const suggestionsDiv = itemDiv.querySelector('.product-suggestions'); + + // Product search functionality + let searchTimeout; + productSearch.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + const searchTerm = e.target.value; + + if (searchTerm.length < 2) { + suggestionsDiv.classList.add('hidden'); + return; + } + + searchTimeout = setTimeout(async () => { + try { + const response = await fetch(`/api/inventory/products/search?q=${encodeURIComponent(searchTerm)}`); + const products = await response.json(); + + suggestionsDiv.innerHTML = products.map(product => ` +
+ ${product.name} +
+ `).join(''); + + suggestionsDiv.classList.remove('hidden'); + } catch (error) { + console.error('Error searching products:', error); + } + }, 300); + }); + + // Handle suggestion selection + suggestionsDiv.addEventListener('click', async (e) => { + const selectedProduct = e.target.closest('[data-tcgplayer_product_id]'); + if (selectedProduct) { + const productId = selectedProduct.dataset.tcgplayer_product_id; + productSearch.value = selectedProduct.textContent.trim(); + productSearch.dataset.tcgplayer_product_id = productId; + suggestionsDiv.classList.add('hidden'); + + // Check expected value + try { + const response = await fetch(`/api/inventory/products/${productId}/expected-value`); + const expectedValue = await response.json(); + + if (expectedValue === null || expectedValue === undefined || isNaN(expectedValue)) { + setEvBtn.classList.remove('hidden'); + } else { + setEvBtn.classList.add('hidden'); + } + } catch (error) { + console.error('Error checking expected value:', error); + setEvBtn.classList.remove('hidden'); + } + } + }); + + // Handle expected value setting + setEvBtn.addEventListener('click', async () => { + const expectedValue = prompt('Enter expected value for this product:'); + if (expectedValue && !isNaN(expectedValue)) { + try { + const response = await fetch('/api/inventory/products/expected-value', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tcgplayer_product_id: parseInt(productSearch.dataset.tcgplayer_product_id), + expected_value: parseFloat(expectedValue) + }) + }); + + if (response.ok) { + setEvBtn.classList.add('hidden'); + } else { + const error = await response.json(); + alert(`Failed to set expected value: ${error.detail}`); + } + } catch (error) { + console.error('Error setting expected value:', error); + alert('Failed to set expected value. Please try again.'); + } + } + }); + + // Handle item removal + const removeBtn = itemDiv.querySelector('.remove-item-btn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + itemDiv.remove(); + }); + } +} + +// API Functions +async function loadVendors() { + try { + const response = await fetch('/api/inventory/vendors'); + const vendors = await response.json(); + const vendorSelect = document.getElementById('vendorSelect'); + + // Clear existing options except the first one + while (vendorSelect.options.length > 1) { + vendorSelect.remove(1); + } + + // Add new options + vendors.forEach(vendor => { + const option = new Option(vendor.name, vendor.id); + vendorSelect.add(option); + }); + } catch (error) { + console.error('Error loading vendors:', error); + } +} + +async function loadMarketplaces() { + try { + const response = await fetch('/api/inventory/marketplaces'); + const marketplaces = await response.json(); + const marketplaceSelect = document.getElementById('marketplaceSelect'); + + // Clear existing options except the first one + while (marketplaceSelect.options.length > 1) { + marketplaceSelect.remove(1); + } + + // Add new options + marketplaces.forEach(marketplace => { + const option = new Option(marketplace.name, marketplace.id); + marketplaceSelect.add(option); + }); + } catch (error) { + console.error('Error loading marketplaces:', error); + } +} + +async function createVendor(name) { + try { + const response = await fetch('/api/inventory/vendors', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ vendor_name: name }) + }); + + if (response.ok) { + const newVendor = await response.json(); + // Add new vendor to select and select it + const vendorSelect = document.getElementById('vendorSelect'); + const option = new Option(newVendor.name, newVendor.id); + vendorSelect.add(option); + vendorSelect.value = newVendor.id; + } else { + throw new Error('Failed to create vendor'); + } + } catch (error) { + console.error('Error creating vendor:', error); + alert('Failed to create vendor. Please try again.'); + } +} + +async function createMarketplace(name) { + try { + const response = await fetch('/api/inventory/marketplaces', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ marketplace_name: name }) + }); + + if (response.ok) { + const newMarketplace = await response.json(); + // Add new marketplace to select and select it + const marketplaceSelect = document.getElementById('marketplaceSelect'); + const option = new Option(newMarketplace.name, newMarketplace.id); + marketplaceSelect.add(option); + marketplaceSelect.value = newMarketplace.id; + } else { + throw new Error('Failed to create marketplace'); + } + } catch (error) { + console.error('Error creating marketplace:', error); + alert('Failed to create marketplace. Please try again.'); + } +} + +// Transaction Management Functions +let currentPage = 1; +let currentLimit = 25; +let totalTransactions = 0; + +// Manabox Files Pagination +let currentManaboxPage = 1; +let currentManaboxLimit = 5; +let totalManaboxFiles = 0; + +// DOM Elements +const transactionsBody = document.getElementById('transactionsBody'); +const prevPageBtn = document.getElementById('prevPageBtn'); +const nextPageBtn = document.getElementById('nextPageBtn'); +const pageInfo = document.getElementById('pageInfo'); +const limitSelect = document.getElementById('limitSelect'); +const transactionDetailsModal = document.getElementById('transactionDetailsModal'); +const transactionDetails = document.getElementById('transactionDetails'); + +// Event Listeners +document.addEventListener('DOMContentLoaded', () => { + loadTransactions(); + + limitSelect.addEventListener('change', (e) => { + currentLimit = parseInt(e.target.value); + currentPage = 1; + loadTransactions(); + }); + + prevPageBtn.addEventListener('click', () => { + if (currentPage > 1) { + currentPage--; + loadTransactions(); + } + }); + + nextPageBtn.addEventListener('click', () => { + if (currentPage * currentLimit < totalTransactions) { + currentPage++; + loadTransactions(); + } + }); +}); + +// Load transactions with pagination +async function loadTransactions() { + try { + const skip = (currentPage - 1) * currentLimit; + const response = await fetch(`/api/inventory/transactions?skip=${skip}&limit=${currentLimit}`); + const data = await response.json(); + + totalTransactions = data.total; + renderTransactions(data.transactions); + updatePaginationControls(); + } catch (error) { + console.error('Error loading transactions:', error); + } +} + +// Render transactions in the table +function renderTransactions(transactions) { + transactionsBody.innerHTML = ''; + + transactions.forEach(transaction => { + const row = document.createElement('tr'); + row.className = 'hover:bg-gray-700 cursor-pointer'; + row.onclick = () => showTransactionDetails(transaction); + + row.innerHTML = ` + ${formatDate(transaction.transaction_date)} + ${transaction.transaction_type} + ${getPartyName(transaction)} + $${transaction.transaction_total_amount.toFixed(2)} + ${transaction.transaction_notes || ''} + `; + + transactionsBody.appendChild(row); + }); +} + +// Update pagination controls +function updatePaginationControls() { + const totalPages = Math.ceil(totalTransactions / currentLimit); + + prevPageBtn.disabled = currentPage === 1; + nextPageBtn.disabled = currentPage === totalPages; + + pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; +} + +// Show transaction details modal +async function showTransactionDetails(transaction) { + try { + const response = await fetch(`/api/inventory/transactions/${transaction.id}`); + const transactionData = await response.json(); + + transactionDetails.innerHTML = ` +
+
+

Date

+

${formatDate(transactionData.transaction_date)}

+
+
+

Type

+

${transactionData.transaction_type}

+
+
+

${transactionData.transaction_type === 'purchase' ? 'Vendor' : 'Customer'}

+

${getPartyName(transactionData)}

+
+
+

Total Amount

+

$${transactionData.transaction_total_amount.toFixed(2)}

+
+
+

Notes

+

${transactionData.transaction_notes || 'No notes'}

+
+
+ +
+

Items

+
+ + + + + + + + + ${transactionData.transaction_items.map(item => ` + + + + + `).join('')} + +
Item IDUnit Price
${item.inventory_item_id}$${item.unit_price.toFixed(2)}
+
+
+ `; + + transactionDetailsModal.classList.remove('hidden'); + } catch (error) { + console.error('Error loading transaction details:', error); + } +} + +// Close transaction details modal +function closeTransactionDetailsModal() { + transactionDetailsModal.classList.add('hidden'); +} + +// Show inventory item details +async function showInventoryItemDetails(inventoryItemId) { + try { + const response = await fetch(`/api/inventory/items/${inventoryItemId}`); + const itemData = await response.json(); + + // Check for open events first if it's not a card + let hasOpenEvents = false; + if (itemData.item_type !== 'card') { + const openEventsResponse = await fetch(`/api/inventory/items/${inventoryItemId}/open-events`); + const openEventsData = await openEventsResponse.json(); + hasOpenEvents = openEventsData.open_events.length > 0; + } + + // Create a new modal for inventory item details + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center'; + modal.innerHTML = ` +
+
+

Inventory Item Details

+ +
+
+
+
+

Item ID

+

${itemData.id}

+
+
+

Physical Item ID

+

${itemData.physical_item_id}

+
+
+

Cost Basis

+

$${itemData.cost_basis.toFixed(2)}

+
+
+

Parent ID

+

${itemData.parent_id || 'None'}

+
+
+

Created At

+

${formatDate(itemData.created_at)}

+
+
+

Updated At

+

${formatDate(itemData.updated_at)}

+
+ ${itemData.listed_price || itemData.recommended_price ? ` +
+

Pricing

+
+ ${itemData.listed_price ? ` +
+ Listed Price: + $${itemData.listed_price.toFixed(2)} +
+ ` : ''} + ${itemData.recommended_price ? ` +
+ Recommended Price: + $${itemData.recommended_price.toFixed(2)} +
+ ` : ''} +
+
+ ` : ''} +
+ +
+

Product Details

+
+
+

Product ID

+

${itemData.product.id}

+
+
+

TCGPlayer Product ID

+

${itemData.product.tcgplayer_product_id}

+
+
+

Name

+

${itemData.product.name}

+
+
+

Image

+ ${itemData.product.name} +
+
+

Category

+

${itemData.product.category_name}

+
+
+

Group

+

${itemData.product.group_name}

+
+
+

Market Price

+

$${itemData.product.market_price.toFixed(2)}

+
+
+

TCGPlayer URL

+ View on TCGPlayer +
+
+
+ + ${itemData.product.category_name === 'Magic' && itemData.item_type !== 'card' && !hasOpenEvents ? ` +
+

Box Actions

+ +
+ ` : ''} + + +
+
+ `; + + document.body.appendChild(modal); + + // Only load open events if not a card + if (itemData.item_type !== 'card') { + await loadOpenEvents(inventoryItemId); + } + } catch (error) { + console.error('Error loading inventory item details:', error); + } +} + +// Load open events for an inventory item +async function loadOpenEvents(inventoryItemId) { + try { + const response = await fetch(`/api/inventory/items/${inventoryItemId}/open-events`); + const data = await response.json(); + + const openEventsSection = document.getElementById('openEventsSection'); + const openEventsTable = document.getElementById('openEventsTable'); + if (!openEventsSection || !openEventsTable) return; + + if (data.open_events.length === 0) { + openEventsSection.classList.add('hidden'); + return; + } + + openEventsSection.classList.remove('hidden'); + openEventsTable.innerHTML = ` + + + + + + + + + + ${data.open_events.map(event => ` + + + + + + `).join('')} + +
DateSource Item IDAction
${formatDate(event.created_at)}${event.source_item_id} + +
+ `; + } catch (error) { + console.error('Error loading open events:', error); + } +} + +// Show resulting items from an open event +async function showOpenEventResultingItems(inventoryItemId, openEventId) { + try { + const response = await fetch(`/api/inventory/items/${inventoryItemId}/open-events/${openEventId}/resulting-items`); + const resultingItems = await response.json(); + + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center'; + modal.innerHTML = ` +
+
+

Resulting Items

+
+ + + +
+
+
+
+ + + + + + + + + + + ${resultingItems.map(item => ` + + + + + + + `).join('')} + +
Card NameSetMarket PriceAction
${item.product.name}${item.product.group_name}$${item.product.market_price.toFixed(2)} + +
+
+
+
+ `; + + document.body.appendChild(modal); + } catch (error) { + console.error('Error loading resulting items:', error); + } +} + +// Add new function to handle confirming listings +async function confirmListingsForOpenEvent(inventoryItemId, openEventId) { + try { + const response = await fetch(`/api/inventory/items/${inventoryItemId}/open-events/${openEventId}/confirm-listings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to confirm listings'); + } + + // Get the filename from the Content-Disposition header + const contentDisposition = response.headers.get('content-disposition'); + let filename = 'tcgplayer_listings.csv'; + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename=(.+)/); + if (filenameMatch) { + filename = filenameMatch[1]; + } + } + + // Create a blob from the response and download it + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + // Close the modal + const modal = document.querySelector('.fixed'); + if (modal) { + modal.remove(); + } + } catch (error) { + console.error('Error confirming listings:', error); + alert(error.message || 'Failed to confirm listings. Please try again.'); + } +} + +// Show open box modal +async function showOpenBoxModal(inventoryItemId, physicalItemId) { + try { + // Fetch Manabox files with pagination + const skip = (currentManaboxPage - 1) * currentManaboxLimit; + const response = await fetch(`/api/manabox/manabox-file-uploads?skip=${skip}&limit=${currentManaboxLimit}`); + const files = await response.json(); + + // Create modal for selecting Manabox file + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center'; + modal.innerHTML = ` +
+
+

Select Manabox Files

+ +
+
+
+ + + + + + + + + + + ${files.map(file => ` + + + + + + + `).join('')} + +
SelectSourceDescriptionUpload Date
+ + ${file.source}${file.description}${formatDate(file.created_at)}
+
+ +
+ + Page ${currentManaboxPage} + +
+
+ +
+
+
+ `; + + document.body.appendChild(modal); + } catch (error) { + console.error('Error loading Manabox files:', error); + } +} + +// Change Manabox page +async function changeManaboxPage(inventoryItemId, physicalItemId, newPage) { + if (newPage < 1) return; + currentManaboxPage = newPage; + const modal = document.querySelector('.fixed'); + if (modal) { + modal.remove(); + } + await showOpenBoxModal(inventoryItemId, physicalItemId); +} + +// Open box with selected Manabox files +async function openBoxWithFiles(inventoryItemId, physicalItemId) { + try { + // Get selected file IDs + const selectedFiles = Array.from(document.querySelectorAll('.manabox-file-checkbox:checked')) + .map(checkbox => parseInt(checkbox.value)); + + if (selectedFiles.length === 0) { + alert('Please select at least one Manabox file'); + return; + } + + const response = await fetch(`/api/inventory/items/${inventoryItemId}/open`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + inventory_item_id: inventoryItemId, + manabox_file_upload_ids: selectedFiles + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to open box'); + } + + const openEvent = await response.json(); + if (!openEvent) { + throw new Error('No data received from server'); + } + + showOpenEventDetails(openEvent); + + // Close the modal + const modal = document.querySelector('.fixed'); + if (modal) { + modal.remove(); + } + } catch (error) { + console.error('Error opening box:', error); + alert(error.message || 'Failed to open box. Please try again.'); + } +} + +// Show open event details +function showOpenEventDetails(openEvent) { + if (!openEvent) { + console.error('No open event data provided'); + return; + } + + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center'; + modal.innerHTML = ` +
+
+

Box Opening Results

+ +
+
+
+
+

Open Event ID

+

${openEvent.id || 'N/A'}

+
+
+

Date

+

${openEvent.created_at ? formatDate(openEvent.created_at) : 'N/A'}

+
+
+
+
+

Opened Items

+ ${openEvent.source_item_id ? ` + + ` : ''} +
+
+ + + + + + + + + + ${openEvent.items && openEvent.items.length > 0 ? + openEvent.items.map(item => ` + + + + + + `).join('') : + ` + + ` + } + +
Card NameSetCondition
${item.card_name || 'N/A'}${item.set_name || 'N/A'}${item.condition || 'N/A'}
No items found
+
+
+
+
+ `; + + document.body.appendChild(modal); +} + +// Add new function to handle creating listings +async function createListingsForOpenEvent(inventoryItemId, openEventId) { + try { + const response = await fetch(`/api/inventory/items/${inventoryItemId}/open-events/${openEventId}/create-listings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to create listings'); + } + + const result = await response.json(); + alert('Listings created successfully!'); + + // Close the modal + const modal = document.querySelector('.fixed'); + if (modal) { + modal.remove(); + } + } catch (error) { + console.error('Error creating listings:', error); + alert(error.message || 'Failed to create listings. Please try again.'); + } +} + +// Helper functions +function formatDate(dateString) { + return new Date(dateString).toLocaleString(); +} + +function getPartyName(transaction) { + if (transaction.transaction_type === 'purchase') { + return transaction.vendor?.name || 'Unknown Vendor'; + } else { + return transaction.customer?.name || 'Unknown Customer'; + } +} + +// Close modal when clicking outside +transactionDetailsModal.addEventListener('click', (e) => { + if (e.target === transactionDetailsModal) { + closeTransactionDetailsModal(); + } +}); + +async function saveTransaction() { + const form = document.getElementById('transactionForm'); + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + const transactionType = document.getElementById('transactionType').value; + + // Collect all items with their data + const items = Array.from(document.querySelectorAll('.item')).map(item => { + const productId = parseInt(item.querySelector('.product-search').dataset.tcgplayer_product_id); + const unitPrice = parseFloat(item.querySelector('.unit-cost').value); + const quantity = parseInt(item.querySelector('.quantity').value); + const itemType = item.querySelector('.item-type').value; + + if (isNaN(productId) || isNaN(unitPrice) || isNaN(quantity)) { + throw new Error('Invalid item data'); + } + + return { + product_id: productId, + unit_price: unitPrice, + quantity: quantity, + item_type: itemType + }; + }); + + if (items.length === 0) { + alert('Please add at least one item to the transaction'); + return; + } + + const transactionData = { + transaction_date: document.getElementById('transactionDate').value, + transaction_notes: document.getElementById('transactionNotes').value, + items: items + }; + + if (transactionType === 'purchase') { + const vendorId = parseInt(document.getElementById('vendorSelect').value); + if (isNaN(vendorId)) { + alert('Please select a vendor'); + return; + } + transactionData.vendor_id = vendorId; + } else { + const customerId = parseInt(document.getElementById('vendorSelect').value); + if (isNaN(customerId)) { + alert('Please select a customer'); + return; + } + transactionData.customer_id = customerId; + + const marketplaceId = document.getElementById('marketplaceSelect').value; + if (marketplaceId) { + transactionData.marketplace_id = parseInt(marketplaceId); + } + } + + try { + const response = await fetch(`/api/inventory/transactions/${transactionType}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(transactionData) + }); + + if (response.ok) { + closeTransactionModal(); + loadTransactions(); + } else { + const error = await response.json(); + alert(`Failed to save transaction: ${error.detail}`); + } + } catch (error) { + console.error('Error saving transaction:', error); + alert('Failed to save transaction. Please try again.'); + } +} \ No newline at end of file