"""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)}