This commit is contained in:
gsinghpal
2026-05-22 18:01:31 -04:00
parent d127e19b45
commit f661724c72
34 changed files with 1011 additions and 59 deletions

View File

@@ -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

View File

@@ -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},
}

View File

@@ -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 →
# <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')
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',