diff --git a/alembic/versions/420691c16f3c_asdf.py b/alembic/versions/420691c16f3c_asdf.py new file mode 100644 index 0000000..8db5cc3 --- /dev/null +++ b/alembic/versions/420691c16f3c_asdf.py @@ -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 ### diff --git a/app/main.py b/app/main.py index 935e7c0..ebc87d5 100644 --- a/app/main.py +++ b/app/main.py @@ -58,12 +58,12 @@ async def lifespan(app: FastAPI): # Get a database session db = SessionLocal() try: - #data_init_service = service_manager.get_service('data_initialization') - #data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=False, archived_prices_start_date="2025-06-07", archived_prices_end_date="2025-06-08") - #logger.info(f"Data initialization results: {data_init}") + data_init_service = service_manager.get_service('data_initialization') + data_init = await data_init_service.initialize_data(db, game_ids=[1,62], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=True, archived_prices_start_date="2025-07-28", archived_prices_end_date="2025-07-30") + logger.info(f"Data initialization results: {data_init}") # Update most recent prices - #MostRecentTCGPlayerPrice.update_most_recent_prices(db) - #logger.info("Most recent prices updated successfully") + 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) @@ -155,6 +155,22 @@ async def read_styles_css(): 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, diff --git a/app/models/inventory_management.py b/app/models/inventory_management.py index 1c3d817..4dec9d4 100644 --- a/app/models/inventory_management.py +++ b/app/models/inventory_management.py @@ -318,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]) \ No newline at end of file + 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") \ No newline at end of file diff --git a/app/routes/inventory_label_routes.py b/app/routes/inventory_label_routes.py new file mode 100644 index 0000000..a188f2e --- /dev/null +++ b/app/routes/inventory_label_routes.py @@ -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 \ No newline at end of file diff --git a/app/routes/routes.py b/app/routes/routes.py index c62f7c3..094c0d0 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -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 # ============================================================================ diff --git a/app/schemas/inventory_label.py b/app/schemas/inventory_label.py new file mode 100644 index 0000000..9ea7426 --- /dev/null +++ b/app/schemas/inventory_label.py @@ -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 \ No newline at end of file diff --git a/app/schemas/tcgplayer.py b/app/schemas/tcgplayer.py index 9e4c380..fa3c446 100644 --- a/app/schemas/tcgplayer.py +++ b/app/schemas/tcgplayer.py @@ -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,7 +74,7 @@ class TCGPlayerAPIOrderSummary(BaseModel): orderDate: datetime orderChannel: str orderStatus: str - buyerName: str + buyerName: Optional[str] = None shippingType: str itemQuantity: int productAmount: float diff --git a/app/schemas/transaction.py b/app/schemas/transaction.py index 6e2fd0c..cf85a4a 100644 --- a/app/schemas/transaction.py +++ b/app/schemas/transaction.py @@ -71,7 +71,7 @@ class TCGPlayerProductResponse(BaseModel): category_id: int group_id: int url: str - market_price: float + market_price: Optional[float] = None category_name: str group_name: str diff --git a/app/services/data_initialization.py b/app/services/data_initialization.py index 03bc04b..2ba5146 100644 --- a/app/services/data_initialization.py +++ b/app/services/data_initialization.py @@ -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( diff --git a/app/services/external_api/base_external_service.py b/app/services/external_api/base_external_service.py index 0b75e86..39fdeac 100644 --- a/app/services/external_api/base_external_service.py +++ b/app/services/external_api/base_external_service.py @@ -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 diff --git a/app/services/external_api/tcgcsv/tcgcsv_service.py b/app/services/external_api/tcgcsv/tcgcsv_service.py index 529b51b..d5580ae 100644 --- a/app/services/external_api/tcgcsv/tcgcsv_service.py +++ b/app/services/external_api/tcgcsv/tcgcsv_service.py @@ -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""" diff --git a/app/services/external_api/tcgplayer/base_tcgplayer_service.py b/app/services/external_api/tcgplayer/base_tcgplayer_service.py index 8a59e79..a887c40 100644 --- a/app/services/external_api/tcgplayer/base_tcgplayer_service.py +++ b/app/services/external_api/tcgplayer/base_tcgplayer_service.py @@ -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) diff --git a/app/services/external_api/tcgplayer/order_management_service.py b/app/services/external_api/tcgplayer/order_management_service.py index 8594dda..12a562c 100644 --- a/app/services/external_api/tcgplayer/order_management_service.py +++ b/app/services/external_api/tcgplayer/order_management_service.py @@ -407,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: diff --git a/app/services/external_api/tcgplayer/tcgplayer_market_data_service.py b/app/services/external_api/tcgplayer/tcgplayer_market_data_service.py new file mode 100644 index 0000000..f1afbd9 --- /dev/null +++ b/app/services/external_api/tcgplayer/tcgplayer_market_data_service.py @@ -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' + } diff --git a/app/services/inventory_label_service.py b/app/services/inventory_label_service.py new file mode 100644 index 0000000..b2d00c9 --- /dev/null +++ b/app/services/inventory_label_service.py @@ -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() \ No newline at end of file diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py index c129c7a..369218d 100644 --- a/app/services/inventory_service.py +++ b/app/services/inventory_service.py @@ -263,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() diff --git a/app/services/label_printer_service.py b/app/services/label_printer_service.py index 18ca827..568db0f 100644 --- a/app/services/label_printer_service.py +++ b/app/services/label_printer_service.py @@ -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: diff --git a/app/services/pricing_service.py b/app/services/pricing_service.py index aa91c9c..096a53c 100644 --- a/app/services/pricing_service.py +++ b/app/services/pricing_service.py @@ -183,7 +183,7 @@ class PricingService(BaseService): """ # Fetch base pricing data cost_basis = price_data.cost_basis - market_price = price_data.market_price + 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 diff --git a/app/services/pull_sheet_service.py b/app/services/pull_sheet_service.py index ab6664f..217268b 100644 --- a/app/services/pull_sheet_service.py +++ b/app/services/pull_sheet_service.py @@ -87,28 +87,38 @@ class PullSheetService(BaseService): 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 - number = str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else '' + # 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().mtgjson_uuid - scryfall_id = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == mtgjson_id).first().scryfall_id - # 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) + 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: @@ -166,7 +176,7 @@ class PullSheetService(BaseService): 'quantity': str(int(row['Quantity'])), # Convert to string for template 'set': row['Set'], 'rarity': row['Rarity'], - 'card_number': str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else '', + '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'] }) diff --git a/app/services/service_manager.py b/app/services/service_manager.py index 98ab249..6e9c8e4 100644 --- a/app/services/service_manager.py +++ b/app/services/service_manager.py @@ -35,7 +35,9 @@ class ServiceManager: 'box': 'app.services.inventory_service.BoxService', 'case': 'app.services.inventory_service.CaseService', 'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService', - 'scryfall': 'app.services.external_api.scryfall.scryfall_service.ScryfallService' + '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 = { diff --git a/app/static/index.html b/app/static/index.html index 3c7a746..26b9ba6 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -31,6 +31,7 @@ Orders Manabox Transactions + Inventory Labels diff --git a/app/static/inventory_labels.html b/app/static/inventory_labels.html new file mode 100644 index 0000000..ecc2302 --- /dev/null +++ b/app/static/inventory_labels.html @@ -0,0 +1,118 @@ + + + + + + Inventory Label Creator + + + + + + + + +
+
+

Inventory Label Creator

+

Create QR code labels for inventory items with optional UPC codes and metadata

+
+ + +
+

Create New Label

+ +
+ +
+ + +

Enter a valid UPC-A, UPC-E, or EAN-13 code

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

Recent Labels

+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/app/static/inventory_labels.js b/app/static/inventory_labels.js new file mode 100644 index 0000000..24c50bc --- /dev/null +++ b/app/static/inventory_labels.js @@ -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 = ` +
+ + +
+
+ + +
+ + `; + + 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 = '

No recent labels to display

'; +} + +// 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(); +}); \ No newline at end of file diff --git a/app/static/transactions.js b/app/static/transactions.js index 5656c3b..8b42e22 100644 --- a/app/static/transactions.js +++ b/app/static/transactions.js @@ -673,7 +673,7 @@ async function showOpenEventResultingItems(inventoryItemId, openEventId) { ${item.product.name} ${item.product.group_name} - $${item.product.market_price.toFixed(2)} + ${item.product.market_price !== null ? `$${item.product.market_price.toFixed(2)}` : 'N/A'}