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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user