addresses and pricing
All checks were successful
Deploy App to Docker / deploy (push) Successful in 1m30s

This commit is contained in:
zman 2025-04-07 16:08:18 -04:00
parent f59f0f350c
commit 9021151b74
7 changed files with 320 additions and 9 deletions

View File

@ -4,6 +4,11 @@ WORKDIR /app
ENV DATABASE_URL=postgresql://poggers:giga!@192.168.1.41:5432/omegatcgdb 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html>
<head>
<style>
/* Setting up the page size - 4x6 inches */
@page {
size: 4in 6in;
margin: 0;
}
/* Force page breaks after each label */
body {
margin: 0;
padding: 0;
width: 4in;
height: 6in;
position: relative;
font-family: Arial, sans-serif;
}
.label-container {
width: 100%;
height: 100%;
position: relative;
box-sizing: border-box;
padding: 0.25in;
page-break-after: always; /* Ensures a page break after each label */
}
/* Return address image in top left */
.return-address {
position: absolute;
top: 0.25in;
left: 0.25in;
width: 1.5in;
height: 0.46in;
}
/* Stamp area in top right */
.stamp-area {
position: absolute;
top: 0.25in;
right: 0.25in;
width: 1in;
height: 1in;
border: 1px dashed #999;
display: flex;
justify-content: center;
align-items: center;
}
.stamp-text {
font-size: 8pt;
color: #999;
text-align: center;
}
/* Main address centered in the middle */
.address {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 3in;
text-align: center;
font-size: 12pt;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="label-container">
<img src="{{ return_address_path }}" class="return-address" alt="Return Address">
<div class="stamp-area">
<span class="stamp-text">PLACE<br>STAMP<br>HERE</span>
</div>
<div class="address">
{{ recipient_name }}<br>
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif %}
{{ city }}, {{ state }} {{ zip_code }}
</div>
</div>
</body>
</html>

View File

@ -317,18 +317,16 @@ async def update_cookies(
detail=f"Failed to update cookies: {str(e)}" detail=f"Failed to update cookies: {str(e)}"
) )
### DEPRECATED ###
class TCGPlayerOrderRequest(BaseModel): class TCGPlayerOrderRequest(BaseModel):
order_ids: List[str] order_ids: List[str]
@router.post("/processOrders", response_model=ProcessOrdersResponse) @router.get("/processOrders", response_model=dict)
async def process_orders( async def process_orders(
body: TCGPlayerOrderRequest,
tcgplayer_api_service: TCGPlayerAPIService = Depends(get_tcgplayer_api_service), tcgplayer_api_service: TCGPlayerAPIService = Depends(get_tcgplayer_api_service),
) -> ProcessOrdersResponse: ) -> ProcessOrdersResponse:
"""Process TCGPlayer orders.""" """Process TCGPlayer orders."""
try: try:
orders = tcgplayer_api_service.process_orders(body.order_ids) orders = tcgplayer_api_service.process_open_orders()
return ProcessOrdersResponse( return ProcessOrdersResponse(
status_code=200, status_code=200,
success=True, success=True,
@ -337,5 +335,3 @@ async def process_orders(
except Exception as e: except Exception as e:
logger.error(f"Process orders failed: {str(e)}") logger.error(f"Process orders failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
### END DEPRECATED ###

View File

@ -8,10 +8,14 @@ from app.db.utils import db_transaction
from typing import List, Dict from typing import List, Dict
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
import pandas as pd import pandas as pd
import numpy as np
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACTIVE_PRICING_ALGORITHIM = 'tcgplayer_recommended_algo' # 'default_pricing_algo' or 'tcgplayer_recommended_algo'
FREE_SHIPPING = True
class PricingService: class PricingService:
def __init__(self, db: Session, file_service: FileService, tcgplayer_service: TCGPlayerService): def __init__(self, db: Session, file_service: FileService, tcgplayer_service: TCGPlayerService):
@ -115,6 +119,98 @@ class PricingService:
row[price_type] = price row[price_type] = price
return row 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: def default_pricing_algo(self, row: pd.Series) -> pd.Series:
"""Default pricing algorithm with complex pricing rules""" """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: def apply_pricing_algo(self, row: pd.Series, pricing_algo: callable = None) -> pd.Series:
"""Modified to handle the pricing algorithm as an instance method""" """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 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) return pricing_algo(row)
def generate_tcgplayer_inventory_update_file_with_pricing(self, open_box_ids: List[str] = None) -> bytes: def generate_tcgplayer_inventory_update_file_with_pricing(self, open_box_ids: List[str] = None) -> bytes:

View File

@ -7,6 +7,8 @@ from app.db.utils import db_transaction
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from uuid import uuid4 as uuid from uuid import uuid4 as uuid
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
import json import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,6 +29,10 @@ class TCGPlayerAPIService:
self.config = TCGPlayerAPIConfig() self.config = TCGPlayerAPIConfig()
self.cookies = self.get_cookies() self.cookies = self.get_cookies()
self.session = None 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: def get_cookies(self) -> dict:
if self.is_in_docker: if self.is_in_docker:
@ -228,15 +234,120 @@ class TCGPlayerAPIService:
"sortingType": "byRelease", "sortingType": "byRelease",
"format": "default", "format": "default",
"timezoneOffset": -4, "timezoneOffset": -4,
"orderNumbers": [order_ids] "orderNumbers": order_ids
} }
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload) response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
if response: if response:
# get filename from response headers # get filename from response headers
filename = response.headers.get('Content-Disposition').split('filename=')[1].strip('"') filename = response.headers.get('Content-Disposition').split('filename=')[1].strip('"')
# save file to disk # save file to disk
with open(filename, 'wb') as f: with open('/app/tmp' + filename, 'wb') as f:
f.write(response.content) 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 = "<html><body>" + "\n".join(labels_html) + "</body></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
)

View File

@ -2,23 +2,32 @@ alembic==1.14.1
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.8.0 anyio==4.8.0
APScheduler==3.11.0 APScheduler==3.11.0
attrs==25.3.0
brother_ql_next==0.11.3
browser-cookie3==0.20.1 browser-cookie3==0.20.1
certifi==2025.1.31 certifi==2025.1.31
charset-normalizer==3.4.1 charset-normalizer==3.4.1
click==8.1.8 click==8.1.8
coverage==7.6.10 coverage==7.6.10
fastapi==0.115.8 fastapi==0.115.8
future==1.0.0
greenlet==3.1.1
h11==0.14.0 h11==0.14.0
httpcore==1.0.7 httpcore==1.0.7
httpx==0.28.1 httpx==0.28.1
idna==3.10 idna==3.10
iniconfig==2.0.0 iniconfig==2.0.0
jeepney==0.9.0
jsons==1.6.3
lz4==4.4.3 lz4==4.4.3
Mako==1.3.9 Mako==1.3.9
MarkupSafe==3.0.2 MarkupSafe==3.0.2
numpy==2.2.2 numpy==2.2.2
packaging==24.2 packaging==24.2
packbits==0.6
pandas==2.2.3 pandas==2.2.3
pdf2image==1.17.0
pillow==11.1.0
pluggy==1.5.0 pluggy==1.5.0
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
pycryptodomex==3.21.0 pycryptodomex==3.21.0
@ -30,12 +39,14 @@ pytest-cov==6.0.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-multipart==0.0.20 python-multipart==0.0.20
pytz==2025.1 pytz==2025.1
pyusb==1.3.1
requests==2.32.3 requests==2.32.3
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.37 SQLAlchemy==2.0.37
starlette==0.45.3 starlette==0.45.3
typing_extensions==4.12.2 typing_extensions==4.12.2
typish==1.9.3
tzdata==2025.1 tzdata==2025.1
tzlocal==5.2 tzlocal==5.2
urllib3==2.3.0 urllib3==2.3.0