Compare commits

...

7 Commits

12 changed files with 250 additions and 83 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.22.8.0',
'version': '19.0.23.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -44,6 +44,7 @@ Provides:
'views/fp_part_catalog_views.xml',
'views/fp_process_node_part_scoped_views.xml',
'views/fp_pricing_rule_views.xml',
'views/fp_additional_charge_type_views.xml',
'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml',
'views/res_partner_views.xml',
@@ -59,6 +60,7 @@ Provides:
'views/fp_configurator_menu.xml',
'views/fp_so_job_sort_views.xml',
'data/fp_sale_description_template_data.xml',
'data/fp_additional_charge_type_data.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="charge_type_tooling" model="fp.additional.charge.type">
<field name="name">Tooling Charge</field>
<field name="sequence">1</field>
</record>
</odoo>

View File

@@ -9,6 +9,7 @@ from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule
from . import fp_sale_description_template
from . import fp_part_description_version
from . import fp_additional_charge_type
from . import fp_so_job_sort
from . import fp_quote_configurator
from . import fp_serial

View File

@@ -0,0 +1,29 @@
# -*- 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 fields, models
class FpAdditionalChargeType(models.Model):
"""A configurable, reusable 'additional charge' label (Tooling, Rush,
Setup, …) picked on the order-entry summary. Searchable + quick-create.
Spec: docs/superpowers/specs/2026-05-29-configurable-charge-tax-lot-pricing-design.md
"""
_name = 'fp.additional.charge.type'
_description = 'Fusion Plating — Additional Charge Type'
_order = 'sequence, name'
name = fields.Char(string='Charge Type', required=True)
default_amount = fields.Monetary(
string='Default Amount', currency_field='currency_id',
help='Optional amount pre-filled when this type is picked on an '
'order. The operator can override it.',
)
currency_id = fields.Many2one(
'res.currency', default=lambda self: self.env.company.currency_id,
)
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)

View File

@@ -326,6 +326,9 @@ class SaleOrderLine(models.Model):
'pair, falling back to the part\'s default range. Prints '
'verbatim on the cert, packing slip, and invoice.',
)
x_fc_is_lot_priced = fields.Boolean(string='Lot Priced')
x_fc_lot_total = fields.Monetary(
string='Lot Total', currency_field='currency_id')
# ---- Express Orders per-line flags (2026-05-26) ----
# Mirror fp.direct.order.line.{customer_line_ref, masking_enabled, bake_instructions}

View File

@@ -47,3 +47,6 @@ access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion
access_fp_part_description_version_user,fp.part.description.version.user,model_fp_part_description_version,base.group_user,1,0,0,0
access_fp_part_description_version_estimator,fp.part.description.version.estimator,model_fp_part_description_version,fusion_plating.group_fp_sales_rep,1,1,1,0
access_fp_part_description_version_manager,fp.part.description.version.manager,model_fp_part_description_version,fusion_plating.group_fp_manager,1,1,1,1
access_fp_additional_charge_type_user,fp.additional.charge.type.user,model_fp_additional_charge_type,base.group_user,1,0,0,0
access_fp_additional_charge_type_estimator,fp.additional.charge.type.estimator,model_fp_additional_charge_type,fusion_plating.group_fp_sales_rep,1,1,1,0
access_fp_additional_charge_type_manager,fp.additional.charge.type.manager,model_fp_additional_charge_type,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
47 access_fp_part_description_version_user fp.part.description.version.user model_fp_part_description_version base.group_user 1 0 0 0
48 access_fp_part_description_version_estimator fp.part.description.version.estimator model_fp_part_description_version fusion_plating.group_fp_sales_rep 1 1 1 0
49 access_fp_part_description_version_manager fp.part.description.version.manager model_fp_part_description_version fusion_plating.group_fp_manager 1 1 1 1
50 access_fp_additional_charge_type_user fp.additional.charge.type.user model_fp_additional_charge_type base.group_user 1 0 0 0
51 access_fp_additional_charge_type_estimator fp.additional.charge.type.estimator model_fp_additional_charge_type fusion_plating.group_fp_sales_rep 1 1 1 0
52 access_fp_additional_charge_type_manager fp.additional.charge.type.manager model_fp_additional_charge_type fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -8,3 +8,4 @@ from . import test_express_so_line_fields
from . import test_express_sale_order_fields
from . import test_express_wizard_fields
from . import test_part_description_history
from . import test_charge_tax_lot

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Configurable charge + order-level tax + lot pricing (spec 2026-05-29)."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_charge_tax_lot')
class TestChargeTaxLot(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'ChargeCust'})
cls.tax13 = cls.env['account.tax'].create({
'name': 'FP Test 13%',
'amount': 13.0,
'amount_type': 'percent',
'type_tax_use': 'sale',
})
# ----- Task 1: charge type model -----
def test_charge_type_quick_create_and_default(self):
ct = self.env['fp.additional.charge.type'].create({
'name': 'Rush Fee', 'default_amount': 75.0,
})
self.assertEqual(ct.name, 'Rush Fee')
self.assertEqual(ct.default_amount, 75.0)
cid, cname = self.env['fp.additional.charge.type'].name_create('Setup')
self.assertTrue(cid)
# ----- Task 3: totals -----
def _make_wizard(self, **kw):
vals = {'partner_id': self.partner.id}
vals.update(kw)
return self.env['fp.direct.order.wizard'].create(vals)
def test_tax_applies_on_subtotal_plus_charge(self):
wiz = self._make_wizard(charge_amount=100.0, tax_id=self.tax13.id)
self.env['fp.direct.order.line'].create({
'wizard_id': wiz.id, 'quantity': 1, 'unit_price': 50.0,
})
wiz.invalidate_recordset()
self.assertEqual(wiz.total_subtotal, 50.0)
self.assertAlmostEqual(wiz.total_tax, 19.5, places=2)
self.assertAlmostEqual(wiz.total_amount, 169.5, places=2)
# ----- Task 4: lot pricing -----
def test_lot_onchange_derives_unit_price(self):
wiz = self._make_wizard()
line = self.env['fp.direct.order.line'].new({
'wizard_id': wiz.id, 'quantity': 500, 'lot_total': 1000.0,
'is_lot_priced': True,
})
line._onchange_lot_pricing()
self.assertEqual(line.unit_price, 2.0)
def test_lot_line_subtotal_uses_lot_total(self):
# Even if unit_price wasn't derived (onchange didn't fire), the
# summary subtotal uses the flat lot_total for lot-priced lines.
wiz = self._make_wizard(tax_id=self.tax13.id)
self.env['fp.direct.order.line'].create({
'wizard_id': wiz.id, 'quantity': 500, 'lot_total': 1000.0,
'is_lot_priced': True, 'unit_price': 0.0,
})
wiz.invalidate_recordset()
self.assertEqual(wiz.total_subtotal, 1000.0)

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_additional_charge_type_list" model="ir.ui.view">
<field name="name">fp.additional.charge.type.list</field>
<field name="model">fp.additional.charge.type</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="default_amount" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="active" widget="boolean_toggle"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
</record>
<record id="action_fp_additional_charge_type" model="ir.actions.act_window">
<field name="name">Additional Charge Types</field>
<field name="res_model">fp.additional.charge.type</field>
<field name="view_mode">list</field>
</record>
<menuitem id="menu_fp_additional_charge_type"
name="Additional Charge Types"
parent="fusion_plating.menu_fp_config_pricing_billing"
action="action_fp_additional_charge_type"
sequence="30"/>
</odoo>

View File

@@ -286,10 +286,17 @@
width="120px"/>
<field name="internal_description" string="Internal Notes" optional="show"/>
<field name="quantity" string="Qty" width="55px"/>
<field name="is_lot_priced" string="Lot" width="50px"/>
<field name="lot_total"
string="Lot Total"
widget="monetary"
options="{'currency_field': 'currency_id'}"
width="90px"/>
<field name="unit_price"
string="Price"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="is_lot_priced"
width="80px"/>
<field name="line_subtotal"
string="Subtotal"
@@ -307,12 +314,6 @@
options="{'no_quick_create': True}"
invisible="not part_catalog_id"
optional="hide"/>
<field name="tax_ids"
string="Tax"
widget="many2many_tags"
options="{'no_create': True}"
optional="show"
width="110px"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
@@ -349,18 +350,28 @@
readonly="1" nolabel="1"/>
</div>
<div class="o_fp_xpr_total_row">
<span class="o_fp_xpr_total_label">Tax</span>
<field name="total_tax"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="1" nolabel="1"/>
<span class="o_fp_xpr_total_label">Additional Charge</span>
<div class="d-flex align-items-center gap-2">
<field name="charge_type_id" nolabel="1"
placeholder="Type..."
options="{'no_open': True}"/>
<field name="charge_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"
nolabel="1"/>
</div>
</div>
<div class="o_fp_xpr_total_row">
<span class="o_fp_xpr_total_label">Tooling Charge</span>
<field name="tooling_charge"
widget="monetary"
options="{'currency_field': 'currency_id'}"
nolabel="1"/>
<span class="o_fp_xpr_total_label">Tax</span>
<div class="d-flex align-items-center gap-2">
<field name="tax_id" nolabel="1"
placeholder="Tax type..."
options="{'no_create': True}"/>
<field name="total_tax"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="1" nolabel="1"/>
</div>
</div>
<div class="o_fp_xpr_total_row">
<span class="o_fp_xpr_total_label">Total Lines</span>

View File

@@ -238,6 +238,21 @@ class FpDirectOrderLine(models.Model):
string='Unit Price',
currency_field='currency_id',
)
is_lot_priced = fields.Boolean(
string='Lot Price',
help='Price the whole quantity as a flat lot total instead of '
'per unit. Unit price is derived = lot total / quantity; '
'the quantity is preserved for production.',
)
lot_total = fields.Monetary(
string='Lot Total', currency_field='currency_id',
)
@api.onchange('is_lot_priced', 'lot_total', 'quantity')
def _onchange_lot_pricing(self):
for line in self:
if line.is_lot_priced and line.quantity:
line.unit_price = (line.lot_total or 0.0) / line.quantity
line_subtotal = fields.Monetary(
string='Subtotal',
currency_field='currency_id',

View File

@@ -316,6 +316,25 @@ class FpDirectOrderWizard(models.Model):
'Carried to the SO and appears as a separate line on the '
'customer invoice.',
)
charge_type_id = fields.Many2one(
'fp.additional.charge.type', string='Additional Charge',
)
charge_amount = fields.Monetary(
string='Charge Amount', currency_field='currency_id',
)
tax_id = fields.Many2one(
'account.tax', string='Tax',
domain="[('type_tax_use', '=', 'sale')]",
default=lambda self: self.env.company.account_sale_tax_id,
help='One tax applied to (subtotal + additional charge). Every '
'line + the charge line gets this tax on the created order.',
)
@api.onchange('charge_type_id')
def _onchange_charge_type_id(self):
for rec in self:
if rec.charge_type_id and not rec.charge_amount:
rec.charge_amount = rec.charge_type_id.default_amount
# ---- PO status pill (computed, display-only) ----
po_status = fields.Selection(
@@ -382,65 +401,44 @@ class FpDirectOrderWizard(models.Model):
# ---- Computes ----
@api.depends(
'line_ids.line_subtotal',
'line_ids.quantity',
'line_ids.unit_price',
'line_ids.tax_ids',
'line_ids.is_lot_priced',
'line_ids.lot_total',
'charge_amount',
'tooling_charge',
'tax_id',
'partner_id',
'currency_id',
)
def _compute_totals(self):
"""Roll up subtotal / tax / grand total across lines + tooling.
"""Roll up subtotal / tax / grand total across lines + charge.
Each line's taxes are computed via account.tax.compute_all so the
Express form's totals card mirrors what the eventual SO will show
once the operator hits Confirm. The tooling charge is added at the
wizard level here AND pushed as an actual sale.order.line at
action_create_order time (so it carries into the invoice).
The order-level ``tax_id`` is applied once to (subtotal + charge),
where charge = ``charge_amount`` (legacy ``tooling_charge`` as
fallback). The charge is also pushed as an actual sale.order.line
at action_create_order time (so it carries into the invoice).
"""
for rec in self:
subtotal = 0.0
subtotal = sum(
(l.lot_total or 0.0) if l.is_lot_priced
else (l.quantity or 0) * (l.unit_price or 0.0)
for l in rec.line_ids
)
charge = rec.charge_amount or rec.tooling_charge or 0.0
tax_total = 0.0
for line in rec.line_ids:
line_pre_tax = (line.quantity or 0) * (line.unit_price or 0.0)
subtotal += line_pre_tax
if line.tax_ids and line_pre_tax:
taxes_res = line.tax_ids.compute_all(
line.unit_price or 0.0,
currency=rec.currency_id,
quantity=line.quantity or 0,
product=None,
partner=rec.partner_id or None,
)
tax_total += (
taxes_res['total_included']
- taxes_res['total_excluded']
)
# Tooling charge: pick the tax set from the FIRST line that
# has one (best proxy for the customer's standard rate). If
# no line has taxes set yet, tooling is shown untaxed in the
# preview; the eventual SO line will apply product defaults.
tooling = rec.tooling_charge or 0.0
if tooling:
first_taxed_line = next(
(l for l in rec.line_ids if l.tax_ids), False,
if rec.tax_id and (subtotal + charge):
res = rec.tax_id.compute_all(
subtotal + charge,
currency=rec.currency_id,
quantity=1,
product=None,
partner=rec.partner_id or None,
)
if first_taxed_line:
tooling_res = first_taxed_line.tax_ids.compute_all(
tooling,
currency=rec.currency_id,
quantity=1,
product=None,
partner=rec.partner_id or None,
)
tax_total += (
tooling_res['total_included']
- tooling_res['total_excluded']
)
tax_total = res['total_included'] - res['total_excluded']
rec.total_subtotal = subtotal
rec.total_tax = tax_total
rec.total_amount = subtotal + tax_total + tooling
rec.total_amount = subtotal + charge + tax_total
rec.total_qty = sum(rec.line_ids.mapped('quantity'))
rec.total_line_count = len(rec.line_ids)
@@ -818,7 +816,7 @@ class FpDirectOrderWizard(models.Model):
'x_fc_internal_notes': self.internal_notes or False,
# material_process is a Many2One since 19.0.22.1.0 — pass .id
'x_fc_material_process': self.material_process.id if self.material_process else False,
'x_fc_tooling_charge': self.tooling_charge or 0.0,
'x_fc_tooling_charge': self.charge_amount or self.tooling_charge or 0.0,
'pricelist_id': self.pricelist_id.id if self.pricelist_id else False,
'validity_date': self.validity_date or False,
'order_line': [],
@@ -858,7 +856,11 @@ class FpDirectOrderWizard(models.Model):
'product_id': product.id,
'name': line_desc,
'product_uom_qty': line.quantity,
'price_unit': line.unit_price or 0.0,
'price_unit': (
(line.lot_total / line.quantity)
if line.is_lot_priced and line.quantity
else (line.unit_price or 0.0)
),
'x_fc_part_catalog_id': part.id,
'x_fc_description_template_id': line.description_template_id.id or False,
'x_fc_internal_description': line.internal_description or False,
@@ -900,32 +902,29 @@ class FpDirectOrderWizard(models.Model):
# When blank, Odoo will compute taxes from the product
# defaults at SO-line save time (the standard behaviour).
# NB. Odoo 19 renamed the SO line field to tax_ids.
'tax_ids': ([(6, 0, line.tax_ids.ids)]
if line.tax_ids else False),
'tax_ids': ([(6, 0, self.tax_id.ids)]
if self.tax_id else False),
'x_fc_is_lot_priced': line.is_lot_priced,
'x_fc_lot_total': line.lot_total or 0.0,
}))
# 4b. Tooling charge — surface as a real sale.order.line so it
# carries through SO.amount_total + invoice naturally, instead of
# sitting orphaned on the SO header. Tax: inherit from the first
# part line that has taxes set (best proxy for "the customer's
# standard tax rate"); falls back to product defaults if no line
# has taxes yet.
if self.tooling_charge:
tooling_taxes = False
first_taxed = next(
(l for l in self.line_ids if l.tax_ids), False,
)
if first_taxed:
tooling_taxes = [(6, 0, first_taxed.tax_ids.ids)]
# 4b. Additional charge — one typed line, taxed by the order-level
# tax. The charge type's name labels the line; charge_amount with
# legacy tooling_charge fallback for in-flight drafts.
charge_amt = self.charge_amount or self.tooling_charge or 0.0
if charge_amt:
charge_name = (self.charge_type_id.name
if self.charge_type_id else _('Additional Charge'))
so_vals['order_line'].append((0, 0, {
'product_id': product.id,
'name': _('Tooling Charge'),
'name': charge_name,
'product_uom_qty': 1.0,
'price_unit': self.tooling_charge,
'price_unit': charge_amt,
'x_fc_internal_description': _(
'One-time tooling fee added via Express Orders.'
'Additional charge added via Express Orders.'
),
'tax_ids': tooling_taxes,
'tax_ids': ([(6, 0, self.tax_id.ids)]
if self.tax_id else False),
}))
# 5. Create — stays in draft / quotation. Sub 1: user reviews