_fetch_banner_candidates collects (overdue) OR (critical-customer + open) records per type. _critical_customer_ids reuses partner.x_fc_rush and partner.x_fc_vip flags when defined (gracefully no-ops when absent). _critical_badge returns RUSH/VIP/AEROSPACE/AS9100 label when the banner reason is critical-customer (no badge when overdue). _build_banner ranks: overdue first by oldest, then critical-customer by oldest, takes top 6, reports total_matching. build() now collects banner candidates from every section in one pass + invokes _build_banner once. Tests cover overdue hold pickup, 6-cap with overflow count, and all_clear when DB is empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
364 lines
15 KiB
Python
364 lines
15 KiB
Python
# -*- 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 = []
|
|
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()
|