File size: 14,588 Bytes
c14ceee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 | """Map module β every customer pinned at their EXACT street address (not city-aggregated).
Coordinates resolve in this order, per customer:
1. Odoo's own partner_latitude / partner_longitude when present;
2. else a geocode of the street address, cached in the writable HF store (`geocoords.json`,
keyed by partner id + an address hash so a changed address re-geocodes). Geocoding uses the
free US Census batch geocoder (these customers are almost all US/NY-metro florists).
Customers with no usable address (or a geocode that fails) fall to a list under the map.
Odoo stays STRICTLY read-only β geocodes are cached in the platform's own store, never written back
to Odoo. The map's purpose is select-and-export (e.g. to assign agents): box/lasso-select on the map
builds an Excel list. Assigning agents happens in Odoo / via that export, never as an app write.
"""
import csv
import io
import hashlib
import core.odoo as O
import core.periods as P
import core.store as store
import modules.sales as sales_mod
import modules.customers as cust_mod
_GEO_NS = 'geocoords' # store file: {str(pid): {lat,lon,src,h,addr}}
_BASE_FROM = '2015-01-01' # "all-time" floor for the customer base
_CENSUS_URL = 'https://geocoding.geo.census.gov/geocoder/locations/addressbatch'
# Americas bounds (US + AK/HI + PR/VI + Canada + Caribbean/Guyana) β wide enough for every real
# customer, tight enough to still reject garbage coordinates (0,0 / Europe / Asia).
_LAT_LO, _LAT_HI, _LON_LO, _LON_HI = 6.0, 60.0, -160.0, -58.0
def _today_iso():
return P.today().isoformat()
def _norm_state(s):
return (s or '').replace(' (US)', '').strip()
def _addr_parts(p):
"""(street, city, state, zip) cleaned from a res.partner row."""
street = ' '.join(x for x in [(p.get('street') or '').strip(),
(p.get('street2') or '').strip()] if x)
return (street, (p.get('city') or '').strip(),
_norm_state(O.m2o_name(p.get('state_id'))), (p.get('zip') or '').strip())
def _addr_str(p):
return ', '.join(x for x in _addr_parts(p) if x)
def _addr_hash(p):
return hashlib.sha1('|'.join(_addr_parts(p)).lower().encode('utf-8')).hexdigest()[:16]
def _has_coords(p):
la, lo = p.get('partner_latitude'), p.get('partner_longitude')
return bool(la) and bool(lo) and (abs(la) > 1e-6 or abs(lo) > 1e-6)
def _in_bounds(lat, lon):
return lat is not None and lon is not None and _LAT_LO <= lat <= _LAT_HI and _LON_LO <= lon <= _LON_HI
def _base_pids(team_id=None, agent_pids=None):
"""Partner ids of every customer with a confirmed order in scope (team/agent), giftware
excluded β via the same order_domain the rest of the app uses."""
g = O.read_group('sale.order',
sales_mod.order_domain(_BASE_FROM, _today_iso(), team_id, partner_ids=agent_pids),
['amount_untaxed:sum'], ['partner_id'], lazy=False)
return [O.m2o_id(r.get('partner_id')) for r in g if O.m2o_id(r.get('partner_id'))]
def _read_partners(pids):
"""One res.partner read: address + Odoo coords + agent, with agent ids resolved to names."""
if not pids:
return [], {}
# ('active','in',[True,False]) keeps archived partners that still carry orders, so the map
# covers every order customer and reconciles to the distinct-partner count.
parts = O.search_read('res.partner', [('id', 'in', list(pids)), ('active', 'in', [True, False])],
['name', 'street', 'street2', 'city', 'state_id', 'zip', 'country_id',
'partner_latitude', 'partner_longitude', 'agent_ids'])
aids = {a for p in parts for a in (p.get('agent_ids') or [])}
anames = ({x['id']: x['name'] for x in
O.search_read('res.partner', [('id', 'in', list(aids))], ['name'])} if aids else {})
return parts, anames
def customer_locations(t=None, team_id=None, agent_pids=None):
"""The map dataset. Returns {'located', 'unlocated', 'stats', 'validation'}.
Each located row: pid, customer, lat, lon, source, address, city, state, zip, agent,
rev_ltm, rev_total, orders, last_order. Unlocated rows carry a 'reason' instead of lat/lon."""
t = t or P.today()
lf, lt = P.ltm(t)
today = _today_iso()
dom = sales_mod.order_domain(_BASE_FROM, today, team_id, partner_ids=agent_pids)
# Wave 1 β five independent reads concurrently (this is the slow part): all-time revenue (which
# also yields the customer set), LTM revenue, last-order dates, the independent base count, and
# the geocode cache. Running them in parallel cuts a cold load from ~15s to ~5s.
all_rev, ltm_rev, last_ord, base_indep, cache = O.parallel([
lambda: cust_mod._cust_rev(_BASE_FROM, today, team_id, agent_pids),
lambda: cust_mod._cust_rev(lf, lt, team_id, agent_pids),
lambda: cust_mod._last_order_dates(_BASE_FROM, today, team_id, agent_pids),
lambda: O.distinct_count('sale.order', dom, 'partner_id'),
lambda: (store.get(_GEO_NS) if store.available() else {}),
])
# Agent-scoped map = the agent's WHOLE assigned book (a "my customers" coverage view), NOT just
# the order-history subset. Active accounts with an address that simply haven't ordered yet are
# still the agent's customers and belong on their map β so the count reconciles to the Agents-page
# book (e.g. an agent's 114 assigned customers show 114, not the 80 who happen to have ordered).
# A never-ordered customer has no BU, so this only applies in the All-BU view; a specific-BU map
# stays order-based, since BU is derived from orders (owner 2026-07-21).
book_scoped = agent_pids is not None and team_id is None
pids = list(set(all_rev.keys()) | (set(agent_pids) if book_scoped else set()))
if not pids:
return {'located': [], 'unlocated': [],
'stats': {'total': 0, 'located': 0, 'unlocated': 0, 'no_agent': 0, 'need_geocode': 0},
'validation': validate_rows(0, 0, 0, base_indep)}
parts, anames = _read_partners(pids) # Wave 2 β needs the customer ids from Wave 1
located, unlocated = [], []
for p in parts:
pid = p['id']
ag = p.get('agent_ids') or []
row = {
'pid': pid, 'customer': p.get('name') or f'#{pid}',
'address': _addr_str(p),
'city': ((p.get('city') or '').strip().title()) or '(none)',
'state': _norm_state(O.m2o_name(p.get('state_id'))) or '(none)',
'zip': (p.get('zip') or '').strip(),
'agent': (anames.get(ag[0]) or '(none)') if ag else '(none)',
'rev_ltm': (ltm_rev.get(pid) or {}).get('rev', 0.0),
'rev_total': (all_rev.get(pid) or {}).get('rev', 0.0),
'orders': (all_rev.get(pid) or {}).get('orders', 0),
'last_order': last_ord.get(pid, ''),
}
lat = lon = src = None
if _has_coords(p):
lat, lon, src = p['partner_latitude'], p['partner_longitude'], 'odoo'
else:
c = cache.get(str(pid))
if c and c.get('h') == _addr_hash(p) and c.get('lat') is not None:
lat, lon, src = c['lat'], c['lon'], c.get('src', 'geocode')
if _in_bounds(lat, lon):
located.append({**row, 'lat': round(lat, 6), 'lon': round(lon, 6), 'source': src})
else:
_street, _city, _stt, _zip = _addr_parts(p)
geocodable = bool(_street and (_city or _zip))
if not row['address']:
reason = 'no address in Odoo'
elif (cache.get(str(pid)) or {}).get('src') == 'census_nomatch':
reason = 'address could not be located'
elif geocodable:
reason = 'not geocoded yet'
else:
reason = 'address too incomplete to locate'
unlocated.append({**row, 'reason': reason})
located.sort(key=lambda r: -r['rev_ltm'])
unlocated.sort(key=lambda r: -r['rev_ltm'])
stats = {
'total': len(parts), 'located': len(located), 'unlocated': len(unlocated),
'no_agent': sum(1 for r in (located + unlocated) if r['agent'] == '(none)'),
'need_geocode': sum(1 for r in unlocated if r['reason'] == 'not geocoded yet'),
}
# Reconcile to the set we set out to map: the agent's full book when book-scoped, else the
# independent distinct-order-customer count.
recon = len(pids) if book_scoped else base_indep
return {'located': located, 'unlocated': unlocated, 'stats': stats,
'validation': validate_rows(len(parts), len(located), len(unlocated), recon,
bad=[r for r in located if not _in_bounds(r['lat'], r['lon'])])}
def coords_for(pids):
"""{pid: {'lat','lon'}} for the given partners β Odoo's own coordinates, else the geocode
CACHE (read-only reuse; NEVER triggers geocoding). Wave-7 W11: the Customer table's Map
VIEW reads these off the pool rows; the standalone Map page retired the same day, and
this module survives as the geocode library."""
if not pids:
return {}
try:
cache = store.get(_GEO_NS) if store.available() else {}
parts = O.search_read(
'res.partner', [('id', 'in', list(pids)), ('active', 'in', [True, False])],
['street', 'street2', 'city', 'state_id', 'zip',
'partner_latitude', 'partner_longitude'])
except Exception:
return {} # a table that renders without pins beats a table that cannot render
out = {}
for p in parts:
lat = lon = None
if _has_coords(p):
lat, lon = p['partner_latitude'], p['partner_longitude']
else:
c = cache.get(str(p['id']))
if c and c.get('h') == _addr_hash(p) and c.get('lat') is not None:
lat, lon = c['lat'], c['lon']
if _in_bounds(lat, lon):
out[p['id']] = {'lat': round(lat, 6), 'lon': round(lon, 6)}
return out
def validate_rows(total, n_loc, n_unloc, base_indep, bad=None):
"""Reconcile the map dataset to an independent Odoo aggregate (distinct order partners)."""
checks = [{
'check': 'Map: located + unlocated == customers in scope',
'a': n_loc + n_unloc, 'b': base_indep, 'gap': (n_loc + n_unloc) - base_indep,
'ok': (n_loc + n_unloc) == base_indep,
}, {
'check': 'Map: every plotted point within plausible bounds',
'a': n_loc - len(bad or []), 'b': n_loc, 'gap': -len(bad or []), 'ok': not (bad or []),
}]
return checks
def validate(team_id=None):
"""Registry hook for validate.py."""
return customer_locations(team_id=team_id)['validation']
# ---------------------------------------------------------------- geocoding (US Census batch)
def _geocodable_pids(team_id=None):
"""Every customer that can appear on a map: order-customers (per the team filter) PLUS every
customer ASSIGNED to an agent. The latter is why an agent's never-ordered accounts can be
geocoded and pinned on their 'my customers' map (customer_locations now includes the full book);
without it those addresses would never enter the geocode queue."""
pids = set(_base_pids(team_id))
for p in O.search_read('res.partner', [('agent_ids', '!=', False), ('active', 'in', [True, False])],
['id'], limit=100000):
pids.add(p['id'])
return list(pids)
def _pending_geocode(team_id=None):
"""res.partner rows with an address, no Odoo coords, and no cache entry for that exact address."""
pids = _geocodable_pids(team_id)
parts, _ = _read_partners(pids)
cache = store.get(_GEO_NS) if store.available() else {}
pend = []
for p in parts:
if _has_coords(p):
continue
street, city, _state, zc = _addr_parts(p)
if not (street and (city or zc)):
continue # too little to geocode β stays unlocated
c = cache.get(str(p['id']))
if c and c.get('h') == _addr_hash(p):
continue # already attempted for this exact address
pend.append(p)
return pend, cache
def geocode_missing(team_id=None, limit=None, _chunk=2000):
"""Geocode pending addresses via the US Census batch geocoder; cache results (incl. no-match,
so we never retry the same address) in the HF store. Returns a summary dict. Read-only on Odoo."""
if not store.available():
return {'error': 'No writable store (HF_TOKEN missing).'}
try:
import requests
except ImportError:
return {'error': 'requests not installed'}
pend, cache = _pending_geocode(team_id)
if limit:
pend = pend[:limit]
if not pend:
return {'attempted': 0, 'matched': 0, 'failed': 0, 'cache_size': len(cache)}
matched = failed = 0
for i in range(0, len(pend), _chunk):
batch = pend[i:i + _chunk]
buf = io.StringIO()
w = csv.writer(buf)
for p in batch:
street, city, state, zc = _addr_parts(p)
w.writerow([p['id'], street, city, state, zc])
try:
resp = requests.post(_CENSUS_URL,
files={'addressFile': ('a.csv', buf.getvalue(), 'text/csv')},
data={'benchmark': 'Public_AR_Current'}, timeout=300)
resp.raise_for_status()
except Exception as e:
store.put(_GEO_NS, cache)
return {'error': f'Census request failed: {e}', 'matched': matched, 'failed': failed}
by_pid = {p['id']: p for p in batch}
for row in csv.reader(io.StringIO(resp.text)):
if not row:
continue
try:
pid = int(row[0])
except (ValueError, IndexError):
continue
p = by_pid.get(pid)
if p is None:
continue
h = _addr_hash(p)
if len(row) >= 6 and row[2] == 'Match' and row[5]:
lon, lat = row[5].split(',')
cache[str(pid)] = {'lat': float(lat), 'lon': float(lon), 'src': 'census',
'h': h, 'addr': row[4]}
matched += 1
else:
cache[str(pid)] = {'lat': None, 'lon': None, 'src': 'census_nomatch', 'h': h}
failed += 1
store.put(_GEO_NS, cache)
return {'attempted': len(pend), 'matched': matched, 'failed': failed, 'cache_size': len(cache)}
|