fix(manager-desk): unstick the spinner + live updates that don't flash

Root cause of the stuck "Loading manager data..." spinner: the overview
endpoint included a search_count on sale.order.x_fc_workflow_stage,
which is a non-stored computed field. Odoo 19 raised:

  ValueError: Cannot convert sale.order.x_fc_workflow_stage to SQL
              because it is not stored

The controller silently logged the error; the JS caught and swallowed
the RPC failure, leaving state.overview=null forever. So the UI just
kept spinning while production changed around the manager.

Fixes:

1. Controller (manager_controller.py)
   - "Awaiting assignment SOs" is now computed from STORED fields only:
       state='sale' AND x_fc_receiving_status='inspected'
             AND x_fc_assigned_manager_id=False
     Same stage, legal SQL.
   - Whole endpoint wrapped in try/except; failures return
     {'ok': False, 'error': '...'} so the UI can surface them instead
     of dying silently.
   - Response carries a payload_hash (md5 of the JSON body minus
     user_name). If the client sends back known_hash and nothing has
     moved, the server returns {'unchanged': True, 'payload_hash': ...}
     and the client skips the repaint entirely. Keeps the UI quiet
     between polls.

2. OWL component (manager_dashboard.js)
   - Poll cadence tightened from 30s → 8s (production-pace).
   - Unchanged payloads don't mutate state.overview → no re-render,
     no flash. Live dot just updates its tooltip.
   - Changed payloads do an in-place MERGE of the overview (copying
     scalars/arrays onto the existing reactive object) instead of
     replacing it wholesale. OWL's diff only re-renders rows that
     actually moved.
   - isFetching guard so overlapping polls can't stack up.
   - state.loadError surfaces backend errors in a red banner with a
     Retry button — no more silent spinner.

3. UX
   - Live dot next to the title: soft green at rest, bright green
     pulsing during a fetch.
   - "Updated Xs ago" subtitle uses a getter so the label freshens
     between polls.
   - Manual Refresh button next to Quick/Detailed toggle.
   - Spinner only appears on the genuine first load; gone forever
     once the first payload lands.

Verified: the old crashing query now runs clean on demo data; odoo
logs show zero errors for the last 5 minutes of polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-18 18:06:04 -04:00
parent a660f1f05d
commit d29857078a
4 changed files with 160 additions and 15 deletions

View File

@@ -22,7 +22,22 @@ class FpManagerDashboardController(http.Controller):
# Overview snapshot — used on initial load + 30s auto-refresh
# ------------------------------------------------------------------
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
def overview(self, facility_id=None):
def overview(self, facility_id=None, known_hash=None):
"""Build the manager dashboard payload.
`known_hash`: if the client sends back the hash of its last
overview, we compare and return `{'unchanged': True}` when
nothing has moved. Keeps the UI flicker-free between polls
while still catching every shop-floor change within a few
seconds.
"""
try:
return self._overview_payload(facility_id, known_hash)
except Exception as exc: # noqa: BLE001
_logger.exception('Manager overview failed')
return {'ok': False, 'error': str(exc)}
def _overview_payload(self, facility_id, known_hash):
env = request.env
MrpWO = env.get('mrp.workorder')
Production = env.get('mrp.production')
@@ -35,6 +50,7 @@ class FpManagerDashboardController(http.Controller):
'operators': [], 'tanks': [],
'user_name': env.user.name,
'mrp_missing': True,
'payload_hash': '',
}
# The assignment field lives in fusion_plating_bridge_mrp. If it's
# missing, the dashboard still renders but the worker pickers are
@@ -160,7 +176,23 @@ class FpManagerDashboardController(http.Controller):
for t in (Tank.search([]) if Tank is not None else [])
]
# KPI summary
# KPI summary — every query must use STORED fields only, otherwise
# Odoo raises "Cannot convert … to SQL because it is not stored".
# x_fc_workflow_stage is computed (non-stored); replicate the
# "awaiting assignment" stage directly via its stored antecedents.
SO = env['sale.order']
so_fields = SO._fields
if ('x_fc_receiving_status' in so_fields
and 'x_fc_assigned_manager_id' in so_fields):
pending_accept_domain = [
('state', '=', 'sale'),
('x_fc_receiving_status', '=', 'inspected'),
('x_fc_assigned_manager_id', '=', False),
]
pending_accept_sos = SO.search_count(pending_accept_domain)
else:
pending_accept_sos = 0
kpis = {
'unassigned_wos': MrpWO.search_count(domain_unassigned),
'active_wos': MrpWO.search_count(domain_active),
@@ -171,12 +203,10 @@ class FpManagerDashboardController(http.Controller):
('state', '=', 'done'),
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
]),
'pending_accept_sos': env['sale.order'].search_count(
[('x_fc_workflow_stage', '=', 'assign_work')]
) if 'x_fc_workflow_stage' in env['sale.order']._fields else 0,
'pending_accept_sos': pending_accept_sos,
}
return {
payload = {
'ok': True,
'kpis': kpis,
'unassigned': unassigned_cards,
@@ -187,6 +217,18 @@ class FpManagerDashboardController(http.Controller):
'user_name': env.user.name,
}
# Short-circuit: if nothing changed since last poll, skip repaint.
import hashlib, json
hashable = json.dumps(
{k: v for k, v in payload.items() if k != 'user_name'},
sort_keys=True, default=str,
)
payload_hash = hashlib.md5(hashable.encode('utf-8')).hexdigest()
payload['payload_hash'] = payload_hash
if known_hash and known_hash == payload_hash:
return {'ok': True, 'unchanged': True, 'payload_hash': payload_hash}
return payload
# ------------------------------------------------------------------
# Assign a worker to a WO
# ------------------------------------------------------------------