diff --git a/site/client_site/static/js/postcard-routes.js b/site/client_site/static/js/postcard-routes.js
index 0732482..3a77907 100644
--- a/site/client_site/static/js/postcard-routes.js
+++ b/site/client_site/static/js/postcard-routes.js
@@ -6,6 +6,27 @@
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) {
var form = document.getElementById("campaign-compose");
if (!form) return;
@@ -18,6 +39,7 @@
var tableWrap = document.getElementById("route-table-wrap");
var tbody = document.getElementById("route-table-body");
var selectedBox = document.getElementById("route-selected-cost");
+ var eddmLink = document.getElementById("eddm-map-link");
var quoteUrl = opts.quoteUrl;
var csrfToken = opts.csrfToken;
var maxRoutes = 2;
@@ -173,6 +195,9 @@
String(row.code) + "" +
"
" + String(row.text || "") + "
" +
"
" + String(row.total) + "
" +
+ "
" + hhSize(row.household_size) + "
" +
+ "
" + income(row.median_income) + "
" +
+ "
" + count(row.businesses) + "
" +
"
" + money(row.cost) + "
";
}).join("");
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
@@ -207,6 +232,9 @@
radius: radius
}).then(function (data) {
renderRoutes(data.routes || []);
+ if (eddmLink && data.eddm_map_url) {
+ eddmLink.href = data.eddm_map_url;
+ }
if (!(data.routes || []).length) {
setStatus("No carrier routes for that search.", true);
} else {
diff --git a/site/directmail/README.md b/site/directmail/README.md
index 68fbedc..869da9c 100644
--- a/site/directmail/README.md
+++ b/site/directmail/README.md
@@ -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
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
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
diff --git a/site/directmail/providers/postcard/usps_eddm.py b/site/directmail/providers/postcard/usps_eddm.py
new file mode 100644
index 0000000..07254a2
--- /dev/null
+++ b/site/directmail/providers/postcard/usps_eddm.py
@@ -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
diff --git a/site/directmail/services.py b/site/directmail/services.py
index 6d1aec3..3fa2107 100644
--- a/site/directmail/services.py
+++ b/site/directmail/services.py
@@ -159,6 +159,10 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
mail_class_for_size,
pick_unit_price,
)
+ from directmail.providers.postcard.usps_eddm import (
+ USPS_EDDM_MAP_URL,
+ enrich_crrt_rows,
+ )
design_id = payload.get("design_id")
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])
else:
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 = []
for row in rows:
per_piece, source = _price(row["total"])
@@ -227,6 +231,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
"mail_class": mail_class,
"zip": zip_code,
"routes": priced,
+ "eddm_map_url": USPS_EDDM_MAP_URL,
"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]
if record_count and len(rows) == 1:
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)
if not record_count:
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,
"total": money_total(per_piece, record_count),
"price_source": source,
+ "eddm_map_url": USPS_EDDM_MAP_URL,
"estimated": {
"per_piece": per_piece,
"pieces": record_count,
diff --git a/site/directmail/templates/directmail/_postcard_route_picker.html b/site/directmail/templates/directmail/_postcard_route_picker.html
index 8c78e6b..ec5bd50 100644
--- a/site/directmail/templates/directmail/_postcard_route_picker.html
+++ b/site/directmail/templates/directmail/_postcard_route_picker.html
@@ -31,14 +31,23 @@
+
+ Open USPS EDDM map
+ — official route map. Use Show Table there for size / income filters.
+ PCM still mails a purchased occupant list, not EDDM postage.
+