feat(quality_dashboard): snapshot endpoint scaffold (Task 2)
Replaces /counts with /snapshot. Helper class FpQualityDashboardSnapshot returns response with correct shape — banner placeholder + per-type sections with open/overdue counts (reuses old counts endpoint thresholds). Items + critical-customer banner come in Tasks 3-5. Per CLAUDE.md Rule 13m, Model.sudo() on cross-module reads. Per Rule 24 the in-self.env check guards missing-model paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,99 +2,146 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
#
|
"""Quality Dashboard snapshot endpoint.
|
||||||
# Sub 12 Phase D — counts endpoint for the Unified Quality Dashboard.
|
|
||||||
|
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 import fields, http
|
||||||
from odoo.http import request
|
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)
|
||||||
|
# Section items — fully populated in Task 3; empty for now
|
||||||
|
return {
|
||||||
|
'type': type_code,
|
||||||
|
'label': cfg['label'],
|
||||||
|
'icon': cfg['icon'],
|
||||||
|
'open': open_count,
|
||||||
|
'overdue': overdue_count,
|
||||||
|
'items': [],
|
||||||
|
'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)
|
||||||
|
|
||||||
|
|
||||||
class FpQualityDashboardController(http.Controller):
|
class FpQualityDashboardController(http.Controller):
|
||||||
|
|
||||||
@http.route('/fp/quality/dashboard/counts',
|
@http.route('/fp/quality/dashboard/snapshot',
|
||||||
type='jsonrpc', auth='user', methods=['POST'])
|
type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def counts(self):
|
def snapshot(self):
|
||||||
"""Return per-tab open + overdue counts for the dashboard.
|
"""Return the full dashboard snapshot. See spec §Data model."""
|
||||||
|
return FpQualityDashboardSnapshot(request.env).build()
|
||||||
"Overdue" definition:
|
|
||||||
- Hold: state='on_hold' for > 3 days
|
|
||||||
- Check: state='pending' for > 1 day
|
|
||||||
- NCR: state in (open, containment, disposition) AND reported >7d
|
|
||||||
- CAPA: due_date < today AND state not in (effective, closed)
|
|
||||||
- RMA: state='received' for > 5 days (triage past due) OR
|
|
||||||
state in (authorised, shipped_to_us) for > 14 days
|
|
||||||
- Certificate: state='draft' for > 1 day (spec 2026-05-25)
|
|
||||||
"""
|
|
||||||
env = request.env
|
|
||||||
today = fields.Date.context_today(env.user)
|
|
||||||
now = fields.Datetime.now()
|
|
||||||
|
|
||||||
Hold = env['fusion.plating.quality.hold']
|
|
||||||
Check = env['fusion.plating.quality.check']
|
|
||||||
Ncr = env['fusion.plating.ncr']
|
|
||||||
Capa = env['fusion.plating.capa']
|
|
||||||
Rma = env['fusion.plating.rma']
|
|
||||||
Cert = env['fp.certificate'] if 'fp.certificate' in env else None
|
|
||||||
|
|
||||||
d3 = fields.Datetime.subtract(now, days=3)
|
|
||||||
d1 = fields.Datetime.subtract(now, days=1)
|
|
||||||
d7 = fields.Datetime.subtract(now, days=7)
|
|
||||||
d5 = fields.Datetime.subtract(now, days=5)
|
|
||||||
d14 = fields.Datetime.subtract(now, days=14)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'holds': {
|
|
||||||
'open': Hold.search_count(
|
|
||||||
[('state', 'in', ('on_hold', 'under_review'))]),
|
|
||||||
'overdue': Hold.search_count([
|
|
||||||
('state', 'in', ('on_hold', 'under_review')),
|
|
||||||
('create_date', '<', d3),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
'checks': {
|
|
||||||
'open': Check.search_count([('state', '=', 'pending')]),
|
|
||||||
'overdue': Check.search_count([
|
|
||||||
('state', '=', 'pending'),
|
|
||||||
('create_date', '<', d1),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
'ncrs': {
|
|
||||||
'open': Ncr.search_count([
|
|
||||||
('state', 'in', ('open', 'containment', 'disposition')),
|
|
||||||
]),
|
|
||||||
'overdue': Ncr.search_count([
|
|
||||||
('state', 'in', ('open', 'containment', 'disposition')),
|
|
||||||
('reported_date', '<', d7),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
'capas': {
|
|
||||||
'open': Capa.search_count([
|
|
||||||
('state', 'not in', ('effective', 'closed')),
|
|
||||||
]),
|
|
||||||
'overdue': Capa.search_count([
|
|
||||||
('state', 'not in', ('effective', 'closed')),
|
|
||||||
('due_date', '<', today),
|
|
||||||
('due_date', '!=', False),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
'rmas': {
|
|
||||||
'open': Rma.search_count([
|
|
||||||
('state', 'not in', ('closed', 'cancelled')),
|
|
||||||
]),
|
|
||||||
'overdue': Rma.search_count([
|
|
||||||
'|',
|
|
||||||
'&', ('state', '=', 'received'),
|
|
||||||
('create_date', '<', d5),
|
|
||||||
'&', ('state', 'in', ('authorised', 'shipped_to_us')),
|
|
||||||
('create_date', '<', d14),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
# Spec 2026-05-25 — Certificates tab
|
|
||||||
'certificates': ({
|
|
||||||
'open': Cert.search_count([('state', '=', 'draft')]),
|
|
||||||
'overdue': Cert.search_count([
|
|
||||||
('state', '=', 'draft'),
|
|
||||||
('create_date', '<', d1),
|
|
||||||
]),
|
|
||||||
} if Cert is not None else {'open': 0, 'overdue': 0}),
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user