feat(fusion_plating_shopfloor): 3 new manager endpoints — funnel, inbox, at_risk (P4.2-P4.4)

Plan tasks P4.2 + P4.3 + P4.4 batched. Adds the backend data layer
for the Manager Desk's 3 new sibling tabs (Phase 4 tablet redesign).

  POST /fp/manager/funnel
      Workflow funnel: jobs grouped by fp.job.workflow.state. Returns
      stages[] with count + top 5 WO cards per stage. Drives the
      default tab on the refactored dashboard.

  POST /fp/manager/approval_inbox
      Four buckets: holds_to_release (state=on_hold|under_review),
      certs_to_issue (all_steps_terminal + draft cert), scrap_to_review
      (last 24h mark_for_scrap holds), override_requests (deferred —
      empty placeholder).

  POST /fp/manager/at_risk
      Three panels: trending_late (top 20 by late_risk_ratio desc),
      hold_reasons (read_group on hold_reason), bottleneck (top 10
      work centres by bottleneck_score from P4.1).

All endpoints respect optional facility_id scope. Cheap implementations
— no caching yet; performance can be added if entech load demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-22 22:17:53 -04:00
parent e762ee4b68
commit 3b7b2477cf

View File

@@ -466,3 +466,231 @@ class FpManagerDashboardController(http.Controller):
),
)
return {'ok': True, 'user_name': user.name}
# ======================================================================
# Phase 4 tablet redesign — 3 new tabs on the Manager Desk
# ======================================================================
@http.route('/fp/manager/funnel', type='jsonrpc', auth='user')
def funnel(self, facility_id=None):
"""Workflow funnel: jobs grouped by fp.job.workflow.state.
One row per workflow stage with its count + top 5 WO cards.
Drives the default tab on the refactored Manager Dashboard.
"""
env = request.env
Job = env['fp.job']
all_states = env['fp.job.workflow.state'].search(
[], order='sequence, id',
)
# All in-flight jobs (not done/cancelled)
job_dom = [('state', 'not in', _NEG_JOB_STATES)]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(job_dom, order='priority desc, date_deadline asc')
# Group jobs by workflow_state_id (in-memory — list is bounded by
# active job count, typically < 200)
by_stage = {ws.id: [] for ws in all_states}
for job in jobs:
if job.workflow_state_id and job.workflow_state_id.id in by_stage:
by_stage[job.workflow_state_id.id].append(job)
def _job_card_compact(job):
return {
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'priority': job.priority or 'normal',
'days_in_stage': (
(fields.Datetime.now() - job.write_date).days
if job.write_date else 0
),
}
stages = []
for ws in all_states:
jobs_in_stage = by_stage[ws.id]
stages.append({
'id': ws.id,
'name': ws.name,
'color': ws.color or 'grey',
'sequence': ws.sequence or 0,
'count': len(jobs_in_stage),
'jobs': [_job_card_compact(j) for j in jobs_in_stage[:5]],
})
return {'ok': True, 'stages': stages}
@http.route('/fp/manager/approval_inbox', type='jsonrpc', auth='user')
def approval_inbox(self, facility_id=None):
"""Approval Inbox: things waiting on a manager decision.
Four buckets: holds to release, certs to issue, recent scrap to
acknowledge, override requests (deferred — empty for now).
"""
env = request.env
# ---- Holds to Release -------------------------------------------
Hold = env['fusion.plating.quality.hold']
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
holds = Hold.search(hold_dom, order='create_date desc', limit=50)
holds_to_release = [
{
'hold_id': h.id,
'name': h.name,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id
else (h.x_fc_job_id.name or '' if 'x_fc_job_id' in Hold._fields else '')
),
'reason': dict(Hold._fields['hold_reason'].selection).get(
h.hold_reason, h.hold_reason or '',
),
'qty': h.qty_on_hold or 0,
'requested_by': h.operator_id.name or '',
'requested_at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in holds
]
# ---- Certs to Issue ---------------------------------------------
# Jobs where all_steps_terminal AND at least one required cert
# is still draft.
Job = env['fp.job']
job_dom = [
('all_steps_terminal', '=', True),
('state', 'not in', _NEG_JOB_STATES),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
terminal_jobs = Job.search(job_dom, order='write_date desc', limit=100)
certs_to_issue = []
for job in terminal_jobs:
try:
if not job._fp_has_draft_required_certs():
continue
except Exception:
continue
certs_to_issue.append({
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'cert_types': list(job._resolve_required_cert_types()),
'all_steps_done_at': fp_format(env, job.write_date) if job.write_date else '',
})
# ---- Scrap to Review --------------------------------------------
# Recent qty_scrapped bumps via S17 hook auto-spawn holds with
# hold_reason in ('scrap', 'other'). Surface the last 24h worth.
from datetime import timedelta
scrap_cutoff = fields.Datetime.now() - timedelta(hours=24)
scrap_holds = Hold.search([
('mark_for_scrap', '=', True),
('create_date', '>=', scrap_cutoff),
], order='create_date desc', limit=20)
scrap_to_review = [
{
'hold_id': h.id,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id else ''
),
'scrap_qty': h.qty_on_hold or 0,
'reason': h.description or '',
'operator': h.operator_id.name or '',
'at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in scrap_holds
]
return {
'ok': True,
'holds_to_release': holds_to_release,
'certs_to_issue': certs_to_issue,
'scrap_to_review': scrap_to_review,
'override_requests': [], # deferred — placeholder
}
@http.route('/fp/manager/at_risk', type='jsonrpc', auth='user')
def at_risk(self, facility_id=None):
"""At-Risk view: trending-late jobs + hold reasons + bottleneck.
Sub-panels:
- trending_late: top 20 jobs by late_risk_ratio desc (> 0)
- hold_reasons: open holds grouped by hold_reason
- bottleneck: work centres sorted by bottleneck_score desc
"""
env = request.env
# ---- Trending Late ----------------------------------------------
Job = env['fp.job']
job_dom = [
('state', 'not in', _NEG_JOB_STATES),
('late_risk_ratio', '>', 0),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
late_jobs = Job.search(
job_dom, order='late_risk_ratio desc', limit=20,
)
trending_late = [
{
'job_id': j.id,
'display_wo_name': j.display_wo_name,
'customer': j.partner_id.name or '',
'late_risk_ratio': round(j.late_risk_ratio, 2),
'deadline': fp_format(env, j.date_deadline, fmt='%Y-%m-%d') if j.date_deadline else '',
'stuck_at': (
j.active_step_id.name
if 'active_step_id' in j._fields and j.active_step_id
else ''
),
}
for j in late_jobs
]
# ---- Hold Reasons grouped --------------------------------------
Hold = env['fusion.plating.quality.hold']
reason_selection = dict(Hold._fields['hold_reason'].selection)
# read_group is the cheap way to bucket
groups = Hold.read_group(
domain=[('state', 'in', ('on_hold', 'under_review'))],
fields=['hold_reason'],
groupby=['hold_reason'],
)
hold_reasons = [
{
'reason': g.get('hold_reason') or 'unknown',
'label': reason_selection.get(g.get('hold_reason'), g.get('hold_reason') or 'unknown'),
'count': g.get('hold_reason_count', 0),
}
for g in groups
]
hold_reasons.sort(key=lambda r: r['count'], reverse=True)
# ---- Bottleneck heatmap ----------------------------------------
WC = env['fp.work.centre']
wc_dom = [('active', '=', True)]
if facility_id:
wc_dom.append(('facility_id', '=', int(facility_id)))
wcs = WC.search(wc_dom)
bottlenecks = []
for wc in wcs:
# Skip work centres with zero queue — no signal
if wc.bottleneck_score <= 0:
continue
bottlenecks.append({
'work_centre_id': wc.id,
'work_centre_name': wc.name,
'score': round(wc.bottleneck_score, 1),
'avg_wait_minutes': round(wc.avg_wait_minutes, 1),
})
bottlenecks.sort(key=lambda b: b['score'], reverse=True)
return {
'ok': True,
'trending_late': trending_late,
'hold_reasons': hold_reasons,
'bottleneck': bottlenecks[:10], # top 10
}