From 8ff380e22927ad614f837f348edb068690ca4180 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 18 May 2026 20:23:25 +0200 Subject: [PATCH] GL report --- modules/account/__init__.py | 2 + modules/account/account.py | 440 +++++++++++++++++- modules/account/account.xml | 57 +++ modules/account/tests/test_module.py | 47 ++ .../general_ledger_detail_context_form.xml | 41 ++ .../view/general_ledger_detail_list.xml | 40 ++ 6 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 modules/account/view/general_ledger_detail_context_form.xml create mode 100644 modules/account/view/general_ledger_detail_list.xml diff --git a/modules/account/__init__.py b/modules/account/__init__.py index d3f280d..3e8d0f2 100755 --- a/modules/account/__init__.py +++ b/modules/account/__init__.py @@ -30,6 +30,8 @@ def register(): account.GeneralLedgerAccountParty, account.GeneralLedgerLine, account.GeneralLedgerLineContext, + account.GeneralLedgerDetail, + account.GeneralLedgerDetailContext, account.BalanceSheetContext, account.BalanceSheetComparisionContext, account.IncomeStatementContext, diff --git a/modules/account/account.py b/modules/account/account.py index 27a7177..f24a9ac 100755 --- a/modules/account/account.py +++ b/modules/account/account.py @@ -7,7 +7,7 @@ from decimal import Decimal from itertools import zip_longest from dateutil.relativedelta import relativedelta -from sql import Column, Literal, Null, Window +from sql import Column, Literal, Null, Union, Window from sql.aggregate import Count, Max, Min, Sum from sql.conditionals import Case, Coalesce @@ -2583,6 +2583,444 @@ class GeneralLedgerLineContext(GeneralLedgerAccountContext): return super().get_context(fields_names=fields_names) +class GeneralLedgerDetailContext(GeneralLedgerAccountContext): + 'General Ledger Detail Context' + __name__ = 'account.general_ledger.detail.context' + + account = fields.Many2One( + 'account.account', "Account", + domain=[ + ('company', '=', Eval('company')), + ('type', '!=', None), + ('closed', '!=', True), + ], + depends=['company']) + account_from = fields.Char("Account From") + account_to = fields.Char("Account To") + currency = fields.Many2One('currency.currency', "Transaction Currency") + party = fields.Many2One( + 'party.party', "Party", + context={ + 'company': Eval('company', -1), + }, + depends=['company']) + document_number = fields.Char("Document Number") + + @classmethod + def default_account(cls): + return Transaction().context.get('account') + + @classmethod + def default_account_from(cls): + return Transaction().context.get('account_from') + + @classmethod + def default_account_to(cls): + return Transaction().context.get('account_to') + + @classmethod + def default_currency(cls): + return Transaction().context.get('currency') + + @classmethod + def default_party(cls): + return Transaction().context.get('party') + + @classmethod + def default_document_number(cls): + return Transaction().context.get('document_number') + + @classmethod + def get_context(cls, fields_names=None): + fields_names = fields_names.copy() if fields_names is not None else [] + fields_names += [ + 'account', 'account_from', 'account_to', 'currency', 'party', + 'document_number'] + return super().get_context(fields_names=fields_names) + + +class GeneralLedgerDetail(DescriptionOriginMixin, ModelSQL, ModelView): + 'General Ledger Detail' + __name__ = 'account.general_ledger.detail' + + row_type = fields.Selection([ + ('opening', "Opening"), + ('movement', "Movement"), + ('closing', "Closing"), + ], "Row Type", sort=False) + row_sequence = fields.Integer("Row Sequence") + + company = fields.Many2One('company.company', "Company") + account = fields.Many2One('account.account', "Account") + account_code = fields.Char("Account Code") + account_name = fields.Char("Account Name") + transaction_currency = fields.Many2One( + 'currency.currency', "Transaction Currency") + base_currency = fields.Many2One('currency.currency', "Base Currency") + + posting_date = fields.Date("Posting Date") + journal = fields.Many2One('account.journal', "Journal") + journal_entry_number = fields.Char("Journal Entry Number") + document_number = fields.Char("Document Number") + voucher_number = fields.Char("Voucher Number") + document_type = fields.Char("Document Type") + document_date = fields.Date("Document Date") + supplier_invoice_number = fields.Char("Supplier Invoice Number") + posting_status = fields.Selection([ + ('draft', "Draft"), + ('posted', "Posted"), + ], "Posting Status", sort=False) + + party = fields.Many2One('party.party', "Party") + counterparty = fields.Function( + fields.Char("Counterparty"), 'get_counterparty') + move = fields.Many2One('account.move', "Move") + origin = fields.Reference('Origin', selection='get_origin') + description = fields.Char("Description") + reference = fields.Char("Reference") + + debit_base_currency = Monetary( + "Debit Base Currency", currency='base_currency', + digits='base_currency') + credit_base_currency = Monetary( + "Credit Base Currency", currency='base_currency', + digits='base_currency') + balance_base_currency = Monetary( + "Balance Base Currency", currency='base_currency', + digits='base_currency') + running_balance_base_currency = Monetary( + "Running Balance Base Currency", currency='base_currency', + digits='base_currency') + + debit_transaction_currency = Monetary( + "Debit Transaction Currency", currency='transaction_currency', + digits='transaction_currency') + credit_transaction_currency = Monetary( + "Credit Transaction Currency", currency='transaction_currency', + digits='transaction_currency') + balance_transaction_currency = Monetary( + "Balance Transaction Currency", currency='transaction_currency', + digits='transaction_currency') + running_balance_transaction_currency = Monetary( + "Running Balance Transaction Currency", + currency='transaction_currency', digits='transaction_currency') + + payable_qty = fields.Numeric("Payable Qty") + uom = fields.Char("UoM") + + entered_by = fields.Many2One('res.user', "Entered By") + entered_date = fields.Date("Entered Date") + modified_by = fields.Many2One('res.user', "Modified By") + modified_date = fields.Date("Modified Date") + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order = [ + ('account_code', 'ASC'), + ('transaction_currency', 'ASC'), + ('row_sequence', 'ASC'), + ('posting_date', 'ASC'), + ('journal_entry_number', 'ASC'), + ('id', 'ASC'), + ] + + @classmethod + def _get_period_ids(cls, name, context): + pool = Pool() + Period = pool.get('account.period') + + period = None + if name == 'opening': + period_ids = [] + if context.get('start_period'): + period = Period(context['start_period']) + elif name == 'closing': + period_ids = [] + if context.get('end_period'): + period = Period(context['end_period']) + else: + periods = Period.search([ + ('fiscalyear', '=', context.get('fiscalyear')), + ('type', '=', 'standard'), + ], + order=[('start_date', 'DESC')], limit=1) + if periods: + period, = periods + else: + start_period_ids = set(cls._get_period_ids('opening', context)) + end_period_ids = set(cls._get_period_ids('closing', context)) + return list(end_period_ids.difference(start_period_ids)) + + if period: + if name == 'opening': + date_clause = ('end_date', '<=', period.start_date) + else: + date_clause = [ + ('end_date', '<=', period.end_date), + ('start_date', '<', period.end_date), + ] + periods = Period.search([ + ('fiscalyear', '=', context.get('fiscalyear')), + date_clause, + ]) + if period.start_date == period.end_date: + periods.append(period) + if periods: + period_ids = [p.id for p in periods] + if name == 'closing': + period_ids.append(period.id) + return period_ids + + @classmethod + def _get_dates(cls, name, context): + if name == 'opening': + to_date = context.get('from_date') or datetime.date.min + if to_date: + try: + to_date -= datetime.timedelta(days=1) + except OverflowError: + pass + return None, to_date + elif name == 'closing': + return None, context.get('to_date') + return context.get('from_date'), context.get('to_date') + + @classmethod + def _query_context(cls, name, context): + if context.get('start_period') or context.get('end_period'): + return { + 'periods': cls._get_period_ids(name, context), + 'from_date': None, + 'to_date': None, + } + elif context.get('from_date') or context.get('to_date'): + from_date, to_date = cls._get_dates(name, context) + return { + 'periods': None, + 'from_date': from_date, + 'to_date': to_date, + } + else: + if name == 'opening': + return { + 'periods': [], + 'from_date': None, + 'to_date': None, + } + return { + 'periods': None, + 'from_date': None, + 'to_date': None, + } + + @classmethod + def _extra_where(cls, line, move, account, tx_currency, context): + where = ( + (move.company == context.get('company')) + & (account.type != Null) + & (account.closed != Literal(True))) + if context.get('account'): + where &= line.account == context['account'] + if context.get('account_from'): + where &= account.code >= context['account_from'] + if context.get('account_to'): + where &= account.code <= context['account_to'] + if context.get('currency'): + where &= tx_currency == context['currency'] + if context.get('party'): + where &= line.party == context['party'] + if context.get('document_number'): + value = '%%%s%%' % context['document_number'] + where &= ( + move.number.ilike(value) + | move.post_number.ilike(value) + | move.ext_ref.ilike(value)) + return where + + @classmethod + def _tables(cls): + pool = Pool() + Line = pool.get('account.move.line') + Move = pool.get('account.move') + Account = pool.get('account.account') + Company = pool.get('company.company') + line = Line.__table__() + move = Move.__table__() + account = Account.__table__() + company = Company.__table__() + return Line, line, move, account, company + + @classmethod + def _line_query(cls, name, context): + Line, line, move, account, company = cls._tables() + tx_currency = Coalesce(line.second_currency, company.currency) + base_amount = Coalesce(line.debit, 0) - Coalesce(line.credit, 0) + second_amount = Coalesce(line.amount_second_currency, 0) + tx_amount = Case( + (line.second_currency != Null, second_amount), + else_=base_amount) + debit_tx = Case( + (line.second_currency == Null, Coalesce(line.debit, 0)), + (second_amount > 0, second_amount), + else_=Literal(0)) + credit_tx = Case( + (line.second_currency == Null, Coalesce(line.credit, 0)), + (second_amount < 0, -second_amount), + else_=Literal(0)) + + with Transaction().set_context( + context, **cls._query_context(name, context)): + line_query, _ = Line.query_get(line) + where = line_query & cls._extra_where( + line, move, account, tx_currency, context) + + window = Window( + [line.account, tx_currency], + order_by=[move.date.asc, move.number.asc, line.id.asc]) + + return (line.join(move, condition=line.move == move.id) + .join(account, condition=line.account == account.id) + .join(company, condition=move.company == company.id) + .select( + (line.id * 3).as_('id'), + line.create_uid.as_('create_uid'), + line.create_date.as_('create_date'), + line.write_uid.as_('write_uid'), + line.write_date.as_('write_date'), + Literal('movement').as_('row_type'), + Literal(1).as_('row_sequence'), + move.company.as_('company'), + line.account.as_('account'), + account.code.as_('account_code'), + account.name.as_('account_name'), + tx_currency.as_('transaction_currency'), + company.currency.as_('base_currency'), + move.date.as_('posting_date'), + move.journal.as_('journal'), + Coalesce(move.post_number, move.number).as_( + 'journal_entry_number'), + Coalesce(move.ext_ref, move.number).as_('document_number'), + move.number.as_('voucher_number'), + move.origin.as_('document_type'), + move.date.as_('document_date'), + Literal(None).as_('supplier_invoice_number'), + move.state.as_('posting_status'), + line.party.as_('party'), + line.move.as_('move'), + line.origin.as_('origin'), + line.description.as_('description'), + Coalesce(move.ext_ref, move.description).as_('reference'), + line.debit.as_('debit_base_currency'), + line.credit.as_('credit_base_currency'), + base_amount.as_('balance_base_currency'), + Sum(base_amount, window=window).as_( + 'running_balance_base_currency'), + debit_tx.as_('debit_transaction_currency'), + credit_tx.as_('credit_transaction_currency'), + tx_amount.as_('balance_transaction_currency'), + Sum(tx_amount, window=window).as_( + 'running_balance_transaction_currency'), + Literal(None).as_('payable_qty'), + Literal(None).as_('uom'), + line.create_uid.as_('entered_by'), + line.create_date.as_('entered_date'), + line.write_uid.as_('modified_by'), + line.write_date.as_('modified_date'), + where=where)) + + @classmethod + def _summary_query(cls, name, context): + Line, line, move, account, company = cls._tables() + tx_currency = Coalesce(line.second_currency, company.currency) + base_amount = Coalesce(line.debit, 0) - Coalesce(line.credit, 0) + tx_amount = Case( + (line.second_currency != Null, + Coalesce(line.amount_second_currency, 0)), + else_=base_amount) + row_sequence = 0 if name == 'opening' else 2 + row_id = ( + (Min(line.id) * 3 - 1) if name == 'opening' + else (Min(line.id) * 3 - 2)) + + with Transaction().set_context( + context, **cls._query_context(name, context)): + line_query, _ = Line.query_get(line) + where = line_query & cls._extra_where( + line, move, account, tx_currency, context) + + return (line.join(move, condition=line.move == move.id) + .join(account, condition=line.account == account.id) + .join(company, condition=move.company == company.id) + .select( + row_id.as_('id'), + Literal(0).as_('create_uid'), + Max(line.create_date).as_('create_date'), + Literal(0).as_('write_uid'), + Max(line.write_date).as_('write_date'), + Literal(name).as_('row_type'), + Literal(row_sequence).as_('row_sequence'), + move.company.as_('company'), + line.account.as_('account'), + account.code.as_('account_code'), + account.name.as_('account_name'), + tx_currency.as_('transaction_currency'), + company.currency.as_('base_currency'), + Literal(None).as_('posting_date'), + Literal(None).as_('journal'), + Literal(None).as_('journal_entry_number'), + Literal(None).as_('document_number'), + Literal(None).as_('voucher_number'), + Literal(None).as_('document_type'), + Literal(None).as_('document_date'), + Literal(None).as_('supplier_invoice_number'), + Literal(None).as_('posting_status'), + Literal(None).as_('party'), + Literal(None).as_('move'), + Literal(None).as_('origin'), + Literal(name.title()).as_('description'), + Literal(None).as_('reference'), + Literal(0).as_('debit_base_currency'), + Literal(0).as_('credit_base_currency'), + Sum(base_amount).as_('balance_base_currency'), + Sum(base_amount).as_('running_balance_base_currency'), + Literal(0).as_('debit_transaction_currency'), + Literal(0).as_('credit_transaction_currency'), + Sum(tx_amount).as_('balance_transaction_currency'), + Sum(tx_amount).as_('running_balance_transaction_currency'), + Literal(None).as_('payable_qty'), + Literal(None).as_('uom'), + Literal(None).as_('entered_by'), + Literal(None).as_('entered_date'), + Literal(None).as_('modified_by'), + Literal(None).as_('modified_date'), + where=where, + group_by=[ + move.company, line.account, account.code, account.name, + tx_currency, company.currency], + having=Sum(base_amount) != 0)) + + @classmethod + def table_query(cls): + pool = Pool() + DetailContext = pool.get('account.general_ledger.detail.context') + context = DetailContext.get_context() + return Union( + cls._summary_query('opening', context), + cls._line_query('movement', context), + cls._summary_query('closing', context)) + + @classmethod + def get_origin(cls): + Line = Pool().get('account.move.line') + return Line.get_origin() + + def get_counterparty(self, name): + if self.party: + return self.party.rec_name + return None + + class GeneralLedger(Report): __name__ = 'account.general_ledger' diff --git a/modules/account/account.xml b/modules/account/account.xml index e7c2e11..fd22196 100755 --- a/modules/account/account.xml +++ b/modules/account/account.xml @@ -806,6 +806,58 @@ this repository contains the full copyright notices and license terms. --> + + account.general_ledger.detail + tree + general_ledger_detail_list + + + + General Ledger Detail + account.general_ledger.detail + account.general_ledger.detail.context + + + + + + + + + + account.general_ledger.detail + + + + + + + account.general_ledger.detail + + + + + + + + + User in companies + account.general_ledger.detail + + + + + + + account.general_ledger.account.context form @@ -817,6 +869,11 @@ this repository contains the full copyright notices and license terms. --> general_ledger_line_context_form + + account.general_ledger.detail.context + form + general_ledger_detail_context_form + account.account.type diff --git a/modules/account/tests/test_module.py b/modules/account/tests/test_module.py index b496535..28da799 100755 --- a/modules/account/tests/test_module.py +++ b/modules/account/tests/test_module.py @@ -2097,5 +2097,52 @@ class AccountTestCase( self.assertEqual(account.parent, new_account) + @with_transaction() + def test_general_ledger_detail_models_are_registered(self): + "Test General Ledger Detail models are registered with expected fields" + pool = Pool() + Detail = pool.get('account.general_ledger.detail') + DetailContext = pool.get('account.general_ledger.detail.context') + + for name in [ + 'row_type', 'account_code', 'transaction_currency', + 'journal_entry_number', 'document_number', + 'debit_base_currency', 'credit_base_currency', + 'running_balance_base_currency', + 'debit_transaction_currency', + 'credit_transaction_currency', + 'running_balance_transaction_currency']: + self.assertIn(name, Detail._fields) + + for name in [ + 'account', 'account_from', 'account_to', 'currency', 'party', + 'document_number']: + self.assertIn(name, DetailContext._fields) + + @with_transaction() + def test_general_ledger_detail_date_contexts(self): + "Test General Ledger Detail opening and movement date contexts" + Detail = Pool().get('account.general_ledger.detail') + context = { + 'from_date': datetime.date(2026, 1, 10), + 'to_date': datetime.date(2026, 1, 31), + } + + self.assertEqual(Detail._query_context('opening', context), { + 'periods': None, + 'from_date': None, + 'to_date': datetime.date(2026, 1, 9), + }) + self.assertEqual(Detail._query_context('movement', context), { + 'periods': None, + 'from_date': datetime.date(2026, 1, 10), + 'to_date': datetime.date(2026, 1, 31), + }) + self.assertEqual(Detail._query_context('closing', context), { + 'periods': None, + 'from_date': None, + 'to_date': datetime.date(2026, 1, 31), + }) + del ModuleTestCase diff --git a/modules/account/view/general_ledger_detail_context_form.xml b/modules/account/view/general_ledger_detail_context_form.xml new file mode 100644 index 0000000..0f13d20 --- /dev/null +++ b/modules/account/view/general_ledger_detail_context_form.xml @@ -0,0 +1,41 @@ + + +
+