Template
Add EDDM size, income, businesses and USPS map link #14
No files matched your search
@@ -6,6 +6,27 @@
|
|||||||
return "$" + v.toFixed(2);
|
return "$" + v.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hhSize(n) {
|
||||||
|
if (n === null || n === undefined || n === "") return "—";
|
||||||
|
var v = Number(n);
|
||||||
|
if (Number.isNaN(v)) return "—";
|
||||||
|
return v.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function income(n) {
|
||||||
|
if (n === null || n === undefined || n === "") return "—";
|
||||||
|
var v = Number(n);
|
||||||
|
if (Number.isNaN(v)) return "—";
|
||||||
|
return "$" + Math.round(v).toLocaleString("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(n) {
|
||||||
|
if (n === null || n === undefined || n === "") return "—";
|
||||||
|
var v = Number(n);
|
||||||
|
if (Number.isNaN(v)) return "—";
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
function initPostcardRoutes(opts) {
|
function initPostcardRoutes(opts) {
|
||||||
var form = document.getElementById("campaign-compose");
|
var form = document.getElementById("campaign-compose");
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -18,6 +39,7 @@
|
|||||||
var tableWrap = document.getElementById("route-table-wrap");
|
var tableWrap = document.getElementById("route-table-wrap");
|
||||||
var tbody = document.getElementById("route-table-body");
|
var tbody = document.getElementById("route-table-body");
|
||||||
var selectedBox = document.getElementById("route-selected-cost");
|
var selectedBox = document.getElementById("route-selected-cost");
|
||||||
|
var eddmLink = document.getElementById("eddm-map-link");
|
||||||
var quoteUrl = opts.quoteUrl;
|
var quoteUrl = opts.quoteUrl;
|
||||||
var csrfToken = opts.csrfToken;
|
var csrfToken = opts.csrfToken;
|
||||||
var maxRoutes = 2;
|
var maxRoutes = 2;
|
||||||
@@ -173,6 +195,9 @@
|
|||||||
String(row.code) + "</label></td>" +
|
String(row.code) + "</label></td>" +
|
||||||
"<td>" + String(row.text || "") + "</td>" +
|
"<td>" + String(row.text || "") + "</td>" +
|
||||||
"<td>" + String(row.total) + "</td>" +
|
"<td>" + String(row.total) + "</td>" +
|
||||||
|
"<td>" + hhSize(row.household_size) + "</td>" +
|
||||||
|
"<td>" + income(row.median_income) + "</td>" +
|
||||||
|
"<td>" + count(row.businesses) + "</td>" +
|
||||||
"<td>" + money(row.cost) + "</td></tr>";
|
"<td>" + money(row.cost) + "</td></tr>";
|
||||||
}).join("");
|
}).join("");
|
||||||
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
|
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
|
||||||
@@ -207,6 +232,9 @@
|
|||||||
radius: radius
|
radius: radius
|
||||||
}).then(function (data) {
|
}).then(function (data) {
|
||||||
renderRoutes(data.routes || []);
|
renderRoutes(data.routes || []);
|
||||||
|
if (eddmLink && data.eddm_map_url) {
|
||||||
|
eddmLink.href = data.eddm_map_url;
|
||||||
|
}
|
||||||
if (!(data.routes || []).length) {
|
if (!(data.routes || []).length) {
|
||||||
setStatus("No carrier routes for that search.", true);
|
setStatus("No carrier routes for that search.", true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ Route mode calls PCM `POST /list/count/zipcode` (or `/radius`) with
|
|||||||
or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default
|
or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default
|
||||||
purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage.
|
purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage.
|
||||||
|
|
||||||
|
Compose also joins USPS EDDM GIS stats (businesses; household size / median
|
||||||
|
income when USPS fills them) and links to
|
||||||
|
[the EDDM map](https://eddm.usps.com/eddm/select-routes.htm) for the official
|
||||||
|
route table.
|
||||||
|
|
||||||
### Campaign completion email
|
### Campaign completion email
|
||||||
|
|
||||||
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
|
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""USPS EDDM map link + public GIS stats for carrier-route compose."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USPS_EDDM_MAP_URL = "https://eddm.usps.com/eddm/select-routes.htm"
|
||||||
|
USPS_EDDM_GIS_URL = (
|
||||||
|
"https://gis.usps.com/arcgis/rest/services/EDDM/selectZIP/GPServer/routes/execute"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CACHE_TTL_SECONDS = 1800
|
||||||
|
_cache: dict[str, tuple[float, dict[str, dict[str, Any]]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def zip5(raw: str | None) -> str:
|
||||||
|
digits = "".join(ch for ch in str(raw or "") if ch.isdigit())
|
||||||
|
return digits[:5] if len(digits) >= 5 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def pcm_code_from_zip_crid(*, zip_code: str, crid_id: str) -> str:
|
||||||
|
return f"{zip_code}:{crid_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def zips_from_route_codes(codes: list[str], *, hint: str = "") -> list[str]:
|
||||||
|
found: list[str] = []
|
||||||
|
z = zip5(hint)
|
||||||
|
if z:
|
||||||
|
found.append(z)
|
||||||
|
for code in codes:
|
||||||
|
z = zip5(str(code).split(":", 1)[0])
|
||||||
|
if z:
|
||||||
|
found.append(z)
|
||||||
|
return list(dict.fromkeys(found))
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(raw: Any) -> int | None:
|
||||||
|
if raw in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(raw: Any) -> float | None:
|
||||||
|
if raw in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_from_attrs(attrs: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
zip_code = zip5(attrs.get("ZIP_CODE"))
|
||||||
|
crid = str(attrs.get("CRID_ID") or "").strip().upper()
|
||||||
|
if not zip_code or not crid:
|
||||||
|
zip_crid = str(attrs.get("ZIP_CRID") or "").strip().upper()
|
||||||
|
if len(zip_crid) >= 9:
|
||||||
|
zip_code = zip_crid[:5]
|
||||||
|
crid = zip_crid[5:]
|
||||||
|
if not zip_code or not crid:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"code": pcm_code_from_zip_crid(zip_code=zip_code, crid_id=crid),
|
||||||
|
"businesses": _as_int(attrs.get("BUS_CNT")),
|
||||||
|
"residential": _as_int(attrs.get("RES_CNT")),
|
||||||
|
"usps_total": _as_int(attrs.get("TOT_CNT")),
|
||||||
|
"household_size": _as_float(attrs.get("AVG_HH_SIZ")),
|
||||||
|
"median_income": _as_int(attrs.get("MED_INCOME")),
|
||||||
|
"city_state": str(attrs.get("CITY_STATE") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_one_zip(zip_code: str) -> dict[str, dict[str, Any]]:
|
||||||
|
response = requests.get(
|
||||||
|
USPS_EDDM_GIS_URL,
|
||||||
|
params={"f": "json", "ZIP": zip_code, "Rte_Box": "R", "UserName": "EDDM"},
|
||||||
|
timeout=25,
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
results = payload.get("results") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(results, list) or not results:
|
||||||
|
return {}
|
||||||
|
value = results[0].get("value") if isinstance(results[0], dict) else None
|
||||||
|
features = value.get("features") if isinstance(value, dict) else None
|
||||||
|
if not isinstance(features, list):
|
||||||
|
return {}
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
for feature in features:
|
||||||
|
if not isinstance(feature, dict):
|
||||||
|
continue
|
||||||
|
attrs = feature.get("attributes")
|
||||||
|
if not isinstance(attrs, dict):
|
||||||
|
continue
|
||||||
|
stats = _stats_from_attrs(attrs)
|
||||||
|
if not stats:
|
||||||
|
continue
|
||||||
|
out[stats["code"]] = stats
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_eddm_route_stats(zip_codes: list[str]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""ZIP → PCM-style ``60540:C001`` stats from USPS EDDM GIS (fail open)."""
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
now = time.monotonic()
|
||||||
|
for zip_code in zip_codes:
|
||||||
|
z = zip5(zip_code)
|
||||||
|
if not z:
|
||||||
|
continue
|
||||||
|
cached = _cache.get(z)
|
||||||
|
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
|
||||||
|
out.update(cached[1])
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
part = _fetch_one_zip(z)
|
||||||
|
except Exception: # noqa: BLE001 — compose still works without GIS
|
||||||
|
logger.exception("USPS EDDM GIS lookup failed for ZIP %s", z)
|
||||||
|
part = {}
|
||||||
|
_cache[z] = (now, part)
|
||||||
|
out.update(part)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_crrt_rows(rows: list[dict[str, Any]], *, zip_hint: str = "") -> list[dict[str, Any]]:
|
||||||
|
"""Attach USPS businesses / size / income onto PCM ZipCRRT rows."""
|
||||||
|
codes = [str(row.get("code") or "") for row in rows]
|
||||||
|
stats = fetch_eddm_route_stats(zips_from_route_codes(codes, hint=zip_hint))
|
||||||
|
enriched = []
|
||||||
|
for row in rows:
|
||||||
|
extra = stats.get(str(row.get("code") or ""), {})
|
||||||
|
merged = {
|
||||||
|
**row,
|
||||||
|
"businesses": extra.get("businesses"),
|
||||||
|
"residential": extra.get("residential"),
|
||||||
|
"household_size": extra.get("household_size"),
|
||||||
|
"median_income": extra.get("median_income"),
|
||||||
|
}
|
||||||
|
if extra.get("city_state") and not (row.get("text") or "").strip():
|
||||||
|
merged["text"] = extra["city_state"]
|
||||||
|
enriched.append(merged)
|
||||||
|
return enriched
|
||||||
@@ -159,6 +159,10 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
|
|||||||
mail_class_for_size,
|
mail_class_for_size,
|
||||||
pick_unit_price,
|
pick_unit_price,
|
||||||
)
|
)
|
||||||
|
from directmail.providers.postcard.usps_eddm import (
|
||||||
|
USPS_EDDM_MAP_URL,
|
||||||
|
enrich_crrt_rows,
|
||||||
|
)
|
||||||
|
|
||||||
design_id = payload.get("design_id")
|
design_id = payload.get("design_id")
|
||||||
size = str(payload.get("size") or "").strip()
|
size = str(payload.get("size") or "").strip()
|
||||||
@@ -210,7 +214,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
|
|||||||
data = list_count_zipcode(zip_codes=[zip_code])
|
data = list_count_zipcode(zip_codes=[zip_code])
|
||||||
else:
|
else:
|
||||||
raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
|
raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
|
||||||
rows = extract_crrt_rows(data)
|
rows = enrich_crrt_rows(extract_crrt_rows(data), zip_hint=zip_code)
|
||||||
priced = []
|
priced = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
per_piece, source = _price(row["total"])
|
per_piece, source = _price(row["total"])
|
||||||
@@ -227,6 +231,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
|
|||||||
"mail_class": mail_class,
|
"mail_class": mail_class,
|
||||||
"zip": zip_code,
|
"zip": zip_code,
|
||||||
"routes": priced,
|
"routes": priced,
|
||||||
|
"eddm_map_url": USPS_EDDM_MAP_URL,
|
||||||
"discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
|
"discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +256,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
|
|||||||
rows = [{"code": code, "text": code, "total": 0} for code in codes]
|
rows = [{"code": code, "text": code, "total": 0} for code in codes]
|
||||||
if record_count and len(rows) == 1:
|
if record_count and len(rows) == 1:
|
||||||
rows[0]["total"] = record_count
|
rows[0]["total"] = record_count
|
||||||
|
rows = enrich_crrt_rows(rows, zip_hint=codes[0] if codes else "")
|
||||||
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
||||||
if not record_count:
|
if not record_count:
|
||||||
record_count = sum(int(r.get("total") or 0) for r in rows)
|
record_count = sum(int(r.get("total") or 0) for r in rows)
|
||||||
@@ -278,6 +284,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
|
|||||||
"per_piece": per_piece,
|
"per_piece": per_piece,
|
||||||
"total": money_total(per_piece, record_count),
|
"total": money_total(per_piece, record_count),
|
||||||
"price_source": source,
|
"price_source": source,
|
||||||
|
"eddm_map_url": USPS_EDDM_MAP_URL,
|
||||||
"estimated": {
|
"estimated": {
|
||||||
"per_piece": per_piece,
|
"per_piece": per_piece,
|
||||||
"pieces": record_count,
|
"pieces": record_count,
|
||||||
|
|||||||
@@ -31,14 +31,23 @@
|
|||||||
<input id="route-state" type="text" maxlength="2" placeholder="IL">
|
<input id="route-state" type="text" maxlength="2" placeholder="IL">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button>
|
<button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button>
|
||||||
|
<p class="hint" style="margin-top:8px">
|
||||||
|
<a id="eddm-map-link" href="https://eddm.usps.com/eddm/select-routes.htm"
|
||||||
|
target="_blank" rel="noopener noreferrer">Open USPS EDDM map</a>
|
||||||
|
— official route map. Use Show Table there for size / income filters.
|
||||||
|
PCM still mails a purchased occupant list, not EDDM postage.
|
||||||
|
</p>
|
||||||
<p class="hint" id="route-status" style="margin-top:8px"></p>
|
<p class="hint" id="route-status" style="margin-top:8px"></p>
|
||||||
<div id="route-table-wrap" hidden style="margin-top:12px">
|
<div id="route-table-wrap" hidden style="margin-top:12px;overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Route</th>
|
<th>Route</th>
|
||||||
<th>Area</th>
|
<th>Area</th>
|
||||||
<th>Households</th>
|
<th>Households</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Income</th>
|
||||||
|
<th>Businesses</th>
|
||||||
<th>Est. cost</th>
|
<th>Est. cost</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -304,13 +304,15 @@ class PostcardUtmPanelTests(TestCase):
|
|||||||
self.assertContains(response, "qrcode.min.js")
|
self.assertContains(response, "qrcode.min.js")
|
||||||
self.assertContains(response, "data-utm-qr")
|
self.assertContains(response, "data-utm-qr")
|
||||||
self.assertContains(response, reverse("campaign_short_link"))
|
self.assertContains(response, reverse("campaign_short_link"))
|
||||||
|
self.assertContains(response, "https://eddm.usps.com/eddm/select-routes.htm")
|
||||||
|
self.assertContains(response, "Open USPS EDDM map")
|
||||||
|
|
||||||
|
|
||||||
class CarrierRoutePostcardTests(TestCase):
|
class CarrierRoutePostcardTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="routes", password="test-pass-123"
|
username="routes", password="test-pass-123", is_staff=True
|
||||||
)
|
)
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="routes", password="test-pass-123")
|
self.client.login(username="routes", password="test-pass-123")
|
||||||
@@ -318,6 +320,12 @@ class CarrierRoutePostcardTests(TestCase):
|
|||||||
email="mail@example.com",
|
email="mail@example.com",
|
||||||
postal_address=Contact.make_postal_address(line1="9 Main"),
|
postal_address=Contact.make_postal_address(line1="9 Main"),
|
||||||
)
|
)
|
||||||
|
self.eddm_patcher = patch(
|
||||||
|
"directmail.providers.postcard.usps_eddm.fetch_eddm_route_stats",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
|
self.eddm_stats = self.eddm_patcher.start()
|
||||||
|
self.addCleanup(self.eddm_patcher.stop)
|
||||||
|
|
||||||
def test_pick_unit_price_uses_break_qty_and_fallback(self):
|
def test_pick_unit_price_uses_break_qty_and_fallback(self):
|
||||||
from directmail.providers.postcard import pcm as pcm_mod
|
from directmail.providers.postcard import pcm as pcm_mod
|
||||||
@@ -371,6 +379,49 @@ class CarrierRoutePostcardTests(TestCase):
|
|||||||
self.assertEqual(len(data["routes"]), 2)
|
self.assertEqual(len(data["routes"]), 2)
|
||||||
self.assertEqual(data["routes"][0]["cost"], 60.0)
|
self.assertEqual(data["routes"][0]["cost"], 60.0)
|
||||||
self.assertEqual(data["mail_class"], "Standard")
|
self.assertEqual(data["mail_class"], "Standard")
|
||||||
|
self.assertEqual(
|
||||||
|
data["eddm_map_url"], "https://eddm.usps.com/eddm/select-routes.htm"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_quote_search_includes_eddm_demographics(self):
|
||||||
|
self.eddm_stats.return_value = {
|
||||||
|
"60540:C001": {
|
||||||
|
"businesses": 22,
|
||||||
|
"household_size": 2.6,
|
||||||
|
"median_income": 120000,
|
||||||
|
"residential": 108,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"listCountID": 11,
|
||||||
|
"recordCount": 120,
|
||||||
|
"breakdown": [
|
||||||
|
{
|
||||||
|
"breakdownType": "ZipCRRT",
|
||||||
|
"data": [
|
||||||
|
{"code": "60540:C001", "text": "NAPERVILLE", "total": 120},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
|
||||||
|
with patch(
|
||||||
|
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"directmail.providers.postcard.pcm.list_count_zipcode",
|
||||||
|
return_value=payload,
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("directmail:postcard_quote"),
|
||||||
|
data='{"action":"search","zip":"60540","size":"68"}',
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
row = response.json()["routes"][0]
|
||||||
|
self.assertEqual(row["businesses"], 22)
|
||||||
|
self.assertEqual(row["household_size"], 2.6)
|
||||||
|
self.assertEqual(row["median_income"], 120000)
|
||||||
|
|
||||||
def test_quote_select_and_save_draft(self):
|
def test_quote_select_and_save_draft(self):
|
||||||
import json
|
import json
|
||||||
|
|||||||
Reference in new issue
Block a user