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:
@@ -442,6 +442,131 @@ class FpCustomerPortal(CustomerPortal):
|
||||
return '%.0f KB' % (size / 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
|
||||
# ==========================================================================
|
||||
|
||||
@@ -147,3 +147,124 @@ class TestPortalDashboard(TransactionCase):
|
||||
# Work Order is placeholder without a backend fp.job link
|
||||
wo = next(g for g in groups if g['key'] == 'work_order')
|
||||
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'])
|
||||
|
||||
Reference in New Issue
Block a user