Compare commits
7 Commits
7efaadc1c1
...
2bd0672b52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd0672b52 | ||
|
|
dc1dacddc2 | ||
|
|
6dde3ec2b1 | ||
|
|
a2ac804238 | ||
|
|
f8929eb686 | ||
|
|
a07a5f931a | ||
|
|
c6022c70f9 |
@@ -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': [
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user