diff --git a/fusion_plating/fusion_plating_portal/controllers/portal.py b/fusion_plating/fusion_plating_portal/controllers/portal.py index fd037f7c..91d1383b 100644 --- a/fusion_plating/fusion_plating_portal/controllers/portal.py +++ b/fusion_plating/fusion_plating_portal/controllers/portal.py @@ -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/'], + 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 # ========================================================================== diff --git a/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py b/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py index 738efecb..e32f63e0 100644 --- a/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py +++ b/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py @@ -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'])