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

560 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../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`:
```python
# -*- 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:
```python
from . import test_charge_tax_lot
```
- [ ] **Step 2: Run → FAIL** (`KeyError: 'fp.additional.charge.type'`).
```bash
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`:
```python
# -*- 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`:
```python
from . import fp_part_description_version
from . import fp_additional_charge_type
```
Append 3 rows to `fusion_plating_configurator/security/ir.model.access.csv`:
```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
<?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
<?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',`:
```python
'views/fp_pricing_rule_views.xml',
'views/fp_additional_charge_type_views.xml',
```
and after `'data/fp_sale_description_template_data.xml',`:
```python
'data/fp_sale_description_template_data.xml',
'data/fp_additional_charge_type_data.xml',
```
Bump `version`: `19.0.22.8.0``19.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**
```bash
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:
```python
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**
```bash
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`:
```python
# ----- 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:
```python
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:
```python
@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**
```bash
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`:
```python
# ----- 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`):
```python
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**
```bash
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):
```python
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:
```python
'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`):
```python
# 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):
```python
'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**
```bash
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:
```xml
<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 Charge****Tax**:
```xml
<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**
```bash
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**
```bash
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):
```bash
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).