| """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' |
| _BASE_FROM = '2015-01-01' |
| _CENSUS_URL = 'https://geocoding.geo.census.gov/geocoder/locations/addressbatch' |
| |
| |
| _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 [], {} |
| |
| |
| 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) |
| |
| |
| |
| 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 {}), |
| ]) |
| |
| |
| |
| |
| |
| |
| 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) |
|
|
| 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'), |
| } |
| |
| |
| 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 {} |
| 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'] |
|
|
|
|
| |
| 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 |
| c = cache.get(str(p['id'])) |
| if c and c.get('h') == _addr_hash(p): |
| continue |
| 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)} |
|
|