Compare commits
No commits in common. "19.0" and "17.0" have entirely different histories.
18
.gitignore
vendored
18
.gitignore
vendored
@ -1,18 +0,0 @@
|
||||
# Python
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
__pycache__/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE / Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Odoo specific
|
||||
*.pot
|
||||
77
README.md
77
README.md
@ -1,58 +1,41 @@
|
||||
# Account Shared Bank Cash
|
||||
|
||||
## Overview
|
||||
This custom Odoo 19 module removes the standard restriction that prevents Chart of Accounts (COA) of type **Bank and Cash** (`asset_cash`) from being shared across multiple companies.
|
||||
|
||||
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.
|
||||
|
||||
### 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`).
|
||||
|
||||
### 3. Centralized Vendor Payment
|
||||
Enables branches to pay vendor bills from a bank account managed by a parent company:
|
||||
- **Branch-Side**: Intercepts the "Register Payment" wizard. Instead of creating a standard payment, it generates a clearing entry that moves the liability from the Vendor (Accounts Payable) to the Parent Company (Inter-company Account).
|
||||
- **Parent-Side**: Automatically creates an actual `account.payment` record in the parent company, paying out of the parent bank journal and debiting the inter-company clearing account.
|
||||
- **Workflow**: Vendor bill remains in the branch, but is marked as **Paid** via the clearing mechanism. The actual cash outflow and bank reconciliation happen in the parent.
|
||||
|
||||
### 4. Multi-Company POS Order and Checkout Bypass
|
||||
Solves Odoo's restrictive `Access to unauthorized or invalid companies` error/warnings in logs when branch cashiers place orders, checkout, or sync POS sessions:
|
||||
- **Environment & Session Sanitization**: Overrides `ir.http._pre_dispatch` and `_dispatch` to safely query the cashier's authorized companies using `sudo()`, and dynamically filters out unauthorized or parent company IDs (such as parent company `2`) from `allowed_company_ids` in request parameters (`request.params`), HTTP sessions (`request.session.context`), and environment context (`request.env.context`).
|
||||
- **Sudo Order Post-Processing**: Inherits `pos.order` to run post-processing (`_process_saved_order`, including picking generation, cost/method computation, and payment posting) under `sudo()`. This allows Odoo to safely read company-dependent properties (like `cost_method` or `property_cost_method`) and generate inventory pickings without triggering security warnings in `environments.py`.
|
||||
|
||||
### 5. Centralized Cash Bank Reconciliation Patch
|
||||
Enables matching of cash journal entries (e.g. from the centralized cash journal) in Odoo's standard bank reconciliation widget:
|
||||
- **Reconciliation Candidate Expansion**: Patches the standard `account.move.line` search method (`_search_account_id`) to automatically include `asset_cash` accounts when filtering candidate line searches. This allows users to reconcile bank statement lines in the parent company directly against mirror/clearing cash entries.
|
||||
|
||||
This module mirrors the parent company's Bank and Cash accounts to branch companies and provides:
|
||||
|
||||
1. **Mirrored Bank/Cash Accounts**: Automatically clones parent company bank/cash accounts and journals to branches on install
|
||||
2. **POS Inter-Company Clearing**: When a POS session closes, creates automated clearing entries between the branch and parent company
|
||||
3. **Centralized Vendor Payment**: Branch vendor bills can be paid via parent company's bank journal with inter-company clearing
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. POS Inter-Company Clearing
|
||||
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").
|
||||
|
||||
On each `POS Payment Method`:
|
||||
- **Inter-Company Clearing Account**: The RK account in the branch company
|
||||
- **Clearing Journal**: A miscellaneous journal for the clearing entry
|
||||
- **Parent Company**: The parent company for the mirror entry
|
||||
- **Parent Bank Journal**: The parent's bank journal (outstanding receipt account will be debited)
|
||||
- **Parent Hubungan RK Account**: The parent's inter-company liability account
|
||||
- **Parent Clearing Journal**: A miscellaneous journal in the parent for the mirror entry
|
||||
|
||||
### 2. Centralized Vendor Payment
|
||||
Configure a **Bank/Cash Journal** in the branch company to act as a bridge:
|
||||
1. Open the journal form (**Accounting > Configuration > Journals**).
|
||||
2. Go to the **Centralized Payment** tab.
|
||||
3. Enable **Is Centralized**.
|
||||
4. Set the **Parent Company**, **Parent Journal**, and both **Inter-Company (RK) Accounts**.
|
||||
5. When registering a payment on a vendor bill, select this journal to trigger the cross-company flow.
|
||||
|
||||
## Author
|
||||
- **Suherdy Yacob**
|
||||
On each `Account Journal` (Bank/Cash):
|
||||
- **Is Centralized Payment**: Enable centralized mode
|
||||
- **Parent Company**: The parent company
|
||||
- **Parent Bank Journal**: The parent's actual bank journal
|
||||
- **Parent Inter-Company Account**: The RK account in the parent (Receivable from branch)
|
||||
- **Branch Inter-Company Account**: The RK account in the branch (Liability to parent)
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- **Odoo 17 Compatibility**: Uses mirror account strategy since Odoo 17 `account.account` uses single `company_id`
|
||||
- **Post-init hook**: Automatically clones parent bank/cash accounts and journals to branches
|
||||
- **Uninstall hook**: Removes mirrored accounts that have no journal items
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `point_of_sale`
|
||||
- `account`
|
||||
|
||||
154
__init__.py
154
__init__.py
@ -1,46 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import wizard
|
||||
from odoo import api, SUPERUSER_ID
|
||||
import logging
|
||||
|
||||
def _auto_share_accounts_post_init(env):
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_share_accounts_post_init(cr, registry):
|
||||
"""
|
||||
Automatically link accounts from the Parent Company (ID 2 - OT) to any Branch Company
|
||||
that does not have its own Chart of Accounts.
|
||||
Automatically mirror Bank & Cash accounts from the Parent Company (ID 2)
|
||||
to branch companies that don't have their own Chart of Accounts.
|
||||
|
||||
Also auto-creates Bank journals in each branch for the mirrored accounts.
|
||||
|
||||
Odoo 17 Strategy: Since account.account uses a single company_id (M2O),
|
||||
we CREATE new account records in each branch company with the same code/name
|
||||
as the parent's bank/cash accounts (instead of sharing via M2M in Odoo 19).
|
||||
"""
|
||||
# Link Parent Company (ID 2) accounts to other companies if they don't have their own COA
|
||||
env.cr.execute("""
|
||||
INSERT INTO account_account_res_company_rel (account_id, company_id)
|
||||
SELECT a.id, c.id
|
||||
FROM account_account a
|
||||
CROSS JOIN res_company c
|
||||
WHERE a.company_id = 2
|
||||
AND c.id != 2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM account_account_res_company_rel r
|
||||
WHERE r.account_id = a.id AND r.company_id = c.id
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
parent_company = env['res.company'].browse(2)
|
||||
if not parent_company.exists():
|
||||
_logger.warning("Parent company (ID=2) not found, skipping auto-share.")
|
||||
return
|
||||
|
||||
branch_companies = env['res.company'].search([('id', '!=', 2)])
|
||||
|
||||
# Find parent's bank/cash accounts
|
||||
parent_bank_cash_accounts = env['account.account'].search([
|
||||
('company_id', '=', parent_company.id),
|
||||
('account_type', '=', 'asset_cash'),
|
||||
])
|
||||
|
||||
if not parent_bank_cash_accounts:
|
||||
_logger.info("No bank/cash accounts found in parent company, skipping.")
|
||||
return
|
||||
|
||||
for branch in branch_companies:
|
||||
# Check if branch already has its own accounts
|
||||
existing_branch_accounts = env['account.account'].search([
|
||||
('company_id', '=', branch.id),
|
||||
('account_type', '=', 'asset_cash'),
|
||||
])
|
||||
|
||||
if existing_branch_accounts:
|
||||
_logger.info("Branch %s already has bank/cash accounts, skipping.", branch.name)
|
||||
continue
|
||||
|
||||
# Clone parent's bank/cash accounts to this branch
|
||||
for parent_acc in parent_bank_cash_accounts:
|
||||
# Check if an account with the same code already exists in this branch
|
||||
existing = env['account.account'].search([
|
||||
('company_id', '=', branch.id),
|
||||
('code', '=', parent_acc.code),
|
||||
], limit=1)
|
||||
|
||||
if not existing:
|
||||
env['account.account'].create({
|
||||
'name': parent_acc.name,
|
||||
'code': parent_acc.code,
|
||||
'account_type': parent_acc.account_type,
|
||||
'company_id': branch.id,
|
||||
'reconcile': parent_acc.reconcile,
|
||||
'currency_id': parent_acc.currency_id.id if parent_acc.currency_id else False,
|
||||
'note': "Auto-mirrored from parent company (%s)" % parent_company.name,
|
||||
})
|
||||
_logger.info(
|
||||
"Created mirror account %s (%s) in branch %s",
|
||||
parent_acc.code, parent_acc.name, branch.name
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM account_account a2
|
||||
WHERE a2.company_id = c.id
|
||||
|
||||
# Auto-create bank journals in branch for mirrored accounts
|
||||
parent_bank_journals = env['account.journal'].search([
|
||||
('company_id', '=', parent_company.id),
|
||||
('type', 'in', ('bank', 'cash')),
|
||||
])
|
||||
|
||||
for parent_journal in parent_bank_journals:
|
||||
existing_journal = env['account.journal'].search([
|
||||
('company_id', '=', branch.id),
|
||||
('code', '=', parent_journal.code),
|
||||
], limit=1)
|
||||
|
||||
if not existing_journal:
|
||||
# Find the mirrored account in the branch
|
||||
branch_account = env['account.account'].search([
|
||||
('company_id', '=', branch.id),
|
||||
('code', '=', parent_journal.default_account_id.code),
|
||||
], limit=1)
|
||||
|
||||
if branch_account:
|
||||
env['account.journal'].create({
|
||||
'name': parent_journal.name,
|
||||
'code': parent_journal.code,
|
||||
'type': parent_journal.type,
|
||||
'company_id': branch.id,
|
||||
'default_account_id': branch_account.id,
|
||||
})
|
||||
_logger.info(
|
||||
"Created mirror journal %s (%s) in branch %s",
|
||||
parent_journal.code, parent_journal.name, branch.name
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
# Set a dummy chart template for branches to satisfy Odoo 19 POS validation
|
||||
env.cr.execute("""
|
||||
UPDATE res_company
|
||||
SET chart_template = 'id'
|
||||
WHERE id != 2 AND chart_template IS NULL
|
||||
""")
|
||||
|
||||
def _cleanup_shared_accounts_uninstall(env):
|
||||
def _cleanup_shared_accounts_uninstall(cr, registry):
|
||||
"""
|
||||
Remove the shared account relations upon uninstallation to prevent
|
||||
UserErrors when Odoo's standard company consistency checks are restored.
|
||||
Remove the auto-mirrored accounts upon uninstallation.
|
||||
Only removes accounts tagged with the auto-mirror note and having no journal items.
|
||||
"""
|
||||
env.cr.execute("""
|
||||
DELETE FROM account_account_res_company_rel
|
||||
WHERE company_id != 2
|
||||
AND account_id IN (SELECT id FROM account_account WHERE company_id = 2)
|
||||
""")
|
||||
from . import controllers
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
mirrored_accounts = env['account.account'].search([
|
||||
('company_id', '!=', 2),
|
||||
('note', 'ilike', 'Auto-mirrored from parent company'),
|
||||
('account_type', '=', 'asset_cash'),
|
||||
])
|
||||
|
||||
for acc in mirrored_accounts:
|
||||
# Only delete if no journal items reference this account
|
||||
has_items = env['account.move.line'].search([
|
||||
('account_id', '=', acc.id),
|
||||
], limit=1)
|
||||
if not has_items:
|
||||
_logger.info("Removing mirrored account %s from company %s", acc.code, acc.company_id.name)
|
||||
acc.unlink()
|
||||
else:
|
||||
_logger.warning(
|
||||
"Cannot remove mirrored account %s from company %s: has journal items",
|
||||
acc.code, acc.company_id.name
|
||||
)
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Account Shared Bank Cash',
|
||||
'version': '1.0',
|
||||
'version': '17.0.1.0',
|
||||
'category': 'Accounting',
|
||||
'summary': 'Allows Bank & Cash accounts to be shared between companies',
|
||||
'summary': 'Mirror parent bank/cash accounts to branches with inter-company clearing',
|
||||
'description': """
|
||||
This module removes the standard restriction that prevents Chart of Accounts (COA) of type 'Bank and Cash' from being shared across multiple companies.
|
||||
This module mirrors the parent company's Bank and Cash accounts and journals to branch companies,
|
||||
and provides automated inter-company clearing for POS sessions and centralized vendor payments.
|
||||
""",
|
||||
'author': 'Suherdy Yacob',
|
||||
'depends': ['point_of_sale', 'account'],
|
||||
|
||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/__manifest__.cpython-312.pyc
Normal file
BIN
__pycache__/__manifest__.cpython-312.pyc
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
from . import main
|
||||
@ -1,19 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Minimal call_kw patch for cache busting on user/company writes.
|
||||
The actual company sanitization is done in environments.py directly.
|
||||
"""
|
||||
import logging
|
||||
import odoo.service.model as _service_model
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_orig_call_kw = _service_model.call_kw
|
||||
|
||||
|
||||
def _call_kw_wrapper(model, name, args, kwargs):
|
||||
return _orig_call_kw(model, name, args, kwargs)
|
||||
|
||||
|
||||
_service_model.call_kw = _call_kw_wrapper
|
||||
_logger.info("account_shared_bank_cash: controllers loaded")
|
||||
@ -1,11 +1,5 @@
|
||||
from . import account_account
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import account_journal
|
||||
from . import account_payment
|
||||
from . import ir_http
|
||||
from . import pos_payment_method
|
||||
from . import pos_session
|
||||
from . import pos_order
|
||||
from . import account_move
|
||||
from . import res_company
|
||||
from . import analytic_mixin
|
||||
|
||||
|
||||
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/account_journal.cpython-312.pyc
Normal file
BIN
models/__pycache__/account_journal.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/account_payment.cpython-312.pyc
Normal file
BIN
models/__pycache__/account_payment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/pos_payment_method.cpython-312.pyc
Normal file
BIN
models/__pycache__/pos_payment_method.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/pos_session.cpython-312.pyc
Normal file
BIN
models/__pycache__/pos_session.cpython-312.pyc
Normal file
Binary file not shown.
@ -1,30 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
class AccountAccount(models.Model):
|
||||
_inherit = 'account.account'
|
||||
|
||||
@api.constrains('company_ids', 'account_type')
|
||||
def _check_company_consistency(self):
|
||||
if accounts_without_company := self.filtered(lambda a: not a.sudo().company_ids):
|
||||
raise ValidationError(
|
||||
self.env._(
|
||||
"The following accounts must be assigned to at least one company:\n%(accounts)s",
|
||||
accounts="\n".join(f"- {account.display_name}" for account in accounts_without_company),
|
||||
),
|
||||
)
|
||||
|
||||
# We explicitly REMOVE the check preventing 'asset_cash' accounts from having len(company_ids) > 1
|
||||
# The standard Odoo validation below was removed:
|
||||
# if self.filtered(lambda a: a.account_type == 'asset_cash' and len(a.company_ids) > 1):
|
||||
# raise ValidationError(_("Bank & Cash accounts cannot be shared between companies."))
|
||||
|
||||
# Need to invalidate the sudo cache as we might have just written on `company_ids`
|
||||
self.invalidate_recordset(fnames=['company_ids'])
|
||||
for companies, accounts in self.grouped(lambda a: a.company_ids).items():
|
||||
if self.env['account.move.line'].sudo().search_count([
|
||||
('account_id', 'in', accounts.ids),
|
||||
'!', ('company_id', 'child_of', companies.ids)
|
||||
], limit=1):
|
||||
raise UserError(_("You can't unlink this company from this account since there are some journal items linked to it."))
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class AccountJournal(models.Model):
|
||||
_inherit = 'account.journal'
|
||||
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, api, fields, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
rk_payment_journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='RK Payment Journal',
|
||||
help='The bank/cash journal used for this Hubungan RK process'
|
||||
)
|
||||
|
||||
def _get_last_sequence(self, relaxed=False, with_prefix=None):
|
||||
self.ensure_one()
|
||||
if not self.rk_payment_journal_id:
|
||||
return super()._get_last_sequence(relaxed=relaxed, with_prefix=with_prefix)
|
||||
|
||||
if self._sequence_field not in self._fields or not self._fields[self._sequence_field].store:
|
||||
raise ValidationError(_('%s is not a stored field', self._sequence_field))
|
||||
|
||||
where_string, param = self._get_last_sequence_domain(relaxed)
|
||||
if self._origin.id:
|
||||
where_string += " AND id != %(id)s "
|
||||
param['id'] = self._origin.id
|
||||
|
||||
prefix_base = f"{self.journal_id.code}-{self.rk_payment_journal_id.code}"
|
||||
|
||||
if with_prefix is not None:
|
||||
where_string += " AND sequence_prefix = %(with_prefix)s "
|
||||
param['with_prefix'] = with_prefix
|
||||
else:
|
||||
where_string += " AND sequence_prefix LIKE %(prefix_like)s "
|
||||
param['prefix_like'] = f"{prefix_base}/%"
|
||||
|
||||
query = f"""
|
||||
SELECT {self._sequence_field} FROM {self._table}
|
||||
{where_string}
|
||||
AND sequence_prefix = (SELECT sequence_prefix FROM {self._table} {where_string} ORDER BY id DESC LIMIT 1)
|
||||
ORDER BY sequence_number DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
self.flush_model([self._sequence_field, 'sequence_number', 'sequence_prefix'])
|
||||
self.env.cr.execute(query, param)
|
||||
return (self.env.cr.fetchone() or [None])[0]
|
||||
|
||||
def _get_starting_sequence(self):
|
||||
self.ensure_one()
|
||||
if self.rk_payment_journal_id:
|
||||
move_date = self.date or self.invoice_date or fields.Date.context_today(self)
|
||||
year_part = "%04d" % move_date.year
|
||||
prefix_base = f"{self.journal_id.code}-{self.rk_payment_journal_id.code}"
|
||||
return f"{prefix_base}/{year_part}/{move_date.month:02d}/0000"
|
||||
return super()._get_starting_sequence()
|
||||
|
||||
def action_post(self):
|
||||
res = super().action_post()
|
||||
|
||||
# 1. Handle Centralized entries in Parent when payment moves are posted
|
||||
for move in self:
|
||||
payments = move.payment_ids or move.origin_payment_id
|
||||
if not payments:
|
||||
continue
|
||||
|
||||
centralized_payments = payments.filtered(
|
||||
lambda p: p.journal_id.sudo().is_centralized and
|
||||
p.company_id != p.journal_id.sudo().parent_company_id
|
||||
)
|
||||
|
||||
for payment in centralized_payments:
|
||||
journal_sudo = payment.journal_id.sudo()
|
||||
parent_company = journal_sudo.parent_company_id
|
||||
parent_journal = journal_sudo.parent_journal_id
|
||||
parent_rk_account = journal_sudo.parent_intercompany_account_id
|
||||
|
||||
if not journal_sudo.branch_intercompany_account_id:
|
||||
raise UserError(_("Please configure the Branch Inter-company Account on journal %s", payment.journal_id.name))
|
||||
if not parent_journal or not parent_rk_account:
|
||||
raise UserError(_("Please configure the Parent Journal and RK Account on journal %s", payment.journal_id.name))
|
||||
|
||||
parent_journal_sudo = parent_journal.sudo()
|
||||
parent_rk_account_sudo = parent_rk_account.sudo()
|
||||
|
||||
print(f">>> DEBUG: Centralized Payment for {payment.name}")
|
||||
print(f" Company: {payment.company_id.id}, Parent Co: {parent_company.id}")
|
||||
print(f" Parent Journal: {parent_journal_sudo.name} (ID: {parent_journal_sudo.id})")
|
||||
print(f" Parent RK: {parent_rk_account_sudo.code} (ID: {parent_rk_account_sudo.id})")
|
||||
|
||||
# Calculate currency-adjusted amount for the parent company
|
||||
parent_currency = parent_company.currency_id
|
||||
if payment.currency_id != parent_currency:
|
||||
amount_parent_curr = payment.currency_id._convert(
|
||||
payment.amount, parent_currency, parent_company, payment.date
|
||||
)
|
||||
else:
|
||||
amount_parent_curr = payment.amount
|
||||
|
||||
# Create the mirroring entry in Parent using sudo
|
||||
parent_move = self.env['account.move'].sudo().with_company(parent_company).create({
|
||||
'move_type': 'entry',
|
||||
'date': payment.date,
|
||||
'company_id': parent_company.id,
|
||||
'journal_id': parent_journal.id,
|
||||
'partner_id': payment.company_id.partner_id.id,
|
||||
'currency_id': parent_currency.id,
|
||||
'ref': f"Centralized Payment: {payment.name} ({payment.company_id.name})",
|
||||
'line_ids': [
|
||||
(0, 0, {
|
||||
'name': payment.memo or f"Centralized Payment: {payment.name}",
|
||||
'account_id': parent_rk_account.id,
|
||||
'debit': amount_parent_curr,
|
||||
'credit': 0.0,
|
||||
'partner_id': payment.company_id.partner_id.id,
|
||||
'company_id': parent_company.id,
|
||||
'currency_id': payment.currency_id.id,
|
||||
'amount_currency': payment.amount,
|
||||
'display_type': 'product',
|
||||
}),
|
||||
(0, 0, {
|
||||
'name': payment.memo or f"Centralized Payment: {payment.name}",
|
||||
'account_id': parent_journal_sudo.default_account_id.id,
|
||||
'debit': 0.0,
|
||||
'credit': amount_parent_curr,
|
||||
'partner_id': payment.company_id.partner_id.id,
|
||||
'company_id': parent_company.id,
|
||||
'currency_id': payment.currency_id.id,
|
||||
'amount_currency': -payment.amount,
|
||||
'display_type': 'product',
|
||||
}),
|
||||
]
|
||||
})
|
||||
parent_move.sudo().action_post()
|
||||
print(f" Parent Move Posted: {parent_move.name}")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
@api.model
|
||||
def _search_account_id(self, operator, value):
|
||||
res = super()._search_account_id(operator, value)
|
||||
if res and isinstance(res, list) and len(res) == 1:
|
||||
term = res[0]
|
||||
if len(term) == 3 and term[0] == 'account_id' and term[1] in ('in', 'not in') and isinstance(term[2], (list, tuple, set)):
|
||||
resolved_ids = list(term[2])
|
||||
reconcile_accounts = self.env['account.account'].sudo().browse(resolved_ids).filtered('reconcile')
|
||||
if reconcile_accounts:
|
||||
cash_accounts = self.env['account.account'].sudo().search([('account_type', '=', 'asset_cash')])
|
||||
if cash_accounts:
|
||||
if term[1] == 'in':
|
||||
res = [
|
||||
'|',
|
||||
('account_id', 'in', tuple(reconcile_accounts.ids)),
|
||||
'&',
|
||||
'&',
|
||||
('account_id', 'in', tuple(cash_accounts.ids)),
|
||||
('journal_id.type', '=', 'cash'),
|
||||
('journal_id.company_id', '=', self.env.company.id)
|
||||
]
|
||||
elif term[1] == 'not in':
|
||||
res = [
|
||||
'&',
|
||||
('account_id', 'not in', tuple(reconcile_accounts.ids)),
|
||||
'|',
|
||||
'|',
|
||||
('account_id', 'not in', tuple(cash_accounts.ids)),
|
||||
('journal_id.type', '!=', 'cash'),
|
||||
('journal_id.company_id', '!=', self.env.company.id)
|
||||
]
|
||||
return res
|
||||
|
||||
def _related_analytic_distribution(self):
|
||||
res = super()._related_analytic_distribution()
|
||||
if res:
|
||||
return self.env['analytic.mixin']._clean_analytic_distribution(res)
|
||||
return res
|
||||
@ -1,22 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, api, fields, _
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
def action_post(self):
|
||||
# 1. Capture info before super() because we might need to create extra moves
|
||||
centralized_payments = self.filtered(
|
||||
lambda p: p.journal_id.is_centralized and p.company_id != p.journal_id.parent_company_id
|
||||
)
|
||||
|
||||
# 2. Validate centralized payments before posting
|
||||
for payment in centralized_payments:
|
||||
if not payment.journal_id.branch_intercompany_account_id:
|
||||
raise UserError(
|
||||
_("Please configure the Branch Inter-company Account on journal %s") % payment.journal_id.name
|
||||
)
|
||||
|
||||
# 3. Call super() to post the original payment(s)
|
||||
res = super().action_post()
|
||||
|
||||
# 4. Handle Centralized entries in Parent
|
||||
for payment in centralized_payments:
|
||||
parent_company = payment.journal_id.parent_company_id
|
||||
parent_journal = payment.journal_id.parent_journal_id
|
||||
parent_rk_account = payment.journal_id.parent_intercompany_account_id
|
||||
|
||||
_logger.info(
|
||||
"Centralized Payment for %s | Company: %s -> Parent: %s | Parent Journal: %s | RK: %s",
|
||||
payment.name, payment.company_id.name, parent_company.name,
|
||||
parent_journal.name, parent_rk_account.code
|
||||
)
|
||||
|
||||
if not parent_journal or not parent_rk_account:
|
||||
raise UserError(
|
||||
_("Please configure the Parent Journal and RK Account on journal %s") % payment.journal_id.name
|
||||
)
|
||||
|
||||
# Create the mirroring entry in Parent
|
||||
# Debit: Parent RK (Receivable from Branch)
|
||||
# Credit: Bank
|
||||
parent_move = self.env['account.move'].with_company(parent_company).create({
|
||||
'move_type': 'entry',
|
||||
'date': payment.date,
|
||||
'company_id': parent_company.id,
|
||||
'journal_id': parent_journal.id,
|
||||
'ref': _("Centralized Payment: %s (%s)") % (payment.name, payment.company_id.name),
|
||||
'line_ids': [
|
||||
(0, 0, {
|
||||
'name': payment.ref or _("Centralized Payment: %s") % payment.name,
|
||||
'account_id': parent_rk_account.id,
|
||||
'debit': payment.amount,
|
||||
'partner_id': payment.partner_id.id,
|
||||
'company_id': parent_company.id,
|
||||
}),
|
||||
(0, 0, {
|
||||
'name': payment.ref or _("Centralized Payment: %s") % payment.name,
|
||||
'account_id': parent_journal.default_account_id.id,
|
||||
'credit': payment.amount,
|
||||
'partner_id': payment.partner_id.id,
|
||||
'company_id': parent_company.id,
|
||||
}),
|
||||
]
|
||||
})
|
||||
parent_move.action_post()
|
||||
_logger.info("Parent Move Posted: %s", parent_move.name)
|
||||
|
||||
return res
|
||||
|
||||
def _prepare_move_line_default_vals(self, write_off_line_vals=None, force_balance=None):
|
||||
"""
|
||||
Override to use the Branch RK account for the liquidity line
|
||||
when the payment is centralized.
|
||||
"""
|
||||
res = super()._prepare_move_line_default_vals(write_off_line_vals, force_balance)
|
||||
journal_sudo = self.journal_id.sudo()
|
||||
if journal_sudo.is_centralized and self.company_id != journal_sudo.parent_company_id:
|
||||
# The first line is usually the liquidity line in outbound payments
|
||||
# or the counterpart line. Odoo 17+ uses _seek_for_lines to identify them.
|
||||
# But here we can just update the account if it matches the journal's default.
|
||||
if self.journal_id.is_centralized and self.company_id != self.journal_id.parent_company_id:
|
||||
# Update the account if it matches the journal's default (liquidity account).
|
||||
for line_vals in res:
|
||||
if line_vals.get('account_id') == journal_sudo.default_account_id.id:
|
||||
line_vals['account_id'] = journal_sudo.branch_intercompany_account_id.id
|
||||
if line_vals.get('account_id') == self.journal_id.default_account_id.id:
|
||||
line_vals['account_id'] = self.journal_id.branch_intercompany_account_id.id
|
||||
return res
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, api
|
||||
|
||||
class AnalyticMixin(models.AbstractModel):
|
||||
_inherit = 'analytic.mixin'
|
||||
|
||||
def _sanitize_values(self, vals, decimal_precision):
|
||||
if 'analytic_distribution' in vals:
|
||||
dist = vals.get('analytic_distribution')
|
||||
if dist:
|
||||
if isinstance(dist, str):
|
||||
import json
|
||||
try:
|
||||
dist = json.loads(dist)
|
||||
except Exception:
|
||||
dist = {}
|
||||
vals['analytic_distribution'] = self._clean_analytic_distribution(dist) or False
|
||||
else:
|
||||
vals['analytic_distribution'] = False
|
||||
return super()._sanitize_values(vals, decimal_precision)
|
||||
|
||||
@api.model
|
||||
def _clean_analytic_distribution(self, dist):
|
||||
if not dist or not isinstance(dist, dict):
|
||||
return {}
|
||||
clean_dist = {}
|
||||
for key, val in dist.items():
|
||||
str_key = str(key)
|
||||
if str_key == '__update__':
|
||||
clean_dist[str_key] = val
|
||||
elif str_key:
|
||||
if all(part.isdigit() for part in str_key.split(',')):
|
||||
try:
|
||||
clean_dist[str_key] = float(val)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return clean_dist
|
||||
@ -1,138 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _pre_dispatch(cls, rule, args):
|
||||
"""
|
||||
Sanitize allowed_company_ids in the request session, request environment,
|
||||
and request parameters contexts BEFORE the environment is fully used.
|
||||
This prevents AccessError from environments.py when any of these contexts
|
||||
contain company IDs that are not in the user's authorized list.
|
||||
"""
|
||||
try:
|
||||
if request.env.uid:
|
||||
# Use sudo() to query the user's companies safely, bypassing company restrictions.
|
||||
user_cids = set(request.env.user.sudo()._get_company_ids())
|
||||
default_cid = request.env.user.sudo().company_id.id
|
||||
|
||||
def sanitize_context_cids(context, user_cids, default_cid):
|
||||
if not isinstance(context, dict) or 'allowed_company_ids' not in context:
|
||||
return False
|
||||
cids = context['allowed_company_ids']
|
||||
if not isinstance(cids, list):
|
||||
return False
|
||||
valid_cids = [c for c in cids if c in user_cids]
|
||||
if not valid_cids:
|
||||
valid_cids = [default_cid]
|
||||
if valid_cids != cids:
|
||||
_logger.warning(
|
||||
"Sanitized allowed_company_ids in context from %s to %s for user %s",
|
||||
cids, valid_cids, request.env.uid,
|
||||
)
|
||||
context['allowed_company_ids'] = valid_cids
|
||||
return True
|
||||
return False
|
||||
|
||||
# Sanitize session context
|
||||
if getattr(request, 'session', None) and getattr(request.session, 'context', None):
|
||||
sanitize_context_cids(request.session.context, user_cids, default_cid)
|
||||
|
||||
# Sanitize request.env.context
|
||||
env_context = dict(request.env.context)
|
||||
if sanitize_context_cids(env_context, user_cids, default_cid):
|
||||
request.update_context(**env_context)
|
||||
|
||||
# Sanitize request params root context
|
||||
if isinstance(getattr(request, 'params', None), dict):
|
||||
root_context = request.params.get('context')
|
||||
if isinstance(root_context, dict):
|
||||
sanitize_context_cids(root_context, user_cids, default_cid)
|
||||
|
||||
# Sanitize request params kwargs context
|
||||
kwargs = request.params.get('kwargs')
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs_context = kwargs.get('context')
|
||||
if isinstance(kwargs_context, dict):
|
||||
sanitize_context_cids(kwargs_context, user_cids, default_cid)
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"IrHttp: could not sanitize company context in _pre_dispatch: %s", e,
|
||||
)
|
||||
# Roll back the aborted transaction so subsequent DB queries in this
|
||||
# request can still execute. Without this PostgreSQL keeps the
|
||||
# connection in an InFailedSqlTransaction state and rejects every
|
||||
# further query in the same request.
|
||||
try:
|
||||
request.env.cr.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return super()._pre_dispatch(rule, args)
|
||||
|
||||
@classmethod
|
||||
def _dispatch(cls, endpoint):
|
||||
"""
|
||||
Sanitize allowed_company_ids in the request parameters (request.params)
|
||||
context AFTER they have been populated by the JSON-RPC dispatcher but
|
||||
BEFORE the controller/model method is executed.
|
||||
"""
|
||||
try:
|
||||
if request.env.uid:
|
||||
# Use sudo() to query the user's companies safely, bypassing company restrictions.
|
||||
user_cids = set(request.env.user.sudo()._get_company_ids())
|
||||
default_cid = request.env.user.sudo().company_id.id
|
||||
|
||||
def sanitize_context_cids(context, user_cids, default_cid):
|
||||
if not isinstance(context, dict) or 'allowed_company_ids' not in context:
|
||||
return False
|
||||
cids = context['allowed_company_ids']
|
||||
if not isinstance(cids, list):
|
||||
return False
|
||||
valid_cids = [c for c in cids if c in user_cids]
|
||||
if not valid_cids:
|
||||
valid_cids = [default_cid]
|
||||
if valid_cids != cids:
|
||||
_logger.warning(
|
||||
"Sanitized allowed_company_ids in _dispatch from %s to %s for user %s",
|
||||
cids, valid_cids, request.env.uid,
|
||||
)
|
||||
context['allowed_company_ids'] = valid_cids
|
||||
return True
|
||||
return False
|
||||
|
||||
# Sanitize request params root context
|
||||
if isinstance(getattr(request, 'params', None), dict):
|
||||
root_context = request.params.get('context')
|
||||
if isinstance(root_context, dict):
|
||||
sanitize_context_cids(root_context, user_cids, default_cid)
|
||||
|
||||
# Sanitize request params kwargs context
|
||||
kwargs = request.params.get('kwargs')
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs_context = kwargs.get('context')
|
||||
if isinstance(kwargs_context, dict):
|
||||
sanitize_context_cids(kwargs_context, user_cids, default_cid)
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"IrHttp: could not sanitize company context in _dispatch: %s", e,
|
||||
)
|
||||
# Roll back the aborted transaction so subsequent DB queries in this
|
||||
# request can still execute. Without this PostgreSQL keeps the
|
||||
# connection in an InFailedSqlTransaction state and rejects every
|
||||
# further query in the same request.
|
||||
try:
|
||||
request.env.cr.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return super()._dispatch(endpoint)
|
||||
@ -1,14 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models
|
||||
|
||||
|
||||
class PosOrder(models.Model):
|
||||
_inherit = 'pos.order'
|
||||
|
||||
def _process_saved_order(self, draft):
|
||||
"""
|
||||
Escalate to superuser during saved order post-processing (picking creation,
|
||||
cost computation, etc.). This avoids multi-company access/environment checks
|
||||
when POS cashier handles cross-company or company-dependent properties.
|
||||
"""
|
||||
return super(PosOrder, self.sudo())._process_saved_order(draft)
|
||||
@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, Command
|
||||
from odoo import models, fields, api, Command, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PosSession(models.Model):
|
||||
_inherit = 'pos.session'
|
||||
|
||||
@ -12,7 +13,7 @@ class PosSession(models.Model):
|
||||
res = super(PosSession, self)._validate_session(balancing_account, amount_to_balance, bank_payment_method_diffs)
|
||||
|
||||
# After the standard validation and account move creation, we create the inter-company clearing moves
|
||||
self.sudo()._create_intercompany_clearing_moves()
|
||||
self._create_intercompany_clearing_moves()
|
||||
|
||||
return res
|
||||
|
||||
@ -47,13 +48,15 @@ class PosSession(models.Model):
|
||||
|
||||
# Manually handle intercompany ones: create the line in main move but skip account.payment
|
||||
for pm, amounts in intercompany_combine.items():
|
||||
combine_receivable_line = MoveLine.create(self._get_combine_receivable_vals(pm, amounts['amount'], amounts['amount_converted']))
|
||||
combine_receivable_line = MoveLine.create(
|
||||
self._get_combine_receivable_vals(pm, amounts['amount'], amounts['amount_converted'])
|
||||
)
|
||||
res_data['payment_method_to_receivable_lines'][pm] = combine_receivable_line
|
||||
|
||||
return res_data
|
||||
|
||||
def _create_intercompany_clearing_moves(self):
|
||||
for session in self.sudo():
|
||||
for session in self:
|
||||
if session.state != 'closed' or not session.move_id:
|
||||
continue
|
||||
|
||||
@ -65,8 +68,7 @@ class PosSession(models.Model):
|
||||
|
||||
for order in orders:
|
||||
for payment in order.payment_ids:
|
||||
pm_id = payment.payment_method_id.id
|
||||
pm = self.env['pos.payment.method'].sudo().browse(pm_id)
|
||||
pm = payment.payment_method_id
|
||||
if pm.intercompany_clearing_account_id and pm.intercompany_clearing_journal_id:
|
||||
if pm not in clearing_amounts:
|
||||
clearing_amounts[pm] = 0.0
|
||||
@ -98,7 +100,8 @@ class PosSession(models.Model):
|
||||
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)
|
||||
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
|
||||
@ -126,32 +129,32 @@ class PosSession(models.Model):
|
||||
continue
|
||||
|
||||
line_ids = []
|
||||
for key, data in aggregated_data.items():
|
||||
for key, agg_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'],
|
||||
'name': _("Total Clearing - %s") % session.name,
|
||||
'account_id': agg_data['receivable_account'].id,
|
||||
'credit': agg_data['total_company_curr'],
|
||||
'debit': 0.0,
|
||||
'currency_id': session.currency_id.id,
|
||||
'amount_currency': -data['total_amount'],
|
||||
'amount_currency': -agg_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,
|
||||
'name': _("Total Due from Parent - %s") % session.name,
|
||||
'account_id': agg_data['intercompany_account'].id,
|
||||
'credit': 0.0,
|
||||
'debit': data['total_company_curr'],
|
||||
'debit': agg_data['total_company_curr'],
|
||||
'currency_id': session.currency_id.id,
|
||||
'amount_currency': data['total_amount'],
|
||||
'amount_currency': agg_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}",
|
||||
'ref': _("Inter-company clearing for %s") % session.name,
|
||||
'move_type': 'entry',
|
||||
'company_id': session.company_id.id,
|
||||
'line_ids': line_ids,
|
||||
@ -159,12 +162,12 @@ class PosSession(models.Model):
|
||||
|
||||
try:
|
||||
clearing_move = self.env['account.move'].sudo().with_company(session.company_id).create(move_vals)
|
||||
clearing_move.sudo()._post()
|
||||
clearing_move._post()
|
||||
|
||||
# 1. Reconcile aggregated lines with session move
|
||||
for key, data in aggregated_data.items():
|
||||
receivable_account = data['receivable_account']
|
||||
pms = data['pms']
|
||||
for key, agg_data in aggregated_data.items():
|
||||
receivable_account = agg_data['receivable_account']
|
||||
pm_list = agg_data['pms']
|
||||
|
||||
try:
|
||||
# Find the aggregated credit line
|
||||
@ -172,31 +175,35 @@ class PosSession(models.Model):
|
||||
lambda l: l.account_id == receivable_account and l.credit > 0
|
||||
)
|
||||
# Find all matching debit lines in the session move
|
||||
# Odoo 19 names these lines as "SessionName - PMName"
|
||||
# Odoo 17 names these lines as "SessionName - PMName"
|
||||
pos_debit_lines = session.move_id.line_ids.filtered(
|
||||
lambda l: l.account_id == receivable_account and l.debit > 0 and \
|
||||
any(l.name.endswith(f" - {pm.name}") for pm in pms)
|
||||
lambda l: l.account_id == receivable_account and l.debit > 0 and
|
||||
any(l.name.endswith(" - %s" % pm.name) for pm in pm_list)
|
||||
)
|
||||
|
||||
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)
|
||||
_logger.warning(
|
||||
"Could not auto-reconcile aggregated clearing lines for session %s: %s",
|
||||
session.name, re_e
|
||||
)
|
||||
|
||||
# 2. Create parent mirror move (aggregated into one entry but separate lines per PM)
|
||||
# 2. Create parent mirror move
|
||||
self._create_aggregated_parent_mirror_move(session, pm_level_data, clearing_move)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create/post aggregated inter-company clearing move for session %s: %s", session.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}")
|
||||
('ref', '=', _("Inter-company clearing for %s") % session.name)
|
||||
])
|
||||
res |= clearing_moves
|
||||
return res
|
||||
@ -225,21 +232,23 @@ class PosSession(models.Model):
|
||||
continue
|
||||
|
||||
outstanding_receipt_account = None
|
||||
for pml in parent_bank_journal.sudo().inbound_payment_method_line_ids:
|
||||
if pml.payment_account_id or pml.default_account_id:
|
||||
outstanding_receipt_account = pml.payment_account_id or pml.default_account_id
|
||||
for pml in parent_bank_journal.inbound_payment_method_line_ids:
|
||||
if pml.payment_account_id:
|
||||
outstanding_receipt_account = pml.payment_account_id
|
||||
break
|
||||
|
||||
if not outstanding_receipt_account:
|
||||
_logger.warning("No outstanding receipt account found on parent bank journal %s. Skipping PM %s.", parent_bank_journal.name, pm.name)
|
||||
_logger.warning(
|
||||
"No outstanding receipt account found on parent bank journal %s. Skipping PM %s.",
|
||||
parent_bank_journal.name, pm.name
|
||||
)
|
||||
continue
|
||||
|
||||
group_key = (parent_company.id, parent_clearing_journal.id, parent_bank_journal.id)
|
||||
group_key = (parent_company.id, parent_clearing_journal.id)
|
||||
if group_key not in parent_groups:
|
||||
parent_groups[group_key] = {
|
||||
'parent_company': parent_company,
|
||||
'parent_clearing_journal': parent_clearing_journal,
|
||||
'parent_bank_journal': parent_bank_journal,
|
||||
'lines_data': []
|
||||
}
|
||||
|
||||
@ -256,7 +265,6 @@ class PosSession(models.Model):
|
||||
for group_key, group_data in parent_groups.items():
|
||||
parent_company = group_data['parent_company']
|
||||
parent_clearing_journal = group_data['parent_clearing_journal']
|
||||
parent_bank_journal = group_data['parent_bank_journal']
|
||||
|
||||
line_ids = []
|
||||
|
||||
@ -273,21 +281,19 @@ class PosSession(models.Model):
|
||||
amount_parent_curr = amount
|
||||
|
||||
line_ids.append(Command.create({
|
||||
'name': f"POS Receipt: {pm.name} ({session.company_id.name})",
|
||||
'name': _("POS Receipt: %s (%s)") % (pm.name, session.company_id.name),
|
||||
'account_id': line_data['outstanding_receipt_account'].id,
|
||||
'debit': amount_parent_curr,
|
||||
'credit': 0.0,
|
||||
'partner_id': session.company_id.partner_id.id,
|
||||
'currency_id': session.currency_id.id,
|
||||
'amount_currency': amount,
|
||||
}))
|
||||
|
||||
line_ids.append(Command.create({
|
||||
'name': f"Due to Branch: {pm.name} ({session.company_id.name})",
|
||||
'name': _("Due to Branch: %s (%s)") % (pm.name, session.company_id.name),
|
||||
'account_id': line_data['parent_rk_account'].id,
|
||||
'debit': 0.0,
|
||||
'credit': amount_parent_curr,
|
||||
'partner_id': session.company_id.partner_id.id,
|
||||
'currency_id': session.currency_id.id,
|
||||
'amount_currency': -amount,
|
||||
}))
|
||||
@ -298,17 +304,15 @@ class PosSession(models.Model):
|
||||
parent_move_vals = {
|
||||
'journal_id': parent_clearing_journal.id,
|
||||
'date': entry_date,
|
||||
'ref': f"POS Mirror: {session.name} - {session.company_id.name}",
|
||||
'ref': _("POS Mirror: %s - %s") % (session.name, session.company_id.name),
|
||||
'move_type': 'entry',
|
||||
'company_id': parent_company.id,
|
||||
'partner_id': session.company_id.partner_id.id,
|
||||
'rk_payment_journal_id': parent_bank_journal.id,
|
||||
'line_ids': line_ids,
|
||||
}
|
||||
|
||||
try:
|
||||
parent_move = self.env['account.move'].sudo().with_company(parent_company).create(parent_move_vals)
|
||||
parent_move.sudo()._post()
|
||||
parent_move._post()
|
||||
_logger.info(
|
||||
"Created aggregated parent mirror entry %s in company %s for session %s (Lines: %s)",
|
||||
parent_move.name, parent_company.name, session.name, len(line_ids)
|
||||
@ -318,12 +322,3 @@ class PosSession(models.Model):
|
||||
"Failed to create aggregated parent mirror entry for session %s in company %s: %s",
|
||||
session.name, parent_company.name, e
|
||||
)
|
||||
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
@api.model
|
||||
def _create_picking_from_pos_order_lines(self, location_dest_id, lines, picking_type, partner=False):
|
||||
return super()._create_picking_from_pos_order_lines(location_dest_id, lines.sudo(), picking_type, partner)
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
compute='_compute_company_id',
|
||||
search='_search_company_id'
|
||||
)
|
||||
|
||||
@api.depends()
|
||||
def _compute_company_id(self):
|
||||
for company in self:
|
||||
company.company_id = company.id
|
||||
|
||||
def _search_company_id(self, operator, value):
|
||||
return [('id', operator, value)]
|
||||
@ -1,26 +0,0 @@
|
||||
|
||||
import sys
|
||||
|
||||
# Get the last session
|
||||
session = env['pos.session'].search([('state', '=', 'closed')], order='id desc', limit=1)
|
||||
print(f"Session: {session.name} (ID {session.id})")
|
||||
|
||||
# Check main POS move
|
||||
print(f"Main POS Move: {session.move_id.name} (ID {session.move_id.id})")
|
||||
for line in session.move_id.line_ids:
|
||||
print(f" Line: {line.name} | Account: {line.account_id.code} | Debit: {line.debit} | Credit: {line.credit} | Reconciled: {line.reconciled}")
|
||||
|
||||
# Check for Bank Journal moves (these should NOT exist for intercompany PMs)
|
||||
# Bank journal moves usually have payment_method_id set on account.payment
|
||||
payments = env['account.payment'].search([('pos_session_id', '=', session.id)])
|
||||
print(f"Payment moves found: {len(payments)}")
|
||||
for p in payments:
|
||||
print(f" Payment: {p.name} | Journal: {p.journal_id.name} | Amount: {p.amount}")
|
||||
|
||||
# Check for Inter-company clearing moves
|
||||
clearing_moves = env['account.move'].search([('ref', 'like', f"Inter-company clearing for {session.name}%")])
|
||||
print(f"Clearing moves found: {len(clearing_moves)}")
|
||||
for move in clearing_moves:
|
||||
print(f"Move: {move.name} | Ref: {move.ref} | State: {move.state}")
|
||||
for line in move.line_ids:
|
||||
print(f" Line: {line.name} | Account: {line.account_id.code} {line.account_id.name} | Debit: {line.debit} | Credit: {line.credit} | Reconciled: {line.reconciled}")
|
||||
@ -1,53 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test script: Verify aggregation to 2 lines on branch side.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
COMPANY_NAME = "Mie Mapan Rungkut Mapan"
|
||||
POS_CONFIG_NAME = "POS Rungkut"
|
||||
|
||||
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)
|
||||
|
||||
# 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()
|
||||
@ -1,27 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_account_journal_form_inherit_centralized" model="ir.ui.view">
|
||||
<field name="name">account.journal.form.inherit.centralized</field>
|
||||
<record id="view_account_journal_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account.journal.form.inherit.shared.bank</field>
|
||||
<field name="model">account.journal</field>
|
||||
<field name="inherit_id" ref="account.view_account_journal_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='bank_account']" position="after">
|
||||
<page name="centralized_payment" string="Centralized Payment" invisible="type not in ('bank', 'cash')">
|
||||
<page name="centralized_payment" string="Centralized Payment">
|
||||
<group>
|
||||
<group string="Configuration">
|
||||
<field name="is_centralized"/>
|
||||
<field name="parent_company_id" invisible="not is_centralized" required="is_centralized"/>
|
||||
<field name="parent_journal_id" invisible="not is_centralized" required="is_centralized"/>
|
||||
</group>
|
||||
<group string="Inter-Company Accounts" invisible="not is_centralized">
|
||||
<field name="branch_intercompany_account_id" required="is_centralized"/>
|
||||
<field name="parent_intercompany_account_id" required="is_centralized"/>
|
||||
<group attrs="{'invisible': [('is_centralized', '=', False)]}">
|
||||
<group string="Parent Company Settings">
|
||||
<field name="parent_company_id"
|
||||
attrs="{'required': [('is_centralized', '=', True)]}"/>
|
||||
<field name="parent_journal_id"
|
||||
attrs="{'required': [('is_centralized', '=', True)]}"/>
|
||||
</group>
|
||||
<group string="Inter-Company (RK) Accounts">
|
||||
<field name="parent_intercompany_account_id"
|
||||
attrs="{'required': [('is_centralized', '=', True)]}"/>
|
||||
<field name="branch_intercompany_account_id"
|
||||
attrs="{'required': [('is_centralized', '=', True)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
<p class="text-muted" invisible="not is_centralized">
|
||||
When this journal is used to pay bills in the current company, the actual bank payment will be recorded in the parent company,
|
||||
and an inter-company clearing entry will be created in the current company.
|
||||
</p>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@ -1,26 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="pos_payment_method_view_form_inherit_intercompany" model="ir.ui.view">
|
||||
<field name="name">pos.payment.method.form.inherit.intercompany</field>
|
||||
<record id="view_pos_payment_method_form_inherit" model="ir.ui.view">
|
||||
<field name="name">pos.payment.method.form.inherit.shared.bank</field>
|
||||
<field name="model">pos.payment.method</field>
|
||||
<field name="inherit_id" ref="point_of_sale.pos_payment_method_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='Payment methods']" position="after">
|
||||
<group name="intercompany_clearing" string="Inter-Company Clearing (Branch Side)">
|
||||
<group name="intercompany_clearing" string="Inter-Company Clearing">
|
||||
<group>
|
||||
<field name="intercompany_clearing_account_id"/>
|
||||
<field name="intercompany_clearing_journal_id"
|
||||
invisible="not intercompany_clearing_account_id"
|
||||
required="intercompany_clearing_account_id"/>
|
||||
attrs="{'required': [('intercompany_clearing_account_id', '!=', False)]}"/>
|
||||
</group>
|
||||
<group name="parent_clearing" string="Inter-Company Clearing (Parent Side)"
|
||||
invisible="not intercompany_clearing_account_id">
|
||||
</group>
|
||||
<group name="parent_mirror_entry" string="Parent Mirror Entry"
|
||||
attrs="{'invisible': [('intercompany_clearing_account_id', '=', False)]}">
|
||||
<group>
|
||||
<field name="parent_company_id"/>
|
||||
<field name="parent_bank_journal_id"
|
||||
required="intercompany_clearing_account_id"/>
|
||||
attrs="{'required': [('parent_company_id', '!=', False)]}"/>
|
||||
<field name="parent_intercompany_account_id"
|
||||
required="intercompany_clearing_account_id"/>
|
||||
attrs="{'required': [('parent_company_id', '!=', False)]}"/>
|
||||
<field name="parent_clearing_journal_id"
|
||||
required="intercompany_clearing_account_id"/>
|
||||
attrs="{'required': [('parent_company_id', '!=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
wizard/__pycache__/account_payment_register.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/account_payment_register.cpython-312.pyc
Normal file
Binary file not shown.
@ -2,12 +2,16 @@
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountPaymentRegister(models.TransientModel):
|
||||
_inherit = 'account.payment.register'
|
||||
|
||||
def _create_payments(self):
|
||||
# Intercept centralized payments
|
||||
"""Intercept centralized payments to create inter-company clearing moves."""
|
||||
if self.journal_id.is_centralized:
|
||||
return self._create_centralized_payments()
|
||||
return super(AccountPaymentRegister, self)._create_payments()
|
||||
@ -15,42 +19,47 @@ class AccountPaymentRegister(models.TransientModel):
|
||||
def _create_centralized_payments(self):
|
||||
"""
|
||||
Custom logic to create inter-company clearing moves when paying via a centralized journal.
|
||||
|
||||
Branch side: Creates a clearing move (Debit Payable, Credit RK)
|
||||
Parent side: Creates an account.payment (Debit RK, Credit Bank)
|
||||
"""
|
||||
journal = self.journal_id
|
||||
if not journal.parent_company_id or not journal.parent_journal_id:
|
||||
raise UserError(_("The selected journal is marked as centralized but is missing parent company/journal configuration."))
|
||||
raise UserError(
|
||||
_("The selected journal is marked as centralized but is missing parent company/journal configuration.")
|
||||
)
|
||||
|
||||
if not journal.branch_intercompany_account_id or not journal.parent_intercompany_account_id:
|
||||
raise UserError(_("Inter-company (RK) accounts must be configured on the centralized journal."))
|
||||
raise UserError(
|
||||
_("Inter-company (RK) accounts must be configured on the centralized journal.")
|
||||
)
|
||||
|
||||
branch_company = self.company_id
|
||||
parent_company = journal.parent_company_id
|
||||
|
||||
payments = self.env['account.payment']
|
||||
|
||||
# We process batches. In centralized mode, we usually expect 1 batch if coming from a single bill.
|
||||
# But we handle multiple if needed.
|
||||
for batch_result in self.batches:
|
||||
# Use _get_batches() - Odoo 17 method (not self.batches property)
|
||||
all_batches = self._get_batches()
|
||||
|
||||
for batch_result in all_batches:
|
||||
lines = batch_result['lines']
|
||||
amount = abs(sum(lines.mapped('amount_residual')))
|
||||
if self.currency_id != branch_company.currency_id:
|
||||
amount = abs(sum(lines.mapped('amount_residual_currency')))
|
||||
|
||||
# Currency logic: debit/credit in company currency, amount_currency in foreign currency.
|
||||
amount_company_curr = abs(sum(lines.mapped('amount_residual')))
|
||||
amount_currency = 0.0
|
||||
currency_id = False
|
||||
|
||||
if self.currency_id != branch_company.currency_id:
|
||||
amount_currency = sum(lines.mapped('amount_residual_currency'))
|
||||
currency_id = self.currency_id.id
|
||||
|
||||
# 1. Create Clearing Move in Branch Company
|
||||
# Debit: Payable Account (clears the vendor bill)
|
||||
# Credit: Inter-company (RK) Account (Liability to Parent)
|
||||
|
||||
# Currency logic: Odoo 19 requires debit/credit in company currency
|
||||
# and amount_currency in foreign/company currency.
|
||||
amount_company_curr = abs(sum(lines.mapped('amount_residual')))
|
||||
|
||||
# Fallback to the company currency if no foreign currency is used
|
||||
currency_id = self.currency_id.id or branch_company.currency_id.id
|
||||
if self.currency_id != branch_company.currency_id:
|
||||
amount_currency = abs(sum(lines.mapped('amount_residual_currency')))
|
||||
else:
|
||||
amount_currency = amount_company_curr
|
||||
|
||||
clearing_move_vals = {
|
||||
'move_type': 'entry',
|
||||
'company_id': branch_company.id,
|
||||
@ -68,7 +77,7 @@ class AccountPaymentRegister(models.TransientModel):
|
||||
'amount_currency': amount_currency if self.payment_type == 'outbound' else -amount_currency,
|
||||
}),
|
||||
(0, 0, {
|
||||
'name': _("Due to Parent (%s)") % parent_company.sudo().name,
|
||||
'name': _("Due to Parent (%s)") % parent_company.name,
|
||||
'partner_id': False,
|
||||
'account_id': journal.branch_intercompany_account_id.id,
|
||||
'debit': amount_company_curr if self.payment_type == 'inbound' else 0.0,
|
||||
@ -97,13 +106,16 @@ class AccountPaymentRegister(models.TransientModel):
|
||||
'amount': abs(amount_currency) if currency_id else amount_company_curr,
|
||||
'currency_id': self.currency_id.id,
|
||||
'date': self.payment_date,
|
||||
'memo': _("Centralized Pay for %s (%s)") % (branch_company.name, ", ".join(lines.move_id.mapped('name'))),
|
||||
'ref': _("Centralized Pay for %s (%s)") % (
|
||||
branch_company.name,
|
||||
", ".join(lines.move_id.mapped('name'))
|
||||
),
|
||||
'destination_account_id': journal.parent_intercompany_account_id.id,
|
||||
}
|
||||
|
||||
# Create payment in parent company context
|
||||
parent_payment = self.env['account.payment'].sudo().with_company(parent_company).create(payment_vals)
|
||||
parent_payment.sudo().action_post()
|
||||
parent_payment = self.env['account.payment'].with_company(parent_company).sudo().create(payment_vals)
|
||||
parent_payment.action_post()
|
||||
|
||||
payments |= parent_payment
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user