From 9021151b74aedc5f0f3154e8c78485dd26acce71 Mon Sep 17 00:00:00 2001 From: zman Date: Mon, 7 Apr 2025 16:08:18 -0400 Subject: [PATCH] addresses and pricing --- Dockerfile | 5 ++ app/assets/images/ccrcardsaddress.png | Bin 0 -> 28607 bytes app/assets/templates/address_label.html | 87 ++++++++++++++++++ app/routes/routes.py | 8 +- app/services/pricing.py | 103 ++++++++++++++++++++- app/services/tcgplayer_api.py | 115 +++++++++++++++++++++++- requirements.txt | 11 +++ 7 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 app/assets/images/ccrcardsaddress.png create mode 100644 app/assets/templates/address_label.html 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 0000000000000000000000000000000000000000..9e95460a600e0dce19c9a577bd08da17f8535d3c GIT binary patch literal 28607 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 + + + + + +
+ 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