Add PCM carrier-route postcard pick-and-send (#15)
CI / test (pull_request) Successful in 24s
CI / test (pull_request) Successful in 24s
## Summary Closes #15. - Let compose target mailing-list contacts or 1–2 USPS carrier routes, with household count and estimated cost shown before save. - Place selected routes through PCM list-count orders instead of importing occupant addresses into Contacts.
This commit is contained in:
1 parent
a867a57750
commit
f7e3c05c59
17 files changed
+1211
-28
No files matched your search
@@ -338,6 +338,210 @@ def get_order(order_id: int | str) -> dict[str, Any]:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def get_design(design_id: int | str) -> dict[str, Any]:
|
||||
data = pcm_request("GET", f"/design/{design_id}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
FIRST_CLASS_ONLY_SIZES = frozenset({"46", "46S"})
|
||||
MAX_SELECTED_ROUTES = 2
|
||||
DEFAULT_LIST_TYPE = "IRL"
|
||||
|
||||
|
||||
def mail_class_for_size(size: str | None) -> str:
|
||||
key = str(size or "").strip()
|
||||
if key in FIRST_CLASS_ONLY_SIZES:
|
||||
return "FirstClass"
|
||||
return "Standard"
|
||||
|
||||
|
||||
def _env_unit_price(mail_class: str) -> float | None:
|
||||
attr = (
|
||||
"PCM_POSTCARD_PRICE_FIRSTCLASS"
|
||||
if mail_class == "FirstClass"
|
||||
else "PCM_POSTCARD_PRICE_STANDARD"
|
||||
)
|
||||
raw = str(getattr(settings, attr, "") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if value >= 0 else None
|
||||
|
||||
|
||||
def pick_unit_price(
|
||||
pricing: list[dict] | None,
|
||||
*,
|
||||
mail_class: str,
|
||||
quantity: int,
|
||||
) -> tuple[float | None, str]:
|
||||
"""Return (dollars per piece, source). Source is pcm|fallback|missing."""
|
||||
rows: list[dict] = []
|
||||
for item in pricing or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("mailClass") or item.get("mail_class") or "") != mail_class:
|
||||
continue
|
||||
try:
|
||||
price = float(item.get("price"))
|
||||
break_qty = int(item.get("breakQty") or item.get("break_qty") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
rows.append({"price": price, "breakQty": break_qty})
|
||||
qty = max(int(quantity or 0), 0)
|
||||
if rows:
|
||||
eligible = [row for row in rows if qty >= row["breakQty"]]
|
||||
chosen = max(eligible, key=lambda row: row["breakQty"]) if eligible else min(
|
||||
rows, key=lambda row: row["breakQty"]
|
||||
)
|
||||
return chosen["price"], "pcm"
|
||||
fallback = _env_unit_price(mail_class)
|
||||
if fallback is not None:
|
||||
return fallback, "fallback"
|
||||
return None, "missing"
|
||||
|
||||
|
||||
def design_pricing(design_id: int | None) -> list[dict]:
|
||||
"""Load PCM per-piece pricing for a design (gallery payload preferred)."""
|
||||
if not design_id:
|
||||
return []
|
||||
try:
|
||||
design = get_design(design_id)
|
||||
except PcmApiError:
|
||||
design = {}
|
||||
pricing = design.get("pricing") if isinstance(design, dict) else None
|
||||
if isinstance(pricing, list) and pricing:
|
||||
return [row for row in pricing if isinstance(row, dict)]
|
||||
try:
|
||||
for item in list_designs(product_type="postcard"):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
did = item.get("designID") or item.get("design_id") or item.get("id")
|
||||
if str(did) != str(design_id):
|
||||
continue
|
||||
extra = item.get("pricing")
|
||||
if isinstance(extra, list):
|
||||
return [row for row in extra if isinstance(row, dict)]
|
||||
except PcmApiError:
|
||||
logger.exception("PCM list_designs failed while loading pricing")
|
||||
return []
|
||||
|
||||
|
||||
def extract_crrt_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Flatten ZipCRRT (or first) breakdown into {code, text, total} rows."""
|
||||
breakdowns = payload.get("breakdown") if isinstance(payload, dict) else None
|
||||
if not isinstance(breakdowns, list):
|
||||
return []
|
||||
preferred = None
|
||||
fallback = None
|
||||
for block in breakdowns:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
kind = str(block.get("breakdownType") or "").upper().replace("_", "")
|
||||
data = block.get("data") if isinstance(block.get("data"), list) else []
|
||||
rows = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = str(item.get("code") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
try:
|
||||
total = int(item.get("total") or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"text": str(item.get("text") or code).strip(),
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
continue
|
||||
if kind in {"ZIPCRRT", "CRRT", "CARRIERROUTE"}:
|
||||
preferred = rows
|
||||
break
|
||||
if fallback is None:
|
||||
fallback = rows
|
||||
return preferred or fallback or []
|
||||
|
||||
|
||||
def _list_type() -> str:
|
||||
return (getattr(settings, "PCM_LIST_TYPE", None) or DEFAULT_LIST_TYPE).strip() or DEFAULT_LIST_TYPE
|
||||
|
||||
|
||||
def list_count_zipcode(*, zip_codes: list[str], list_type: str | None = None) -> dict[str, Any]:
|
||||
data = pcm_request(
|
||||
"POST",
|
||||
"/list/count/zipcode",
|
||||
json_body={
|
||||
"listType": list_type or _list_type(),
|
||||
"breakdownType": "ZipCRRT",
|
||||
"zipCodes": zip_codes,
|
||||
"demographics": [],
|
||||
},
|
||||
)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def list_count_radius(
|
||||
*,
|
||||
address: str,
|
||||
city: str,
|
||||
state: str,
|
||||
zip_code: str,
|
||||
miles: float,
|
||||
list_type: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
data = pcm_request(
|
||||
"POST",
|
||||
"/list/count/radius",
|
||||
json_body={
|
||||
"listType": list_type or _list_type(),
|
||||
"breakdownType": "ZipCRRT",
|
||||
"radius": {
|
||||
"radius": miles,
|
||||
"address": address,
|
||||
"city": city,
|
||||
"state": state,
|
||||
"zip": zip_code,
|
||||
},
|
||||
"demographics": [],
|
||||
},
|
||||
)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def list_count_carrier_route(
|
||||
*,
|
||||
carrier_routes: list[str],
|
||||
list_type: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
data = pcm_request(
|
||||
"POST",
|
||||
"/list/count/carrier-route",
|
||||
json_body={
|
||||
"listType": list_type or _list_type(),
|
||||
"breakdownType": "ZipCRRT",
|
||||
"carrierRoutes": carrier_routes,
|
||||
"demographics": [],
|
||||
},
|
||||
)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _order_id_from_response(data: dict[str, Any]) -> str:
|
||||
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
|
||||
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
|
||||
order_id = data["results"][0].get("orderID")
|
||||
if order_id is None:
|
||||
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
|
||||
return str(order_id)
|
||||
|
||||
|
||||
def place_postcard_order(
|
||||
*,
|
||||
design_id: int,
|
||||
@@ -358,13 +562,32 @@ def place_postcard_order(
|
||||
data = pcm_request("POST", "/order/postcard", json_body=payload)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("Unexpected order response from PCM")
|
||||
return _order_id_from_response(data)
|
||||
|
||||
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
|
||||
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
|
||||
order_id = data["results"][0].get("orderID")
|
||||
if order_id is None:
|
||||
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
|
||||
return str(order_id)
|
||||
|
||||
def place_postcard_order_with_list_count(
|
||||
*,
|
||||
design_id: int,
|
||||
list_count_id: int,
|
||||
record_count: int,
|
||||
ext_ref: str,
|
||||
mail_class: str = "Standard",
|
||||
) -> str:
|
||||
"""Place a postcard order against a PCM purchased list count."""
|
||||
payload = {
|
||||
"designID": design_id,
|
||||
"mailClass": mail_class,
|
||||
"extRefNbr": ext_ref,
|
||||
"listCountID": list_count_id,
|
||||
"recordCount": record_count,
|
||||
"returnAddress": return_address_from_settings(),
|
||||
}
|
||||
data = pcm_request(
|
||||
"POST", "/order/postcard/with-list-count", json_body=payload
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("Unexpected list-count order response from PCM")
|
||||
return _order_id_from_response(data)
|
||||
|
||||
|
||||
def design_id_from_template(template) -> int | None:
|
||||
@@ -396,6 +619,24 @@ class PcmProvider:
|
||||
"(save a design from the postcard designer first)"
|
||||
)
|
||||
|
||||
targeting = {}
|
||||
if message.campaign_id and isinstance(
|
||||
getattr(message.campaign, "route_targeting", None), dict
|
||||
):
|
||||
targeting = message.campaign.route_targeting
|
||||
list_count_id = targeting.get("list_count_id")
|
||||
record_count = targeting.get("record_count")
|
||||
if list_count_id and record_count:
|
||||
mail_class = str(targeting.get("mail_class") or "Standard")
|
||||
order_id = place_postcard_order_with_list_count(
|
||||
design_id=design_id,
|
||||
list_count_id=int(list_count_id),
|
||||
record_count=int(record_count),
|
||||
ext_ref=str(message.pk),
|
||||
mail_class=mail_class,
|
||||
)
|
||||
return PostcardResult(provider_id=order_id)
|
||||
|
||||
recipient = contact_to_pcm_recipient(
|
||||
message.contact, ext_ref=str(message.pk)
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user