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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user