From 8f90de43fdba3373f538296b409331f3af1f3a1e Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 30 Apr 2026 08:45:38 +0700 Subject: [PATCH] feat: implement aggregated inter-company clearing for branch POS sessions and define parent mirror entry flow --- README.md | 19 ++++- models/pos_session.py | 162 ++++++++++++++++++++++++++------------ scripts/debug_clearing.py | 58 ++++++++++++-- 3 files changed, 178 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 0b0030c..d159b57 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,27 @@ This custom Odoo 19 module removes the standard restriction that prevents Chart By default, Odoo restricts a Bank and Cash account to a single company. This module patches `_check_company_consistency` on the `account.account` model to allow you to configure multiple companies for a single Bank/Cash account. This is particularly useful for setups where multiple Point of Sale (POS) shops across different companies deposit into the same physical bank account or cash account. ## Features +### 1. Shared COA Support - Overrides `account.account` company validation to allow `len(company_ids) > 1` on `asset_cash` accounts. - Ensures POS payments and closing entries seamlessly use the shared account. -> **Warning:** While POS and other payments will post to the shared account flawlessly, Odoo's standard Bank Statement Reconciliation strictly requires that statements and journal lines belong to the exact same company. Therefore, you cannot perform cross-company bank reconciliation natively in a single screen. +### 2. Automated Inter-Company Clearing +When a branch-side POS session is closed, the module automates the inter-company accounting flow: +- **Branch Company (Origin)**: + - Aggregates all bank clearing entries into a **single aggregated journal entry** (usually just 2 lines: Total Receivable vs Total Inter-company Account). + - Automatically reconciles these clearing lines against the main POS session move. + - Visually links the clearing moves to the POS Session's **"Journal Items"** dashboard. +- **Parent Company (Destination)**: + - Automatically creates **separate mirror journal entries** for each payment method (e.g., separate entries for BCA, BTN, BRI). + - Debits the parent bank's **Outstanding Receipt Account**, allowing for seamless reconciliation against incoming bank statement lines in the parent's accounting dashboard. + - Credits the Inter-company Liability account (e.g., `229101 Hubungan RK`). + +## Configuration +To enable the automated inter-company clearing, navigate to **Point of Sale > Configuration > Payment Methods** and configure the following in the "Inter-Company Clearing" section: +- **Parent Company**: Select the target company (e.g., OT). +- **Parent Bank Journal**: Select the journal in the parent company that receives the funds. +- **Parent Inter-company Account**: The liability account in the parent company (e.g., `229101 Hubungan RK`). +- **Parent Clearing Journal**: The journal in the parent company used to record these mirror entries (e.g., "Inter-Company Clearing"). ## Author - **Suherdy Yacob** diff --git a/models/pos_session.py b/models/pos_session.py index 4c5260c..d72dffc 100644 --- a/models/pos_session.py +++ b/models/pos_session.py @@ -70,78 +70,136 @@ class PosSession(models.Model): if pm not in clearing_amounts: clearing_amounts[pm] = 0.0 clearing_amounts[pm] += payment.amount - + + # Group PMs by their clearing journal + journal_to_pms = {} for pm, amount in clearing_amounts.items(): if session.currency_id.is_zero(amount): continue + journal = pm.intercompany_clearing_journal_id + if journal not in journal_to_pms: + journal_to_pms[journal] = [] + journal_to_pms[journal].append(pm) + + for clearing_journal, pms in journal_to_pms.items(): + aggregated_data = {} # Key: (receivable_account, intercompany_account) + pm_level_data = [] # For parent mirror entries + + for pm in pms: + amount = clearing_amounts[pm] + receivable_account = self._get_receivable_account(pm) + if not receivable_account: + continue + + intercompany_account = pm.intercompany_clearing_account_id - # The account to CLEAR is the receivable account used in the main POS move (e.g., AR in Transit) - receivable_account = self._get_receivable_account(pm) - if not receivable_account: - _logger.warning("No receivable account found on payment method %s, skipping inter-company clearing.", pm.name) + # Convert amount to company currency if needed + amount_company_curr = amount + if session.currency_id != session.company_id.currency_id: + amount_company_curr = session.currency_id._convert( + amount, session.company_id.currency_id, session.company_id, session.stop_at or fields.Date.context_today(session) + ) + + # Store PM level data for parent mirror + pm_level_data.append({ + 'pm': pm, + 'amount': amount, + 'amount_company_curr': amount_company_curr, + }) + + # Aggregate for branch move + key = (receivable_account, intercompany_account) + if key not in aggregated_data: + aggregated_data[key] = { + 'total_amount': 0.0, + 'total_company_curr': 0.0, + 'pms': [], + 'receivable_account': receivable_account, + 'intercompany_account': intercompany_account, + } + aggregated_data[key]['total_amount'] += amount + aggregated_data[key]['total_company_curr'] += amount_company_curr + aggregated_data[key]['pms'].append(pm) + + if not aggregated_data: continue + + line_ids = [] + for key, data in aggregated_data.items(): + # CREDIT: Total AR in Transit + line_ids.append(Command.create({ + 'name': f"Total Clearing - {session.name}", + 'account_id': data['receivable_account'].id, + 'credit': data['total_company_curr'], + 'debit': 0.0, + 'currency_id': session.currency_id.id, + 'amount_currency': -data['total_amount'], + })) - intercompany_account = pm.intercompany_clearing_account_id - clearing_journal = pm.intercompany_clearing_journal_id - - # Convert amount to company currency if needed - amount_company_curr = amount - if session.currency_id != session.company_id.currency_id: - amount_company_curr = session.currency_id._convert( - amount, session.company_id.currency_id, session.company_id, session.stop_at or fields.Date.context_today(session) - ) - - # --- BRANCH SIDE: Clearing Move --- - # DEBIT: Hubungan RK (129101) - records that parent owes us - # CREDIT: AR in Transit - clears the receivable from the main POS move + # DEBIT: Total Hubungan RK + line_ids.append(Command.create({ + 'name': f"Total Due from Parent - {session.name}", + 'account_id': data['intercompany_account'].id, + 'credit': 0.0, + 'debit': data['total_company_curr'], + 'currency_id': session.currency_id.id, + 'amount_currency': data['total_amount'], + })) + + # --- BRANCH SIDE: Aggregated Clearing Move (Target: 2 items) --- move_vals = { 'journal_id': clearing_journal.id, 'date': session.stop_at or fields.Date.context_today(session), - 'ref': f"Inter-company clearing for {session.name} ({pm.name})", + 'ref': f"Inter-company clearing for {session.name}", 'move_type': 'entry', 'company_id': session.company_id.id, - 'line_ids': [ - Command.create({ - 'name': f"Clearing: {pm.name}", - 'account_id': receivable_account.id, - 'credit': amount_company_curr, - 'debit': 0.0, - 'currency_id': session.currency_id.id, - 'amount_currency': -amount, - }), - Command.create({ - 'name': f"Due from Parent: {pm.name}", - 'account_id': intercompany_account.id, - 'credit': 0.0, - 'debit': amount_company_curr, - 'currency_id': session.currency_id.id, - 'amount_currency': amount, - }) - ] + 'line_ids': line_ids, } try: clearing_move = self.env['account.move'].sudo().with_company(session.company_id).create(move_vals) clearing_move._post() - # Auto-reconcile with the main POS move lines (branch side) - try: - clearing_credit_line = clearing_move.line_ids.filtered(lambda l: l.account_id == receivable_account and l.credit > 0) - # Find the debit line in the session move that hits the same receivable account and matches the PM name - pos_debit_lines = session.move_id.line_ids.filtered( - lambda l: l.account_id == receivable_account and l.debit > 0 and l.name == f"{session.name} - {pm.name}" - ) + # 1. Reconcile aggregated lines with session move + for key, data in aggregated_data.items(): + receivable_account = data['receivable_account'] + pms = data['pms'] - if clearing_credit_line and pos_debit_lines: - (clearing_credit_line + pos_debit_lines).reconcile() - except Exception as re_e: - _logger.warning("Could not auto-reconcile inter-company clearing lines for session %s: %s", session.name, re_e) + try: + # Find the aggregated credit line + clearing_credit_line = clearing_move.line_ids.filtered( + lambda l: l.account_id == receivable_account and l.credit > 0 + ) + # Find all matching debit lines in the session move + pm_names = [pm.name for pm in pms] + pos_debit_lines = session.move_id.line_ids.filtered( + lambda l: l.account_id == receivable_account and l.debit > 0 and \ + any(name in l.name for name in pm_names) + ) + + if clearing_credit_line and pos_debit_lines: + (clearing_credit_line + pos_debit_lines).reconcile() + except Exception as re_e: + _logger.warning("Could not auto-reconcile aggregated clearing lines for session %s: %s", session.name, re_e) - # --- PARENT SIDE: Mirror Entry --- - self._create_parent_mirror_move(session, pm, amount, amount_company_curr, clearing_move) - + # 2. Create parent mirror moves (separate per PM as requested) + for data in pm_level_data: + self._create_parent_mirror_move(session, data['pm'], data['amount'], data['amount_company_curr'], clearing_move) + except Exception as e: - _logger.error("Failed to create/post inter-company clearing move for session %s, PM %s: %s", session.name, pm.name, e) + _logger.error("Failed to create/post aggregated inter-company clearing move for session %s: %s", session.name, e) + + + + def _get_related_account_moves(self): + res = super()._get_related_account_moves() + for session in self: + clearing_moves = self.env['account.move'].sudo().search([ + ('company_id', '=', session.company_id.id), + ('ref', '=', f"Inter-company clearing for {session.name}") + ]) + res |= clearing_moves + return res def _create_parent_mirror_move(self, session, pm, amount, amount_company_curr, branch_clearing_move): """Create the mirror journal entry in the parent company. diff --git a/scripts/debug_clearing.py b/scripts/debug_clearing.py index 599b7f5..2d5eedb 100644 --- a/scripts/debug_clearing.py +++ b/scripts/debug_clearing.py @@ -1,11 +1,53 @@ +# -*- coding: utf-8 -*- +""" +Test script: Verify aggregation to 2 lines on branch side. +""" +from datetime import datetime -# Fix: Change the Studio view's required attribute from True to conditional -view = env['ir.ui.view'].browse(2122) -print(f"Before: {view.arch_db}") +COMPANY_NAME = "Mie Mapan Rungkut Mapan" +POS_CONFIG_NAME = "POS Rungkut" -new_arch = view.arch_db.replace('required="True"', 'required="state == "draft""') -view.write({'arch_db': new_arch}) +company = env['res.company'].search([('name', '=', COMPANY_NAME)], limit=1) +pos_config = env['pos.config'].search([('name', '=', POS_CONFIG_NAME), ('company_id', '=', company.id)], limit=1) -print(f"\nAfter: {view.arch_db}") -env.cr.commit() -print("\nDone! x_studio_analytic_account is now only required for draft entries.") +# Get BCA and BTN +bca_pm = pos_config.payment_method_ids.filtered(lambda pm: pm.name == 'BCA') +btn_pm = pos_config.payment_method_ids.filtered(lambda pm: pm.name == 'BTN') + +# Create session +session = env['pos.session'].with_company(company).create({ + 'user_id': env.uid, + 'config_id': pos_config.id, +}) +session.sudo().set_opening_control(0, None) + +# Create 2 orders +product = env['product.product'].search([('available_in_pos', '=', True)], limit=1) +for pm, amount in [(bca_pm, 10000), (btn_pm, 20000)]: + order = env['pos.order'].create({ + 'company_id': company.id, + 'session_id': session.id, + 'pos_reference': f'TEST/{session.id}/{pm.name}', + 'date_order': datetime.now(), + 'amount_tax': 0, 'amount_total': amount, 'amount_paid': amount, 'amount_return': 0, + 'lines': [(0, 0, {'product_id': product.id, 'qty': 1, 'price_unit': amount, 'price_subtotal': amount, 'price_subtotal_incl': amount})], + }) + env['pos.payment'].create({ + 'pos_order_id': order.id, 'payment_method_id': pm.id, 'amount': amount, 'payment_date': datetime.now(), + }) + order.action_pos_order_paid() + +# Close session +session.sudo().action_pos_session_closing_control() + +# Verify related moves +related_moves = session._get_related_account_moves() +clearing_move = related_moves.filtered(lambda m: m.ref == f"Inter-company clearing for {session.name}") + +print(f"Session: {session.name}") +print(f"Clearing Move: {clearing_move.name}") +print(f"Number of lines in clearing move: {len(clearing_move.line_ids)}") +for line in clearing_move.line_ids: + print(f" {line.account_id.code} {line.name}: {line.debit or -line.credit}") + +env.cr.rollback()