feat(configurator): complete all deferred Phase D/E/F tasks

Ships the remaining items from the Sales UX Uplift plan:

D2 BOM Items kanban
  New view_sale_order_line_bom_kanban grouped by x_fc_part_catalog_id.
  Smart button 'BOM Items' on SO form opens it.

D5 Archive line
  x_fc_archived Boolean on sale.order.line plus action_archive_line /
  action_unarchive_line. Acknowledgement report filters out archived
  lines.

D6 Add Quoted Lines sub-wizard
  New fp.add.from.quote.wizard parallel to fp.add.from.so.wizard. Pick
  quotes for this customer and clone them into direct-order lines
  carrying part, coating, qty, unit price (from calculated or
  override), and notes. Button '+ Add From Quotes' on wizard Lines tab.

D7 SO Acknowledgement PDF
  New ir.actions.report + QWeb template in configurator/report/.
  Header shows customer / contact / PO / Customer Job #, Bill-To,
  Ship-To, planned start + customer deadline + ship-via. Line table
  skips archived lines. Includes external notes, blanket-order
  callout, and customer-signature + vendor-signature blocks.
  Binding added to sale.order so it shows up under Print menu.

D9 Quick-nav chip bar
  New smart buttons on SO form: Invoices / Pickings / NCRs / Files
  with counts and icons. Each opens a filtered list. NCR button
  appears only when fusion_plating_quality is installed.

D10 SO/WO perspective toggle
  view_sale_order_line_wo_kanban grouped by x_fc_wo_group_tag. Smart
  button 'By WO' on SO form.

D11 Assemblies minimal model
  fp.sale.assembly + fp.sale.assembly.line with name, ship_to, count,
  procured_count, completed_at. UX (forms / kanbans / integration
  into receiving) deferred — model only for now.

D14 Uploaded Files
  Files smart button on SO form opens ir.attachment kanban filtered
  to this SO. Count appears in the chip bar.

F4 Signed tracking
  x_fc_signed_at / x_fc_signed_by / x_fc_is_signed on sale.order +
  action_mark_signed helper. Signed column on quotes list view.

F10 New Quote
  Kept on existing action_fp_quotations (already surfaces the
  default New button).

E5/F9 Action icons per row
  Deferred — requires a custom widget; the native PDF action via the
  Print menu covers 80% of the use case.

Bumped to 19.0.8.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 21:45:17 -04:00
parent b85e208856
commit b834ae3117
13 changed files with 636 additions and 1 deletions

View File

@@ -13,5 +13,6 @@ from . import fp_sale_description_template
from . import fp_quote_configurator
from . import sale_order
from . import sale_order_line
from . import fp_sale_assembly
from . import res_partner
from . import fp_process_node

View File

@@ -0,0 +1,69 @@
# -*- 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 FpSaleAssembly(models.Model):
"""Hierarchical kit / assembly on a sale order line.
A sale.order.line can carry child parts that make up an assembly.
Useful when the customer sends a kit (e.g. housing + cover + two
bolts) and each sub-part needs its own receive count + processing
status but they all bill as one kit.
Phase D11 shipped minimal: just the data model. Full UX (hierarchy
kanban, procurement tracking) is a follow-on.
"""
_name = 'fp.sale.assembly'
_description = 'Fusion Plating - Sales Order Assembly'
_order = 'sequence, id'
name = fields.Char(string='Assembly Name', required=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Parent SO Line',
required=True, ondelete='cascade',
)
order_id = fields.Many2one(
'sale.order', related='sale_order_line_id.order_id',
store=True, readonly=True,
)
partner_id = fields.Many2one(
related='order_id.partner_id', store=True, readonly=True,
)
line_ids = fields.One2many(
'fp.sale.assembly.line', 'assembly_id',
string='Assembly Lines',
)
ship_to = fields.Char(string='Ship To')
count = fields.Integer(string='Count', default=1)
procured_count = fields.Integer(
string='Procured Count',
compute='_compute_procured_count',
)
completed_at = fields.Datetime(string='Completed At')
@api.depends('line_ids.procured_qty')
def _compute_procured_count(self):
for rec in self:
rec.procured_count = sum(rec.line_ids.mapped('procured_qty'))
class FpSaleAssemblyLine(models.Model):
_name = 'fp.sale.assembly.line'
_description = 'Fusion Plating - Assembly Line'
_order = 'sequence, id'
name = fields.Char(string='Part Number', required=True)
sequence = fields.Integer(default=10)
assembly_id = fields.Many2one(
'fp.sale.assembly', required=True, ondelete='cascade',
)
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)
qty_per_assembly = fields.Float(string='Qty / Assembly', default=1.0)
procured_qty = fields.Float(string='Procured Qty', default=0.0)

View File

@@ -167,6 +167,29 @@ class SaleOrder(models.Model):
string='Part Numbers',
compute='_compute_part_numbers_summary',
)
x_fc_signed_at = fields.Datetime(
string='Signed On', tracking=True,
help='When the customer signed / accepted this quote.',
)
x_fc_signed_by = fields.Char(
string='Signed By', tracking=True,
help='Name of the customer signatory.',
)
x_fc_is_signed = fields.Boolean(
string='Signed', compute='_compute_is_signed', store=True,
)
@api.depends('x_fc_signed_at')
def _compute_is_signed(self):
for rec in self:
rec.x_fc_is_signed = bool(rec.x_fc_signed_at)
def action_mark_signed(self):
self.ensure_one()
self.write({
'x_fc_signed_at': fields.Datetime.now(),
'x_fc_signed_by': self.partner_id.name,
})
@api.depends('state')
def _compute_email_status(self):
@@ -253,6 +276,114 @@ class SaleOrder(models.Model):
'context': {'search_default_group_production_id': 1},
}
# ---- Quick-nav counts for smart buttons (Phase D9 / D14) ----
x_fc_invoice_count = fields.Integer(
string='Invoices', compute='_compute_nav_counts',
)
x_fc_ncr_count = fields.Integer(
string='NCRs', compute='_compute_nav_counts',
)
x_fc_picking_count = fields.Integer(
string='Pickings', compute='_compute_nav_counts',
)
x_fc_attachment_count = fields.Integer(
string='Files', compute='_compute_nav_counts',
)
def _compute_nav_counts(self):
NCR = self.env.get('fusion.plating.ncr')
for rec in self:
rec.x_fc_invoice_count = len(rec.invoice_ids)
rec.x_fc_picking_count = len(rec.picking_ids)
rec.x_fc_attachment_count = self.env['ir.attachment'].sudo().search_count([
('res_model', '=', 'sale.order'),
('res_id', '=', rec.id),
])
if NCR and 'sale_order_id' in NCR._fields:
rec.x_fc_ncr_count = NCR.sudo().search_count([
('sale_order_id', '=', rec.id),
])
else:
rec.x_fc_ncr_count = 0
def action_view_invoices(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Invoices',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', self.invoice_ids.ids)],
}
def action_view_pickings(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Deliveries / Pickings',
'res_model': 'stock.picking',
'view_mode': 'list,form',
'domain': [('id', 'in', self.picking_ids.ids)],
}
def action_view_ncrs(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'NCRs',
'res_model': 'fusion.plating.ncr',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
}
def action_view_files(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Files',
'res_model': 'ir.attachment',
'view_mode': 'kanban,list,form',
'domain': [
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
],
'context': {
'default_res_model': 'sale.order',
'default_res_id': self.id,
},
}
def action_view_bom_items(self):
"""Open SO lines grouped by part catalog (Phase D2)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'BOM Items - %s' % self.name,
'res_model': 'sale.order.line',
'view_mode': 'kanban,list,form',
'views': [
(self.env.ref('fusion_plating_configurator.view_sale_order_line_bom_kanban').id, 'kanban'),
(False, 'list'),
(False, 'form'),
],
'domain': [('order_id', '=', self.id)],
}
def action_view_wo_perspective(self):
"""Open SO lines grouped by WO tag (Phase D10)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Lines by WO - %s' % self.name,
'res_model': 'sale.order.line',
'view_mode': 'kanban,list',
'views': [
(self.env.ref('fusion_plating_configurator.view_sale_order_line_wo_kanban').id, 'kanban'),
(False, 'list'),
],
'domain': [('order_id', '=', self.id)],
}
@api.depends('commitment_date')
def _compute_deadline_countdown(self):
from datetime import datetime

View File

@@ -46,3 +46,17 @@ class SaleOrderLine(models.Model):
string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.',
)
x_fc_archived = fields.Boolean(
string='Archived',
default=False,
help='Archived lines are hidden from the default list view but '
'preserved for audit. Useful when a part is cancelled mid-order.',
)
def action_archive_line(self):
self.write({'x_fc_archived': True})
return True
def action_unarchive_line(self):
self.write({'x_fc_archived': False})
return True