feat(bridge_mrp): SO workflow stage + contextual buttons (CHUNK 3/4)

Sale Order form now guides the user through the next step without
making them navigate between screens.

New computed field sale.order.x_fc_workflow_stage with 12 stages:
  draft → awaiting_parts → inspecting → accept_parts → assign_work
       → in_production → ready_to_ship → shipped → invoicing
       → paid → complete (+ cancelled)

Driven by SO.state + x_fc_receiving_status + MO state +
delivery.state + invoice payment state.

Five contextual header buttons (only 1-2 visible at any time,
fusion_claims pattern — invisible="x_fc_workflow_stage != 'foo'"):

  Mark Inspecting       → flips receiving to 'inspecting'
  Accept Parts          → flips receiving to 'accepted' + SO status
                          to 'inspected', unlocks manager assignment
  Assign To Me & Release → manager claims the job, confirms all draft
                          MOs (which auto-generates WOs already)
  Open Shop Floor       → jumps to Plant Overview during production
  Mark Shipped          → closes open delivery records → triggers
                          auto-invoice per strategy

Info banner shows current stage + assigned manager on the sheet so
users always know where they are.

New fields:
  sale.order.x_fc_assigned_manager_id (Many2one res.users, tracked)
  mrp.production.x_fc_assigned_manager_id (Many2one, propagated on
                                           MO confirm)

MO.action_confirm() now pulls the assigned manager from the SO (or
falls back to SO.user_id) and sets it on the MO — sets up the
Manager Dashboard (chunk 2) and role-based assignment (chunk 4) to
filter "my jobs" cleanly.

Smoke-tested across 10 demo SOs — stages compute correctly:
  S00028 → ready_to_ship, S00027-25 → awaiting_parts,
  S00023-20 → complete/shipped, S00029 → draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-17 19:57:41 -04:00
parent 28dd7fdd76
commit 095d9f487c
3 changed files with 252 additions and 0 deletions

View File

@@ -32,6 +32,13 @@ class MrpProduction(models.Model):
string='Portal Job',
help='The portal job linked to this manufacturing order.',
)
x_fc_assigned_manager_id = fields.Many2one(
'res.users', string='Assigned Manager',
help='The manager responsible for this MO. Auto-filled when the SO '
'is assigned. Used by the Manager Dashboard to surface '
'unassigned jobs.',
tracking=True,
)
x_fc_recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
@@ -421,6 +428,16 @@ class MrpProduction(models.Model):
res = super().action_confirm()
PortalJob = self.env['fusion.plating.portal.job']
for mo in self:
# Propagate assigned manager from SO → MO on first confirm
if mo.origin and not mo.x_fc_assigned_manager_id:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if so and 'x_fc_assigned_manager_id' in so._fields:
manager = so.x_fc_assigned_manager_id or so.user_id
if manager:
mo.x_fc_assigned_manager_id = manager.id
if mo.x_fc_portal_job_id:
# Already linked — just update state
mo.x_fc_portal_job_id.write({'state': 'in_progress'})

View File

@@ -36,6 +36,185 @@ class SaleOrder(models.Model):
compute='_compute_fp_related_records',
)
# ------------------------------------------------------------------
# Workflow stage — drives which contextual next-step button appears
# on the SO form header. Shows ONE action at a time so users aren't
# overwhelmed. Pattern mirrors fusion_claims ADP case buttons.
# ------------------------------------------------------------------
x_fc_workflow_stage = fields.Selection(
[
('draft', 'Quotation — awaiting confirmation'),
('awaiting_parts', 'Parts en route'),
('inspecting', 'Inspecting received parts'),
('accept_parts', 'Ready to accept parts'),
('assign_work', 'Ready to assign manager'),
('in_production', 'In production'),
('ready_to_ship', 'Production complete — ready to ship'),
('shipped', 'Shipped — awaiting invoice'),
('invoicing', 'Awaiting invoice / payment'),
('paid', 'Paid'),
('complete', 'Complete'),
('cancelled', 'Cancelled'),
],
compute='_compute_workflow_stage',
string='Workflow Stage',
help='Current position in the SO → Ship → Invoice workflow. '
'Drives which next-step button is shown on the SO header.',
)
x_fc_assigned_manager_id = fields.Many2one(
'res.users', string='Assigned Manager',
help='The manager responsible for this job. Set automatically when '
'the MO is confirmed (falls back to the salesperson).',
tracking=True,
)
@api.depends(
'state', 'invoice_status',
'x_fc_receiving_status', 'x_fc_production_count',
'x_fc_delivery_count', 'x_fc_assigned_manager_id',
)
def _compute_workflow_stage(self):
Production = self.env['mrp.production']
Delivery = self.env.get('fusion.plating.delivery')
for so in self:
if so.state == 'cancel':
so.x_fc_workflow_stage = 'cancelled'
continue
if so.state in ('draft', 'sent'):
so.x_fc_workflow_stage = 'draft'
continue
# state == 'sale' or 'done' from here on
mos = Production.search([('origin', '=', so.name)]) if so.name else Production
all_mos_done = bool(mos) and all(m.state == 'done' for m in mos)
# Any delivery marked delivered?
shipped = False
if Delivery is not None and mos:
jobs = mos.mapped('x_fc_portal_job_id')
if jobs:
shipped = bool(Delivery.search_count(
[('job_ref', 'in', jobs.mapped('name')),
('state', '=', 'delivered')]
))
# Paid vs invoiced
if so.invoice_status == 'invoiced' and so.invoice_ids:
latest = so.invoice_ids.filtered(lambda i: i.state == 'posted')
all_paid = latest and all(
i.payment_state in ('paid', 'in_payment') for i in latest
)
if shipped and all_paid:
so.x_fc_workflow_stage = 'complete'
continue
if all_paid and not shipped:
so.x_fc_workflow_stage = 'paid'
continue
if shipped:
so.x_fc_workflow_stage = 'shipped'
continue
if all_mos_done:
so.x_fc_workflow_stage = 'ready_to_ship'
continue
# Parts receiving state governs the early phase
recv_status = so.x_fc_receiving_status or 'not_received'
if recv_status == 'not_received':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status == 'partial' or recv_status == 'received':
so.x_fc_workflow_stage = 'inspecting'
continue
if recv_status == 'inspected':
if not so.x_fc_assigned_manager_id:
so.x_fc_workflow_stage = 'assign_work'
continue
# Manager assigned, MOs exist → in production
so.x_fc_workflow_stage = 'in_production'
continue
# Fallback
so.x_fc_workflow_stage = 'in_production' if mos else 'awaiting_parts'
# ------------------------------------------------------------------
# Next-step action buttons (only one or two visible at any time)
# ------------------------------------------------------------------
def action_fp_mark_inspected(self):
"""Flip all open receiving records from draft/inspecting → inspecting."""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state in ('draft',):
rec.state = 'inspecting'
self.message_post(body=_('Parts marked as inspecting.'))
return True
def action_fp_accept_parts(self):
"""Mark receiving as accepted; this unlocks manager assignment."""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state in ('draft', 'inspecting'):
rec.state = 'accepted'
# flip SO receiving status to 'inspected' if possible
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'inspected'
self.message_post(body=_('Parts accepted — ready to assign manager.'))
return True
def action_fp_assign_to_me(self):
"""Manager claims this job (themselves) and confirms linked MOs."""
self.ensure_one()
user = self.env.user
self.x_fc_assigned_manager_id = user.id
mos = self.env['mrp.production'].search(
[('origin', '=', self.name), ('state', '=', 'draft')]
)
mos.action_confirm()
for mo in mos:
if 'x_fc_assigned_manager_id' in mo._fields and not mo.x_fc_assigned_manager_id:
mo.x_fc_assigned_manager_id = user.id
self.message_post(
body=_('Job assigned to <b>%s</b>. %d MO(s) released to the floor.')
% (user.name, len(mos)),
)
return True
def action_fp_mark_shipped(self):
"""Mark the draft delivery as shipped (triggers auto-invoice)."""
self.ensure_one()
Delivery = self.env.get('fusion.plating.delivery')
if Delivery is None:
return False
mos = self.env['mrp.production'].search([('origin', '=', self.name)])
jobs = mos.mapped('x_fc_portal_job_id')
deliveries = Delivery.search(
[('job_ref', 'in', jobs.mapped('name')), ('state', '!=', 'delivered')]
)
for dlv in deliveries:
dlv.action_mark_delivered()
self.message_post(
body=_('%d delivery record(s) marked delivered. '
'Invoice flow triggered per invoice strategy.')
% len(deliveries),
)
return True
def action_fp_open_shop_floor(self):
"""Jump to the Plant Overview filtered to this SO's WOs."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_plant_overview',
'name': _('Shop Floor — %s') % self.name,
'target': 'current',
}
@api.depends('name', 'state')
def _compute_fp_related_records(self):
Production = self.env['mrp.production']

View File

@@ -52,6 +52,62 @@
string="Deliveries"/>
</button>
</xpath>
<!-- ===== Contextual workflow buttons on the header =====
One (sometimes two) visible at a time. Pattern mirrors
fusion_claims ADP handling — invisible bindings key off
the computed x_fc_workflow_stage selector. -->
<xpath expr="//header" position="inside">
<field name="x_fc_workflow_stage" invisible="1"/>
<field name="x_fc_assigned_manager_id" invisible="1"/>
<button name="action_fp_mark_inspected"
string="Mark Inspecting" type="object"
class="btn-primary" icon="fa-search"
invisible="x_fc_workflow_stage != 'inspecting'"
help="Move receiving record(s) into the Inspecting state."/>
<button name="action_fp_accept_parts"
string="Accept Parts" type="object"
class="btn-primary" icon="fa-check"
invisible="x_fc_workflow_stage not in ('inspecting', 'accept_parts')"
help="Parts pass inspection — ready for assignment."/>
<button name="action_fp_assign_to_me"
string="Assign To Me &amp; Release" type="object"
class="btn-primary" icon="fa-user-plus"
invisible="x_fc_workflow_stage != 'assign_work'"
help="Take ownership as manager and release MOs to the shop floor."/>
<button name="action_fp_open_shop_floor"
string="Open Shop Floor" type="object"
class="btn-secondary" icon="fa-industry"
invisible="x_fc_workflow_stage != 'in_production'"
help="Jump to the Plant Overview to watch production."/>
<button name="action_fp_mark_shipped"
string="Mark Shipped" type="object"
class="btn-success" icon="fa-truck"
invisible="x_fc_workflow_stage != 'ready_to_ship'"
help="Close the open delivery record(s) and fire auto-invoice per strategy."/>
</xpath>
<!-- Show the workflow stage on the sheet so users always
know what step they're on (readonly banner). -->
<xpath expr="//sheet" position="inside">
<div class="alert alert-info mb-2"
style="border-radius: 6px;"
invisible="x_fc_workflow_stage in ('draft', 'complete', 'cancelled')">
<i class="fa fa-compass me-2"/>
<strong>Current stage:</strong>
<field name="x_fc_workflow_stage" readonly="1" nolabel="1" class="ms-1"/>
<span class="ms-3 text-muted"
invisible="not x_fc_assigned_manager_id">
· Assigned to
<field name="x_fc_assigned_manager_id" readonly="1" nolabel="1" class="ms-1"/>
</span>
</div>
</xpath>
</field>
</record>