Compare commits

..

12 Commits

Author SHA1 Message Date
gsinghpal
774d21863e Merge branch 'feat/assessment-visit' into main
Brings the fusion_portal assessment-visit (Start-a-Visit) feature into main: funding-source selectors on accessibility forms, ADP multi-device grouping + combination guard, Assessment Visit model + accessibility funding grouping, assessment visit workspace, portal entry tile, and email consolidation (10 commits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:29:11 -04:00
gsinghpal
2f8b6b3ae0 changes 2026-06-03 19:50:45 -04:00
gsinghpal
2285b7b814 fix(fusion_portal): readable gradient for the Start-a-Visit tile
The tile used an inline background gradient with no !important, so the theme's
.card background rule overrode it - the tile rendered near-white with invisible
white text. Replace with a dedicated .portal-visit-card class (mirrors
.portal-new-assessment-card: gradient !important, transparent card-body, white
text, styled icon-circle) in a distinct blue->indigo gradient so the two
featured tiles read as different. Bump 19.0.2.10.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:58:11 -04:00
gsinghpal
837e7b09b7 fix(fusion_portal): define visit._assessment_sale_type (ADP grouping key)
action_complete_visit referenced self._assessment_sale_type() to group ADP
devices by funding, but the method was never defined - any visit containing an
ADP device would have raised AttributeError. Mirrors
fusion.assessment._create_draft_sale_order: adp_odsp for ODSP client streams,
adp otherwise. Caught by the clone ADP-grouping smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:48:16 -04:00
gsinghpal
ed91135a3f feat(fusion_portal): wire ADP/express into visit + portal entry tile + email consolidation
- express save captures visit_id; when visit-linked, action=submit saves the
  ADP assessment as a draft (signature + Page 11 PDF still captured) and returns
  to the visit instead of completing into a standalone SO, so the visit groups
  the ADP devices into one funding-routed order. Non-visit express flow unchanged.
- portal dashboard: featured 'Start a Visit' tile (sales reps) -> /my/visit/new.
- fix duplicate-authorizer email: _send_completion_notifications no longer
  re-emails the authorizer (already emailed with the full report by
  _send_assessment_completed_email); it now only notifies the client.
- visit grouped accessibility SOs now send one office completion email per SO.

Bump 19.0.2.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:39:37 -04:00
gsinghpal
20de9a6b69 chore(fusion_portal): bump to 19.0.2.9.0 for assessment-visit redesign
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:16:56 -04:00
gsinghpal
21cfd55419 feat(fusion_portal): Phase 3 - assessment visit workspace (accessibility path)
Adds the portal workspace: /my/visit/new starts a visit; /my/visit/<id> shows the
add-as-you-go workspace (add buttons -> existing forms carrying ?visit_id, a
deferred client+funding form, and a Complete button). Accessibility forms launched
from a visit save as a DRAFT linked to it (JS carries visit_id into the form; the
controller captures it and skips the per-assessment SO) - the VISIT completion then
creates the grouped per-funding sale orders.

NOT YET: express/ADP form visit-linking, email consolidation, polished tablet UI.
Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:37:08 -04:00
gsinghpal
89467432a7 feat(fusion_portal): Phase 2b - ADP multi-device grouping + combination guard
The visit groups its ADP assessments by funding type onto ONE ADP order (first
device creates the SO via the existing express completion; the rest attach),
enforcing the combination rule: at most one seated-mobility device (manual WC /
power WC / scooter) + optionally one walker, no duplicates. Also fixes a Phase 1b
bug - it called action_complete() (needs signatures, returns an action dict) for
ADP; now uses action_complete_express() which returns the SO.

Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:38 -04:00
gsinghpal
e0ddd9ef40 feat(fusion_portal): Phase 2a - mobility scooter ADP type + power-mobility home-access rule
Adds 'scooter' as a 4th ADP equipment type with a lean Express-form section
(scooter type + max range) and the power-mobility home-accessibility hard rule
(scooter + powerchair): "is the home usable inside and outside, no lifting?" - if No,
prompts adding an accessibility item (ramp / porch lift). Captures
x_fc_power_home_accessible + notes; the section toggles via the existing
equipment-select JS; controller parses the new fields.

NOT YET: ADP multi-device + combination rules (the bigger restructure).
Untested locally (Enterprise dep) - to be verified on a westin-v19 clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:22:47 -04:00
gsinghpal
b17bd615bf feat(fusion_portal): Phase 1b - Assessment Visit model + accessibility funding grouping
Adds fusion.assessment.visit: the hub that bundles a home visit's assessments and,
on completion, groups its ACCESSIBILITY assessments by funding workflow
(x_fc_sale_type) and creates ONE draft sale order per workflow, reusing the existing
MOD/ODSP/etc. pipelines + the chatter-note pattern. ADP assessments keep one-SO-each
for now (ADP multi-device grouping is Phase 2).

- New model + sequence (VISIT/YYYY/NNNN) + ACL + backend list/form/menu.
- visit_id added to fusion.assessment, fusion.accessibility.assessment, sale.order.
- action_complete_visit() group-and-routes; MOD $15k cap + income-threshold flag
  surfaced (informational, no auto-enforcement).

NOT YET: completion-email consolidation; ADP multi-device (Phase 2); portal
add-as-you-go workspace (Phase 3). Untested locally (Enterprise knowledge dep) -
to be verified on a westin-v19 clone before prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:00:53 -04:00
gsinghpal
e36aaab306 fix(fusion_portal): validate funding_source in accessibility save (parity with booking)
Coerce an unexpected/tampered funding_source to direct_private instead of passing
it raw into create() (which would raise on the Selection field). Mirrors the
/book-assessment controller; the whitelist is derived from the model selection so
it auto-covers hardship and any future values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:38:42 -04:00
gsinghpal
37efc5b858 feat(fusion_portal): funding-source selector on accessibility forms
Reps can now mark an accessibility assessment's funding source on the web form
(Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) so the
generated draft sale order routes to the correct funding pipeline instead of
always defaulting to private pay. Adds Hardship to the x_fc_funding_source
selection + sale_type_map; the new form <select> is auto-serialised by the
existing FormData submit, and accessibility_assessment_save now maps
funding_source -> x_fc_funding_source. The model + SO routing were already in
place (2026-04 audit fix) — this closes the form + controller gap.

Plan: docs/superpowers/plans/2026-06-02-accessibility-funding-selector.md
Spec: docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:33:24 -04:00
5 changed files with 169 additions and 25 deletions

View File

@@ -346,15 +346,30 @@ class ResUsers(models.Model):
string='Login Audit Count', string='Login Audit Count',
compute='_compute_x_fc_login_audit_count', compute='_compute_x_fc_login_audit_count',
) )
# NON-STORED on purpose — do NOT re-add store=True.
#
# These were store=True computed-from-the-audit-One2many. That meant every
# successful-login audit row (written through an INDEPENDENT
# registry.cursor(), see _fc_record_login_event) forced a recompute that
# flushed a write-back onto THIS res_users row. During portal-invitation
# acceptance the request has already locked that row (auth_signup just set
# the password in the same transaction), so the audit cursor's write-back
# blocked on the request's own row lock while the request's Python blocked
# waiting for the audit cursor — a self-deadlock Postgres cannot detect
# (the holder shows 'idle in transaction', not lock-waiting). Workers
# wedged for up to limit_time_real (20 min) and odoo-westin went
# unresponsive every time an invite was accepted (issue 2026-06-03).
#
# Keeping them non-stored means creating an audit row never touches
# res_users. They compute on read (display-only on the user form). The
# regression guard is tests.test_last_login_fields_not_stored.
x_fc_last_successful_login = fields.Datetime( x_fc_last_successful_login = fields.Datetime(
string='Last Successful Login', string='Last Successful Login',
compute='_compute_x_fc_last_successful_login', compute='_compute_x_fc_last_successful_login',
store=True,
) )
x_fc_last_login_ip = fields.Char( x_fc_last_login_ip = fields.Char(
string='Last Login IP', size=45, string='Last Login IP', size=45,
compute='_compute_x_fc_last_successful_login', compute='_compute_x_fc_last_successful_login',
store=True,
) )
@api.depends('x_fc_login_audit_ids') @api.depends('x_fc_login_audit_ids')

View File

@@ -303,6 +303,54 @@ class TestFusionLoginAuditModel(TransactionCase):
self.assertGreaterEqual(user.x_fc_login_audit_count, 1) self.assertGreaterEqual(user.x_fc_login_audit_count, 1)
self.assertEqual(user.x_fc_last_login_ip, '198.51.100.42') self.assertEqual(user.x_fc_last_login_ip, '198.51.100.42')
def test_last_login_fields_not_stored(self):
"""Regression guard for the 2026-06-03 invitation-acceptance hang.
x_fc_last_successful_login / x_fc_last_login_ip MUST stay non-stored.
When they were store=True (computed from the audit One2many), creating
the success audit row through the independent registry cursor forced a
write-back onto the very res_users row the request had already locked
(auth_signup had just set the password) -> a self-deadlock Postgres
cannot see (the holder shows 'idle in transaction'). Workers wedged for
up to limit_time_real and odoo-westin became unresponsive whenever an
invitation was accepted. Non-stored means audit-row creation never
touches res_users, so the deadlock cannot form.
"""
fields_ = self.env['res.users']._fields
self.assertFalse(
fields_['x_fc_last_successful_login'].store,
"x_fc_last_successful_login must be non-stored (see docstring)")
self.assertFalse(
fields_['x_fc_last_login_ip'].store,
"x_fc_last_login_ip must be non-stored (see docstring)")
def test_audit_row_create_does_not_write_res_users(self):
"""Creating a login-audit row must not write the linked res_users row.
This is the behavioural half of the deadlock guard: with the fields
non-stored, inserting an audit row for a user leaves that user's
write_date untouched (no recompute -> no res_users UPDATE -> nothing
to contend with the request's own row lock).
"""
user = self.env['res.users'].sudo().create({
'name': 'NoWriteback Tester',
'login': 'nowriteback-tester@example.com',
'password': 'nowriteback-tester-pw-1',
})
user.flush_recordset()
before = user.write_date
self.env['fusion.login.audit'].sudo().create({
'user_id': user.id,
'attempted_login': user.login,
'result': 'success',
'database': self.env.cr.dbname,
'ip_address': '198.51.100.7',
})
user.invalidate_recordset()
self.assertEqual(
user.write_date, before,
"Audit-row create must not write back to res_users")
def test_action_view_login_audit_returns_window_action(self): def test_action_view_login_audit_returns_window_action(self):
"""The smart-button action returns an act_window scoped to this user.""" """The smart-button action returns an act_window scoped to this user."""
user = self.env['res.users'].sudo().create({ user = self.env['res.users'].sudo().create({

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Reports — Templates', 'name': 'Fusion Reports — Templates',
'version': '19.0.1.2.0', 'version': '19.0.1.3.0',
'category': 'Tools/Reports', 'category': 'Tools/Reports',
'summary': 'Branded PDF templates for Quotation, Sales Order, Invoice, Delivery, Purchase Order, and Payment Receipt.', 'summary': 'Branded PDF templates for Quotation, Sales Order, Invoice, Delivery, Purchase Order, and Payment Receipt.',
'description': """ 'description': """

View File

@@ -78,14 +78,18 @@
</tbody> </tbody>
</table> </table>
<t t-set="has_discount" t-value="any(l.discount for l in doc.invoice_line_ids)"/>
<t t-set="col_count" t-value="8 if has_discount else 7"/>
<t t-set="total_discount" t-value="sum(l.price_unit * l.quantity * l.discount / 100.0 for l in doc.invoice_line_ids if not l.display_type or l.display_type == 'product')"/>
<table class="bordered"> <table class="bordered">
<thead> <thead>
<tr> <tr>
<th style="width: 12%;">SKU</th> <th style="width: 11%;">SKU</th>
<th class="text-start" style="width: 40%;">DESCRIPTION</th> <th class="text-start" style="width: 33%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th> <th style="width: 7%;">QTY</th>
<th style="width: 8%;">UOM</th> <th style="width: 7%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th> <th style="width: 13%;">UNIT PRICE</th>
<th t-if="has_discount" style="width: 9%;">DISCOUNT</th>
<th style="width: 8%;">TAX</th> <th style="width: 8%;">TAX</th>
<th style="width: 12%;">AMOUNT</th> <th style="width: 12%;">AMOUNT</th>
</tr> </tr>
@@ -93,10 +97,10 @@
<tbody> <tbody>
<t t-foreach="doc.invoice_line_ids" t-as="line"> <t t-foreach="doc.invoice_line_ids" t-as="line">
<t t-if="line.display_type == 'line_section'"> <t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr> <tr class="section-row"><td t-att-colspan="col_count"><strong t-field="line.name"/></td></tr>
</t> </t>
<t t-elif="line.display_type == 'line_note'"> <t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr> <tr class="note-row"><td t-att-colspan="col_count"><span t-field="line.name"/></td></tr>
</t> </t>
<t t-elif="line.display_type == 'product' or not line.display_type"> <t t-elif="line.display_type == 'product' or not line.display_type">
<tr> <tr>
@@ -109,6 +113,10 @@
<td class="text-end"> <td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
<td t-if="has_discount" class="text-center">
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
<t t-else="">-</t>
</td>
<td class="text-center"> <td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/> <t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td> </td>
@@ -139,9 +147,23 @@
<tr> <tr>
<td style="min-width: 150px;">Subtotal</td> <td style="min-width: 150px;">Subtotal</td>
<td class="text-end" style="min-width: 110px;"> <td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-out="doc.amount_untaxed + total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<t t-if="has_discount">
<tr>
<td>Discount</td>
<td class="text-end">
<span t-out="-total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Net Amount</td>
<td class="text-end">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
<tr> <tr>
<td>Taxes</td> <td>Taxes</td>
<td class="text-end"> <td class="text-end">
@@ -254,25 +276,29 @@
</tbody> </tbody>
</table> </table>
<t t-set="has_discount" t-value="any(l.discount for l in doc.invoice_line_ids)"/>
<t t-set="col_count" t-value="8 if has_discount else 7"/>
<t t-set="total_discount" t-value="sum(l.price_unit * l.quantity * l.discount / 100.0 for l in doc.invoice_line_ids if not l.display_type or l.display_type == 'product')"/>
<table class="bordered"> <table class="bordered">
<thead> <thead>
<tr> <tr>
<th style="width: 10%;">SKU</th> <th style="width: 10%;">SKU</th>
<th class="text-start" style="width: 40%;">DESCRIPTION</th> <th class="text-start" style="width: 32%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th> <th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th> <th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th> <th style="width: 12%;">UNIT PRICE</th>
<th t-if="has_discount" style="width: 10%;">DISCOUNT</th>
<th style="width: 10%;">TAX</th> <th style="width: 10%;">TAX</th>
<th style="width: 12%;">AMOUNT</th> <th style="width: 10%;">AMOUNT</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<t t-foreach="doc.invoice_line_ids" t-as="line"> <t t-foreach="doc.invoice_line_ids" t-as="line">
<t t-if="line.display_type == 'line_section'"> <t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr> <tr class="section-row"><td t-att-colspan="col_count"><strong t-field="line.name"/></td></tr>
</t> </t>
<t t-elif="line.display_type == 'line_note'"> <t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr> <tr class="note-row"><td t-att-colspan="col_count"><span t-field="line.name"/></td></tr>
</t> </t>
<t t-elif="line.display_type == 'product' or not line.display_type"> <t t-elif="line.display_type == 'product' or not line.display_type">
<tr> <tr>
@@ -285,6 +311,10 @@
<td class="text-end"> <td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
<td t-if="has_discount" class="text-center">
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
<t t-else="">-</t>
</td>
<td class="text-center"> <td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/> <t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td> </td>
@@ -315,9 +345,23 @@
<tr> <tr>
<td style="min-width: 200px;">Subtotal</td> <td style="min-width: 200px;">Subtotal</td>
<td class="text-end" style="min-width: 150px;"> <td class="text-end" style="min-width: 150px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-out="doc.amount_untaxed + total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<t t-if="has_discount">
<tr>
<td>Discount</td>
<td class="text-end">
<span t-out="-total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Net Amount</td>
<td class="text-end">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
<tr> <tr>
<td>Taxes</td> <td>Taxes</td>
<td class="text-end"> <td class="text-end">

View File

@@ -64,14 +64,18 @@
</tbody> </tbody>
</table> </table>
<t t-set="has_discount" t-value="any(l.discount for l in doc.order_line)"/>
<t t-set="col_count" t-value="8 if has_discount else 7"/>
<t t-set="total_discount" t-value="sum(l.price_unit * l.product_uom_qty * l.discount / 100.0 for l in doc.order_line if not l.display_type or l.display_type == 'product')"/>
<table class="bordered"> <table class="bordered">
<thead> <thead>
<tr> <tr>
<th style="width: 12%;">SKU</th> <th style="width: 11%;">SKU</th>
<th class="text-start" style="width: 38%;">DESCRIPTION</th> <th class="text-start" style="width: 33%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th> <th style="width: 7%;">QTY</th>
<th style="width: 8%;">UOM</th> <th style="width: 7%;">UOM</th>
<th style="width: 14%;">UNIT PRICE</th> <th style="width: 13%;">UNIT PRICE</th>
<th t-if="has_discount" style="width: 9%;">DISCOUNT</th>
<th style="width: 8%;">TAX</th> <th style="width: 8%;">TAX</th>
<th style="width: 12%;">AMOUNT</th> <th style="width: 12%;">AMOUNT</th>
</tr> </tr>
@@ -79,10 +83,10 @@
<tbody> <tbody>
<t t-foreach="doc.order_line" t-as="line"> <t t-foreach="doc.order_line" t-as="line">
<t t-if="line.display_type == 'line_section'"> <t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr> <tr class="section-row"><td t-att-colspan="col_count"><strong t-field="line.name"/></td></tr>
</t> </t>
<t t-elif="line.display_type == 'line_note'"> <t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr> <tr class="note-row"><td t-att-colspan="col_count"><span t-field="line.name"/></td></tr>
</t> </t>
<t t-elif="not line.display_type or line.display_type == 'product'"> <t t-elif="not line.display_type or line.display_type == 'product'">
<tr> <tr>
@@ -101,6 +105,10 @@
<td class="text-end"> <td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
<td t-if="has_discount" class="text-center">
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
<t t-else="">-</t>
</td>
<td class="text-center"> <td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/> <t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td> </td>
@@ -125,9 +133,23 @@
<tr> <tr>
<td style="min-width: 150px;">Subtotal</td> <td style="min-width: 150px;">Subtotal</td>
<td class="text-end" style="min-width: 110px;"> <td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-out="doc.amount_untaxed + total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<t t-if="has_discount">
<tr>
<td>Discount</td>
<td class="text-end">
<span t-out="-total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Net Amount</td>
<td class="text-end">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
<tr> <tr>
<td>Taxes</td> <td>Taxes</td>
<td class="text-end"> <td class="text-end">
@@ -236,6 +258,7 @@
<t t-set="has_discount" t-value="any(l.discount for l in doc.order_line)"/> <t t-set="has_discount" t-value="any(l.discount for l in doc.order_line)"/>
<t t-set="col_count" t-value="8 if has_discount else 7"/> <t t-set="col_count" t-value="8 if has_discount else 7"/>
<t t-set="total_discount" t-value="sum(l.price_unit * l.product_uom_qty * l.discount / 100.0 for l in doc.order_line if not l.display_type or l.display_type == 'product')"/>
<table class="bordered"> <table class="bordered">
<thead> <thead>
<tr> <tr>
@@ -302,9 +325,23 @@
<tr> <tr>
<td style="min-width: 200px;">Subtotal</td> <td style="min-width: 200px;">Subtotal</td>
<td class="text-end" style="min-width: 150px;"> <td class="text-end" style="min-width: 150px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-out="doc.amount_untaxed + total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<t t-if="has_discount">
<tr>
<td>Discount</td>
<td class="text-end">
<span t-out="-total_discount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Net Amount</td>
<td class="text-end">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
<tr> <tr>
<td>Taxes</td> <td>Taxes</td>
<td class="text-end"> <td class="text-end">