Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-29-configurable-charge-tax-lot-pricing-plan.md
gsinghpal 7efaadc1c1 docs(plating): implementation plan for charge type + order-level tax + lot pricing
Bite-sized TDD plan: charge-type model + config UI, wizard charge/tax fields,
totals = one tax on (subtotal+charge), per-line lot pricing, SO-create tax on
all lines + typed charge line, and the express summary/line view changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:25:25 -04:00

24 KiB
Raw Blame History

Configurable Charge + Order-Level Tax + Lot Pricing — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: In Direct/Express order entry, replace the fixed "Tooling Charge" with a configurable/creatable charge type, add one order-level tax applied to (subtotal + charge), and add per-line lot pricing.

Architecture: A small fp.additional.charge.type model feeds a searchable/quick-create dropdown. The wizard gains charge_type_id + charge_amount + a single tax_id; _compute_totals is simplified to one tax on (subtotal + charge). Lot pricing is a per-line is_lot_priced + lot_total whose onchange derives unit_price. On SO-create the one tax goes on every line + the charge line.

Tech Stack: Odoo 19 (ORM, @api.onchange, account.tax.compute_all), QWeb form views, TransactionCase.

Spec: docs/superpowers/specs/2026-05-29-configurable-charge-tax-lot-pricing-design.md

Conventions:

  • Local runner (db modsdev): docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_plating_configurator -u fusion_plating_configurator --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60. Local Docker is down this session — tests are written now and run on entech in the batched deploy. python -m py_compile + xml.dom.minidom.parse are the local sanity checks.
  • One version bump on fusion_plating_configurator/__manifest__.py (Task 1).

Phase 1 — Charge type model + config UI

Task 1: fp.additional.charge.type model, ACL, views/menu, seed

Files:

  • Create: fusion_plating_configurator/models/fp_additional_charge_type.py

  • Create: fusion_plating_configurator/views/fp_additional_charge_type_views.xml

  • Create: fusion_plating_configurator/data/fp_additional_charge_type_data.xml

  • Create: fusion_plating_configurator/tests/test_charge_tax_lot.py

  • Modify: fusion_plating_configurator/models/__init__.py

  • Modify: fusion_plating_configurator/tests/__init__.py

  • Modify: fusion_plating_configurator/security/ir.model.access.csv

  • Modify: fusion_plating_configurator/__manifest__.py (data list + version)

  • Step 1: Write the failing test

Create fusion_plating_configurator/tests/test_charge_tax_lot.py:

# -*- 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)
        # name_create (quick-create from the dropdown) works on a bare name
        cid, cname = self.env['fp.additional.charge.type'].name_create('Setup')
        self.assertTrue(cid)

Register in fusion_plating_configurator/tests/__init__.py — append:

from . import test_charge_tax_lot
  • Step 2: Run → FAIL (KeyError: 'fp.additional.charge.type').
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_plating_configurator -u fusion_plating_configurator --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
  • Step 3: Create the model

fusion_plating_configurator/models/fp_additional_charge_type.py:

# -*- 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)

Add to fusion_plating_configurator/models/__init__.py after fp_part_description_version:

from . import fp_part_description_version
from . import fp_additional_charge_type

Append 3 rows to fusion_plating_configurator/security/ir.model.access.csv:

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
  • Step 4: Views + menu + seed

fusion_plating_configurator/views/fp_additional_charge_type_views.xml:

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

fusion_plating_configurator/data/fp_additional_charge_type_data.xml:

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

Register both in fusion_plating_configurator/__manifest__.py data list — add after 'views/fp_pricing_rule_views.xml',:

        'views/fp_pricing_rule_views.xml',
        'views/fp_additional_charge_type_views.xml',

and after 'data/fp_sale_description_template_data.xml',:

        'data/fp_sale_description_template_data.xml',
        'data/fp_additional_charge_type_data.xml',

Bump version: 19.0.22.8.019.0.23.0.0.

  • Step 5: Run → PASS (same command). Then python -m py_compile fusion_plating_configurator/models/fp_additional_charge_type.py and python -c "import xml.dom.minidom; [xml.dom.minidom.parse(f) for f in ['fusion_plating_configurator/views/fp_additional_charge_type_views.xml','fusion_plating_configurator/data/fp_additional_charge_type_data.xml']]; print('XML_OK')".

  • Step 6: Commit

git add fusion_plating_configurator/models/fp_additional_charge_type.py \
        fusion_plating_configurator/models/__init__.py \
        fusion_plating_configurator/views/fp_additional_charge_type_views.xml \
        fusion_plating_configurator/data/fp_additional_charge_type_data.xml \
        fusion_plating_configurator/security/ir.model.access.csv \
        fusion_plating_configurator/tests/test_charge_tax_lot.py \
        fusion_plating_configurator/tests/__init__.py \
        fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): fp.additional.charge.type model + config menu + seed"

Phase 2 — Wizard charge + tax fields + recompute

Task 2: charge_type_id + charge_amount + tax_id on the wizard

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard.py (fields ~:312, add onchange)

  • Step 1: Add the fields + onchange. After the tooling_charge field (keep it as a legacy column for in-flight drafts), add:

    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

Confirm api is imported at the top of the file (it is — the wizard uses @api.depends).

  • Step 2: py_compile + commit
python -m py_compile fusion_plating_configurator/wizard/fp_direct_order_wizard.py
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py
git commit -m "feat(configurator): wizard charge_type_id + charge_amount + order-level tax_id"

Task 3: _compute_totals — one tax on (subtotal + charge)

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard.py (_compute_totals ~:386-446)

  • Step 1: Write the failing test — append to TestChargeTaxLot:

    # ----- 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()
        # subtotal 50 + charge 100 = 150; 13% -> 19.50
        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)

If fp.direct.order.wizard create raises for a missing required field, set it in _make_wizard (read the model's required=True fields). currency_id typically defaults from the company.

  • Step 2: Run → FAIL (tax still computed per-line + tooling-after-tax → wrong numbers).

  • Step 3: Replace the compute body. Replace the per-line tax loop + tooling block (the body of _compute_totals, roughly :404-445) with:

        for rec in self:
            subtotal = sum(
                (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
            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,
                )
                tax_total = res['total_included'] - res['total_excluded']
            rec.total_subtotal = subtotal
            rec.total_tax = tax_total
            rec.total_amount = subtotal + charge + tax_total
            rec.total_qty = sum(rec.line_ids.mapped('quantity'))
            rec.total_line_count = len(rec.line_ids)

Update the @api.depends decorator above _compute_totals to:

    @api.depends(
        'line_ids.quantity',
        'line_ids.unit_price',
        'charge_amount',
        'tooling_charge',
        'tax_id',
        'partner_id',
        'currency_id',
    )
  • Step 4: Run → PASS. py_compile.

  • Step 5: Commit

git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py \
        fusion_plating_configurator/tests/test_charge_tax_lot.py
git commit -m "feat(configurator): totals = one tax on (subtotal + charge)"

Phase 3 — Lot pricing (per line)

Task 4: is_lot_priced + lot_total + onchange on the wizard line

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_line.py (fields ~:235-240)

  • Step 1: Write the failing test — append to TestChargeTaxLot:

    # ----- 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)
  • Step 2: Run → FAIL (AttributeError: ... '_onchange_lot_pricing').

  • Step 3: Add the fields + onchange after unit_price (:237-240):

    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

Confirm api is imported in fp_direct_order_line.py (it is — the file uses @api.onchange already).

  • Step 4: Run → PASS. py_compile.

  • Step 5: Commit

git add fusion_plating_configurator/wizard/fp_direct_order_line.py \
        fusion_plating_configurator/tests/test_charge_tax_lot.py
git commit -m "feat(configurator): per-line lot pricing (derive unit price, keep qty)"

Phase 4 — SO creation: order-level tax + typed charge line

Task 5: apply the one tax to every line + the charge line; name the charge by type

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_wizard.py (action_create_order part-line vals :903-904, tooling line :913-929)

  • Modify: fusion_plating_configurator/models/sale_order_line.py (SO-line ref fields)

  • Step 1: SO-line ref fields. In sale_order_line.py, add (near the other x_fc_* fields):

    x_fc_is_lot_priced = fields.Boolean(string='Lot Priced')
    x_fc_lot_total = fields.Monetary(
        string='Lot Total', currency_field='currency_id')
  • Step 2: Part lines → order-level tax + lot stamp. In action_create_order, change the part-line tax_ids (:903-904) and add the lot fields:
                '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,
            }))
  • Step 3: Charge line → typed + order-level tax. Replace the tooling-line block (:913-929):
        # Additional charge — one typed line, taxed by the order-level tax.
        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': charge_name,
                'product_uom_qty': 1.0,
                'price_unit': charge_amt,
                'x_fc_internal_description': _(
                    'Additional charge added via Express Orders.'
                ),
                'tax_ids': ([(6, 0, self.tax_id.ids)]
                            if self.tax_id else False),
            }))

Also, near the SO header vals (:821 x_fc_tooling_charge), add the charge ref (leave x_fc_tooling_charge line in place):

            'x_fc_tooling_charge': self.charge_amount or self.tooling_charge or 0.0,

(SO header keeps a single amount field; the type is captured on the charge line's name. No new SO header field needed — keeps it minimal.)

  • Step 4: py_compile + commit
python -m py_compile fusion_plating_configurator/wizard/fp_direct_order_wizard.py \
    fusion_plating_configurator/models/sale_order_line.py
git add fusion_plating_configurator/wizard/fp_direct_order_wizard.py \
        fusion_plating_configurator/models/sale_order_line.py
git commit -m "feat(configurator): SO-create applies one tax to all lines + typed charge line"

Integration (full SO create + tax on every line) is verified on entech in Phase 6.


Phase 5 — Express summary + line views

Task 6: reorder summary (charge type+amount, tax type+amount) + line lot toggle + remove per-line tax

Files:

  • Modify: fusion_plating_configurator/views/fp_express_order_views.xml (line list :288-316, summary card :343-385)

  • Step 1: Line list — add lot fields, remove per-line tax. In the line_ids list, after the unit_price field block (:289-293) add the lot toggle + total, and make unit_price readonly when lot-priced; then DELETE the tax_ids block (:310-315):

Replace the unit_price field block with:

                            <field name="is_lot_priced" string="Lot" width="50px"/>
                            <field name="lot_total"
                                   string="Lot Total"
                                   widget="monetary"
                                   options="{'currency_field': 'currency_id'}"
                                   column_invisible="0"
                                   width="90px"/>
                            <field name="unit_price"
                                   string="Price"
                                   widget="monetary"
                                   options="{'currency_field': 'currency_id'}"
                                   readonly="is_lot_priced"
                                   width="80px"/>

And remove the entire <field name="tax_ids" …/> block (:310-315) — the order-level tax now governs.

  • Step 2: Summary card — reorder + new fields. Replace the three rows Tax (:351-357) and Tooling Charge (:358-364) so the order becomes Subtotal → Additional ChargeTax:
                                <div class="o_fp_xpr_total_row">
                                    <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">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>

(Subtotal row :344-350 stays first; Total Lines/Total Quantity/Grand Total stay after.)

  • Step 3: Validate XML
python -c "import xml.dom.minidom; xml.dom.minidom.parse(r'fusion_plating_configurator/views/fp_express_order_views.xml'); print('XML_OK')"
  • Step 4: Commit
git add fusion_plating_configurator/views/fp_express_order_views.xml
git commit -m "feat(configurator): express summary — charge type + tax type + lot column"

Phase 6 — Deploy + verify on entech (batched)

  • Step 1: Run the suite (entech during deploy, or modsdev when Docker's up):
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_plating_configurator -u fusion_plating_configurator --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: test_charge_tax_lot passes + no regressions.

  • Step 2: Manual walkthrough on entech (hard-refresh the browser first — JS/SCSS bundle unaffected, but the form view changed):
    1. Open Express / Direct Order. The summary shows Subtotal → Additional Charge (type dropdown + amount) → Tax (type dropdown + amount) → Total Lines → Total Quantity → Grand Total.
    2. Pick a charge type (or type a new one → "Create"); confirm the amount pre-fills from its default; confirm Tax = tax_id × (subtotal + charge) and Grand Total updates.
    3. On a line, tick Lot, set qty 500 + Lot Total 1000 → unit price shows 2.00, line subtotal 1000, qty stays 500.
    4. Create the order → the charge appears as a line named by its type; every line + the charge line carry the chosen tax; the SO total matches the summary.

Notes / deferred

  • Quote configurator, multiple charges, multiple taxes at once, per-charge-type product mapping, fiscal-position tax defaulting — out of scope (spec §7).
  • tooling_charge (wizard) + x_fc_tooling_charge (SO header) are kept as legacy columns; new flows use charge_amount. The compute + create both fall back to tooling_charge so in-flight saved drafts don't lose their value.
  • Lot rounding: lot_total / quantity is rounded to the price decimal precision; even divisions are exact, odd ones can differ by a cent or two on the line total (accepted per design D5).