# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. """Quality Dashboard snapshot endpoint. Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md Replaces the old /fp/quality/dashboard/counts router-style endpoint with a single /fp/quality/dashboard/snapshot that returns: - banner: up to 6 most urgent items across all types (red "Needs Attention Today" surface) - sections: 6 per-type sections in canonical order, each with top-5 items + open/overdue counts """ import logging from datetime import timedelta from odoo import fields, http from odoo.http import request _logger = logging.getLogger(__name__) # Canonical section order β€” drives sections[] in the response. # Sections whose model isn't installed are omitted (no error). SECTION_ORDER = ['cert', 'hold', 'ncr', 'rma', 'capa', 'check'] # Per-type config: model name, kanban xmlid, display label, icon. TYPE_CONFIG = { 'cert': {'model': 'fp.certificate', 'label': 'Certificates', 'icon': '🏷️', 'kanban_xmlid': 'fusion_plating_certificates.action_fp_certificate'}, 'hold': {'model': 'fusion.plating.quality.hold', 'label': 'Holds', 'icon': 'πŸ›‘', 'kanban_xmlid': 'fusion_plating_quality.action_fp_quality_hold'}, 'ncr': {'model': 'fusion.plating.ncr', 'label': 'NCRs', 'icon': 'πŸ”¬', 'kanban_xmlid': 'fusion_plating_quality.action_fp_ncr'}, 'rma': {'model': 'fusion.plating.rma', 'label': 'RMAs', 'icon': '↩️', 'kanban_xmlid': 'fusion_plating_quality.action_fp_rma'}, 'capa': {'model': 'fusion.plating.capa', 'label': 'CAPAs', 'icon': 'πŸ“‹', 'kanban_xmlid': 'fusion_plating_quality.action_fp_capa'}, 'check': {'model': 'fusion.plating.quality.check', 'label': 'QC Checks', 'icon': 'βœ“', 'kanban_xmlid': 'fusion_plating_quality.action_fp_quality_check'}, } # Per-type "overdue" thresholds (reused from the old counts endpoint β€” # battle-tested). CAPA branches on due_date < today via use_due_date. OVERDUE_THRESHOLDS = { 'cert': {'days': 1, 'use_due_date': False, 'state_domain': [('state', '=', 'draft')]}, 'hold': {'days': 3, 'use_due_date': False, 'state_domain': [('state', 'in', ('on_hold', 'under_review'))]}, 'ncr': {'days': 7, 'use_due_date': False, 'state_domain': [('state', 'in', ('open', 'containment', 'disposition'))]}, 'rma': {'days': 5, 'use_due_date': False, 'state_domain': [('state', '=', 'received')]}, 'capa': {'days': None, 'use_due_date': True, 'state_domain': [('state', 'not in', ('closed', 'effective'))]}, 'check': {'days': 1, 'use_due_date': False, 'state_domain': [('state', '=', 'pending')]}, } class FpQualityDashboardSnapshot: """Builds the dashboard snapshot in one pass. Per Rule 13m, all cross-module field reads are sudo'd so non-admin users (Sales Reps viewing the dashboard) don't AccessError on partner_id.x_fc_rush / part_catalog_id.name / customer_spec_id.code. The Open-action navigates via standard act_window which re-enforces ACL on click (Rule 24 / D15). """ BANNER_MAX = 6 SECTION_TOP_N = 5 def __init__(self, env): self.env = env self.now = fields.Datetime.now() self.today = fields.Date.context_today(env.user) def build(self): sections = [] all_banner_candidates = [] for type_code in SECTION_ORDER: cfg = TYPE_CONFIG[type_code] if cfg['model'] not in self.env: continue Model = self.env[cfg['model']].sudo() section = self._build_section(type_code) if section is None: continue sections.append(section) # Collect banner candidates with type_code attached for rec, urgency, badge in self._fetch_banner_candidates( type_code, Model): all_banner_candidates.append( (rec, urgency, badge, type_code), ) banner = self._build_banner(all_banner_candidates) return { 'banner': banner, 'sections': sections, 'computed_at': self.now.isoformat(), } def _build_section(self, type_code): """Return section dict with top-N items + counts. Returns None when the model isn't installed.""" cfg = TYPE_CONFIG[type_code] if cfg['model'] not in self.env: return None # Open/overdue counts using the existing state filters state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain']) Model = self.env[cfg['model']].sudo() open_count = Model.search_count(state_dom) overdue_count = self._overdue_count(type_code, Model) return { 'type': type_code, 'label': cfg['label'], 'icon': cfg['icon'], 'open': open_count, 'overdue': overdue_count, 'items': self._fetch_section_items(type_code, Model), 'open_kanban_xmlid': cfg['kanban_xmlid'], } def _overdue_count(self, type_code, Model): """Count overdue per the OVERDUE_THRESHOLDS dispatch table.""" cfg = OVERDUE_THRESHOLDS[type_code] dom = list(cfg['state_domain']) if cfg['use_due_date']: dom += [('due_date', '<', self.today), ('due_date', '!=', False)] else: cutoff = self.now - timedelta(days=cfg['days']) dom += [('create_date', '<', cutoff)] return Model.search_count(dom) def _fetch_section_items(self, type_code, Model): """Top-N open records for the type, ordered by urgency (overdue first by oldest create_date, then by create_date asc). Returns list of item dicts in the snapshot shape. """ state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain']) # We need urgency labels per record β€” fetch open set, sort in # Python by (overdue_flag, age) so the top-N reflects urgency. recs = Model.search(state_dom, limit=200) # safety cap if not recs: return [] overdue_ids = set(self._overdue_ids(type_code, Model)) # Sort: overdue first (by oldest create_date), then non-overdue # by oldest create_date. def rank_key(r): is_od = r.id in overdue_ids return (0 if is_od else 1, r.create_date or self.now) recs_sorted = sorted(recs, key=rank_key) top = recs_sorted[:self.SECTION_TOP_N] return [self._build_item(type_code, r, r.id in overdue_ids) for r in top] def _overdue_ids(self, type_code, Model): """IDs of overdue records for the type β€” reuses _overdue_count domain logic.""" cfg = OVERDUE_THRESHOLDS[type_code] dom = list(cfg['state_domain']) if cfg['use_due_date']: dom += [('due_date', '<', self.today), ('due_date', '!=', False)] else: cutoff = self.now - timedelta(days=cfg['days']) dom += [('create_date', '<', cutoff)] return Model.search(dom).ids def _build_item(self, type_code, rec, is_overdue): """Shape one record into the snapshot item dict.""" cfg = TYPE_CONFIG[type_code] # Customer name β€” partner_id direct, OR via job_id for check partner = self._resolve_partner(rec) return { 'id': rec.id, 'name': rec.display_name or rec.name or f'#{rec.id}', 'customer': partner.name if partner else '', 'subtitle': self._build_subtitle(type_code, rec, is_overdue), 'urgency': 'overdue' if is_overdue else 'normal', 'critical_badge': None, # populated in Task 4 'open_action': { 'res_model': cfg['model'], 'res_id': rec.id, }, } def _resolve_partner(self, rec): """Walk to partner: direct partner_id, or via job_id (checks), or via ncr_id (capa). Defensive on missing fields.""" if 'partner_id' in rec._fields and rec.partner_id: return rec.partner_id if 'job_id' in rec._fields and rec.job_id \ and 'partner_id' in rec.job_id._fields: return rec.job_id.partner_id if 'ncr_id' in rec._fields and rec.ncr_id \ and 'partner_id' in rec.ncr_id._fields: return rec.ncr_id.partner_id return False def _build_subtitle(self, type_code, rec, is_overdue): """Second-line text per type. Implementation-phase choice per spec open question 1.""" # Age in human terms β€” hours if < 24h, days otherwise if rec.create_date: age = self.now - rec.create_date hours = int(age.total_seconds() / 3600) age_str = (f'{hours}h' if hours < 24 else f'{hours // 24}d') else: age_str = '' if is_overdue: return f'{age_str} overdue' if age_str else 'overdue' return age_str # ===== Banner ===================================================== def _fetch_banner_candidates(self, type_code, Model): """Per-type pull of records that qualify for the banner: (overdue) OR (critical-customer AND state-is-open). Returns list of (rec, urgency, critical_badge) tuples β€” deduped. """ overdue_ids = set(self._overdue_ids(type_code, Model)) critical_ids = set(self._critical_customer_ids(type_code, Model)) # Open state filter (anything in the type's state_domain) state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain']) open_recs = Model.search(state_dom) results = [] for rec in open_recs: is_overdue = rec.id in overdue_ids is_critical = rec.id in critical_ids if not (is_overdue or is_critical): continue urgency = 'overdue' if is_overdue else 'critical_customer' badge = None if is_overdue else self._critical_badge(rec) results.append((rec, urgency, badge)) return results def _critical_customer_ids(self, type_code, Model): """IDs of records belonging to critical customers (rush / vip / aerospace). Defensive on missing fields per Rule 24.""" partner_field_map = { 'cert': 'partner_id', 'hold': 'partner_id', 'ncr': 'partner_id', 'rma': 'partner_id', 'capa': 'partner_id', # may be False if linked via ncr_id 'check': 'job_id.partner_id', } # Find partners with critical flags Partner = self.env['res.partner'].sudo() critical_partner_clauses = [] if 'x_fc_rush' in Partner._fields: critical_partner_clauses.append(('x_fc_rush', '=', True)) if 'x_fc_vip' in Partner._fields: critical_partner_clauses.append(('x_fc_vip', '=', True)) if not critical_partner_clauses: return [] # no flags defined in this codebase # OR the conditions critical_partner_domain = list(critical_partner_clauses) if len(critical_partner_clauses) > 1: critical_partner_domain = ( ['|'] * (len(critical_partner_clauses) - 1) + critical_partner_clauses ) critical_partner_ids = Partner.search(critical_partner_domain).ids if not critical_partner_ids: return [] partner_path = partner_field_map.get(type_code, 'partner_id') # Compose the record-side filter β€” direct or via dotted path dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain']) dom.append((partner_path, 'in', critical_partner_ids)) try: return Model.search(dom).ids except Exception as e: # Field doesn't exist on the model (e.g. fusion.plating.capa # may not have partner_id directly). Best-effort: skip. _logger.debug( "critical_customer probe failed for %s: %s", type_code, e, ) return [] def _critical_badge(self, rec): """Return 'RUSH' / 'VIP' / 'AEROSPACE' / 'AS9100' / None based on which signal qualified the record. Defensive on missing fields.""" partner = self._resolve_partner(rec) if partner: if 'x_fc_rush' in partner._fields and getattr(partner, 'x_fc_rush', False): return 'RUSH' if 'x_fc_vip' in partner._fields and getattr(partner, 'x_fc_vip', False): return 'VIP' # Aerospace β€” check part name OR spec code if reachable for path in ('part_catalog_id.name', 'customer_spec_id.code'): head, _, attr = path.partition('.') if head in rec._fields and rec[head] \ and attr in rec[head]._fields: val = (rec[head][attr] or '').upper() if 'AEROSPACE' in val: return 'AEROSPACE' if val.startswith('AS9100') or val.startswith('NADCAP'): return 'AS9100' return None def _build_banner(self, all_candidates): """Rank: overdue first by oldest create_date, then critical- customer by oldest create_date. Take top 6. all_candidates: list of (rec, urgency, badge, type_code) tuples. """ def rank_key(tup): rec, urgency, _badge, _t = tup # overdue=0 sorts before critical_customer=1; then oldest first. return (0 if urgency == 'overdue' else 1, rec.create_date or self.now) ranked = sorted(all_candidates, key=rank_key) top = ranked[:self.BANNER_MAX] return { 'items': [self._build_banner_item(t) for t in top], 'all_clear': len(all_candidates) == 0, 'total_matching': len(all_candidates), } def _build_banner_item(self, tup): """Shape one banner item from (rec, urgency, badge, type_code).""" rec, urgency, badge, type_code = tup cfg = TYPE_CONFIG[type_code] partner = self._resolve_partner(rec) is_overdue = (urgency == 'overdue') return { 'type': type_code, 'id': rec.id, 'name': rec.display_name or rec.name or f'#{rec.id}', 'customer': partner.name if partner else '', 'subtitle': self._build_subtitle(type_code, rec, is_overdue), 'urgency': urgency, 'critical_badge': badge, 'open_action': { 'res_model': cfg['model'], 'res_id': rec.id, }, } class FpQualityDashboardController(http.Controller): @http.route('/fp/quality/dashboard/snapshot', type='jsonrpc', auth='user', methods=['POST']) def snapshot(self): """Return the full dashboard snapshot. See spec Β§Data model.""" return FpQualityDashboardSnapshot(request.env).build()