diff --git a/fusion_faxes/__manifest__.py b/fusion_faxes/__manifest__.py
index 09b2e495..c8b391e7 100644
--- a/fusion_faxes/__manifest__.py
+++ b/fusion_faxes/__manifest__.py
@@ -4,7 +4,7 @@
{
'name': 'Fusion Faxes',
- 'version': '19.0.2.0.0',
+ 'version': '19.0.2.1.1',
'category': 'Productivity',
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
'description': """
diff --git a/fusion_faxes/data/ir_config_parameter_data.xml b/fusion_faxes/data/ir_config_parameter_data.xml
index 52a2e8e5..71317ad3 100644
--- a/fusion_faxes/data/ir_config_parameter_data.xml
+++ b/fusion_faxes/data/ir_config_parameter_data.xml
@@ -32,5 +32,13 @@
+
+
+ fusion_faxes.show_send_fax_button
+ True
+
+
diff --git a/fusion_faxes/models/account_move.py b/fusion_faxes/models/account_move.py
index 7b4aad9f..c3a439d2 100644
--- a/fusion_faxes/models/account_move.py
+++ b/fusion_faxes/models/account_move.py
@@ -17,12 +17,26 @@ class AccountMove(models.Model):
string='Fax Count',
compute='_compute_fax_count',
)
+ x_ff_show_send_fax_button = fields.Boolean(
+ string='Show Send Fax Button',
+ compute='_compute_show_send_fax_button',
+ help='Driven by the Settings toggle '
+ '(fusion_faxes.show_send_fax_button).',
+ )
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for move in self:
move.x_ff_fax_count = len(move.x_ff_fax_ids)
+ def _compute_show_send_fax_button(self):
+ param = self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_faxes.show_send_fax_button', 'True',
+ )
+ show = str(param).lower() not in ('false', '0', '')
+ for move in self:
+ move.x_ff_show_send_fax_button = show
+
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this invoice."""
self.ensure_one()
diff --git a/fusion_faxes/models/res_config_settings.py b/fusion_faxes/models/res_config_settings.py
index a0a27555..0d198480 100644
--- a/fusion_faxes/models/res_config_settings.py
+++ b/fusion_faxes/models/res_config_settings.py
@@ -15,6 +15,15 @@ class ResConfigSettings(models.TransientModel):
string='Enable RingCentral Faxing',
config_parameter='fusion_faxes.ringcentral_enabled',
)
+ ff_show_send_fax_button = fields.Boolean(
+ string='Show "Send Fax" Button on Sale Orders & Invoices',
+ config_parameter='fusion_faxes.show_send_fax_button',
+ default=True,
+ help='When enabled, the "Send Fax" header button appears on '
+ 'sale order and invoice forms (for users in the Fax User '
+ 'group). Turn off to hide the button without removing '
+ 'fax-user access.',
+ )
ff_ringcentral_server_url = fields.Char(
string='RingCentral Server URL',
config_parameter='fusion_faxes.ringcentral_server_url',
@@ -103,7 +112,15 @@ class ResConfigSettings(models.TransientModel):
}
def set_values(self):
- """Protect credential fields from being blanked accidentally."""
+ """Protect credential fields from being blanked accidentally
+ and force-persist the Send Fax Boolean.
+
+ Odoo's stock ``set_param`` removes the row when a Boolean
+ config_parameter is False, which makes the ``get_param``
+ fallback default kick in — toggling OFF then would silently
+ re-show the button. We bypass that by writing 'True' / 'False'
+ as a string after super() runs so the row always exists.
+ """
protected_keys = [
'fusion_faxes.ringcentral_client_id',
'fusion_faxes.ringcentral_client_secret',
@@ -122,4 +139,9 @@ class ResConfigSettings(models.TransientModel):
existing = ICP.get_param(key, '')
if existing:
ICP.set_param(key, existing)
- return super().set_values()
+ res = super().set_values()
+ ICP.set_param(
+ 'fusion_faxes.show_send_fax_button',
+ 'True' if self.ff_show_send_fax_button else 'False',
+ )
+ return res
diff --git a/fusion_faxes/models/sale_order.py b/fusion_faxes/models/sale_order.py
index 485afdb5..8834a196 100644
--- a/fusion_faxes/models/sale_order.py
+++ b/fusion_faxes/models/sale_order.py
@@ -17,12 +17,28 @@ class SaleOrder(models.Model):
string='Fax Count',
compute='_compute_fax_count',
)
+ x_ff_show_send_fax_button = fields.Boolean(
+ string='Show Send Fax Button',
+ compute='_compute_show_send_fax_button',
+ help='Driven by the Settings toggle '
+ '(fusion_faxes.show_send_fax_button). Default True for '
+ 'back-compat — the button stays visible until a manager '
+ 'turns it off.',
+ )
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for order in self:
order.x_ff_fax_count = len(order.x_ff_fax_ids)
+ def _compute_show_send_fax_button(self):
+ param = self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_faxes.show_send_fax_button', 'True',
+ )
+ show = str(param).lower() not in ('false', '0', '')
+ for order in self:
+ order.x_ff_show_send_fax_button = show
+
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this sale order."""
self.ensure_one()
diff --git a/fusion_faxes/views/account_move_views.xml b/fusion_faxes/views/account_move_views.xml
index 950c26a0..0bf93831 100644
--- a/fusion_faxes/views/account_move_views.xml
+++ b/fusion_faxes/views/account_move_views.xml
@@ -20,9 +20,11 @@
+
diff --git a/fusion_faxes/views/res_config_settings_views.xml b/fusion_faxes/views/res_config_settings_views.xml
index d5185383..de3bbf98 100644
--- a/fusion_faxes/views/res_config_settings_views.xml
+++ b/fusion_faxes/views/res_config_settings_views.xml
@@ -26,6 +26,20 @@
+
+
+
+
+
+
+
+
+ Show the "Send Fax" header button on sale orders and invoices.
+ Turn off to hide the button without removing fax-user access.
+
+
+
+
diff --git a/fusion_faxes/views/sale_order_views.xml b/fusion_faxes/views/sale_order_views.xml
index 7047c02e..53a98487 100644
--- a/fusion_faxes/views/sale_order_views.xml
+++ b/fusion_faxes/views/sale_order_views.xml
@@ -20,9 +20,11 @@
+
diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py
index d3b45179..d2eff8b7 100644
--- a/fusion_plating/fusion_plating_configurator/__manifest__.py
+++ b/fusion_plating/fusion_plating_configurator/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
- 'version': '19.0.21.5.6',
+ 'version': '19.0.21.7.2',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -56,10 +56,12 @@ Provides:
'wizard/fp_part_catalog_import_wizard_views.xml',
'wizard/fp_serial_bulk_add_wizard_views.xml',
'views/fp_configurator_menu.xml',
+ 'views/fp_so_job_sort_views.xml',
'data/fp_sale_description_template_data.xml',
],
'assets': {
'web.assets_backend': [
+ 'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
@@ -72,6 +74,13 @@ Provides:
'fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml',
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
],
+ # Register the Job Status pill SCSS in both bundles so the
+ # `@if $o-webclient-color-scheme == dark` branch compiles for
+ # the dark variant (see CLAUDE.md "Dark Mode" — Odoo 19 has no
+ # runtime DOM toggle, two pre-built bundles).
+ 'web.assets_web_dark': [
+ 'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
+ ],
},
'installable': True,
'application': False,
diff --git a/fusion_plating/fusion_plating_configurator/models/__init__.py b/fusion_plating/fusion_plating_configurator/models/__init__.py
index 95789dbe..c29ec608 100644
--- a/fusion_plating/fusion_plating_configurator/models/__init__.py
+++ b/fusion_plating/fusion_plating_configurator/models/__init__.py
@@ -8,6 +8,7 @@ from . import fp_part_catalog
from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule
from . import fp_sale_description_template
+from . import fp_so_job_sort
from . import fp_quote_configurator
from . import fp_serial
from . import sale_order
diff --git a/fusion_plating/fusion_plating_configurator/models/fp_so_job_sort.py b/fusion_plating/fusion_plating_configurator/models/fp_so_job_sort.py
new file mode 100644
index 00000000..65f0d3ff
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/models/fp_so_job_sort.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+
+from odoo import api, fields, models
+
+
+class FpSoJobSort(models.Model):
+ """A user-defined grouping bucket for sale orders ("Job Sorting").
+
+ Same pattern as `fusion.plating.tank.section` — every shop slices its
+ SO backlog differently (by customer programme, by priority, by
+ fabricator group, by week, etc.). Sections are free-form, renameable,
+ quick-creatable from the M2O dropdown, and let users group the SO
+ list with fold/expand sections.
+ """
+ _name = 'fp.so.job.sort'
+ _description = 'Fusion Plating — Sale Order Job Sort'
+ _order = 'sequence, name'
+
+ name = fields.Char(
+ string='Job Sorting',
+ required=True,
+ translate=True,
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ color = fields.Integer(string='Color', default=0)
+ fold = fields.Boolean(
+ string='Folded by Default',
+ help='When set, this section appears collapsed in the grouped '
+ 'SO list so the body rows are hidden until expanded.',
+ )
+ description = fields.Text(string='Description', translate=True)
+ active = fields.Boolean(default=True)
+
+ sale_order_ids = fields.One2many(
+ 'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
+ )
+ sale_order_count = fields.Integer(
+ compute='_compute_sale_order_count',
+ )
+
+ @api.depends('sale_order_ids')
+ def _compute_sale_order_count(self):
+ for rec in self:
+ rec.sale_order_count = len(rec.sale_order_ids)
+
+ def action_view_sale_orders(self):
+ self.ensure_one()
+ return {
+ 'name': self.name,
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'sale.order',
+ 'view_mode': 'list,form',
+ 'domain': [('x_fc_job_sort_id', '=', self.id)],
+ 'context': {'default_x_fc_job_sort_id': self.id},
+ }
diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order.py b/fusion_plating/fusion_plating_configurator/models/sale_order.py
index 4b3cf2dc..decaaa93 100644
--- a/fusion_plating/fusion_plating_configurator/models/sale_order.py
+++ b/fusion_plating/fusion_plating_configurator/models/sale_order.py
@@ -110,6 +110,16 @@ class SaleOrder(models.Model):
help="Customer's internal job number for cross-referencing.",
tracking=True,
)
+ x_fc_job_sort_id = fields.Many2one(
+ 'fp.so.job.sort',
+ string='Job Sorting',
+ ondelete='set null',
+ tracking=True,
+ help='Free-form bucket that groups this SO in the "Sale Orders '
+ 'by Sorting" list view. Quick-create from the dropdown — '
+ 'each shop slices its backlog differently (customer programme, '
+ 'priority, week, etc.).',
+ )
x_fc_planned_start_date = fields.Date(
string='Planned Start Date', tracking=True,
)
@@ -151,6 +161,16 @@ class SaleOrder(models.Model):
string='Deadline',
compute='_compute_deadline_countdown',
)
+ # Drives the colour of the Deadline column. Computed in the same pass
+ # as x_fc_deadline_countdown so the buckets always agree with the
+ # human-readable countdown string.
+ x_fc_deadline_urgency = fields.Selection(
+ [('overdue', 'Overdue'),
+ ('urgent', 'Due within 2 days'),
+ ('safe', 'More than 2 days')],
+ string='Deadline Urgency',
+ compute='_compute_deadline_countdown',
+ )
x_fc_order_completion_date = fields.Date(
string='Order Completion Date',
compute='_compute_order_completion_date',
@@ -263,6 +283,136 @@ class SaleOrder(models.Model):
compute='_compute_invoiced_amount',
currency_field='currency_id',
)
+ # Single "Job Status" pill rendered in the SO list. Pipeline order:
+ # Draft → Awaiting Parts → Parts Partial → Ready to Start →
+ #
→ Ready to Ship → Ship Booked → In Transit →
+ # Delivered → Invoiced → Paid → Cancelled.
+ # Rendered as an Html field so each kind can carry its own tint via
+ # an .fp-kind-* class — Bootstrap's 5 decoration-* slots aren't
+ # enough to give every phase a distinct colour. SCSS bundle at
+ # static/src/scss/fp_job_status_pill.scss owns the colour map.
+ x_fc_fp_job_status = fields.Html(
+ string='Job Status',
+ compute='_compute_fp_job_status',
+ sanitize=False,
+ help='Single at-a-glance pill that advances through the order '
+ 'lifecycle: receiving → WO progress → shipping → invoicing.',
+ )
+ x_fc_fp_job_status_kind = fields.Selection(
+ [('muted', 'Draft (grey)'),
+ ('warning', 'Awaiting / Partial (amber)'),
+ ('primary', 'Ready / Milestone (purple)'),
+ ('info', 'Active Work (blue)'),
+ ('shipping', 'Shipping (cyan)'),
+ ('delivered', 'Delivered (teal)'),
+ ('invoiced', 'Invoiced (lime)'),
+ ('paid', 'Paid (green bold)'),
+ ('danger', 'Cancelled (red)')],
+ string='Job Status Kind',
+ compute='_compute_fp_job_status',
+ help='Colour category that backs the Job Status pill — also '
+ 'usable for filtering / grouping in the list search panel.',
+ )
+
+ @api.depends(
+ 'state',
+ 'x_fc_receiving_status',
+ 'x_fc_wo_completion',
+ 'invoice_ids.state',
+ 'invoice_ids.payment_state',
+ 'invoice_ids.move_type',
+ )
+ def _compute_fp_job_status(self):
+ from markupsafe import Markup as _Markup
+ from markupsafe import escape as _escape
+ for so in self:
+ label, kind = self._fp_resolve_job_status(so)
+ so.x_fc_fp_job_status_kind = kind
+ so.x_fc_fp_job_status = _Markup(
+ '%s'
+ ) % (_Markup(kind), _escape(label))
+
+ @staticmethod
+ def _fp_resolve_job_status(so):
+ # Terminal SO states first.
+ if so.state == 'cancel':
+ return ('Cancelled', 'danger')
+ if so.state in ('draft', 'sent'):
+ return ('Draft', 'muted')
+
+ # Invoice phase (terminal positive states).
+ posted = so.invoice_ids.filtered(
+ lambda m: m.state == 'posted'
+ and m.move_type in ('out_invoice', 'out_refund')
+ )
+ if posted and all(
+ m.payment_state in ('paid', 'in_payment') for m in posted
+ ):
+ return ('Paid', 'paid')
+
+ # Shipping phase signals — read once.
+ ship_status = None
+ if 'x_fc_receiving_ids' in so._fields:
+ for r in so.x_fc_receiving_ids:
+ ship = (
+ r.x_fc_outbound_shipment_id
+ if 'x_fc_outbound_shipment_id' in r._fields else False
+ )
+ if not ship:
+ continue
+ # Latch the most-advanced status across all receivings.
+ rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
+ cur = (
+ 'delivered' if ship.status == 'delivered'
+ else 'in_transit' if ship.status == 'shipped'
+ else 'booked' if ship.status in ('confirmed', 'draft')
+ else None
+ )
+ if rank[cur] > rank[ship_status]:
+ ship_status = cur
+
+ if posted and ship_status == 'delivered':
+ return ('Invoiced', 'invoiced')
+ if ship_status == 'delivered':
+ return ('Delivered', 'delivered')
+ if ship_status == 'in_transit':
+ return ('In Transit', 'shipping')
+
+ # WO phase — figure out total steps and the current step name.
+ tot = 0
+ current_step_name = None
+ Job = so.env.get('fp.job')
+ if Job is not None and so.name:
+ jobs = Job.sudo().search([('origin', '=', so.name)])
+ if jobs:
+ steps = jobs.mapped('step_ids').sorted(
+ lambda s: (s.job_id.id, s.sequence)
+ )
+ tot = len(steps)
+ # Priority: in_progress → paused → next ready/pending.
+ current = (
+ steps.filtered(lambda s: s.state == 'in_progress')[:1]
+ or steps.filtered(lambda s: s.state == 'paused')[:1]
+ or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
+ )
+ current_step_name = current.name if current else None
+
+ all_steps_done = tot > 0 and current_step_name is None
+
+ if all_steps_done:
+ if ship_status == 'booked':
+ return ('Ship Booked', 'shipping')
+ return ('Ready to Ship', 'primary')
+ if current_step_name:
+ return (current_step_name, 'info')
+
+ # Receiving phase (no WO yet).
+ recv = so.x_fc_receiving_status or 'not_received'
+ if recv == 'received':
+ return ('Ready to Start', 'primary')
+ if recv == 'partial':
+ return ('Parts Partial', 'warning')
+ return ('Awaiting Parts', 'warning')
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
def _compute_lead_time_display(self):
@@ -536,9 +686,11 @@ class SaleOrder(models.Model):
def _compute_deadline_countdown(self):
from datetime import datetime
now = fields.Datetime.now()
+ TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
for rec in self:
if not rec.commitment_date:
rec.x_fc_deadline_countdown = False
+ rec.x_fc_deadline_urgency = False
continue
target = rec.commitment_date
if isinstance(target, datetime):
@@ -549,12 +701,13 @@ class SaleOrder(models.Model):
secs = int(delta.total_seconds())
if secs == 0:
rec.x_fc_deadline_countdown = 'due now'
+ rec.x_fc_deadline_urgency = 'overdue'
continue
past = secs < 0
- secs = abs(secs)
- days = secs // 86400
- hours = (secs % 86400) // 3600
- mins = (secs % 3600) // 60
+ abs_secs = abs(secs)
+ days = abs_secs // 86400
+ hours = (abs_secs % 86400) // 3600
+ mins = (abs_secs % 3600) // 60
bits = []
if days:
bits.append('%dd' % days)
@@ -566,6 +719,12 @@ class SaleOrder(models.Model):
rec.x_fc_deadline_countdown = (
'overdue %s' % phrase if past else 'in %s' % phrase
)
+ if past:
+ rec.x_fc_deadline_urgency = 'overdue'
+ elif secs <= TWO_DAYS:
+ rec.x_fc_deadline_urgency = 'urgent'
+ else:
+ rec.x_fc_deadline_urgency = 'safe'
@api.depends(
'order_line.x_fc_effective_part_deadline',
diff --git a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
index 45ce88d3..0f57b279 100644
--- a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv
@@ -42,3 +42,5 @@ access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
+access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0
+access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fusion_plating_manager,1,1,1,1
diff --git a/fusion_plating/fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss b/fusion_plating/fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss
new file mode 100644
index 00000000..9f68a425
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss
@@ -0,0 +1,68 @@
+// =============================================================================
+// Fusion Plating — Job Status pill on the SO list
+// Copyright 2026 Nexa Systems Inc. · License OPL-1
+//
+// One pill per row, one colour per phase, vibrant + saturated so phases
+// pop at a glance against both the light and dark Odoo bundles. Same
+// hue map for both modes — saturated 500-level Tailwind hues with white
+// text give consistent contrast against either page background.
+// =============================================================================
+
+// ----- Vibrant tints (light + dark) -----
+$_fp-muted-bg : #6b7280; // slate
+$_fp-warning-bg : #f59e0b; // amber
+$_fp-primary-bg : #8b5cf6; // violet
+$_fp-info-bg : #3b82f6; // blue
+$_fp-shipping-bg : #06b6d4; // cyan
+$_fp-delivered-bg : #14b8a6; // teal
+$_fp-invoiced-bg : #84cc16; // lime
+$_fp-paid-bg : #16a34a; // green
+$_fp-danger-bg : #ef4444; // red
+
+// Matching glow shadows — darker tone of the same hue for a subtle
+// drop-shadow that gives the pill a "lifted" feel without being noisy.
+$_fp-muted-glow : rgba(31, 41, 55, 0.35);
+$_fp-warning-glow : rgba(180, 83, 9, 0.45);
+$_fp-primary-glow : rgba(91, 33, 182, 0.45);
+$_fp-info-glow : rgba(29, 78, 216, 0.45);
+$_fp-shipping-glow : rgba(14, 116, 144, 0.45);
+$_fp-delivered-glow : rgba(15, 118, 110, 0.45);
+$_fp-invoiced-glow : rgba(101, 163, 13, 0.45);
+$_fp-paid-glow : rgba(21, 128, 61, 0.5);
+$_fp-danger-glow : rgba(185, 28, 28, 0.45);
+
+// =============================================================================
+// Pill base
+// =============================================================================
+.fp-job-status {
+ display: inline-block;
+ padding: 0.4em 0.95em;
+ border-radius: 999px;
+ font-weight: 600;
+ font-size: 0.82em;
+ line-height: 1.25;
+ letter-spacing: 0.015em;
+ white-space: nowrap;
+ text-align: center;
+ min-width: 72px;
+ color: #ffffff !important;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
+}
+
+// =============================================================================
+// Per-kind tints — same map applies to light + dark bundles. White text
+// gives consistent contrast against any saturated mid-tone hue.
+// =============================================================================
+.fp-kind-muted { background-color: $_fp-muted-bg; box-shadow: 0 1px 3px $_fp-muted-glow; }
+.fp-kind-warning { background-color: $_fp-warning-bg; box-shadow: 0 1px 3px $_fp-warning-glow; }
+.fp-kind-primary { background-color: $_fp-primary-bg; box-shadow: 0 1px 3px $_fp-primary-glow; }
+.fp-kind-info { background-color: $_fp-info-bg; box-shadow: 0 1px 3px $_fp-info-glow; }
+.fp-kind-shipping { background-color: $_fp-shipping-bg; box-shadow: 0 1px 3px $_fp-shipping-glow; }
+.fp-kind-delivered { background-color: $_fp-delivered-bg; box-shadow: 0 1px 3px $_fp-delivered-glow; }
+.fp-kind-invoiced { background-color: $_fp-invoiced-bg; box-shadow: 0 1px 3px $_fp-invoiced-glow; }
+.fp-kind-paid {
+ background-color: $_fp-paid-bg;
+ box-shadow: 0 1px 4px $_fp-paid-glow;
+ font-weight: 700;
+}
+.fp-kind-danger { background-color: $_fp-danger-bg; box-shadow: 0 1px 3px $_fp-danger-glow; }
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_so_job_sort_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_so_job_sort_views.xml
new file mode 100644
index 00000000..f2be97ef
--- /dev/null
+++ b/fusion_plating/fusion_plating_configurator/views/fp_so_job_sort_views.xml
@@ -0,0 +1,261 @@
+
+
+
+
+
+
+ fp.so.job.sort.list
+ fp.so.job.sort
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.so.job.sort.form
+ fp.so.job.sort
+
+
+
+
+
+
+ Job Sorting
+ fp.so.job.sort
+ list,form
+
+
+
+
+
+
+ sale.order.kanban.fp.by_sorting
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.order.list.fp.by_sorting
+ sale.order
+ 99
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.order.search.fp.by_sorting
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ list,kanban,form
+
+
+
+
+ Sale Orders (by Sorting)
+ sale.order
+ list,form
+
+
+ [('state', 'not in', ('draft', 'sent'))]
+ {'search_default_group_by_job_sort': 1}
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
index bf963ca5..7c459cc3 100644
--- a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
@@ -123,6 +123,15 @@
+
+
+
+
+
+
+
@@ -390,12 +409,21 @@
+
+
-
+
@@ -465,8 +493,8 @@
sale.order
-
-
+
+
@@ -474,15 +502,16 @@
-
+
-
+
@@ -582,7 +611,10 @@
-
+
Sale Orders
sale.order
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
index 6e311efe..deac806c 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
@@ -79,6 +79,13 @@ class FpDirectOrderWizard(models.Model):
help="Customer's internal job number for cross-referencing. "
"Appears on work orders and invoices.",
)
+ job_sort_id = fields.Many2one(
+ 'fp.so.job.sort',
+ string='Job Sorting',
+ help='Free-form bucket that groups the new SO in the '
+ '"Sale Orders by Sorting" list. Type a new label in the '
+ 'dropdown to create a section on the fly.',
+ )
# ---- Scheduling ----
planned_start_date = fields.Date(
@@ -533,6 +540,7 @@ class FpDirectOrderWizard(models.Model):
'x_fc_po_pending': self.po_pending,
'x_fc_po_expected_date': self.po_expected_date or False,
'x_fc_customer_job_number': self.customer_job_number or False,
+ 'x_fc_job_sort_id': self.job_sort_id.id or False,
'x_fc_planned_start_date': self.planned_start_date,
'x_fc_internal_deadline': self.internal_deadline,
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
index 6e0a03c2..d86eb943 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
@@ -70,6 +70,9 @@
options="{'no_create_edit': True}"
invisible="not partner_id"/>
+
fusion.plating.portal.job.state mapping. Kept tight so
- # the customer doesn't see internal states. Anything not in this map
- # leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
- _FP_JOB_STATE_TO_PORTAL_STATE = {
- 'confirmed': 'received',
- 'in_progress': 'in_progress',
- 'done': 'ready_to_ship',
- # 'on_hold' and 'cancelled' intentionally omitted — managers choose
- # what to surface to the customer.
- }
+ # Sub-portal state sync — see fusion_plating_portal/.../fp_portal_job.py
+ # `_fp_recompute_portal_state` for the rules. The mapping table that
+ # used to live here was replaced by the helper so shipment / invoice
+ # signals can't drift away from the WO state any more.
def write(self, vals):
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
@@ -783,13 +777,13 @@ class FpJob(models.Model):
if job.state != new_state:
state_changed_ids.add(job.id)
result = super().write(vals)
- # Mirror state to portal_job for records that actually changed.
+ # Mirror state to portal_job via the central recompute helper, so
+ # the portal state always derives from the WO + shipment + invoice
+ # together rather than the most-recent event flag.
if state_changed_ids:
- target = self._FP_JOB_STATE_TO_PORTAL_STATE.get(vals.get('state'))
- if target:
- for job in self.filtered(lambda j: j.id in state_changed_ids):
- if job.portal_job_id and job.portal_job_id.state != target:
- job.portal_job_id.sudo().write({'state': target})
+ for job in self.filtered(lambda j: j.id in state_changed_ids):
+ if job.portal_job_id:
+ job.portal_job_id._fp_recompute_portal_state()
if not scrap_deltas:
return result
Hold = (self.env['fusion.plating.quality.hold']
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_receiving.py b/fusion_plating/fusion_plating_jobs/models/fp_receiving.py
new file mode 100644
index 00000000..f240df8b
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/models/fp_receiving.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Adds the Work Order smart button + header action to fp.receiving so
+# the receiving form mirrors the SO's WO entry point. Button appears
+# once the receiving is closed and stays until every linked fp.job
+# reaches state='done'.
+
+from odoo import _, fields, models
+
+
+class FpReceiving(models.Model):
+ _inherit = 'fp.receiving'
+
+ x_fc_fp_job_count = fields.Integer(
+ string='Work Orders',
+ compute='_compute_fp_job_count',
+ )
+ x_fc_show_work_order_btn = fields.Boolean(
+ string='Show Work Order Button',
+ compute='_compute_show_work_order_btn',
+ help='True once this receiving is closed and at least one linked '
+ 'work order is still open (state != done). Hidden again '
+ 'when every job is done.',
+ )
+
+ def _compute_fp_job_count(self):
+ Job = self.env['fp.job'].sudo()
+ for rec in self:
+ if rec.sale_order_id:
+ rec.x_fc_fp_job_count = Job.search_count(
+ [('sale_order_id', '=', rec.sale_order_id.id)]
+ )
+ else:
+ rec.x_fc_fp_job_count = 0
+
+ def _compute_show_work_order_btn(self):
+ Job = self.env['fp.job'].sudo()
+ for rec in self:
+ if rec.state != 'closed' or not rec.sale_order_id:
+ rec.x_fc_show_work_order_btn = False
+ continue
+ jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
+ rec.x_fc_show_work_order_btn = bool(jobs) and any(
+ j.state != 'done' for j in jobs
+ )
+
+ def action_view_fp_jobs(self):
+ """Open the work order(s) linked to this receiving's sale order."""
+ self.ensure_one()
+ if not self.sale_order_id:
+ return False
+ jobs = self.env['fp.job'].search([
+ ('sale_order_id', '=', self.sale_order_id.id),
+ ])
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Work Orders'),
+ 'res_model': 'fp.job',
+ 'view_mode': 'list,form',
+ 'domain': [('sale_order_id', '=', self.sale_order_id.id)],
+ 'context': {'default_sale_order_id': self.sale_order_id.id},
+ }
+ if len(jobs) == 1:
+ action.update({'view_mode': 'form', 'res_id': jobs.id})
+ return action
diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py
index b44659c7..05676cc4 100644
--- a/fusion_plating/fusion_plating_jobs/models/sale_order.py
+++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py
@@ -24,6 +24,13 @@ class SaleOrder(models.Model):
string='Work Orders',
compute='_compute_fp_job_count',
)
+ x_fc_show_work_order_btn = fields.Boolean(
+ string='Show Work Order Header Button',
+ compute='_compute_show_work_order_btn',
+ help='True once any receiving record on this SO has closed and '
+ 'at least one work order is still open (state != done). '
+ 'Hidden again when every WO is done.',
+ )
x_fc_fp_certificate_count = fields.Integer(
string='Certificates',
compute='_compute_fp_certificate_count',
@@ -114,6 +121,25 @@ class SaleOrder(models.Model):
[('sale_order_id', '=', so.id)]
)
+ def _compute_show_work_order_btn(self):
+ Job = self.env['fp.job'].sudo()
+ Recv = self.env.get('fp.receiving')
+ for so in self:
+ if Recv is None:
+ so.x_fc_show_work_order_btn = False
+ continue
+ has_closed_recv = bool(Recv.sudo().search_count([
+ ('sale_order_id', '=', so.id),
+ ('state', '=', 'closed'),
+ ]))
+ if not has_closed_recv:
+ so.x_fc_show_work_order_btn = False
+ continue
+ jobs = Job.search([('sale_order_id', '=', so.id)])
+ so.x_fc_show_work_order_btn = bool(jobs) and any(
+ j.state != 'done' for j in jobs
+ )
+
def _compute_fp_certificate_count(self):
Cert = self.env['fp.certificate'].sudo()
for so in self:
diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
index ea33d84c..8b76e959 100644
--- a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
+++ b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
@@ -63,15 +63,10 @@
-
+ icon="fa-qrcode"
+ invisible="state == 'draft'"
+ help="Print Sticker"/>
+
+
+
+ fp.receiving.form.fp.jobs
+ fp.receiving
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml b/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml
index 54d39cc4..29936b99 100644
--- a/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml
+++ b/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml
@@ -33,6 +33,19 @@
+
+
+
+
+
+
+
+
+
+