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 '%.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
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
Reference in New Issue
Block a user