fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
14.6 kB
"""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)}