# -*- 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_fusion_plating_quality_hold'}, 'ncr': {'model': 'fusion.plating.ncr', 'label': 'NCRs', 'icon': 'πŸ”¬', 'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_ncr'}, 'rma': {'model': 'fusion.plating.rma', 'label': 'RMAs', 'icon': '↩️', 'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_rma'}, 'capa': {'model': 'fusion.plating.capa', 'label': 'CAPAs', 'icon': 'πŸ“‹', 'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_capa'}, 'check': {'model': 'fusion.plating.quality.check', 'label': 'QC Checks', 'icon': 'βœ“', 'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_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 = [] for type_code in SECTION_ORDER: section = self._build_section(type_code) if section is None: continue # model not installed β€” omit sections.append(section) # Banner placeholder for Task 2 β€” fully implemented in Task 4. banner = {'items': [], 'all_clear': True, 'total_matching': 0} 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 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()