feat(portal): account_summary controller + 3 unit tests

New /my/account_summary route. Splits posted account.move into
Invoices (out_invoice) / Credit Memos (out_refund) / Statements
(V1 placeholder). Open Balance helper sums amount_residual across
open invoices for the partner's commercial tree.

Search filters name OR ref (customer PO). Sort options: date desc/asc,
amount desc/asc. Filter pills: open / closed / all.

Tests cover the tab partitioning, the open-balance sum, and the
search behaviour. Helpers use commercial_partner.env so they work
in both HTTP context and unit tests without requiring request.env.
Test scaffolding uses fp_from_so_invoice=True context flag and
invoice_payment_term_id to satisfy the fusion_plating_jobs and
fusion_plating_invoicing create/post gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-17 14:13:48 -04:00
parent 8225061dfa
commit b92a396934
2 changed files with 246 additions and 0 deletions

View File

@@ -442,6 +442,131 @@ class FpCustomerPortal(CustomerPortal):
return '%.0f KB' % (size / 1024) return '%.0f KB' % (size / 1024)
return '%.1f MB' % (size / (1024 * 1024)) return '%.1f MB' % (size / (1024 * 1024))
# ==========================================================================
# Account Summary (Sub-A IA) — invoices + credits + statements
# ==========================================================================
_FP_ACCOUNT_SUMMARY_TABS = [
('invoices', 'Invoices', 'out_invoice'),
('credit_memos', 'Credit Memos', 'out_refund'),
('statements', 'Statements', None), # placeholder in V1
]
_FP_ACCOUNT_SUMMARY_FILTERS = ['open', 'closed', 'all']
_FP_ACCOUNT_SUMMARY_SORTS = {
'date_desc': 'invoice_date desc, id desc',
'date_asc': 'invoice_date asc, id asc',
'amount_desc': 'amount_total desc, id desc',
'amount_asc': 'amount_total asc, id asc',
}
_FP_ACCOUNT_SUMMARY_PER_PAGE = 10
def _fp_account_summary_open_balance(self, commercial_partner):
"""Sum of amount_residual across this partner's open invoices.
Uses commercial_partner.env so this helper works both in HTTP
context (where request.env is active) and in unit tests (where
the partner record already carries the test env).
"""
env = commercial_partner.env
moves = env['account.move'].sudo().search([
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('amount_residual', '>', 0),
])
return sum(moves.mapped('amount_residual'))
def _fp_account_summary_data(self, commercial_partner, tab, filter_state,
search, sort, page):
"""Return {records, total, pager_offset} for one tab+filter combination.
tab — 'invoices' | 'credit_memos' | 'statements'
filter_state — 'open' | 'closed' | 'all'
search — substring matched against name OR ref (case-insensitive)
sort — key from _FP_ACCOUNT_SUMMARY_SORTS
page — 1-indexed
Uses commercial_partner.env so this helper works both in HTTP
context and in unit tests without requiring request to be active.
"""
env = commercial_partner.env
if tab == 'statements':
# V1 placeholder — Statements is a 'coming soon' tab.
return {'records': env['account.move'].browse(), 'total': 0,
'offset': 0}
# Resolve move_type from tab key
move_type = next(
(mt for k, _l, mt in self._FP_ACCOUNT_SUMMARY_TABS if k == tab),
'out_invoice',
)
domain = [
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', move_type),
('state', '=', 'posted'),
]
if filter_state == 'open':
domain.append(('amount_residual', '>', 0))
elif filter_state == 'closed':
domain.append(('amount_residual', '=', 0))
if search:
domain.append('|')
domain.append(('name', 'ilike', search))
domain.append(('ref', 'ilike', search))
Move = env['account.move'].sudo()
order = self._FP_ACCOUNT_SUMMARY_SORTS.get(sort, 'invoice_date desc')
total = Move.search_count(domain)
offset = max(0, (page - 1) * self._FP_ACCOUNT_SUMMARY_PER_PAGE)
records = Move.search(domain, order=order, limit=self._FP_ACCOUNT_SUMMARY_PER_PAGE, offset=offset)
return {'records': records, 'total': total, 'offset': offset}
@http.route(
['/my/account_summary', '/my/account_summary/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_account_summary(self, page=1, tab='invoices',
filter_state='open', search='', sort='date_desc',
**kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# Sanitize inputs
if tab not in [k for k, _l, _t in self._FP_ACCOUNT_SUMMARY_TABS]:
tab = 'invoices'
if filter_state not in self._FP_ACCOUNT_SUMMARY_FILTERS:
filter_state = 'open'
if sort not in self._FP_ACCOUNT_SUMMARY_SORTS:
sort = 'date_desc'
data = self._fp_account_summary_data(
commercial, tab, filter_state, search, sort, page,
)
open_balance = self._fp_account_summary_open_balance(commercial)
pager = portal_pager(
url='/my/account_summary',
url_args={'tab': tab, 'filter_state': filter_state,
'search': search, 'sort': sort},
total=data['total'],
page=page,
step=self._FP_ACCOUNT_SUMMARY_PER_PAGE,
)
values = {
'page_name': 'fp_account_summary',
'records': data['records'],
'tabs': self._FP_ACCOUNT_SUMMARY_TABS,
'active_tab': tab,
'filter_state': filter_state,
'search': search,
'sort': sort,
'open_balance': open_balance,
'currency': commercial.property_account_receivable_id.currency_id
if commercial.property_account_receivable_id else request.env.company.currency_id,
'pager': pager,
'total': data['total'],
}
return request.render('fusion_plating_portal.portal_my_account_summary', values)
# ========================================================================== # ==========================================================================
# DASHBOARD # DASHBOARD
# ========================================================================== # ==========================================================================

View File

@@ -147,3 +147,124 @@ class TestPortalDashboard(TransactionCase):
# Work Order is placeholder without a backend fp.job link # Work Order is placeholder without a backend fp.job link
wo = next(g for g in groups if g['key'] == 'work_order') wo = next(g for g in groups if g['key'] == 'work_order')
self.assertTrue(all(d.get('pending') for d in wo['docs'])) self.assertTrue(all(d.get('pending') for d in wo['docs']))
def test_account_summary_partitions_invoices_and_credits(self):
"""Account Summary helper splits posted moves by move_type."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
# fp_from_so_invoice=True bypasses the fusion_plating_jobs enforcement
# that normally requires invoices to originate from a Sale Order.
# payment_term is required by fusion_plating_invoicing's action_post gate.
# Both are test-data scaffolding only; they do not affect what is tested.
pt = self.env.ref('account.account_payment_term_immediate')
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_payment_term_id': pt.id,
'invoice_line_ids': [(0, 0, {
'name': 'Test plating',
'quantity': 1,
'price_unit': 250.00,
})],
})
inv.action_post()
cm = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_refund',
'invoice_date': '2026-05-02',
'invoice_payment_term_id': pt.id,
'invoice_line_ids': [(0, 0, {
'name': 'Test credit',
'quantity': 1,
'price_unit': 50.00,
})],
})
cm.action_post()
controller = FpCustomerPortal()
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
# Tab=invoices -> only out_invoice
names = data['records'].mapped('name')
self.assertIn(inv.name, names)
self.assertNotIn(cm.name, names)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='credit_memos',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
names = data['records'].mapped('name')
self.assertIn(cm.name, names)
self.assertNotIn(inv.name, names)
def test_account_summary_open_balance_sums_residuals(self):
"""Open Balance pill = sum of amount_residual across open invoices."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
pt = self.env.ref('account.account_payment_term_immediate')
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_payment_term_id': pt.id,
'invoice_line_ids': [(0, 0, {
'name': 'Open inv',
'quantity': 1,
'price_unit': 750.00,
})],
})
inv.action_post()
controller = FpCustomerPortal()
open_balance = controller._fp_account_summary_open_balance(
self.partner.commercial_partner_id,
)
# The 750 invoice has amount_residual = 750 until paid
self.assertEqual(open_balance, 750.00)
def test_account_summary_search_matches_name_and_ref(self):
"""Search box filters by invoice number OR customer PO (ref)."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
pt = self.env.ref('account.account_payment_term_immediate')
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_payment_term_id': pt.id,
'ref': 'PO-CUSTOMER-99999',
'invoice_line_ids': [(0, 0, {
'name': 'Sale',
'quantity': 1,
'price_unit': 100.0,
})],
})
inv.action_post()
controller = FpCustomerPortal()
# Search by ref (customer PO)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='99999', sort='date_desc', page=1,
)
self.assertIn(inv, data['records'])
# Search that matches nothing
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='zzznotfoundzzz', sort='date_desc', page=1,
)
self.assertNotIn(inv, data['records'])