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