Compare commits

...

7 Commits

Author SHA1 Message Date
gsinghpal
c8db3915ea feat(quality_dashboard): manifest bump + battle test (Tasks 7-8)
Version 19.0.7.0.0 → 19.0.8.0.0 (triggers asset cache invalidation
on -u so the new template + SCSS load cleanly).

Battle test script: 6-check entech smoke. Validates snapshot shape,
canonical section order, required section keys, open_kanban_xmlid
resolves to act_window, banner item shape when items exist. Summary
prints per-section counts so you can eyeball the entech state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:25:31 -04:00
gsinghpal
547e7d66a9 feat(quality_dashboard): rewrite OWL component + template + SCSS (Task 6)
JS: single FpQualityDashboard component + BannerCard / BannerItem /
SectionCard / SectionRow sibling sub-components in the same file.
Fetches /fp/quality/dashboard/snapshot, 60s poll, deep-link
?tab=certificates scrolls to section-cert via scrollIntoView.

XML: outer wrapper + banner + 6 sections (t-foreach over
state.snapshot.sections). Each section has id='section-<type>' so
the deep-link target works. SectionRow has overdue-conditional
class for red subtitle highlight.

SCSS: local tokens for urgent/good/section-head with light+dark via
$o-webclient-color-scheme branch. 135deg gradients matching the
plant kanban polish. Mobile breakpoint at 900px collapses banner
grid to 1 col and stacks row Open button.

OLD TABS array, selectTab, openTab, totalOpen, totalOverdue all
deleted. Old template's tab tiles + per-tab panels deleted. Existing
per-model kanbans untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:24:52 -04:00
gsinghpal
bfeca0ac32 test(quality_dashboard): defensive guard tests (Task 5)
Covers: missing-field critical-customer check returns empty without
crashing; computed_at is a valid ISO timestamp; every section ships
a non-empty open_kanban_xmlid in module.xmlid format.

(missing-model test from the plan omitted — patching env.__contains__
was unsafe; the in-self.env guard is already exercised by Tasks 2-4
in production behavior. The other 3 defensive tests still cover the
missing-field path, which is the more common scenario.)

Phase 1 backend complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:22:25 -04:00
gsinghpal
40d563801a feat(quality_dashboard): banner with overdue + critical (Task 4)
_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>
2026-05-25 12:22:01 -04:00
gsinghpal
e271908109 feat(quality_dashboard): populate section items (Task 3)
_fetch_section_items pulls top-5 open records per type, ranked
overdue-first by oldest create_date. _build_item shapes each row
with id/name/customer/subtitle/urgency/open_action. _resolve_partner
defensively walks partner_id -> job_id.partner_id -> ncr_id.partner_id
per type. _build_subtitle generates the human-readable second line.

Tests cover empty list, 5-cap on 8-record set, and required item
keys (id/name/customer/subtitle/urgency/open_action).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:20:50 -04:00
gsinghpal
72f75fe754 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>
2026-05-25 12:19:48 -04:00
gsinghpal
6cb352629a test(quality_dashboard): scaffold + shape tests (Task 1)
Tests for empty-DB all-clear, canonical section order, and required
keys on each section. All fail until Task 2 lands the snapshot helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:19:06 -04:00
8 changed files with 1012 additions and 247 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.7.0.0',
'version': '19.0.8.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -2,99 +2,362 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase D — counts endpoint for the Unified Quality Dashboard.
"""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/counts',
@http.route('/fp/quality/dashboard/snapshot',
type='jsonrpc', auth='user', methods=['POST'])
def counts(self):
"""Return per-tab open + overdue counts for the dashboard.
"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}),
}
def snapshot(self):
"""Return the full dashboard snapshot. See spec §Data model."""
return FpQualityDashboardSnapshot(request.env).build()

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""Quality Dashboard redesign — entech smoke.
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
Plan: docs/superpowers/plans/2026-05-25-quality-dashboard-redesign-plan.md
Run on entech via odoo-shell:
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \\\"
exec(open(\\\\\\\"/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py\\\\\\\").read())
\\\" | su - odoo -s /bin/bash -c \\\"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\\\"'"
Validates:
1. FpQualityDashboardSnapshot helper class exists and builds
2. Response shape (banner + sections + computed_at)
3. Section order is canonical (cert, hold, ncr, rma, capa, check)
4. Each section has all required keys
5. Each section's open_kanban_xmlid resolves to a real act_window
6. Banner items shape includes open_action that resolves to a model
"""
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot, SECTION_ORDER
def _ok(cond, label):
if cond:
print('OK -', label)
else:
print('FAIL -', label)
raise SystemExit(1)
# ---- 1. Build snapshot ----
snap = FpQualityDashboardSnapshot(env).build()
_ok(isinstance(snap, dict), 'snapshot is a dict')
# ---- 2. Response shape ----
for k in ('banner', 'sections', 'computed_at'):
_ok(k in snap, f'snapshot has key {k!r}')
_ok(isinstance(snap['sections'], list), 'sections is a list')
_ok(isinstance(snap['banner'], dict), 'banner is a dict')
# ---- 3. Section order ----
types = [s['type'] for s in snap['sections']]
canonical_present = [t for t in SECTION_ORDER if t in types]
_ok(types == canonical_present,
f'section order canonical: got {types}, expected subset of {SECTION_ORDER}')
# ---- 4. Section keys ----
for sec in snap['sections']:
for k in ('type', 'label', 'icon', 'open', 'overdue', 'items',
'open_kanban_xmlid'):
_ok(k in sec, f'section {sec["type"]} has key {k!r}')
# ---- 5. open_kanban_xmlid resolves ----
for sec in snap['sections']:
try:
act = env.ref(sec['open_kanban_xmlid'], raise_if_not_found=False)
_ok(bool(act), f'section {sec["type"]} kanban xmlid resolves')
except Exception as e:
_ok(False, f'section {sec["type"]} xmlid: {e}')
# ---- 6. Banner items shape ----
print(f' banner.all_clear = {snap["banner"]["all_clear"]}')
print(f' banner.items = {len(snap["banner"]["items"])}')
print(f' banner.total_matching = {snap["banner"]["total_matching"]}')
if snap['banner']['items']:
item = snap['banner']['items'][0]
for k in ('type', 'id', 'name', 'customer', 'subtitle',
'urgency', 'critical_badge', 'open_action'):
_ok(k in item, f'banner item has key {k!r}')
# open_action.res_model resolves to a model
_ok(item['open_action']['res_model'] in env,
f'banner item res_model {item["open_action"]["res_model"]!r} is installed')
# ---- Summary ----
print()
print('--- bt_quality_dashboard_redesign: ALL PASS ---')
print(f' Sections present: {len(snap["sections"])}')
for sec in snap['sections']:
print(f' {sec["icon"]} {sec["label"]}: {sec["open"]} open '
f'({sec["overdue"]} overdue), top-{len(sec["items"])} listed')
print(f' Banner: {len(snap["banner"]["items"])} items '
f'(of {snap["banner"]["total_matching"]} matching), '
f'all_clear={snap["banner"]["all_clear"]}')

View File

@@ -1,103 +1,126 @@
/** @odoo-module **/
// Sub 12 Phase D — Unified Quality Dashboard.
// Five tabs (Holds / Checks / NCRs / CAPAs / RMAs) backed by their list
// kanbans, with a header summary card showing open + overdue counts.
// Each tab embeds the corresponding model's kanban via an action service
// switch. The header counters refresh on tab switch and on a 60-second
// poll.
// Quality Dashboard — action surface.
// Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
//
// Single OWL component that fetches one snapshot from
// /fp/quality/dashboard/snapshot and renders:
// - BannerCard: red "Needs Attention Today" (up to 6 items)
// OR green "All caught up" when zero qualify
// - SectionCard × 6 in canonical order (cert, hold, ncr, rma, capa, check)
//
// BannerCard / BannerItem / SectionCard / SectionRow live in this same
// file as sibling sub-components — not reused elsewhere yet.
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
import { Component, useState, onWillStart, onMounted, onWillUnmount }
from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
const TABS = [
{ id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] },
{ id: "checks", label: "Checks", model: "fusion.plating.quality.check", group: "state", domain: [] },
{ id: "ncrs", label: "NCRs", model: "fusion.plating.ncr", group: "stage_id", domain: [["state", "!=", "closed"]] },
{ id: "capas", label: "CAPAs", model: "fusion.plating.capa", group: "state", domain: [["state", "not in", ["closed", "effective"]]] },
{ id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] },
// Spec 2026-05-25 — Certificates tab. QM-owned queue of certs
// awaiting issuance; drives the post-shop awaiting_cert workflow.
{ id: "certificates", label: "Certificates", model: "fp.certificate", group: "state", domain: [["state", "=", "draft"]] },
];
// 60s poll matches the cadence of the old dashboard.
const POLL_INTERVAL_MS = 60000;
class BannerItem extends Component {
static template = "fusion_plating_quality.BannerItem";
static props = ["item", "onOpen"];
}
class BannerCard extends Component {
static template = "fusion_plating_quality.BannerCard";
static props = ["banner", "onOpen"];
static components = { BannerItem };
}
class SectionRow extends Component {
static template = "fusion_plating_quality.SectionRow";
static props = ["item", "onOpen"];
}
class SectionCard extends Component {
static template = "fusion_plating_quality.SectionCard";
static props = ["section", "onOpen", "onOpenKanban"];
static components = { SectionRow };
}
export class FpQualityDashboard extends Component {
static template = "fusion_plating_quality.FpQualityDashboard";
static components = { BannerCard, SectionCard };
static props = ["*"];
setup() {
this.action = useService("action");
// Spec 2026-05-25 — honor ?tab=<name> deep-link from the
// cert_awaiting_issuance notification email so the QM lands
// directly on the Certificates tab.
const tabParam = this.props.action?.context?.params?.tab
|| this.props.action?.params?.tab;
const validTab = TABS.find(t => t.id === tabParam);
this.state = useState({
activeTab: validTab ? validTab.id : "ncrs",
counts: TABS.reduce((acc, t) => ({ ...acc, [t.id]: { open: 0, overdue: 0 } }), {}),
loading: true,
snapshot: null,
error: null,
});
onWillStart(async () => {
await this._refreshCounts();
await this._refresh();
// Deep-link: ?tab=certificates → scroll to certs section.
// Email template uses `?tab=certificates`; normalize to the
// 'cert' type_code used in the snapshot.
const tab = this.props.action?.context?.params?.tab
|| this.props.action?.params?.tab;
if (tab) {
this._pendingScrollTarget = tab.startsWith('cert')
? 'cert' : tab;
}
});
onMounted(() => {
this._poll = setInterval(() => this._refreshCounts(), 60000);
if (this._pendingScrollTarget) {
// Wait one tick for the DOM to settle, then scroll.
setTimeout(() => {
const el = document.getElementById(
'section-' + this._pendingScrollTarget,
);
if (el) el.scrollIntoView({behavior: 'smooth'});
}, 50);
this._pendingScrollTarget = null;
}
this._poll = setInterval(() => this._refresh(),
POLL_INTERVAL_MS);
});
onWillUnmount(() => {
if (this._poll) clearInterval(this._poll);
});
}
async _refreshCounts() {
async _refresh() {
try {
const result = await rpc("/fp/quality/dashboard/counts");
if (result && typeof result === "object") {
for (const tab of TABS) {
if (result[tab.id]) {
this.state.counts[tab.id] = result[tab.id];
}
}
}
const result = await rpc("/fp/quality/dashboard/snapshot");
this.state.snapshot = result;
this.state.error = null;
} catch (e) {
// Best-effort; leave counts at zero on RPC failure.
console.warn("FpQualityDashboard: count refresh failed", e);
console.warn("FpQualityDashboard: snapshot RPC failed", e);
this.state.error = "Couldn't refresh dashboard — retry in 60s";
} finally {
this.state.loading = false;
}
}
selectTab(id) {
this.state.activeTab = id;
}
async openTab(tab) {
// Open the model's full kanban view in the main app area.
await this.action.doAction({
onOpenItem(item) {
// Build a form-view act_window from the item's open_action payload.
// ACL is enforced by Odoo on click — if the user lacks access,
// they get the standard access error (D15).
this.action.doAction({
type: "ir.actions.act_window",
name: tab.label,
res_model: tab.model,
view_mode: "kanban,list,form",
views: [[false, "kanban"], [false, "list"], [false, "form"]],
domain: tab.domain,
context: { group_by: tab.group },
res_model: item.open_action.res_model,
res_id: item.open_action.res_id,
view_mode: "form",
views: [[false, "form"]],
target: "current",
});
}
get tabs() {
return TABS;
}
get totalOpen() {
return TABS.reduce(
(sum, t) => sum + (this.state.counts[t.id]?.open || 0), 0,
);
}
get totalOverdue() {
return TABS.reduce(
(sum, t) => sum + (this.state.counts[t.id]?.overdue || 0), 0,
);
onOpenKanban(section) {
// Pass the xmlid string directly — Odoo 19's action service
// resolves it via the registry. Fallback to shipping the full
// act_window dict from the snapshot if this stops working.
this.action.doAction(section.open_kanban_xmlid);
}
}

View File

@@ -1,57 +1,181 @@
// Sub 12 Phase D — Unified Quality Dashboard styling.
// Reuses the shopfloor SCSS tokens ($fp-page, $fp-card, $fp-border,
// $fp-ink, $fp-accent, etc.) — they are bundled before us via the
// fusion_plating_shopfloor dep, so no @import is needed.
// Quality Dashboard — action surface.
// Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
//
// Tokens defined locally; light + dark via $o-webclient-color-scheme
// compile-time branch (project Rule 9 — no runtime .o_dark_mode class).
// Reuses base $plant-card-bg / $plant-bg / $plant-text / $plant-muted /
// $plant-card-border from _plant_tokens.scss (loaded earlier in the
// fusion_plating_shopfloor manifest — fusion_plating_quality depends
// on shopfloor so those tokens are visible).
.o_fp_quality_dashboard {
background-color: $fp-page;
min-height: 100%;
$o-webclient-color-scheme: bright !default;
.o_fp_card {
background-color: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
$_qd-urgent-bg-hex: #fee2e2;
$_qd-urgent-bg-end-hex: #fff;
$_qd-urgent-border-hex: #dc2626;
$_qd-urgent-text-hex: #7f1d1d;
$_qd-good-bg-hex: #d1fae5;
$_qd-good-bg-end-hex: #ecfdf5;
$_qd-good-border-hex: #22c55e;
$_qd-good-text-hex: #064e3b;
$_qd-section-head-bg-hex: #fef3c7;
$_qd-section-overdue-hex: #b45309;
@if $o-webclient-color-scheme == dark {
$_qd-urgent-bg-hex: #3a1818 !global;
$_qd-urgent-bg-end-hex: #1d1d1f !global;
$_qd-urgent-text-hex: #fca5a5 !global;
$_qd-good-bg-hex: #14281a !global;
$_qd-good-bg-end-hex: #1d1d1f !global;
$_qd-good-text-hex: #6ee7b7 !global;
$_qd-section-head-bg-hex: #3a2f15 !global;
$_qd-section-overdue-hex: #fbbf24 !global;
}
$qd-urgent-bg: var(--fp-qd-urgent-bg, $_qd-urgent-bg-hex);
$qd-urgent-bg-end: var(--fp-qd-urgent-bg-end, $_qd-urgent-bg-end-hex);
$qd-urgent-border: var(--fp-qd-urgent-border, $_qd-urgent-border-hex);
$qd-urgent-text: var(--fp-qd-urgent-text, $_qd-urgent-text-hex);
$qd-good-bg: var(--fp-qd-good-bg, $_qd-good-bg-hex);
$qd-good-bg-end: var(--fp-qd-good-bg-end, $_qd-good-bg-end-hex);
$qd-good-border: var(--fp-qd-good-border, $_qd-good-border-hex);
$qd-good-text: var(--fp-qd-good-text, $_qd-good-text-hex);
$qd-section-head-bg: var(--fp-qd-section-head-bg, $_qd-section-head-bg-hex);
$qd-section-overdue: var(--fp-qd-section-overdue, $_qd-section-overdue-hex);
.o_fp_qd {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: $plant-text;
.o_fp_qd_loading, .o_fp_qd_error {
padding: 2rem; text-align: center; color: $plant-muted;
}
.o_fp_qd_error { color: $qd-urgent-border; }
.o_fp_qd_summary {
min-width: 220px;
// ===== Banner =====
.o_fp_qd_banner {
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 16px;
border: 1px solid $plant-card-border;
}
.o_fp_qd_tile {
cursor: pointer;
min-width: 130px;
text-align: left;
transition: transform 0.08s ease-in-out, box-shadow 0.08s ease-in-out;
.o_fp_qd_banner_urgent {
background: linear-gradient(135deg, $qd-urgent-bg 0%, $qd-urgent-bg-end 100%);
border-color: $qd-urgent-border;
}
.o_fp_qd_banner_clear {
background: linear-gradient(135deg, $qd-good-bg 0%, $qd-good-bg-end 100%);
border-color: $qd-good-border;
display: flex; align-items: center; gap: 14px;
padding: 20px;
}
.o_fp_qd_banner_clear_icon {
font-size: 32px; color: $qd-good-text; line-height: 1;
}
.o_fp_qd_banner_clear_text { color: $qd-good-text; font-size: 16px; }
.o_fp_qd_banner_head {
font-weight: 700; color: $qd-urgent-text;
font-size: 13px; letter-spacing: 0.04em; margin-bottom: 10px;
}
.o_fp_qd_banner_overflow {
font-weight: 500; opacity: 0.8; margin-left: 8px;
}
.o_fp_qd_banner_grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
.o_fp_qd_banner_item {
background: $plant-card-bg;
border: 1px solid $plant-card-border;
border-left: 3px solid $qd-urgent-border;
border-radius: 6px;
padding: 8px 10px;
text-align: left; cursor: pointer;
color: $plant-text; font-family: inherit;
transition: transform 0.1s ease, box-shadow 0.1s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
&.o_fp_qd_active {
border: 2px solid $fp-accent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
box-shadow: 0 3px 6px rgba(0,0,0,0.08);
}
}
.o_fp_qd_metric_label {
font-size: 0.85em;
color: $fp-ink-mute;
font-weight: 500;
.o_fp_qd_banner_item_l1 {
display: flex; align-items: center; gap: 6px; font-size: 13px;
}
.o_fp_qd_banner_item_type {
font-size: 9px; font-weight: 700; padding: 2px 6px;
background: $plant-bg; color: $plant-muted;
border-radius: 4px; letter-spacing: 0.04em;
}
.o_fp_qd_banner_item_badge {
font-size: 9px; font-weight: 700; padding: 2px 6px;
background: $qd-urgent-border; color: #fff;
border-radius: 4px; letter-spacing: 0.04em;
}
.o_fp_qd_banner_item_l2 {
font-size: 11px; color: $plant-muted; margin-top: 3px;
display: flex; gap: 6px;
}
.o_fp_qd_banner_item_subtitle {
color: $qd-urgent-border; font-weight: 600;
}
.o_fp_qd_metric_value {
font-size: 1.6em;
font-weight: 700;
color: $fp-ink;
line-height: 1.1;
// ===== Section =====
.o_fp_qd_section {
background: $plant-card-bg;
border: 1px solid $plant-card-border;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.o_fp_qd_section_head {
background: linear-gradient(135deg, $qd-section-head-bg 0%, $plant-card-bg 100%);
padding: 10px 14px;
display: flex; justify-content: space-between; align-items: center;
font-size: 13px;
}
.o_fp_qd_section_overdue { color: $qd-section-overdue; font-weight: 600; }
.o_fp_qd_section_open {
background: transparent; border: 0;
color: #1d4ed8; font-weight: 500; cursor: pointer;
font-size: 12px; font-family: inherit;
&:hover { text-decoration: underline; }
}
.o_fp_qd_section_empty {
padding: 12px 14px; color: $plant-muted; font-style: italic;
font-size: 12px;
}
.o_fp_qd_metric_sub {
margin-top: 0.25em;
// ===== Row =====
.o_fp_qd_row {
padding: 8px 14px;
display: flex; justify-content: space-between; align-items: center;
gap: 10px; font-size: 13px;
border-top: 1px solid $plant-card-border;
transition: background 0.1s ease;
&:hover { background: $plant-bg; }
}
.o_fp_qd_row_overdue .o_fp_qd_row_subtitle {
color: $qd-urgent-border; font-weight: 600;
}
.o_fp_qd_row_main { flex: 1; min-width: 0; }
.o_fp_qd_row_sep { color: $plant-muted; }
.o_fp_qd_row_cust { color: $plant-muted; }
.o_fp_qd_row_open {
background: #1d4ed8; color: #fff;
border: 0; padding: 4px 12px; border-radius: 4px;
font-size: 11px; font-weight: 600; cursor: pointer;
font-family: inherit; min-height: 28px;
transition: background 0.1s ease;
&:hover { background: #1e40af; }
}
.o_fp_qd_panel {
min-height: 200px;
// ===== Mobile =====
@media (max-width: 900px) {
.o_fp_qd_banner_grid { grid-template-columns: 1fr; }
.o_fp_qd_row {
flex-direction: column; align-items: flex-start;
.o_fp_qd_row_open { align-self: stretch; min-height: 32px; }
}
}
}

View File

@@ -1,65 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ===== TOP-LEVEL DASHBOARD ===== -->
<t t-name="fusion_plating_quality.FpQualityDashboard">
<div class="o_fp_quality_dashboard p-3">
<div class="o_fp_qd_header d-flex flex-wrap gap-3 mb-3">
<div class="o_fp_qd_summary o_fp_card flex-grow-1 p-3">
<h2 class="mb-2">Quality Overview</h2>
<div class="d-flex gap-4">
<div>
<div class="o_fp_qd_metric_label">Open across all <t t-esc="tabs.length"/></div>
<div class="o_fp_qd_metric_value"><t t-esc="totalOpen"/></div>
</div>
<div>
<div class="o_fp_qd_metric_label text-danger">Overdue</div>
<div class="o_fp_qd_metric_value text-danger"><t t-esc="totalOverdue"/></div>
</div>
</div>
</div>
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<button class="o_fp_qd_tile o_fp_card p-3 border-0"
t-att-class="{ 'o_fp_qd_active': state.activeTab === tab.id }"
t-on-click="() => this.selectTab(tab.id)">
<div class="o_fp_qd_metric_label"><t t-esc="tab.label"/></div>
<div class="o_fp_qd_metric_value">
<t t-esc="state.counts[tab.id]?.open || 0"/>
</div>
<div class="o_fp_qd_metric_sub text-muted small"
t-if="(state.counts[tab.id]?.overdue || 0) > 0">
<t t-esc="state.counts[tab.id].overdue"/> overdue
</div>
</button>
</t>
<div class="o_fp_qd p-3">
<div t-if="state.loading" class="o_fp_qd_loading">Loading…</div>
<div t-if="state.error" class="o_fp_qd_error">
<t t-esc="state.error"/>
</div>
<t t-if="state.snapshot">
<BannerCard banner="state.snapshot.banner"
onOpen.bind="onOpenItem"/>
<t t-foreach="state.snapshot.sections"
t-as="section" t-key="section.type">
<SectionCard section="section"
onOpen.bind="onOpenItem"
onOpenKanban.bind="onOpenKanban"/>
</t>
</t>
</div>
</t>
<div class="o_fp_qd_body">
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<div t-if="state.activeTab === tab.id" class="o_fp_qd_panel o_fp_card p-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h3 class="mb-1"><t t-esc="tab.label"/></h3>
<div class="text-muted small">
<t t-esc="state.counts[tab.id]?.open || 0"/> open
<t t-if="(state.counts[tab.id]?.overdue || 0) > 0">
<t t-esc="state.counts[tab.id].overdue"/> overdue
</t>
</div>
</div>
<button class="btn btn-primary"
t-on-click="() => this.openTab(tab)">
Open <t t-esc="tab.label"/> Kanban
</button>
</div>
<p class="text-muted">
Click "Open Kanban" to drill into the full
<t t-esc="tab.label.toLowerCase()"/> board with stage / state grouping,
drag-and-drop, and the standard filters.
</p>
</div>
<!-- ===== BANNER CARD ===== -->
<t t-name="fusion_plating_quality.BannerCard">
<div t-if="props.banner.all_clear"
class="o_fp_qd_banner o_fp_qd_banner_clear">
<div class="o_fp_qd_banner_clear_icon"></div>
<div class="o_fp_qd_banner_clear_text">
<strong>All caught up</strong> — no critical items right now
</div>
</div>
<div t-else="" class="o_fp_qd_banner o_fp_qd_banner_urgent">
<div class="o_fp_qd_banner_head">
⚠️ NEEDS ATTENTION TODAY ·
<t t-esc="props.banner.total_matching"/>
<span t-if="props.banner.total_matching > props.banner.items.length"
class="o_fp_qd_banner_overflow">
(showing <t t-esc="props.banner.items.length"/>
of <t t-esc="props.banner.total_matching"/>
see sections below for the rest)
</span>
</div>
<div class="o_fp_qd_banner_grid">
<t t-foreach="props.banner.items"
t-as="item" t-key="item.type + '_' + item.id">
<BannerItem item="item" onOpen="props.onOpen"/>
</t>
</div>
</div>
</t>
<t t-name="fusion_plating_quality.BannerItem">
<button class="o_fp_qd_banner_item"
t-on-click="() => props.onOpen(props.item)">
<div class="o_fp_qd_banner_item_l1">
<span class="o_fp_qd_banner_item_name">
<strong t-esc="props.item.name"/>
</span>
<span class="o_fp_qd_banner_item_type"
t-esc="props.item.type.toUpperCase()"/>
<span t-if="props.item.critical_badge"
class="o_fp_qd_banner_item_badge"
t-esc="props.item.critical_badge"/>
</div>
<div class="o_fp_qd_banner_item_l2">
<span class="o_fp_qd_banner_item_cust"
t-esc="props.item.customer"/>
<span class="o_fp_qd_banner_item_subtitle"
t-esc="props.item.subtitle"/>
</div>
</button>
</t>
<!-- ===== SECTION CARD ===== -->
<t t-name="fusion_plating_quality.SectionCard">
<div class="o_fp_qd_section"
t-att-id="'section-' + props.section.type">
<div class="o_fp_qd_section_head">
<span class="o_fp_qd_section_title">
<t t-esc="props.section.icon"/>
<strong t-esc="props.section.label"/>
· <t t-esc="props.section.open"/> open
<t t-if="props.section.overdue">
·
<span class="o_fp_qd_section_overdue">
<t t-esc="props.section.overdue"/> overdue
</span>
</t>
</span>
<button class="o_fp_qd_section_open"
t-on-click="() => props.onOpenKanban(props.section)">
Open all →
</button>
</div>
<div t-if="props.section.items.length === 0"
class="o_fp_qd_section_empty">
No open items
</div>
<t t-else="" t-foreach="props.section.items"
t-as="item" t-key="item.id">
<SectionRow item="item" onOpen="props.onOpen"/>
</t>
</div>
</t>
<t t-name="fusion_plating_quality.SectionRow">
<div class="o_fp_qd_row"
t-att-class="props.item.urgency === 'overdue'
? 'o_fp_qd_row_overdue' : ''">
<div class="o_fp_qd_row_main">
<strong t-esc="props.item.name"/>
<span class="o_fp_qd_row_sep"> · </span>
<span class="o_fp_qd_row_cust" t-esc="props.item.customer"/>
<span t-if="props.item.subtitle"
class="o_fp_qd_row_subtitle">
<span class="o_fp_qd_row_sep"> · </span>
<t t-esc="props.item.subtitle"/>
</span>
</div>
<button class="o_fp_qd_row_open"
t-on-click="() => props.onOpen(props.item)">
Open →
</button>
</div>
</t>
</templates>

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_part_catalog_contract_review_enforcement
from . import test_dashboard_snapshot

View File

@@ -0,0 +1,207 @@
# -*- coding: utf-8 -*-
"""Quality Dashboard snapshot endpoint tests.
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
Plan: docs/superpowers/plans/2026-05-25-quality-dashboard-redesign-plan.md
"""
from odoo.tests.common import TransactionCase
class TestDashboardSnapshotShape(TransactionCase):
"""Response shape + section ordering tests."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def test_snapshot_has_expected_top_level_keys(self):
snap = self._build()
self.assertIn('banner', snap)
self.assertIn('sections', snap)
self.assertIn('computed_at', snap)
def test_section_order_is_canonical(self):
snap = self._build()
types_present = [s['type'] for s in snap['sections']]
# Canonical order — cert, hold, ncr, rma, capa, check.
# Some types may be absent if their model isn't installed; the
# PRESENT ones must appear in this relative order.
canonical = ['cert', 'hold', 'ncr', 'rma', 'capa', 'check']
order_in_canonical = [canonical.index(t) for t in types_present]
self.assertEqual(
order_in_canonical, sorted(order_in_canonical),
f"sections out of order: got {types_present}",
)
def test_empty_db_returns_all_clear(self):
snap = self._build()
self.assertTrue(snap['banner']['all_clear'])
self.assertEqual(snap['banner']['items'], [])
self.assertEqual(snap['banner']['total_matching'], 0)
def test_each_section_has_required_keys(self):
snap = self._build()
for section in snap['sections']:
for key in ('type', 'label', 'icon', 'open', 'overdue',
'items', 'open_kanban_xmlid'):
self.assertIn(
key, section,
f"section {section.get('type')} missing key {key!r}",
)
self.assertIsInstance(section['items'], list)
self.assertIsInstance(section['open'], int)
self.assertIsInstance(section['overdue'], int)
class TestDashboardSnapshotItems(TransactionCase):
"""Per-section items list — ranking + cap + shape."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def _get_section(self, snap, type_code):
for s in snap['sections']:
if s['type'] == type_code:
return s
return None
def test_section_items_empty_when_no_records(self):
snap = self._build()
cert_sec = self._get_section(snap, 'cert')
if cert_sec is None:
self.skipTest('fp.certificate not installed')
self.assertEqual(cert_sec['items'], [])
def test_section_items_capped_at_5(self):
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self.env['res.partner'].create({'name': 'Cust'})
# Create 8 holds in the same open state
Hold = self.env['fusion.plating.quality.hold']
for i in range(8):
Hold.create({
'partner_id': partner.id,
'state': 'on_hold',
'reason': f'test hold {i}',
})
snap = self._build()
sec = self._get_section(snap, 'hold')
self.assertEqual(len(sec['items']), 5)
self.assertEqual(sec['open'], 8)
def test_item_has_required_keys(self):
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self.env['res.partner'].create({'name': 'Cust'})
hold = self.env['fusion.plating.quality.hold'].create({
'partner_id': partner.id, 'state': 'on_hold', 'reason': 'x',
})
snap = self._build()
sec = self._get_section(snap, 'hold')
self.assertEqual(len(sec['items']), 1)
item = sec['items'][0]
for k in ('id', 'name', 'customer', 'subtitle',
'urgency', 'open_action'):
self.assertIn(k, item, f'item missing key {k!r}')
self.assertEqual(item['open_action']['res_model'],
'fusion.plating.quality.hold')
self.assertEqual(item['open_action']['res_id'], hold.id)
class TestDashboardSnapshotBanner(TransactionCase):
"""Banner population + ranking."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def test_banner_picks_up_overdue_hold(self):
from datetime import datetime, timedelta
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self.env['res.partner'].create({'name': 'Cust'})
hold = self.env['fusion.plating.quality.hold'].create({
'partner_id': partner.id, 'state': 'on_hold', 'reason': 'old',
})
# Backdate create_date past the 3-day overdue threshold
old = datetime.now() - timedelta(days=5)
self.env.cr.execute(
"UPDATE fusion_plating_quality_hold SET create_date = %s WHERE id = %s",
(old, hold.id),
)
hold.invalidate_recordset(['create_date'])
snap = self._build()
self.assertFalse(snap['banner']['all_clear'])
self.assertEqual(snap['banner']['total_matching'], 1)
self.assertEqual(len(snap['banner']['items']), 1)
item = snap['banner']['items'][0]
self.assertEqual(item['type'], 'hold')
self.assertEqual(item['urgency'], 'overdue')
def test_banner_caps_at_6_with_overflow_count(self):
from datetime import datetime, timedelta
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self.env['res.partner'].create({'name': 'Cust'})
Hold = self.env['fusion.plating.quality.hold']
# 8 overdue holds
old = datetime.now() - timedelta(days=5)
for i in range(8):
h = Hold.create({
'partner_id': partner.id, 'state': 'on_hold',
'reason': f'overdue {i}',
})
self.env.cr.execute(
"UPDATE fusion_plating_quality_hold SET create_date = %s WHERE id = %s",
(old, h.id),
)
snap = self._build()
self.assertEqual(len(snap['banner']['items']), 6)
self.assertEqual(snap['banner']['total_matching'], 8)
self.assertFalse(snap['banner']['all_clear'])
def test_banner_all_clear_when_zero(self):
snap = self._build()
# Empty DB — no overdue, no critical
self.assertTrue(snap['banner']['all_clear'])
self.assertEqual(snap['banner']['items'], [])
class TestDashboardSnapshotDefensive(TransactionCase):
"""Module-not-installed + missing-field guards."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def test_missing_partner_field_falls_through(self):
# Empty DB + helper must not raise even when x_fc_rush absent.
# (In this codebase x_fc_rush isn't a registered field — only
# read defensively via the `in partner._fields` check. The
# snapshot must build cleanly.)
snap = self._build()
self.assertIn('banner', snap)
self.assertTrue(snap['banner']['all_clear'])
def test_computed_at_is_iso_string(self):
from datetime import datetime
snap = self._build()
self.assertIsInstance(snap['computed_at'], str)
# Must be parseable as ISO timestamp
datetime.fromisoformat(snap['computed_at'])
def test_all_sections_have_open_kanban_xmlid(self):
snap = self._build()
for section in snap['sections']:
self.assertTrue(
section['open_kanban_xmlid'],
f"section {section['type']} missing open_kanban_xmlid",
)
# Should contain a dot (module.xmlid format)
self.assertIn('.', section['open_kanban_xmlid'])