feat: implement aggregated inter-company clearing for branch POS sessions and define parent mirror entry flow

This commit is contained in:
Suherdy Yacob 2026-04-30 08:45:38 +07:00
parent 63f551d551
commit 8f90de43fd
3 changed files with 178 additions and 61 deletions

View File

@ -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. 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 ## Features
### 1. Shared COA Support
- Overrides `account.account` company validation to allow `len(company_ids) > 1` on `asset_cash` accounts. - 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. - 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 ## Author
- **Suherdy Yacob** - **Suherdy Yacob**

View File

@ -71,18 +71,27 @@ class PosSession(models.Model):
clearing_amounts[pm] = 0.0 clearing_amounts[pm] = 0.0
clearing_amounts[pm] += payment.amount clearing_amounts[pm] += payment.amount
# Group PMs by their clearing journal
journal_to_pms = {}
for pm, amount in clearing_amounts.items(): for pm, amount in clearing_amounts.items():
if session.currency_id.is_zero(amount): if session.currency_id.is_zero(amount):
continue continue
journal = pm.intercompany_clearing_journal_id
if journal not in journal_to_pms:
journal_to_pms[journal] = []
journal_to_pms[journal].append(pm)
# The account to CLEAR is the receivable account used in the main POS move (e.g., AR in Transit) 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) receivable_account = self._get_receivable_account(pm)
if not receivable_account: if not receivable_account:
_logger.warning("No receivable account found on payment method %s, skipping inter-company clearing.", pm.name)
continue continue
intercompany_account = pm.intercompany_clearing_account_id intercompany_account = pm.intercompany_clearing_account_id
clearing_journal = pm.intercompany_clearing_journal_id
# Convert amount to company currency if needed # Convert amount to company currency if needed
amount_company_curr = amount amount_company_curr = amount
@ -91,57 +100,106 @@ class PosSession(models.Model):
amount, session.company_id.currency_id, session.company_id, session.stop_at or fields.Date.context_today(session) amount, session.company_id.currency_id, session.company_id, session.stop_at or fields.Date.context_today(session)
) )
# --- BRANCH SIDE: Clearing Move --- # Store PM level data for parent mirror
# DEBIT: Hubungan RK (129101) - records that parent owes us pm_level_data.append({
# CREDIT: AR in Transit - clears the receivable from the main POS move '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'],
}))
# 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 = { move_vals = {
'journal_id': clearing_journal.id, 'journal_id': clearing_journal.id,
'date': session.stop_at or fields.Date.context_today(session), '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', 'move_type': 'entry',
'company_id': session.company_id.id, 'company_id': session.company_id.id,
'line_ids': [ 'line_ids': 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,
})
]
} }
try: try:
clearing_move = self.env['account.move'].sudo().with_company(session.company_id).create(move_vals) clearing_move = self.env['account.move'].sudo().with_company(session.company_id).create(move_vals)
clearing_move._post() clearing_move._post()
# Auto-reconcile with the main POS move lines (branch side) # 1. Reconcile aggregated lines with session move
for key, data in aggregated_data.items():
receivable_account = data['receivable_account']
pms = data['pms']
try: try:
clearing_credit_line = clearing_move.line_ids.filtered(lambda l: l.account_id == receivable_account and l.credit > 0) # Find the aggregated credit line
# Find the debit line in the session move that hits the same receivable account and matches the PM name 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( 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}" 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: if clearing_credit_line and pos_debit_lines:
(clearing_credit_line + pos_debit_lines).reconcile() (clearing_credit_line + pos_debit_lines).reconcile()
except Exception as re_e: except Exception as re_e:
_logger.warning("Could not auto-reconcile inter-company clearing lines for session %s: %s", session.name, re_e) _logger.warning("Could not auto-reconcile aggregated clearing lines for session %s: %s", session.name, re_e)
# --- PARENT SIDE: Mirror Entry --- # 2. Create parent mirror moves (separate per PM as requested)
self._create_parent_mirror_move(session, pm, amount, amount_company_curr, clearing_move) 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: 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): def _create_parent_mirror_move(self, session, pm, amount, amount_company_curr, branch_clearing_move):
"""Create the mirror journal entry in the parent company. """Create the mirror journal entry in the parent company.

View File

@ -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 COMPANY_NAME = "Mie Mapan Rungkut Mapan"
view = env['ir.ui.view'].browse(2122) POS_CONFIG_NAME = "POS Rungkut"
print(f"Before: {view.arch_db}")
new_arch = view.arch_db.replace('required="True"', 'required="state == "draft""') company = env['res.company'].search([('name', '=', COMPANY_NAME)], limit=1)
view.write({'arch_db': new_arch}) pos_config = env['pos.config'].search([('name', '=', POS_CONFIG_NAME), ('company_id', '=', company.id)], limit=1)
print(f"\nAfter: {view.arch_db}") # Get BCA and BTN
env.cr.commit() bca_pm = pos_config.payment_method_ids.filtered(lambda pm: pm.name == 'BCA')
print("\nDone! x_studio_analytic_account is now only required for draft entries.") 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()