From 3b7b2477cffde9919518f30cdebd8c6249dac0db Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 22 May 2026 22:17:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(fusion=5Fplating=5Fshopfloor):=203=20new?= =?UTF-8?q?=20manager=20endpoints=20=E2=80=94=20funnel,=20inbox,=20at=5Fri?= =?UTF-8?q?sk=20(P4.2-P4.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../controllers/manager_controller.py | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py index fedfe8cc..6c4d5d26 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py @@ -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 + }