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