feat: add full Printful API v2 integration (mockups + fulfillment)
- PrintfulClient: catalog variants (cached 24h), mockup generation (async create+poll), order submission, variant ID resolution - Mockup endpoint tries Printful API first for Printful-provider designs, falls back to Pillow compositing for others (e.g. Prodigi stickers) - Order service routes items by provider from design metadata: provider=printful → Printful API, provider=prodigi → Prodigi API - Sandbox mode creates draft orders on Printful (not fulfilled) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
acfe6cc350
commit
54d064f3fa
|
|
@ -1,8 +1,10 @@
|
||||||
"""Designs API endpoints."""
|
"""Designs API endpoints."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
@ -11,6 +13,7 @@ from app.config import get_settings
|
||||||
from app.schemas.design import Design
|
from app.schemas.design import Design
|
||||||
from app.services.design_service import DesignService
|
from app.services.design_service import DesignService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
design_service = DesignService()
|
design_service = DesignService()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -31,6 +34,13 @@ MOCKUP_TEMPLATES = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Map mockup type → matching product types from metadata
|
||||||
|
_TYPE_MAP = {
|
||||||
|
"shirt": ("shirt", "tshirt", "tee", "hoodie"),
|
||||||
|
"sticker": ("sticker",),
|
||||||
|
"print": ("print",),
|
||||||
|
}
|
||||||
|
|
||||||
# Cache generated mockups in memory: (slug, product_type) → PNG bytes
|
# Cache generated mockups in memory: (slug, product_type) → PNG bytes
|
||||||
_mockup_cache: dict[tuple[str, str], bytes] = {}
|
_mockup_cache: dict[tuple[str, str], bytes] = {}
|
||||||
|
|
||||||
|
|
@ -66,7 +76,7 @@ async def get_design_image(slug: str):
|
||||||
image_path,
|
image_path,
|
||||||
media_type="image/png",
|
media_type="image/png",
|
||||||
headers={
|
headers={
|
||||||
"Cache-Control": "public, max-age=86400", # Cache for 24 hours
|
"Cache-Control": "public, max-age=86400",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,8 +85,9 @@ async def get_design_image(slug: str):
|
||||||
async def get_design_mockup(slug: str, type: str = "shirt"):
|
async def get_design_mockup(slug: str, type: str = "shirt"):
|
||||||
"""Serve the design composited onto a product mockup template.
|
"""Serve the design composited onto a product mockup template.
|
||||||
|
|
||||||
Composites the design image onto a product template (shirt, sticker, print)
|
For Printful-provider designs: fetches photorealistic mockup from
|
||||||
using Pillow. Result is cached in memory for fast subsequent requests.
|
Printful's mockup generator API (cached after first generation).
|
||||||
|
For other designs: composites with Pillow using local templates.
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
type: Product type — "shirt", "sticker", or "print" (default: shirt)
|
type: Product type — "shirt", "sticker", or "print" (default: shirt)
|
||||||
|
|
@ -89,20 +100,100 @@ async def get_design_mockup(slug: str, type: str = "shirt"):
|
||||||
headers={"Cache-Control": "public, max-age=86400"},
|
headers={"Cache-Control": "public, max-age=86400"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Load design to check provider
|
||||||
|
design = await design_service.get_design(slug)
|
||||||
|
if not design:
|
||||||
|
raise HTTPException(status_code=404, detail="Design not found")
|
||||||
|
|
||||||
|
# Find a Printful-provider product matching the requested mockup type
|
||||||
|
printful_product = None
|
||||||
|
accepted_types = _TYPE_MAP.get(type, (type,))
|
||||||
|
for p in design.products:
|
||||||
|
if p.provider == "printful" and p.type in accepted_types:
|
||||||
|
printful_product = p
|
||||||
|
break
|
||||||
|
|
||||||
|
# Try Printful mockup API for Printful-provider designs
|
||||||
|
if printful_product and settings.printful_api_token:
|
||||||
|
png_bytes = await _get_printful_mockup(slug, printful_product)
|
||||||
|
if png_bytes:
|
||||||
|
_mockup_cache[cache_key] = png_bytes
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(png_bytes),
|
||||||
|
media_type="image/png",
|
||||||
|
headers={"Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback: Pillow compositing with local templates
|
||||||
|
return await _pillow_mockup(slug, type)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_printful_mockup(slug: str, product) -> bytes | None:
|
||||||
|
"""Fetch mockup from Printful API. Returns PNG bytes or None."""
|
||||||
|
from app.pod.printful_client import PrintfulClient
|
||||||
|
|
||||||
|
printful = PrintfulClient()
|
||||||
|
if not printful.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
product_id = int(product.sku)
|
||||||
|
|
||||||
|
# Get first variant for mockup preview
|
||||||
|
variants = await printful.get_catalog_variants(product_id)
|
||||||
|
if not variants:
|
||||||
|
logger.warning(f"No Printful variants for product {product_id}")
|
||||||
|
return None
|
||||||
|
variant_ids = [variants[0]["id"]]
|
||||||
|
|
||||||
|
# Public image URL for Printful to download
|
||||||
|
image_url = f"https://fungiswag.jeffemmett.com/api/designs/{slug}/image"
|
||||||
|
|
||||||
|
# Generate mockup (blocks up to ~60s on first call)
|
||||||
|
mockups = await printful.generate_mockup_and_wait(
|
||||||
|
product_id=product_id,
|
||||||
|
variant_ids=variant_ids,
|
||||||
|
image_url=image_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not mockups:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find a mockup URL from the result
|
||||||
|
mockup_url = None
|
||||||
|
for m in mockups:
|
||||||
|
mockup_url = m.get("mockup_url") or m.get("url")
|
||||||
|
if mockup_url:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not mockup_url:
|
||||||
|
logger.warning(f"No mockup URL in Printful response for {slug}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Download the mockup image
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.get(mockup_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Printful mockup failed for {slug}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _pillow_mockup(slug: str, type: str) -> StreamingResponse:
|
||||||
|
"""Generate mockup using Pillow compositing with local templates."""
|
||||||
template_config = MOCKUP_TEMPLATES.get(type)
|
template_config = MOCKUP_TEMPLATES.get(type)
|
||||||
if not template_config:
|
if not template_config:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown product type: {type}")
|
raise HTTPException(status_code=400, detail=f"Unknown product type: {type}")
|
||||||
|
|
||||||
# Load design image
|
|
||||||
image_path = await design_service.get_design_image_path(slug)
|
image_path = await design_service.get_design_image_path(slug)
|
||||||
if not image_path or not Path(image_path).exists():
|
if not image_path or not Path(image_path).exists():
|
||||||
raise HTTPException(status_code=404, detail="Design image not found")
|
raise HTTPException(status_code=404, detail="Design image not found")
|
||||||
|
|
||||||
# Load template image from frontend/public/mockups/
|
# Load template from frontend/public/mockups/ or /app/mockups/ (Docker mount)
|
||||||
template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups"
|
template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups"
|
||||||
template_path = template_dir / template_config["template"]
|
template_path = template_dir / template_config["template"]
|
||||||
|
|
||||||
# Fallback: check if templates are mounted at /app/frontend/public/mockups/
|
|
||||||
if not template_path.exists():
|
if not template_path.exists():
|
||||||
template_path = Path("/app/mockups") / template_config["template"]
|
template_path = Path("/app/mockups") / template_config["template"]
|
||||||
if not template_path.exists():
|
if not template_path.exists():
|
||||||
|
|
@ -133,6 +224,7 @@ async def get_design_mockup(slug: str, type: str = "shirt"):
|
||||||
png_bytes = buf.getvalue()
|
png_bytes = buf.getvalue()
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
|
cache_key = (slug, type)
|
||||||
_mockup_cache[cache_key] = png_bytes
|
_mockup_cache[cache_key] = png_bytes
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
"""Printful Print-on-Demand API client (v2).
|
||||||
|
|
||||||
|
Handles catalog lookup, mockup generation, and order submission.
|
||||||
|
API v2 docs: https://developers.printful.com/docs/v2-beta/
|
||||||
|
Rate limit: 120 req/60s (leaky bucket), lower for mockups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
BASE_URL = "https://api.printful.com/v2"
|
||||||
|
|
||||||
|
# In-memory cache for catalog variants: {product_id: {"variants": [...], "ts": float}}
|
||||||
|
_variant_cache: dict[int, dict] = {}
|
||||||
|
_VARIANT_CACHE_TTL = 86400 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
class PrintfulClient:
|
||||||
|
"""Client for the Printful v2 API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_token = settings.printful_api_token
|
||||||
|
self.sandbox = settings.pod_sandbox_mode
|
||||||
|
self.enabled = bool(self.api_token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.api_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Catalog ──
|
||||||
|
|
||||||
|
async def get_catalog_variants(self, product_id: int) -> list[dict]:
|
||||||
|
"""Get variants for a catalog product (cached 24h).
|
||||||
|
|
||||||
|
Each variant has: id (int), size (str), color (str), color_code (str).
|
||||||
|
"""
|
||||||
|
cached = _variant_cache.get(product_id)
|
||||||
|
if cached and (time.time() - cached["ts"]) < _VARIANT_CACHE_TTL:
|
||||||
|
return cached["variants"]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{BASE_URL}/catalog-products/{product_id}/catalog-variants",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
variants = resp.json().get("data", [])
|
||||||
|
|
||||||
|
_variant_cache[product_id] = {"variants": variants, "ts": time.time()}
|
||||||
|
return variants
|
||||||
|
|
||||||
|
async def resolve_variant_id(
|
||||||
|
self,
|
||||||
|
product_id: int,
|
||||||
|
size: str,
|
||||||
|
color: str = "Black",
|
||||||
|
) -> int | None:
|
||||||
|
"""Resolve (product_id, size, color) → Printful catalog_variant_id.
|
||||||
|
|
||||||
|
Our metadata uses SKU "71" + variants ["S","M","L",...].
|
||||||
|
Printful orders require numeric catalog_variant_id.
|
||||||
|
"""
|
||||||
|
variants = await self.get_catalog_variants(product_id)
|
||||||
|
|
||||||
|
# Try exact match on size + color
|
||||||
|
for v in variants:
|
||||||
|
if (
|
||||||
|
v.get("size", "").upper() == size.upper()
|
||||||
|
and color.lower() in v.get("color", "").lower()
|
||||||
|
):
|
||||||
|
return v.get("id")
|
||||||
|
|
||||||
|
# Fallback: match size only
|
||||||
|
for v in variants:
|
||||||
|
if v.get("size", "").upper() == size.upper():
|
||||||
|
return v.get("id")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Mockup Generation ──
|
||||||
|
|
||||||
|
async def create_mockup_task(
|
||||||
|
self,
|
||||||
|
product_id: int,
|
||||||
|
variant_ids: list[int],
|
||||||
|
image_url: str,
|
||||||
|
placement: str = "front_large",
|
||||||
|
) -> str:
|
||||||
|
"""Start async mockup generation task.
|
||||||
|
|
||||||
|
Returns task_id to poll with get_mockup_task().
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"product_id": product_id,
|
||||||
|
"variant_ids": variant_ids,
|
||||||
|
"format": "png",
|
||||||
|
"placements": [
|
||||||
|
{
|
||||||
|
"placement": placement,
|
||||||
|
"image_url": image_url,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{BASE_URL}/mockup-tasks",
|
||||||
|
headers=self._headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json().get("data", {})
|
||||||
|
task_id = data.get("task_key") or data.get("id") or data.get("task_id")
|
||||||
|
logger.info(f"Printful mockup task created: {task_id}")
|
||||||
|
return str(task_id)
|
||||||
|
|
||||||
|
async def get_mockup_task(self, task_id: str) -> dict:
|
||||||
|
"""Poll mockup task status.
|
||||||
|
|
||||||
|
Returns dict with "status" (pending/completed/failed) and
|
||||||
|
"mockups" list when completed.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{BASE_URL}/mockup-tasks",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"task_key": task_id},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("data", {})
|
||||||
|
|
||||||
|
async def generate_mockup_and_wait(
|
||||||
|
self,
|
||||||
|
product_id: int,
|
||||||
|
variant_ids: list[int],
|
||||||
|
image_url: str,
|
||||||
|
placement: str = "front_large",
|
||||||
|
max_polls: int = 20,
|
||||||
|
poll_interval: float = 3.0,
|
||||||
|
) -> list[dict] | None:
|
||||||
|
"""Create mockup task and poll until complete.
|
||||||
|
|
||||||
|
Returns list of mockup dicts with "mockup_url" fields,
|
||||||
|
or None on failure/timeout.
|
||||||
|
"""
|
||||||
|
task_id = await self.create_mockup_task(
|
||||||
|
product_id, variant_ids, image_url, placement
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(max_polls):
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
result = await self.get_mockup_task(task_id)
|
||||||
|
status = result.get("status", "")
|
||||||
|
|
||||||
|
if status == "completed":
|
||||||
|
return (
|
||||||
|
result.get("mockups", [])
|
||||||
|
or result.get("catalog_variant_mockups", [])
|
||||||
|
)
|
||||||
|
elif status == "failed":
|
||||||
|
reasons = result.get("failure_reasons", [])
|
||||||
|
logger.error(f"Mockup task {task_id} failed: {reasons}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.warning(f"Mockup task {task_id} timed out after {max_polls} polls")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Orders ──
|
||||||
|
|
||||||
|
async def create_order(
|
||||||
|
self,
|
||||||
|
items: list[dict],
|
||||||
|
recipient: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a fulfillment order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of dicts with:
|
||||||
|
- catalog_variant_id (int)
|
||||||
|
- quantity (int)
|
||||||
|
- image_url (str) — public URL to design
|
||||||
|
- placement (str, default "front_large")
|
||||||
|
recipient: dict with name, address1, city, state_code,
|
||||||
|
country_code, zip, email (optional)
|
||||||
|
"""
|
||||||
|
if not self.enabled:
|
||||||
|
raise ValueError("Printful API token not configured")
|
||||||
|
|
||||||
|
order_items = []
|
||||||
|
for item in items:
|
||||||
|
order_items.append({
|
||||||
|
"source": "catalog",
|
||||||
|
"catalog_variant_id": item["catalog_variant_id"],
|
||||||
|
"quantity": item.get("quantity", 1),
|
||||||
|
"placements": [
|
||||||
|
{
|
||||||
|
"placement": item.get("placement", "front_large"),
|
||||||
|
"technique": "dtg",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"url": item["image_url"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"recipient": recipient,
|
||||||
|
"items": order_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sandbox mode: create as draft (not sent to production)
|
||||||
|
if self.sandbox:
|
||||||
|
payload["draft"] = True
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{BASE_URL}/orders",
|
||||||
|
headers=self._headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json().get("data", {})
|
||||||
|
logger.info(f"Printful order created: {result.get('id')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_order(self, order_id: str) -> dict:
|
||||||
|
"""Get order details by Printful order ID."""
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{BASE_URL}/orders/{order_id}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("data", {})
|
||||||
|
|
@ -14,7 +14,9 @@ from app.models.customer import Customer
|
||||||
from app.models.cart import Cart
|
from app.models.cart import Cart
|
||||||
from app.schemas.order import OrderResponse, OrderItemResponse
|
from app.schemas.order import OrderResponse, OrderItemResponse
|
||||||
from app.services.flow_service import FlowService
|
from app.services.flow_service import FlowService
|
||||||
|
from app.pod.printful_client import PrintfulClient
|
||||||
from app.pod.prodigi_client import ProdigiClient
|
from app.pod.prodigi_client import ProdigiClient
|
||||||
|
from app.services.design_service import DesignService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -181,38 +183,133 @@ class OrderService:
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
async def _submit_to_pod(self, order: Order):
|
async def _submit_to_pod(self, order: Order):
|
||||||
"""Submit order items to Prodigi for fulfillment.
|
"""Route order items to the correct POD provider for fulfillment.
|
||||||
|
|
||||||
Groups items by POD provider and submits orders.
|
Reads each item's design metadata to determine provider (printful/prodigi),
|
||||||
Design images are served via public URL for Prodigi to download.
|
groups items, and submits separate orders per provider.
|
||||||
"""
|
"""
|
||||||
prodigi = ProdigiClient()
|
|
||||||
if not prodigi.enabled:
|
|
||||||
logger.info("Prodigi not configured, skipping POD submission")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Need shipping address for POD — skip if not available
|
|
||||||
if not order.shipping_address_line1:
|
if not order.shipping_address_line1:
|
||||||
logger.info(f"Order {order.id} has no shipping address, skipping POD")
|
logger.info(f"Order {order.id} has no shipping address, skipping POD")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Collect Prodigi items from order
|
design_service = DesignService()
|
||||||
|
printful_items = []
|
||||||
prodigi_items = []
|
prodigi_items = []
|
||||||
|
|
||||||
for item in order.items:
|
for item in order.items:
|
||||||
# Build public image URL for Prodigi to download
|
|
||||||
# TODO: Use CDN URL in production; for now use the API endpoint
|
|
||||||
image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image"
|
image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image"
|
||||||
|
|
||||||
|
design = await design_service.get_design(item.product_slug)
|
||||||
|
provider = "prodigi" # default
|
||||||
|
product_sku = item.variant or item.product_slug
|
||||||
|
|
||||||
|
if design and design.products:
|
||||||
|
product_config = design.products[0]
|
||||||
|
provider = product_config.provider
|
||||||
|
product_sku = product_config.sku
|
||||||
|
|
||||||
|
if provider == "printful":
|
||||||
|
# Extract size from variant string (e.g. "71-M" → "M", or just "M")
|
||||||
|
size = item.variant or "M"
|
||||||
|
if "-" in size:
|
||||||
|
size = size.split("-", 1)[1]
|
||||||
|
|
||||||
|
printful_items.append({
|
||||||
|
"order_item": item,
|
||||||
|
"product_id": int(product_sku),
|
||||||
|
"size": size,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"image_url": image_url,
|
||||||
|
})
|
||||||
|
else:
|
||||||
prodigi_items.append({
|
prodigi_items.append({
|
||||||
|
"order_item": item,
|
||||||
"sku": item.variant or item.product_slug,
|
"sku": item.variant or item.product_slug,
|
||||||
"copies": item.quantity,
|
"quantity": item.quantity,
|
||||||
"sizing": "fillPrintArea",
|
"image_url": image_url,
|
||||||
"assets": [{"printArea": "default", "url": image_url}],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if not prodigi_items:
|
if printful_items:
|
||||||
|
await self._submit_to_printful(order, printful_items)
|
||||||
|
if prodigi_items:
|
||||||
|
await self._submit_to_prodigi(order, prodigi_items)
|
||||||
|
|
||||||
|
async def _submit_to_printful(self, order: Order, items: list[dict]):
|
||||||
|
"""Submit items to Printful for fulfillment."""
|
||||||
|
printful = PrintfulClient()
|
||||||
|
if not printful.enabled:
|
||||||
|
logger.info("Printful not configured, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
order_items = []
|
||||||
|
for item_data in items:
|
||||||
|
variant_id = await printful.resolve_variant_id(
|
||||||
|
product_id=item_data["product_id"],
|
||||||
|
size=item_data["size"],
|
||||||
|
)
|
||||||
|
if not variant_id:
|
||||||
|
logger.error(
|
||||||
|
f"Could not resolve Printful variant for product "
|
||||||
|
f"{item_data['product_id']} size {item_data['size']}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
order_items.append({
|
||||||
|
"catalog_variant_id": variant_id,
|
||||||
|
"quantity": item_data["quantity"],
|
||||||
|
"image_url": item_data["image_url"],
|
||||||
|
"placement": "front_large",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not order_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
recipient = {
|
||||||
|
"name": order.shipping_name or "",
|
||||||
|
"address1": order.shipping_address_line1 or "",
|
||||||
|
"address2": order.shipping_address_line2 or "",
|
||||||
|
"city": order.shipping_city or "",
|
||||||
|
"state_code": order.shipping_state or "",
|
||||||
|
"country_code": order.shipping_country or "",
|
||||||
|
"zip": order.shipping_postal_code or "",
|
||||||
|
"email": order.shipping_email or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await printful.create_order(
|
||||||
|
items=order_items,
|
||||||
|
recipient=recipient,
|
||||||
|
)
|
||||||
|
pod_order_id = str(result.get("id", ""))
|
||||||
|
|
||||||
|
for item_data in items:
|
||||||
|
item_data["order_item"].pod_provider = "printful"
|
||||||
|
item_data["order_item"].pod_order_id = pod_order_id
|
||||||
|
item_data["order_item"].pod_status = "submitted"
|
||||||
|
|
||||||
|
order.status = OrderStatus.PROCESSING.value
|
||||||
|
await self.db.commit()
|
||||||
|
logger.info(f"Submitted order {order.id} to Printful: {pod_order_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to submit order {order.id} to Printful: {e}")
|
||||||
|
|
||||||
|
async def _submit_to_prodigi(self, order: Order, items: list[dict]):
|
||||||
|
"""Submit items to Prodigi for fulfillment."""
|
||||||
|
prodigi = ProdigiClient()
|
||||||
|
if not prodigi.enabled:
|
||||||
|
logger.info("Prodigi not configured, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
prodigi_items = []
|
||||||
|
for item_data in items:
|
||||||
|
prodigi_items.append({
|
||||||
|
"sku": item_data["sku"],
|
||||||
|
"copies": item_data["quantity"],
|
||||||
|
"sizing": "fillPrintArea",
|
||||||
|
"assets": [{"printArea": "default", "url": item_data["image_url"]}],
|
||||||
|
})
|
||||||
|
|
||||||
recipient = {
|
recipient = {
|
||||||
"name": order.shipping_name or "",
|
"name": order.shipping_name or "",
|
||||||
"email": order.shipping_email or "",
|
"email": order.shipping_email or "",
|
||||||
|
|
@ -234,11 +331,10 @@ class OrderService:
|
||||||
)
|
)
|
||||||
pod_order_id = result.get("id")
|
pod_order_id = result.get("id")
|
||||||
|
|
||||||
# Update order items with Prodigi order ID
|
for item_data in items:
|
||||||
for item in order.items:
|
item_data["order_item"].pod_provider = "prodigi"
|
||||||
item.pod_provider = "prodigi"
|
item_data["order_item"].pod_order_id = pod_order_id
|
||||||
item.pod_order_id = pod_order_id
|
item_data["order_item"].pod_status = "submitted"
|
||||||
item.pod_status = "submitted"
|
|
||||||
|
|
||||||
order.status = OrderStatus.PROCESSING.value
|
order.status = OrderStatus.PROCESSING.value
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue