From 7ff52c776b0f6e27873bce083d121db9a4803e00 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Tue, 22 Sep 2026 12:40:24 -0700 Subject: [PATCH] Add EDDM size, income, businesses and USPS map link (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #13. - Mirror Monica: join USPS EDDM GIS stats onto the carrier-route picker (household size, median income, business count). - Add Open USPS EDDM map link next to Find routes. - GIS miss stays a dash and does not block Find routes. PCM households and cost unchanged. Monica: ai_ml_operations/monica_site#17 ## Test plan - [ ] Find routes for a ZIP; table shows Size / Income / Businesses columns - [ ] Missing GIS values render as — - [ ] Open USPS EDDM map opens eddm.usps.com - [ ] Quote and 1–2 route send still work - [ ] `CarrierRoutePostcardTests` pass Reviewed-on: https://git.aimloperations.com/westfarn/web_django_template/pulls/14 --- site/client_site/static/js/postcard-routes.js | 28 ++++ site/directmail/README.md | 5 + .../providers/postcard/usps_eddm.py | 151 ++++++++++++++++++ site/directmail/services.py | 9 +- .../directmail/_postcard_route_picker.html | 11 +- site/directmail/tests.py | 53 +++++- 6 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 site/directmail/providers/postcard/usps_eddm.py 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. +

-