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',
|
string='Portal Job',
|
||||||
help='The portal job linked to this manufacturing order.',
|
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(
|
x_fc_recipe_id = fields.Many2one(
|
||||||
'fusion.plating.process.node',
|
'fusion.plating.process.node',
|
||||||
string='Recipe',
|
string='Recipe',
|
||||||
@@ -421,6 +428,16 @@ class MrpProduction(models.Model):
|
|||||||
res = super().action_confirm()
|
res = super().action_confirm()
|
||||||
PortalJob = self.env['fusion.plating.portal.job']
|
PortalJob = self.env['fusion.plating.portal.job']
|
||||||
for mo in self:
|
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:
|
if mo.x_fc_portal_job_id:
|
||||||
# Already linked — just update state
|
# Already linked — just update state
|
||||||
mo.x_fc_portal_job_id.write({'state': 'in_progress'})
|
mo.x_fc_portal_job_id.write({'state': 'in_progress'})
|
||||||
|
|||||||
@@ -36,6 +36,185 @@ class SaleOrder(models.Model):
|
|||||||
compute='_compute_fp_related_records',
|
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')
|
@api.depends('name', 'state')
|
||||||
def _compute_fp_related_records(self):
|
def _compute_fp_related_records(self):
|
||||||
Production = self.env['mrp.production']
|
Production = self.env['mrp.production']
|
||||||
|
|||||||
@@ -52,6 +52,62 @@
|
|||||||
string="Deliveries"/>
|
string="Deliveries"/>
|
||||||
</button>
|
</button>
|
||||||
</xpath>
|
</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>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user