diff --git a/Dockerfile b/Dockerfile index 1036041..a9898b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,11 @@ WORKDIR /app ENV DATABASE_URL=postgresql://poggers:giga!@192.168.1.41:5432/omegatcgdb +RUN apt-get update && \ + apt-get install -y fonts-liberation && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/app/assets/images/ccrcardsaddress.png b/app/assets/images/ccrcardsaddress.png new file mode 100644 index 0000000..9e95460 Binary files /dev/null and b/app/assets/images/ccrcardsaddress.png differ diff --git a/app/assets/templates/address_label.html b/app/assets/templates/address_label.html new file mode 100644 index 0000000..477e52d --- /dev/null +++ b/app/assets/templates/address_label.html @@ -0,0 +1,87 @@ + + + + + + +
+ Return Address + +
+ PLACE
STAMP
HERE
+
+ +
+ {{ recipient_name }}
+ {{ address_line1 }}
+ {% if address_line2 %}{{ address_line2 }}
{% endif %} + {{ city }}, {{ state }} {{ zip_code }} +
+
+ + diff --git a/app/routes/routes.py b/app/routes/routes.py index 9d81ac9..902264f 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -317,18 +317,16 @@ async def update_cookies( detail=f"Failed to update cookies: {str(e)}" ) -### DEPRECATED ### class TCGPlayerOrderRequest(BaseModel): order_ids: List[str] -@router.post("/processOrders", response_model=ProcessOrdersResponse) +@router.get("/processOrders", response_model=dict) async def process_orders( - body: TCGPlayerOrderRequest, tcgplayer_api_service: TCGPlayerAPIService = Depends(get_tcgplayer_api_service), ) -> ProcessOrdersResponse: """Process TCGPlayer orders.""" try: - orders = tcgplayer_api_service.process_orders(body.order_ids) + orders = tcgplayer_api_service.process_open_orders() return ProcessOrdersResponse( status_code=200, success=True, @@ -337,5 +335,3 @@ async def process_orders( except Exception as e: logger.error(f"Process orders failed: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) - -### END DEPRECATED ### \ No newline at end of file diff --git a/app/services/pricing.py b/app/services/pricing.py index 454d328..52412a0 100644 --- a/app/services/pricing.py +++ b/app/services/pricing.py @@ -8,10 +8,14 @@ from app.db.utils import db_transaction from typing import List, Dict from decimal import Decimal, ROUND_HALF_UP import pandas as pd +import numpy as np import logging logger = logging.getLogger(__name__) +ACTIVE_PRICING_ALGORITHIM = 'tcgplayer_recommended_algo' # 'default_pricing_algo' or 'tcgplayer_recommended_algo' +FREE_SHIPPING = True + class PricingService: def __init__(self, db: Session, file_service: FileService, tcgplayer_service: TCGPlayerService): @@ -115,6 +119,98 @@ class PricingService: row[price_type] = price return row + def smooth_markup(price, markup_bands): + """ + Applies a smoothed markup based on the given price and markup bands. + Uses numpy for smooth transitions. + """ + # Convert markup bands to lists for easy lookup + markups = np.array(list(markup_bands.keys())) + min_prices = np.array([x[0] for x in markup_bands.values()]) + max_prices = np.array([x[1] for x in markup_bands.values()]) + + # Find the index of the price's range + idx = np.where((min_prices <= price) & (max_prices >= price))[0] + + if len(idx) > 0: + # If price is within a defined range, return the markup + markup = markups[idx[0]] + else: + # If price is not directly within any range, check smooth transitions + # Find the closest two bands for interpolation + idx_lower = np.argmax(max_prices <= price) # Closest range below the price + idx_upper = np.argmax(min_prices > price) # Closest range above the price + + if idx_lower != idx_upper: + # Linear interpolation between the two neighboring markups + price_diff = (price - max_prices[idx_lower]) / (min_prices[idx_upper] - max_prices[idx_lower]) + markup = np.interp(price_diff, [0, 1], [markups[idx_lower], markups[idx_upper]]) + + # Apply the markup to the price + return price * markup + + def tcgplayer_recommended_algo(self, row: pd.Series) -> pd.Series: + # Convert input values to Decimal for precise arithmetic + tcg_low = Decimal(str(row.get('tcg_low_price'))) if not pd.isna(row.get('tcg_low_price')) else None + tcg_low_shipping = Decimal(str(row.get('tcg_low_price_with_shipping'))) if not pd.isna(row.get('tcg_low_price_with_shipping')) else None + tcg_market_price = Decimal(str(row.get('tcg_market_price'))) if not pd.isna(row.get('tcg_market_price')) else None + current_price = Decimal(str(row.get('tcg_marketplace_price'))) if not pd.isna(row.get('tcgplayer_marketplace_price')) else None + total_quantity = str(row.get('total_quantity')) if not pd.isna(row.get('total_quantity')) else "0" + added_quantity = str(row.get('add_to_quantity')) if not pd.isna(row.get('add_to_quantity')) else "0" + quantity = int(total_quantity) + int(added_quantity) + + if tcg_market_price is None: + logger.warning(f"Missing pricing data for row: {row}") + row['new_price'] = None + return row + # Define precision for rounding + TWO_PLACES = Decimal('0.01') + + # Apply pricing rules + markup_bands = { + 2.53: (Decimal('0.01'), Decimal('0.50')), + 1.42: (Decimal('0.51'), Decimal('1.00')), + 1.29: (Decimal('1.01'), Decimal('3.00')), + 1.17: (Decimal('3.01'), Decimal('20.00')), + 1.07: (Decimal('20.01'), Decimal('35.00')), + 1.05: (Decimal('35.01'), Decimal('50.00')), + 1.03: (Decimal('50.01'), Decimal('100.00')), + 1.02: (Decimal('100.01'), Decimal('200.00')), + 1.01: (Decimal('200.01'), Decimal('1000.00')) + } + + if quantity > 3: + quantity_markup = Decimal('0.1') + for markup in markup_bands: + markup = markup + quantity_markup + quantity_markup = quantity_markup - Decimal('0.01') + + if FREE_SHIPPING: + free_shipping_markup = Decimal('0.05') + for markup in markup_bands: + markup = markup + free_shipping_markup + free_shipping_markup = free_shipping_markup - Decimal('0.005') + + # Apply the smoothed markup + new_price = self.smooth_markup(tcg_market_price, markup_bands) + + if tcg_low_shipping is not None and tcg_low_shipping < new_price: + new_price = tcg_low_shipping + + if new_price < Decimal('0.25'): + new_price = Decimal('0.25') + + if current_price / new_price > Decimal('0.25'): + logger.warning(f"Price drop too large for row: {row}") + new_price = current_price + + # Ensure exactly 2 decimal places + new_price = new_price.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + # Convert back to float or string as needed for your dataframe + row['new_price'] = float(new_price) + + return row + def default_pricing_algo(self, row: pd.Series) -> pd.Series: """Default pricing algorithm with complex pricing rules""" @@ -167,8 +263,13 @@ class PricingService: def apply_pricing_algo(self, row: pd.Series, pricing_algo: callable = None) -> pd.Series: """Modified to handle the pricing algorithm as an instance method""" - if pricing_algo is None: + if pricing_algo is None or ACTIVE_PRICING_ALGORITHIM == 'default_pricing_algo': pricing_algo = self.default_pricing_algo + elif ACTIVE_PRICING_ALGORITHIM == 'tcgplayer_recommended_algo': + pricing_algo = self.tcgplayer_recommended_algo + else: + pricing_algo = self.default_pricing_algo + return pricing_algo(row) def generate_tcgplayer_inventory_update_file_with_pricing(self, open_box_ids: List[str] = None) -> bytes: diff --git a/app/services/tcgplayer_api.py b/app/services/tcgplayer_api.py index 4878b76..43f9530 100644 --- a/app/services/tcgplayer_api.py +++ b/app/services/tcgplayer_api.py @@ -7,6 +7,8 @@ from app.db.utils import db_transaction from sqlalchemy.orm import Session from datetime import datetime from uuid import uuid4 as uuid +from jinja2 import Environment, FileSystemLoader +from weasyprint import HTML import json logger = logging.getLogger(__name__) @@ -27,6 +29,10 @@ class TCGPlayerAPIService: self.config = TCGPlayerAPIConfig() self.cookies = self.get_cookies() self.session = None + self.template_dir = "/app/app/assets/templates" + self.env = Environment(loader=FileSystemLoader(self.template_dir)) + self.address_label_template = self.env.get_template("address_label.html") + self.return_address_png = "/app/app/assets/images/ccrcardsaddress.png" def get_cookies(self) -> dict: if self.is_in_docker: @@ -228,15 +234,120 @@ class TCGPlayerAPIService: "sortingType": "byRelease", "format": "default", "timezoneOffset": -4, - "orderNumbers": [order_ids] + "orderNumbers": order_ids } response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload) if response: # get filename from response headers filename = response.headers.get('Content-Disposition').split('filename=')[1].strip('"') # save file to disk - with open(filename, 'wb') as f: + with open('/app/tmp' + filename, 'wb') as f: f.write(response.content) + + return filename + + def get_pull_sheet_for_orders(self, order_ids: list[str]): + url = f"{self.config.ORDER_BASE_URL}/pull-sheets/export{self.config.API_VERSION}" + payload = { + "orderNumbers": order_ids, + "timezoneOffset": -4, + } + response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload) + if response: + # get filename from response headers + filename = response.headers.get('Content-Disposition').split('filename=')[1].strip('"') + # save file to disk + with open('/app/tmp' + filename, 'wb') as f: + f.write(response.content) + + return filename + + def get_address_labels_pdf(self, order_ids: list[str]): + labels_html = [] + + for order_id in order_ids: + order = self.get_order(order_id) + + if order: + try: + # Extract relevant information from the order + order_info = { + "recipient_name": order['shippingAddress']['recipientName'], + "address_line1": order['shippingAddress']['addressOne'], + "address_line2": order['shippingAddress'].get('addressTwo', ''), + "city": order['shippingAddress']['city'], + "state": order['shippingAddress']['territory'], + "zip_code": order['shippingAddress']['postalCode'], + "return_address_path": self.return_address_png + } + + # Render the label HTML using the template + labels_html.append(self.address_label_template.render(order_info)) + except KeyError as e: + print(f"Missing field in order {order_id}: {e}") + continue + + if labels_html: + # Combine the rendered labels into one HTML string + full_html = "" + "\n".join(labels_html) + "" + + # Generate a unique output filename with a timestamp + output_filename = f'/app/tmp/address_labels_{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf' + + # Generate the PDF from the HTML string + HTML(string=full_html).write_pdf(output_filename) + + return output_filename + else: + print("No orders found or no valid labels generated.") + return None + + def process_open_orders(self): + # get all open orders + url = f"{self.config.ORDER_BASE_URL}/search{self.config.API_VERSION}" + """{"searchRange":"LastThreeMonths","filters":{"sellerKey":"e576ed4c","orderStatuses":["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"],"fulfillmentTypes":["Normal"]},"sortBy":[{"sortingType":"orderStatus","direction":"ascending"},{"sortingType":"orderDate","direction":"ascending"}],"from":0,"size":25}""" + payload = { + "searchRange": "LastThreeMonths", + "filters": { + "sellerKey": self.config.SELLER_KEY, + "orderStatuses": ["Processing", "ReadyToShip", "Received", "Pulling", "ReadyForPickup"], + "fulfillmentTypes": ["Normal"] + }, + "sortBy": [ + {"sortingType": "orderStatus", "direction": "ascending"}, + {"sortingType": "orderDate", "direction": "ascending"} + ], + "from": 0, + "size": 100 + } + response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload) + if response: + orders = response.json() + if orders and 'orders' in orders: + order_ids = [order['orderNumber'] for order in orders['orders']] + # get packing slip pdf + packing_slip_filename = self.get_packing_slip_pdf_for_orders(order_ids) + # get pull sheet pdf + pull_sheet_filename = self.get_pull_sheet_for_orders(order_ids) + # get address labels pdf + address_labels_filename = self.get_address_labels_pdf(order_ids) + with open(packing_slip_filename, 'rb') as packing_slip_file, \ + open(pull_sheet_filename, 'rb') as pull_sheet_file, \ + open(address_labels_filename, 'rb') as address_labels_file: + files = [ + packing_slip_file, + # pull_sheet_file, + address_labels_file + ] + # request post pdfs + for file in files: + self.requests_util.bare_request( + url="tcgportal.local:8000/upload", + method='POST', + files=file + ) + + diff --git a/requirements.txt b/requirements.txt index dec4795..4629454 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,23 +2,32 @@ alembic==1.14.1 annotated-types==0.7.0 anyio==4.8.0 APScheduler==3.11.0 +attrs==25.3.0 +brother_ql_next==0.11.3 browser-cookie3==0.20.1 certifi==2025.1.31 charset-normalizer==3.4.1 click==8.1.8 coverage==7.6.10 fastapi==0.115.8 +future==1.0.0 +greenlet==3.1.1 h11==0.14.0 httpcore==1.0.7 httpx==0.28.1 idna==3.10 iniconfig==2.0.0 +jeepney==0.9.0 +jsons==1.6.3 lz4==4.4.3 Mako==1.3.9 MarkupSafe==3.0.2 numpy==2.2.2 packaging==24.2 +packbits==0.6 pandas==2.2.3 +pdf2image==1.17.0 +pillow==11.1.0 pluggy==1.5.0 psycopg2-binary==2.9.10 pycryptodomex==3.21.0 @@ -30,12 +39,14 @@ pytest-cov==6.0.0 python-dateutil==2.9.0.post0 python-multipart==0.0.20 pytz==2025.1 +pyusb==1.3.1 requests==2.32.3 six==1.17.0 sniffio==1.3.1 SQLAlchemy==2.0.37 starlette==0.45.3 typing_extensions==4.12.2 +typish==1.9.3 tzdata==2025.1 tzlocal==5.2 urllib3==2.3.0