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}
|
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