changes
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Faxes',
|
'name': 'Fusion Faxes',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.2.1.1',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
|
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -32,5 +32,13 @@
|
|||||||
<field name="value"></field>
|
<field name="value"></field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- UI toggle — when False, hides the "Send Fax" header button
|
||||||
|
on sale orders and invoices. Smart "Faxes" button (count
|
||||||
|
badge) is unaffected. -->
|
||||||
|
<record id="config_show_send_fax_button" model="ir.config_parameter">
|
||||||
|
<field name="key">fusion_faxes.show_send_fax_button</field>
|
||||||
|
<field name="value">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
</data>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -17,12 +17,26 @@ class AccountMove(models.Model):
|
|||||||
string='Fax Count',
|
string='Fax Count',
|
||||||
compute='_compute_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')
|
@api.depends('x_ff_fax_ids')
|
||||||
def _compute_fax_count(self):
|
def _compute_fax_count(self):
|
||||||
for move in self:
|
for move in self:
|
||||||
move.x_ff_fax_count = len(move.x_ff_fax_ids)
|
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):
|
def action_send_fax(self):
|
||||||
"""Open the Send Fax wizard pre-filled with this invoice."""
|
"""Open the Send Fax wizard pre-filled with this invoice."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
string='Enable RingCentral Faxing',
|
string='Enable RingCentral Faxing',
|
||||||
config_parameter='fusion_faxes.ringcentral_enabled',
|
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(
|
ff_ringcentral_server_url = fields.Char(
|
||||||
string='RingCentral Server URL',
|
string='RingCentral Server URL',
|
||||||
config_parameter='fusion_faxes.ringcentral_server_url',
|
config_parameter='fusion_faxes.ringcentral_server_url',
|
||||||
@@ -103,7 +112,15 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def set_values(self):
|
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 = [
|
protected_keys = [
|
||||||
'fusion_faxes.ringcentral_client_id',
|
'fusion_faxes.ringcentral_client_id',
|
||||||
'fusion_faxes.ringcentral_client_secret',
|
'fusion_faxes.ringcentral_client_secret',
|
||||||
@@ -122,4 +139,9 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
existing = ICP.get_param(key, '')
|
existing = ICP.get_param(key, '')
|
||||||
if existing:
|
if existing:
|
||||||
ICP.set_param(key, 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
|
||||||
|
|||||||
@@ -17,12 +17,28 @@ class SaleOrder(models.Model):
|
|||||||
string='Fax Count',
|
string='Fax Count',
|
||||||
compute='_compute_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')
|
@api.depends('x_ff_fax_ids')
|
||||||
def _compute_fax_count(self):
|
def _compute_fax_count(self):
|
||||||
for order in self:
|
for order in self:
|
||||||
order.x_ff_fax_count = len(order.x_ff_fax_ids)
|
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):
|
def action_send_fax(self):
|
||||||
"""Open the Send Fax wizard pre-filled with this sale order."""
|
"""Open the Send Fax wizard pre-filled with this sale order."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
|
|
||||||
<!-- Send Fax header button (fax users only) -->
|
<!-- Send Fax header button (fax users only) -->
|
||||||
<xpath expr="//header" position="inside">
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||||
<button name="action_send_fax" string="Send Fax"
|
<button name="action_send_fax" string="Send Fax"
|
||||||
type="object" class="btn-secondary"
|
type="object" class="btn-secondary"
|
||||||
icon="fa-fax"
|
icon="fa-fax"
|
||||||
|
invisible="not x_ff_show_send_fax_button"
|
||||||
groups="fusion_faxes.group_fax_user"/>
|
groups="fusion_faxes.group_fax_user"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Show "Send Fax" button toggle -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="ff_show_send_fax_button"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="ff_show_send_fax_button"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Show the "Send Fax" header button on sale orders and invoices.
|
||||||
|
Turn off to hide the button without removing fax-user access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Server URL -->
|
<!-- Server URL -->
|
||||||
<div class="col-12 col-lg-6 o_setting_box"
|
<div class="col-12 col-lg-6 o_setting_box"
|
||||||
invisible="not ff_ringcentral_enabled">
|
invisible="not ff_ringcentral_enabled">
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
|
|
||||||
<!-- Send Fax header button (fax users only) -->
|
<!-- Send Fax header button (fax users only) -->
|
||||||
<xpath expr="//header" position="inside">
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||||
<button name="action_send_fax" string="Send Fax"
|
<button name="action_send_fax" string="Send Fax"
|
||||||
type="object" class="btn-secondary"
|
type="object" class="btn-secondary"
|
||||||
icon="fa-fax"
|
icon="fa-fax"
|
||||||
|
invisible="not x_ff_show_send_fax_button"
|
||||||
groups="fusion_faxes.group_fax_user"/>
|
groups="fusion_faxes.group_fax_user"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.21.5.6',
|
'version': '19.0.21.7.2',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -56,10 +56,12 @@ Provides:
|
|||||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||||
'views/fp_configurator_menu.xml',
|
'views/fp_configurator_menu.xml',
|
||||||
|
'views/fp_so_job_sort_views.xml',
|
||||||
'data/fp_sale_description_template_data.xml',
|
'data/fp_sale_description_template_data.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'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/scss/fp_3d_viewer.scss',
|
||||||
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||||
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
'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/xml/fp_part_process_composer.xml',
|
||||||
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
|
'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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from . import fp_part_catalog
|
|||||||
from . import fp_pricing_complexity_surcharge
|
from . import fp_pricing_complexity_surcharge
|
||||||
from . import fp_pricing_rule
|
from . import fp_pricing_rule
|
||||||
from . import fp_sale_description_template
|
from . import fp_sale_description_template
|
||||||
|
from . import fp_so_job_sort
|
||||||
from . import fp_quote_configurator
|
from . import fp_quote_configurator
|
||||||
from . import fp_serial
|
from . import fp_serial
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
|
|||||||
@@ -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},
|
||||||
|
}
|
||||||
@@ -110,6 +110,16 @@ class SaleOrder(models.Model):
|
|||||||
help="Customer's internal job number for cross-referencing.",
|
help="Customer's internal job number for cross-referencing.",
|
||||||
tracking=True,
|
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(
|
x_fc_planned_start_date = fields.Date(
|
||||||
string='Planned Start Date', tracking=True,
|
string='Planned Start Date', tracking=True,
|
||||||
)
|
)
|
||||||
@@ -151,6 +161,16 @@ class SaleOrder(models.Model):
|
|||||||
string='Deadline',
|
string='Deadline',
|
||||||
compute='_compute_deadline_countdown',
|
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(
|
x_fc_order_completion_date = fields.Date(
|
||||||
string='Order Completion Date',
|
string='Order Completion Date',
|
||||||
compute='_compute_order_completion_date',
|
compute='_compute_order_completion_date',
|
||||||
@@ -263,6 +283,136 @@ class SaleOrder(models.Model):
|
|||||||
compute='_compute_invoiced_amount',
|
compute='_compute_invoiced_amount',
|
||||||
currency_field='currency_id',
|
currency_field='currency_id',
|
||||||
)
|
)
|
||||||
|
# Single "Job Status" pill rendered in the SO list. Pipeline order:
|
||||||
|
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
|
||||||
|
# <Step Name> → 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(
|
||||||
|
'<span class="fp-job-status fp-kind-%s">%s</span>'
|
||||||
|
) % (_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')
|
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
|
||||||
def _compute_lead_time_display(self):
|
def _compute_lead_time_display(self):
|
||||||
@@ -536,9 +686,11 @@ class SaleOrder(models.Model):
|
|||||||
def _compute_deadline_countdown(self):
|
def _compute_deadline_countdown(self):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
|
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.commitment_date:
|
if not rec.commitment_date:
|
||||||
rec.x_fc_deadline_countdown = False
|
rec.x_fc_deadline_countdown = False
|
||||||
|
rec.x_fc_deadline_urgency = False
|
||||||
continue
|
continue
|
||||||
target = rec.commitment_date
|
target = rec.commitment_date
|
||||||
if isinstance(target, datetime):
|
if isinstance(target, datetime):
|
||||||
@@ -549,12 +701,13 @@ class SaleOrder(models.Model):
|
|||||||
secs = int(delta.total_seconds())
|
secs = int(delta.total_seconds())
|
||||||
if secs == 0:
|
if secs == 0:
|
||||||
rec.x_fc_deadline_countdown = 'due now'
|
rec.x_fc_deadline_countdown = 'due now'
|
||||||
|
rec.x_fc_deadline_urgency = 'overdue'
|
||||||
continue
|
continue
|
||||||
past = secs < 0
|
past = secs < 0
|
||||||
secs = abs(secs)
|
abs_secs = abs(secs)
|
||||||
days = secs // 86400
|
days = abs_secs // 86400
|
||||||
hours = (secs % 86400) // 3600
|
hours = (abs_secs % 86400) // 3600
|
||||||
mins = (secs % 3600) // 60
|
mins = (abs_secs % 3600) // 60
|
||||||
bits = []
|
bits = []
|
||||||
if days:
|
if days:
|
||||||
bits.append('%dd' % days)
|
bits.append('%dd' % days)
|
||||||
@@ -566,6 +719,12 @@ class SaleOrder(models.Model):
|
|||||||
rec.x_fc_deadline_countdown = (
|
rec.x_fc_deadline_countdown = (
|
||||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
'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(
|
@api.depends(
|
||||||
'order_line.x_fc_effective_part_deadline',
|
'order_line.x_fc_effective_part_deadline',
|
||||||
|
|||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -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; }
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
Job Sorting:
|
||||||
|
- Section model views (list/form) under Configuration → Sales.
|
||||||
|
- Alternate SO list ("Sale Orders by Sorting") grouped by job sort
|
||||||
|
with foldable sections and create-from-here support.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ===== Section management (Configuration) ===== -->
|
||||||
|
<record id="view_fp_so_job_sort_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.so.job.sort.list</field>
|
||||||
|
<field name="model">fp.so.job.sort</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Job Sorting" editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="color" widget="color_picker"/>
|
||||||
|
<field name="fold" widget="boolean_toggle"/>
|
||||||
|
<field name="sale_order_count"/>
|
||||||
|
<field name="active" widget="boolean_toggle" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_so_job_sort_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.so.job.sort.form</field>
|
||||||
|
<field name="model">fp.so.job.sort</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Job Sorting">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_sale_orders" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-shopping-cart">
|
||||||
|
<field name="sale_order_count" widget="statinfo"
|
||||||
|
string="Sale Orders"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1><field name="name" placeholder="e.g. Rush Orders"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="color" widget="color_picker"/>
|
||||||
|
<field name="fold"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<field name="description"
|
||||||
|
placeholder="What kinds of orders belong in this section?"/>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_so_job_sort" model="ir.actions.act_window">
|
||||||
|
<field name="name">Job Sorting</field>
|
||||||
|
<field name="res_model">fp.so.job.sort</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_so_job_sort"
|
||||||
|
name="Job Sorting"
|
||||||
|
parent="fusion_plating.menu_fp_config_pricing_billing"
|
||||||
|
action="action_fp_so_job_sort"
|
||||||
|
sequence="25"/>
|
||||||
|
|
||||||
|
<!-- ===== Kanban grouped by Job Sorting =====
|
||||||
|
Groups SOs into foldable columns by x_fc_job_sort_id.
|
||||||
|
Drag-drop between columns rewrites the bucket; quick-create on
|
||||||
|
the column header creates a new fp.so.job.sort row. Wired into
|
||||||
|
the existing Sale Orders action below so it shows up in the
|
||||||
|
view-switcher next to the flat list. -->
|
||||||
|
<record id="view_sale_order_kanban_fp_by_sorting" model="ir.ui.view">
|
||||||
|
<field name="name">sale.order.kanban.fp.by_sorting</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban default_group_by="x_fc_job_sort_id"
|
||||||
|
group_create="true"
|
||||||
|
group_edit="true"
|
||||||
|
group_delete="true"
|
||||||
|
quick_create="false"
|
||||||
|
sample="1">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="amount_total"/>
|
||||||
|
<field name="currency_id"/>
|
||||||
|
<field name="x_fc_part_numbers_summary"/>
|
||||||
|
<field name="x_fc_customer_job_number"/>
|
||||||
|
<field name="x_fc_deadline_countdown"/>
|
||||||
|
<field name="x_fc_deadline_urgency"/>
|
||||||
|
<field name="x_fc_fp_job_status"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="card">
|
||||||
|
<div class="o_kanban_card_content p-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||||
|
<strong><field name="name"/></strong>
|
||||||
|
<span t-att-class="'badge ' + (
|
||||||
|
record.x_fc_deadline_urgency.raw_value == 'overdue' and 'text-bg-danger' or
|
||||||
|
record.x_fc_deadline_urgency.raw_value == 'urgent' and 'text-bg-warning' or
|
||||||
|
record.x_fc_deadline_urgency.raw_value == 'safe' and 'text-bg-success' or
|
||||||
|
'text-bg-light')"
|
||||||
|
t-if="record.x_fc_deadline_countdown.raw_value">
|
||||||
|
<field name="x_fc_deadline_countdown"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mb-1">
|
||||||
|
<field name="partner_id"/>
|
||||||
|
</div>
|
||||||
|
<div class="small mb-1" t-if="record.x_fc_part_numbers_summary.raw_value">
|
||||||
|
<i class="fa fa-cube me-1"/>
|
||||||
|
<field name="x_fc_part_numbers_summary"/>
|
||||||
|
</div>
|
||||||
|
<div class="small mb-2" t-if="record.x_fc_customer_job_number.raw_value">
|
||||||
|
<i class="fa fa-hashtag me-1"/>
|
||||||
|
<field name="x_fc_customer_job_number"/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<field name="x_fc_fp_job_status" widget="html"/>
|
||||||
|
<strong>
|
||||||
|
<field name="amount_total" widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===== Sale Orders by Sorting (alternate SO list) ===== -->
|
||||||
|
<!-- Duplicate of view_sale_order_list_fp but renamed and intended
|
||||||
|
to be opened with group_by=x_fc_job_sort_id by default so the
|
||||||
|
user sees foldable sections per Job Sorting bucket. -->
|
||||||
|
<record id="view_sale_order_list_fp_by_sorting" model="ir.ui.view">
|
||||||
|
<field name="name">sale.order.list.fp.by_sorting</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="priority">99</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Sale Orders by Sorting" create="0"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-muted="state == 'cancel'"
|
||||||
|
decoration-danger="x_fc_is_late_forecast">
|
||||||
|
<header>
|
||||||
|
<button name="%(action_fp_direct_order_wizard)d"
|
||||||
|
type="action"
|
||||||
|
string="New Order"
|
||||||
|
class="btn-primary"
|
||||||
|
display="always"/>
|
||||||
|
</header>
|
||||||
|
<field name="name" optional="show"/>
|
||||||
|
<field name="partner_id" optional="show"/>
|
||||||
|
<field name="x_fc_po_number" optional="show"/>
|
||||||
|
<field name="x_fc_customer_job_number" optional="show"/>
|
||||||
|
<field name="x_fc_job_sort_id" optional="show"
|
||||||
|
options="{'no_create_edit': False, 'no_open': True}"/>
|
||||||
|
<field name="x_fc_internal_deadline" optional="show"/>
|
||||||
|
<field name="commitment_date" string="Customer Deadline"
|
||||||
|
optional="show"/>
|
||||||
|
<field name="x_fc_order_completion_date" string="Completion"
|
||||||
|
optional="show"/>
|
||||||
|
<field name="x_fc_is_late_forecast" optional="hide"
|
||||||
|
widget="boolean_toggle"/>
|
||||||
|
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||||
|
<field name="x_fc_deadline_countdown" optional="show"
|
||||||
|
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||||
|
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||||
|
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||||
|
<field name="x_fc_wo_completion" optional="show"/>
|
||||||
|
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||||
|
<field name="x_fc_part_numbers_summary" string="Part"
|
||||||
|
optional="show"/>
|
||||||
|
<field name="amount_total" sum="Total" optional="show"/>
|
||||||
|
<field name="x_fc_invoiced_amount" sum="Invoiced"
|
||||||
|
optional="hide"
|
||||||
|
widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
|
<field name="x_fc_fp_job_status" widget="html"
|
||||||
|
string="Job Status" optional="show" readonly="1"/>
|
||||||
|
<field name="x_fc_receiving_status" widget="badge"
|
||||||
|
optional="hide"
|
||||||
|
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||||
|
decoration-info="x_fc_receiving_status == 'partial'"
|
||||||
|
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||||
|
<field name="x_fc_delivery_method" optional="hide"/>
|
||||||
|
<field name="currency_id" column_invisible="1"/>
|
||||||
|
<field name="state" widget="badge" optional="show"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Search view for the alternate list: surface "Group by Job
|
||||||
|
Sorting" as a search-default filter. -->
|
||||||
|
<record id="view_sale_order_search_fp_by_sorting" model="ir.ui.view">
|
||||||
|
<field name="name">sale.order.search.fp.by_sorting</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Sale Orders by Sorting">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="x_fc_part_numbers_summary" string="Part"/>
|
||||||
|
<field name="x_fc_customer_job_number"/>
|
||||||
|
<field name="x_fc_po_number"/>
|
||||||
|
<field name="x_fc_job_sort_id"/>
|
||||||
|
<filter name="late_forecast" string="Late Forecast"
|
||||||
|
domain="[('x_fc_is_late_forecast','=',True)]"/>
|
||||||
|
<filter name="cancelled" string="Cancelled"
|
||||||
|
domain="[('state','=','cancel')]"/>
|
||||||
|
<separator/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_by_job_sort"
|
||||||
|
string="Job Sorting"
|
||||||
|
context="{'group_by': 'x_fc_job_sort_id'}"/>
|
||||||
|
<filter name="group_by_customer"
|
||||||
|
string="Customer"
|
||||||
|
context="{'group_by': 'partner_id'}"/>
|
||||||
|
<filter name="group_by_state"
|
||||||
|
string="Status"
|
||||||
|
context="{'group_by': 'state'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Append the kanban view to the existing Sale Orders action so
|
||||||
|
users can switch from the flat list to the grouped-by-sorting
|
||||||
|
kanban (foldable columns, drag-drop bucket reassignment) via
|
||||||
|
the view-switcher icon in the top-right of the SO list. -->
|
||||||
|
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||||
|
<field name="view_mode">list,kanban,form</field>
|
||||||
|
<field name="view_ids" eval="[(5, 0, 0),
|
||||||
|
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')}),
|
||||||
|
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_sale_order_kanban_fp_by_sorting')})]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_sale_orders_by_sorting" model="ir.actions.act_window">
|
||||||
|
<field name="name">Sale Orders (by Sorting)</field>
|
||||||
|
<field name="res_model">sale.order</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="view_id" ref="view_sale_order_list_fp_by_sorting"/>
|
||||||
|
<field name="search_view_id" ref="view_sale_order_search_fp_by_sorting"/>
|
||||||
|
<field name="domain">[('state', 'not in', ('draft', 'sent'))]</field>
|
||||||
|
<field name="context">{'search_default_group_by_job_sort': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_sale_orders_by_sorting"
|
||||||
|
name="Sale Orders (by Sorting)"
|
||||||
|
parent="fusion_plating_configurator.menu_fp_sales"
|
||||||
|
action="action_fp_sale_orders_by_sorting"
|
||||||
|
sequence="12"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -123,6 +123,15 @@
|
|||||||
<field name="commitment_date" string="Delivery Date"
|
<field name="commitment_date" string="Delivery Date"
|
||||||
readonly="state in ('cancel',)"/>
|
readonly="state in ('cancel',)"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Job Sorting sits right under Payment Terms — a free-form
|
||||||
|
bucket that groups the SO in the "Sale Orders by Sorting"
|
||||||
|
list. Quick-create from the dropdown. -->
|
||||||
|
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="after">
|
||||||
|
<field name="x_fc_job_sort_id"
|
||||||
|
options="{'no_create_edit': False, 'no_open': True}"
|
||||||
|
placeholder="Type to create a new bucket..."/>
|
||||||
|
</xpath>
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
<page string="Plating" name="plating_tab">
|
<page string="Plating" name="plating_tab">
|
||||||
<!-- Multi-part summary: read-only list of every order line
|
<!-- Multi-part summary: read-only list of every order line
|
||||||
@@ -368,19 +377,29 @@
|
|||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
display="always"/>
|
display="always"/>
|
||||||
</header>
|
</header>
|
||||||
<field name="name"/>
|
<field name="name" optional="show"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id" optional="show"/>
|
||||||
<field name="x_fc_po_number"/>
|
<field name="x_fc_po_number" optional="show"/>
|
||||||
<field name="x_fc_customer_job_number" optional="show"/>
|
<field name="x_fc_customer_job_number" optional="show"/>
|
||||||
<field name="x_fc_internal_deadline" optional="show"/>
|
<field name="x_fc_internal_deadline" optional="show"/>
|
||||||
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
||||||
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
|
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
|
||||||
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
||||||
<field name="x_fc_deadline_countdown" optional="show"/>
|
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||||
|
<field name="x_fc_deadline_countdown" optional="show"
|
||||||
|
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||||
|
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||||
|
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||||
<field name="x_fc_wo_completion" optional="show"/>
|
<field name="x_fc_wo_completion" optional="show"/>
|
||||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||||
<field name="x_fc_part_catalog_id" optional="hide"/>
|
<!-- "Part" column — walks order_line.x_fc_part_catalog_id
|
||||||
<field name="amount_total" sum="Total"/>
|
and shows a compact summary (e.g. "M1234, M5678
|
||||||
|
(+3 more)"). The header x_fc_part_catalog_id field
|
||||||
|
is rarely populated in the configurator flow; the
|
||||||
|
line carries the authoritative part link. -->
|
||||||
|
<field name="x_fc_part_numbers_summary" string="Part"
|
||||||
|
optional="show"/>
|
||||||
|
<field name="amount_total" sum="Total" optional="show"/>
|
||||||
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
@@ -390,12 +409,21 @@
|
|||||||
<field name="x_fc_margin_percent" optional="hide"
|
<field name="x_fc_margin_percent" optional="hide"
|
||||||
widget="percentage"/>
|
widget="percentage"/>
|
||||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
<field name="x_fc_is_blanket_order" optional="hide"/>
|
||||||
|
<!-- Single Job Status pill. Renders as HTML with a
|
||||||
|
per-kind class (.fp-kind-*) so every phase carries
|
||||||
|
its own distinct tint — see
|
||||||
|
static/src/scss/fp_job_status_pill.scss. -->
|
||||||
|
<field name="x_fc_fp_job_status" widget="html"
|
||||||
|
string="Job Status" optional="show"
|
||||||
|
readonly="1"/>
|
||||||
<field name="x_fc_receiving_status" widget="badge"
|
<field name="x_fc_receiving_status" widget="badge"
|
||||||
|
optional="hide"
|
||||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||||
|
decoration-info="x_fc_receiving_status == 'partial'"
|
||||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||||
<field name="x_fc_delivery_method" optional="hide"/>
|
<field name="x_fc_delivery_method" optional="hide"/>
|
||||||
<field name="currency_id" column_invisible="1"/>
|
<field name="currency_id" column_invisible="1"/>
|
||||||
<field name="state" widget="badge"/>
|
<field name="state" widget="badge" optional="show"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -465,8 +493,8 @@
|
|||||||
<field name="model">sale.order</field>
|
<field name="model">sale.order</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Quotations" decoration-muted="state == 'cancel'">
|
<list string="Quotations" decoration-muted="state == 'cancel'">
|
||||||
<field name="name"/>
|
<field name="name" optional="show"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id" optional="show"/>
|
||||||
<field name="x_fc_part_numbers_summary" optional="show"/>
|
<field name="x_fc_part_numbers_summary" optional="show"/>
|
||||||
<field name="x_fc_po_number" optional="hide"/>
|
<field name="x_fc_po_number" optional="hide"/>
|
||||||
<field name="x_fc_customer_job_number" optional="hide"/>
|
<field name="x_fc_customer_job_number" optional="hide"/>
|
||||||
@@ -474,15 +502,16 @@
|
|||||||
<field name="validity_date" string="Expires" optional="show"/>
|
<field name="validity_date" string="Expires" optional="show"/>
|
||||||
<field name="x_fc_follow_up_date" optional="show"/>
|
<field name="x_fc_follow_up_date" optional="show"/>
|
||||||
<field name="x_fc_follow_up_user_id" optional="show"/>
|
<field name="x_fc_follow_up_user_id" optional="show"/>
|
||||||
<field name="amount_total" sum="Total"/>
|
<field name="amount_total" sum="Total" optional="show"/>
|
||||||
<field name="x_fc_is_signed" widget="boolean_toggle"
|
<field name="x_fc_is_signed" widget="boolean_toggle"
|
||||||
string="Signed" optional="show"/>
|
string="Signed" optional="show"/>
|
||||||
<field name="x_fc_email_status" widget="badge"
|
<field name="x_fc_email_status" widget="badge"
|
||||||
|
optional="show"
|
||||||
decoration-info="x_fc_email_status == 'sent'"
|
decoration-info="x_fc_email_status == 'sent'"
|
||||||
decoration-warning="x_fc_email_status == 'opened'"
|
decoration-warning="x_fc_email_status == 'opened'"
|
||||||
decoration-success="x_fc_email_status == 'won'"/>
|
decoration-success="x_fc_email_status == 'won'"/>
|
||||||
<field name="currency_id" column_invisible="1"/>
|
<field name="currency_id" column_invisible="1"/>
|
||||||
<field name="state" widget="badge"/>
|
<field name="state" widget="badge" optional="show"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -582,7 +611,10 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
|
<!-- ===== Window Action — Confirmed Sale Orders =====
|
||||||
|
The kanban view_mode + kanban view_id are appended in
|
||||||
|
fp_so_job_sort_views.xml after the kanban view is defined, so
|
||||||
|
we don't hit a missing-ref at module load. -->
|
||||||
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||||
<field name="name">Sale Orders</field>
|
<field name="name">Sale Orders</field>
|
||||||
<field name="res_model">sale.order</field>
|
<field name="res_model">sale.order</field>
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
help="Customer's internal job number for cross-referencing. "
|
help="Customer's internal job number for cross-referencing. "
|
||||||
"Appears on work orders and invoices.",
|
"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 ----
|
# ---- Scheduling ----
|
||||||
planned_start_date = fields.Date(
|
planned_start_date = fields.Date(
|
||||||
@@ -533,6 +540,7 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'x_fc_po_pending': self.po_pending,
|
'x_fc_po_pending': self.po_pending,
|
||||||
'x_fc_po_expected_date': self.po_expected_date or False,
|
'x_fc_po_expected_date': self.po_expected_date or False,
|
||||||
'x_fc_customer_job_number': self.customer_job_number 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_planned_start_date': self.planned_start_date,
|
||||||
'x_fc_internal_deadline': self.internal_deadline,
|
'x_fc_internal_deadline': self.internal_deadline,
|
||||||
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
|
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
options="{'no_create_edit': True}"
|
options="{'no_create_edit': True}"
|
||||||
invisible="not partner_id"/>
|
invisible="not partner_id"/>
|
||||||
<field name="customer_job_number"/>
|
<field name="customer_job_number"/>
|
||||||
|
<field name="job_sort_id"
|
||||||
|
options="{'no_create_edit': False, 'no_open': True}"
|
||||||
|
placeholder="Type to create a new bucket..."/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Purchase Order">
|
<group string="Purchase Order">
|
||||||
<field name="po_number"
|
<field name="po_number"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.10.16.9',
|
'version': '19.0.10.18.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
@@ -57,11 +57,11 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
# so the statusbar's m2o has its targets available at view-render time).
|
# so the statusbar's m2o has its targets available at view-render time).
|
||||||
'data/fp_workflow_state_data.xml',
|
'data/fp_workflow_state_data.xml',
|
||||||
'views/fp_workflow_state_views.xml',
|
'views/fp_workflow_state_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
|
||||||
'views/fp_job_step_quick_look_views.xml',
|
'views/fp_job_step_quick_look_views.xml',
|
||||||
'views/fp_job_form_inherit.xml',
|
'views/fp_job_form_inherit.xml',
|
||||||
'views/fp_job_quality_buttons.xml',
|
'views/fp_job_quality_buttons.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
|
'views/fp_receiving_views.xml',
|
||||||
'views/fp_certificate_views.xml',
|
'views/fp_certificate_views.xml',
|
||||||
'views/fp_job_consumption_views.xml',
|
'views/fp_job_consumption_views.xml',
|
||||||
'views/fp_step_priority_views.xml',
|
'views/fp_step_priority_views.xml',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from . import fp_certificate
|
|||||||
from . import fp_thickness_reading
|
from . import fp_thickness_reading
|
||||||
from . import fp_delivery
|
from . import fp_delivery
|
||||||
from . import fp_racking_inspection
|
from . import fp_racking_inspection
|
||||||
|
from . import fp_receiving
|
||||||
|
|
||||||
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
||||||
from . import fp_notification_trigger
|
from . import fp_notification_trigger
|
||||||
|
|||||||
@@ -137,10 +137,13 @@ class AccountMove(models.Model):
|
|||||||
if not job or not job.portal_job_id:
|
if not job or not job.portal_job_id:
|
||||||
return
|
return
|
||||||
portal = job.portal_job_id
|
portal = job.portal_job_id
|
||||||
if 'state' in portal._fields:
|
|
||||||
portal.state = 'complete'
|
|
||||||
if 'invoice_ref' in portal._fields:
|
if 'invoice_ref' in portal._fields:
|
||||||
portal.invoice_ref = self.name
|
portal.invoice_ref = self.name
|
||||||
|
# Recompute state via the central helper — it'll only land on
|
||||||
|
# 'complete' if the WO is actually done AND the shipment is
|
||||||
|
# delivered. Posting an invoice early no longer skips the floor.
|
||||||
|
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||||
|
portal._fp_recompute_portal_state()
|
||||||
_logger.info(
|
_logger.info(
|
||||||
'Invoice %s linked to fp.job %s portal %s',
|
'Invoice %s linked to fp.job %s portal %s',
|
||||||
self.name, job.name, portal.name,
|
self.name, job.name, portal.name,
|
||||||
|
|||||||
@@ -745,16 +745,10 @@ class FpJob(models.Model):
|
|||||||
'name': self.portal_job_id.name,
|
'name': self.portal_job_id.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
# fp.job.state -> fusion.plating.portal.job.state mapping. Kept tight so
|
# Sub-portal state sync — see fusion_plating_portal/.../fp_portal_job.py
|
||||||
# the customer doesn't see internal states. Anything not in this map
|
# `_fp_recompute_portal_state` for the rules. The mapping table that
|
||||||
# leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
|
# used to live here was replaced by the helper so shipment / invoice
|
||||||
_FP_JOB_STATE_TO_PORTAL_STATE = {
|
# signals can't drift away from the WO state any more.
|
||||||
'confirmed': 'received',
|
|
||||||
'in_progress': 'in_progress',
|
|
||||||
'done': 'ready_to_ship',
|
|
||||||
# 'on_hold' and 'cancelled' intentionally omitted — managers choose
|
|
||||||
# what to surface to the customer.
|
|
||||||
}
|
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
|
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
|
||||||
@@ -783,13 +777,13 @@ class FpJob(models.Model):
|
|||||||
if job.state != new_state:
|
if job.state != new_state:
|
||||||
state_changed_ids.add(job.id)
|
state_changed_ids.add(job.id)
|
||||||
result = super().write(vals)
|
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:
|
if state_changed_ids:
|
||||||
target = self._FP_JOB_STATE_TO_PORTAL_STATE.get(vals.get('state'))
|
for job in self.filtered(lambda j: j.id in state_changed_ids):
|
||||||
if target:
|
if job.portal_job_id:
|
||||||
for job in self.filtered(lambda j: j.id in state_changed_ids):
|
job.portal_job_id._fp_recompute_portal_state()
|
||||||
if job.portal_job_id and job.portal_job_id.state != target:
|
|
||||||
job.portal_job_id.sudo().write({'state': target})
|
|
||||||
if not scrap_deltas:
|
if not scrap_deltas:
|
||||||
return result
|
return result
|
||||||
Hold = (self.env['fusion.plating.quality.hold']
|
Hold = (self.env['fusion.plating.quality.hold']
|
||||||
|
|||||||
67
fusion_plating/fusion_plating_jobs/models/fp_receiving.py
Normal file
67
fusion_plating/fusion_plating_jobs/models/fp_receiving.py
Normal file
@@ -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
|
||||||
@@ -24,6 +24,13 @@ class SaleOrder(models.Model):
|
|||||||
string='Work Orders',
|
string='Work Orders',
|
||||||
compute='_compute_fp_job_count',
|
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(
|
x_fc_fp_certificate_count = fields.Integer(
|
||||||
string='Certificates',
|
string='Certificates',
|
||||||
compute='_compute_fp_certificate_count',
|
compute='_compute_fp_certificate_count',
|
||||||
@@ -114,6 +121,25 @@ class SaleOrder(models.Model):
|
|||||||
[('sale_order_id', '=', so.id)]
|
[('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):
|
def _compute_fp_certificate_count(self):
|
||||||
Cert = self.env['fp.certificate'].sudo()
|
Cert = self.env['fp.certificate'].sudo()
|
||||||
for so in self:
|
for so in self:
|
||||||
|
|||||||
@@ -63,15 +63,10 @@
|
|||||||
<field name="all_steps_terminal" invisible="1"/>
|
<field name="all_steps_terminal" invisible="1"/>
|
||||||
<field name="next_milestone_action" invisible="1"/>
|
<field name="next_milestone_action" invisible="1"/>
|
||||||
<button name="action_print_sticker" type="object"
|
<button name="action_print_sticker" type="object"
|
||||||
string="Print Sticker"
|
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-tag"
|
icon="fa-qrcode"
|
||||||
invisible="state == 'draft'"/>
|
invisible="state == 'draft'"
|
||||||
<button name="action_print_wo_detail" type="object"
|
help="Print Sticker"/>
|
||||||
string="Print WO Detail"
|
|
||||||
class="btn-secondary"
|
|
||||||
icon="fa-file-text-o"
|
|
||||||
invisible="state in ('draft', 'cancelled')"/>
|
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- Sub 14 — Replace the generic Draft/Confirmed/In Progress/Done
|
<!-- Sub 14 — Replace the generic Draft/Confirmed/In Progress/Done
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
Adds the Work Order smart button + header button to fp.receiving so
|
||||||
|
the receiving form mirrors the SO's WO entry point. Header button
|
||||||
|
appears once state == 'closed' and at least one linked fp.job is
|
||||||
|
still open. Smart button is always visible when WOs exist.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_receiving_form_fp_jobs" model="ir.ui.view">
|
||||||
|
<field name="name">fp.receiving.form.fp.jobs</field>
|
||||||
|
<field name="model">fp.receiving</field>
|
||||||
|
<field name="inherit_id" ref="fusion_plating_receiving.view_fp_receiving_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
|
||||||
|
<!-- Work Order header button — only after receiving is
|
||||||
|
closed and while at least one job is still open. -->
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||||
|
<button name="action_view_fp_jobs"
|
||||||
|
string="Work Order" type="object"
|
||||||
|
class="btn-primary" icon="fa-cogs"
|
||||||
|
invisible="not x_fc_show_work_order_btn"
|
||||||
|
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Work Order smart button on the button_box (mirrors the
|
||||||
|
one on the SO form). Always visible when count > 0. -->
|
||||||
|
<xpath expr="//div[@name='button_box']" position="inside">
|
||||||
|
<button name="action_view_fp_jobs" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-cogs"
|
||||||
|
invisible="x_fc_fp_job_count == 0">
|
||||||
|
<field name="x_fc_fp_job_count" widget="statinfo" string="WO"/>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -33,6 +33,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Work Order header action — appears once any linked
|
||||||
|
receiving is closed AND at least one WO is still open.
|
||||||
|
Reuses the existing action_view_fp_jobs smart-button
|
||||||
|
target so single-job SOs land on the form directly. -->
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||||
|
<button name="action_view_fp_jobs"
|
||||||
|
string="Work Order" type="object"
|
||||||
|
class="btn-primary" icon="fa-cogs"
|
||||||
|
invisible="not x_fc_show_work_order_btn"
|
||||||
|
help="Open the Work Order(s) for this order. Hidden automatically once every linked WO is marked Done."/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
|
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
|
||||||
line under the SO name (the big SO-30000 heading). Only
|
line under the SO name (the big SO-30000 heading). Only
|
||||||
renders once the SO has been confirmed (quote_ref is set
|
renders once the SO has been confirmed (quote_ref is set
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Customer Portal',
|
'name': 'Fusion Plating — Customer Portal',
|
||||||
'version': '19.0.4.3.0',
|
'version': '19.0.4.4.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||||
'CoC downloads, invoice access.',
|
'CoC downloads, invoice access.',
|
||||||
|
|||||||
@@ -263,6 +263,83 @@ class FpPortalJob(models.Model):
|
|||||||
walk(mo.x_fc_recipe_id, 0)
|
walk(mo.x_fc_recipe_id, 0)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# State recompute — single source of truth derived from upstream models
|
||||||
|
# ==========================================================================
|
||||||
|
# The portal state should ALWAYS reflect the real shop-floor state of the
|
||||||
|
# linked fp.job(s), the outbound shipment(s), and the customer invoice.
|
||||||
|
# Earlier paths wrote state directly from each event hook (tracking number
|
||||||
|
# arrived → 'shipped'; invoice posted → 'complete') which drifted out of
|
||||||
|
# sync the moment any of those events fired before the job was actually
|
||||||
|
# done — e.g. a FedEx label booked early would promote portal state to
|
||||||
|
# 'shipped' even though the WO was still in 'confirmed'. The helper below
|
||||||
|
# is the new single source of truth; the hooks now delegate to it.
|
||||||
|
def _fp_recompute_portal_state(self):
|
||||||
|
"""Derive portal state from fp.job + shipment + invoice and write
|
||||||
|
it back if it differs. Safe to call from any sync hook; only
|
||||||
|
writes when the target state actually changes."""
|
||||||
|
Job = self.env.get('fp.job')
|
||||||
|
if Job is None:
|
||||||
|
return
|
||||||
|
for portal in self:
|
||||||
|
jobs = Job.sudo().search([('portal_job_id', '=', portal.id)])
|
||||||
|
if not jobs:
|
||||||
|
# No linked job — leave manual edits alone.
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_done = all(j.state == 'done' for j in jobs)
|
||||||
|
any_in_progress = any(
|
||||||
|
j.state in ('in_progress', 'done') for j in jobs
|
||||||
|
)
|
||||||
|
|
||||||
|
# Walk SO → fp.receiving → fusion.shipment for shipment status.
|
||||||
|
ship_delivered = False
|
||||||
|
ship_in_transit = False
|
||||||
|
for j in jobs:
|
||||||
|
so = j.sale_order_id
|
||||||
|
if not so or 'x_fc_receiving_ids' not in so._fields:
|
||||||
|
continue
|
||||||
|
for recv in so.x_fc_receiving_ids:
|
||||||
|
ship = (
|
||||||
|
recv.x_fc_outbound_shipment_id
|
||||||
|
if 'x_fc_outbound_shipment_id' in recv._fields
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
if not ship:
|
||||||
|
continue
|
||||||
|
if ship.status == 'delivered':
|
||||||
|
ship_delivered = True
|
||||||
|
elif ship.status == 'shipped':
|
||||||
|
ship_in_transit = True
|
||||||
|
|
||||||
|
# Invoice signal — any posted customer invoice on the SO.
|
||||||
|
invoiced = False
|
||||||
|
for j in jobs:
|
||||||
|
so = j.sale_order_id
|
||||||
|
if not so:
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
m.state == 'posted' and m.move_type in ('out_invoice', 'out_refund')
|
||||||
|
for m in so.invoice_ids
|
||||||
|
):
|
||||||
|
invoiced = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Resolve target state.
|
||||||
|
if all_done and invoiced and ship_delivered:
|
||||||
|
target = 'complete'
|
||||||
|
elif all_done and (ship_delivered or ship_in_transit):
|
||||||
|
target = 'shipped'
|
||||||
|
elif all_done:
|
||||||
|
target = 'ready_to_ship'
|
||||||
|
elif any_in_progress:
|
||||||
|
target = 'in_progress'
|
||||||
|
else:
|
||||||
|
target = 'received'
|
||||||
|
|
||||||
|
if portal.state != target:
|
||||||
|
portal.sudo().write({'state': target})
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Per-stage timestamp snapshots
|
# Per-stage timestamp snapshots
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Receiving & Inspection',
|
'name': 'Fusion Plating — Receiving & Inspection',
|
||||||
'version': '19.0.3.25.0',
|
'version': '19.0.3.27.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -160,14 +160,14 @@ class FusionShipment(models.Model):
|
|||||||
vals['packing_list_attachment_id'] = (
|
vals['packing_list_attachment_id'] = (
|
||||||
delivery.packing_list_attachment_id.id
|
delivery.packing_list_attachment_id.id
|
||||||
)
|
)
|
||||||
# Once a tracking number exists, the parts have been picked
|
|
||||||
# by the carrier (or are about to be) — advance the portal
|
|
||||||
# state to 'shipped' so the customer sees their order is
|
|
||||||
# on its way. The 'delivered' status flips when FedEx
|
|
||||||
# tracking reports the delivery.
|
|
||||||
if self.tracking_number and portal.state in (
|
|
||||||
'received', 'in_progress', 'ready_to_ship',
|
|
||||||
):
|
|
||||||
vals['state'] = 'shipped'
|
|
||||||
if vals:
|
if vals:
|
||||||
portal.sudo().write(vals)
|
portal.sudo().write(vals)
|
||||||
|
# State is now derived centrally — see
|
||||||
|
# fusion.plating.portal.job._fp_recompute_portal_state. It
|
||||||
|
# only promotes to 'shipped' when every linked WO is done
|
||||||
|
# AND the shipment.status is 'shipped' or 'delivered'. A
|
||||||
|
# FedEx label booked early (tracking number without the
|
||||||
|
# carrier actually picking up) no longer leapfrogs the
|
||||||
|
# shop floor.
|
||||||
|
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||||
|
portal.sudo()._fp_recompute_portal_state()
|
||||||
|
|||||||
@@ -15,12 +15,29 @@ class SaleOrder(models.Model):
|
|||||||
x_fc_receiving_count = fields.Integer(
|
x_fc_receiving_count = fields.Integer(
|
||||||
string='Receiving Count', compute='_compute_receiving_count',
|
string='Receiving Count', compute='_compute_receiving_count',
|
||||||
)
|
)
|
||||||
|
x_fc_show_receive_parts_btn = fields.Boolean(
|
||||||
|
string='Show Receive Parts Button',
|
||||||
|
compute='_compute_show_receive_parts_btn',
|
||||||
|
help='True once the SO is confirmed and there is still at least '
|
||||||
|
'one receiving record that is not yet closed. Hidden again '
|
||||||
|
'when every receiving record has been closed.',
|
||||||
|
)
|
||||||
|
|
||||||
@api.depends('x_fc_receiving_ids')
|
@api.depends('x_fc_receiving_ids')
|
||||||
def _compute_receiving_count(self):
|
def _compute_receiving_count(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
||||||
|
|
||||||
|
@api.depends('state', 'x_fc_receiving_ids.state')
|
||||||
|
def _compute_show_receive_parts_btn(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.state not in ('sale', 'done'):
|
||||||
|
rec.x_fc_show_receive_parts_btn = False
|
||||||
|
continue
|
||||||
|
rec.x_fc_show_receive_parts_btn = any(
|
||||||
|
r.state != 'closed' for r in rec.x_fc_receiving_ids
|
||||||
|
)
|
||||||
|
|
||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
"""Override to auto-create receiving record on SO confirmation.
|
"""Override to auto-create receiving record on SO confirmation.
|
||||||
|
|
||||||
|
|||||||
@@ -76,3 +76,25 @@ $fp-quote-muted : var(--fp-quote-muted, $_fp-quote-muted-hex);
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Receive Parts header button — dark yellow / goldenrod
|
||||||
|
// Applied to the SO-form header action that opens the receiving record(s).
|
||||||
|
// Uses !important to defeat the .o_form_statusbar .btn cascades in both
|
||||||
|
// the light and dark Odoo bundles.
|
||||||
|
// =============================================================================
|
||||||
|
.o_fp_receive_parts_btn,
|
||||||
|
.o_form_statusbar .o_statusbar_buttons .o_fp_receive_parts_btn,
|
||||||
|
button.o_fp_receive_parts_btn {
|
||||||
|
background-color: #b8860b !important;
|
||||||
|
border-color: #9e7700 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background-color: #9e7700 !important;
|
||||||
|
border-color: #7a5b00 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa { color: #ffffff !important; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,21 @@
|
|||||||
<field name="x_fc_receiving_count" widget="statinfo" string="Receiving"/>
|
<field name="x_fc_receiving_count" widget="statinfo" string="Receiving"/>
|
||||||
</button>
|
</button>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Receive Parts header action — appears after SO confirmation
|
||||||
|
while at least one receiving record is still open, and
|
||||||
|
disappears once every receiving record is closed. Reuses
|
||||||
|
the existing action_view_receiving method so a single
|
||||||
|
receiving opens directly while multiples land on the list. -->
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="x_fc_show_receive_parts_btn" invisible="1"/>
|
||||||
|
<button name="action_view_receiving"
|
||||||
|
string="Receive Parts" type="object"
|
||||||
|
class="btn o_fp_receive_parts_btn"
|
||||||
|
icon="fa-archive"
|
||||||
|
invisible="not x_fc_show_receive_parts_btn"
|
||||||
|
help="Open the receiving record(s) for this order. Hidden automatically once all receiving records are closed."/>
|
||||||
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user