Compare commits
40 Commits
fusion_acc
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068a654c2b | ||
|
|
71f39c8d33 | ||
|
|
125f48377a | ||
|
|
a730942d24 | ||
|
|
aab4b5e958 | ||
|
|
c8ca37099b | ||
|
|
d36933d7f4 | ||
|
|
1817f63c67 | ||
|
|
1ebff01d35 | ||
|
|
ff6d21a561 | ||
|
|
6896c71b79 | ||
|
|
111792599c | ||
|
|
679dbaa979 | ||
|
|
b15bf2293e | ||
|
|
9d8db0f9b1 | ||
|
|
ef2ccb89cf | ||
|
|
51d8ce494d | ||
|
|
190c296240 | ||
|
|
12fa20c4f1 | ||
|
|
b834ae3117 | ||
|
|
b85e208856 | ||
|
|
e3001b5297 | ||
|
|
97c733b7c3 | ||
|
|
94eb7ef415 | ||
|
|
3f807d0152 | ||
|
|
842efd828c | ||
|
|
2476961f50 | ||
|
|
6b4b0c9eb7 | ||
|
|
31bd8d1e56 | ||
|
|
d437d1d959 | ||
|
|
43a26b6849 | ||
|
|
059276886d | ||
|
|
9642a07306 | ||
|
|
f55022c3d6 | ||
|
|
f0c3661277 | ||
|
|
6fa4140d11 | ||
|
|
e34c1bcc8d | ||
|
|
95db3aff0f | ||
|
|
9423a93961 | ||
|
|
057157587d |
@@ -140,7 +140,11 @@ class TestFollowupAdapter(TransactionCase):
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAssetsAdapter(TransactionCase):
|
||||
def test_list_assets_returns_list(self):
|
||||
def test_list_assets_returns_dict_with_assets(self):
|
||||
# Phase 3 (fusion_accounting_assets) wired list_assets to return
|
||||
# {count, total, assets} — consistent with bank_rec.list_unreconciled etc.
|
||||
adapter = get_adapter(self.env, 'assets')
|
||||
rows = adapter.list_assets()
|
||||
self.assertIsInstance(rows, list)
|
||||
self.assertIsInstance(rows, dict)
|
||||
self.assertIn('assets', rows)
|
||||
self.assertIsInstance(rows['assets'], list)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "variables";
|
||||
// Variables come from _variables.scss via manifest concatenation order.
|
||||
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
|
||||
|
||||
.o_fusion_assets {
|
||||
background: $asset-bg-secondary;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "variables";
|
||||
// Variables come from _variables.scss via manifest concatenation order.
|
||||
|
||||
[data-color-scheme="dark"] .o_fusion_assets {
|
||||
background: #1f2937; color: #f9fafb;
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/assets";
|
||||
|
||||
export class AssetsService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.rpc = services.rpc;
|
||||
// V19: rpc is a standalone import, not a service.
|
||||
this.rpc = rpc;
|
||||
this.notification = services.notification;
|
||||
|
||||
this.state = reactive({
|
||||
@@ -142,7 +144,7 @@ export class AssetsService {
|
||||
}
|
||||
|
||||
export const assetsService = {
|
||||
dependencies: ["rpc", "notification"],
|
||||
dependencies: ["notification"],
|
||||
start(env, services) { return new AssetsService(env, services); },
|
||||
};
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ class FusionMigrationWizard(models.TransientModel):
|
||||
Phase 0) and then runs the bank-rec bootstrap. Returns a
|
||||
notification summarizing both.
|
||||
"""
|
||||
_ = super().action_run_migration()
|
||||
# Don't bind super()'s return value to `_` \u2014 that shadows the
|
||||
# imported translation function and breaks the _("...") calls below.
|
||||
super().action_run_migration()
|
||||
result = self._bank_rec_bootstrap_step()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "variables";
|
||||
// Variables come from _variables.scss via manifest concatenation order.
|
||||
|
||||
// ============================================================
|
||||
// AI Suggestion strip (inline, on each statement line card)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "variables";
|
||||
// Variables come from _variables.scss via manifest concatenation order.
|
||||
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
|
||||
|
||||
// ============================================================
|
||||
// Bank reconciliation kanban container
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@import "variables";
|
||||
|
||||
// Variables come from _variables.scss via manifest concatenation order.
|
||||
// Activated via [data-color-scheme="dark"] on body or any ancestor.
|
||||
// Mirrors Odoo's standard dark-mode trigger pattern.
|
||||
|
||||
|
||||
@@ -14,13 +14,15 @@ import { registry } from "@web/core/registry";
|
||||
import { reactive, useState, EventBus } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/bank_rec";
|
||||
|
||||
export class BankReconciliationService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.rpc = services.rpc;
|
||||
// V19: rpc is no longer a service — imported as a standalone function above.
|
||||
this.rpc = rpc;
|
||||
this.notification = services.notification;
|
||||
this.orm = services.orm;
|
||||
|
||||
@@ -400,7 +402,7 @@ export class BankReconciliationService {
|
||||
}
|
||||
|
||||
export const bankReconciliationService = {
|
||||
dependencies: ["rpc", "notification", "orm"],
|
||||
dependencies: ["notification", "orm"],
|
||||
start(env, services) {
|
||||
return new BankReconciliationService(env, services);
|
||||
},
|
||||
|
||||
@@ -29,30 +29,50 @@ def make_bank_journal(env, *, name='Test Bank', code=None):
|
||||
|
||||
|
||||
def make_bank_statement(env, *, journal=None, name='Test Statement', date_=None):
|
||||
"""Create a bank statement. Auto-creates a bank journal if not provided."""
|
||||
"""Create a bank statement.
|
||||
|
||||
NOTE: in V19 Community, ``account.bank.statement.journal_id`` is a
|
||||
read-only computed field derived from ``line_ids.journal_id`` — direct
|
||||
writes are silently dropped. Enterprise's ``account_accountant`` used to
|
||||
override this to make it writable; without Enterprise we have to derive
|
||||
the journal from a line. We attach a single token line at create time
|
||||
(later removed/replaced by the test) to bootstrap the journal.
|
||||
"""
|
||||
journal = journal or make_bank_journal(env)
|
||||
return env['account.bank.statement'].create({
|
||||
'name': name,
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
'line_ids': [(0, 0, {
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
'payment_ref': 'Statement bootstrap line',
|
||||
'amount': 0.0,
|
||||
})],
|
||||
})
|
||||
|
||||
|
||||
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
|
||||
partner=None, memo='Test line', date_=None):
|
||||
"""Create a bank statement line. Creates statement if not provided.
|
||||
"""Create a bank statement line. Creates a journal (and optionally a
|
||||
statement) if not provided.
|
||||
|
||||
Most-common factory in tests. Defaults give a $100 line with no partner."""
|
||||
if not statement:
|
||||
statement = make_bank_statement(env, journal=journal, date_=date_)
|
||||
return env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': statement.journal_id.id,
|
||||
In V19 Community, lines can exist standalone — a statement is not
|
||||
required. We create one only if the test explicitly passes ``statement=``.
|
||||
"""
|
||||
if statement and not journal:
|
||||
journal = statement.journal_id
|
||||
if not journal:
|
||||
journal = make_bank_journal(env)
|
||||
vals = {
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
'payment_ref': memo,
|
||||
'amount': amount,
|
||||
'partner_id': partner.id if partner else False,
|
||||
})
|
||||
}
|
||||
if statement:
|
||||
vals['statement_id'] = statement.id
|
||||
return env['account.bank.statement.line'].create(vals)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
2
fusion_accounting_documents/__init__.py
Normal file
2
fusion_accounting_documents/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizards
|
||||
48
fusion_accounting_documents/__manifest__.py
Normal file
48
fusion_accounting_documents/__manifest__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Documents Bridge',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'Bridges the Documents app to Accounting: route scanned bills into vendor invoices.',
|
||||
'description': """
|
||||
Fusion Accounting — Documents Bridge
|
||||
====================================
|
||||
|
||||
A Fusion-native replacement for Enterprise's ``documents_account`` module.
|
||||
|
||||
Adds:
|
||||
|
||||
- ``documents.document.move_id`` — Many2one to the linked accounting move.
|
||||
- ``documents.document.is_invoice_candidate`` — computed flag for PDFs/images
|
||||
not yet linked to a move.
|
||||
- ``documents.document.action_create_invoice()`` — opens a wizard that
|
||||
creates a draft vendor bill and copies the document's binary as an
|
||||
attachment on the new ``account.move``.
|
||||
- ``account.move.source_document_ids`` — reverse linkage with a stat button
|
||||
on the invoice form.
|
||||
- A ``fusion.create.invoice.from.document.wizard`` model + form view.
|
||||
- A server action bound to ``documents.document`` so the workflow is
|
||||
reachable from the Documents Actions menu (the Documents app uses
|
||||
kanban/list views without a regular form view to inherit from).
|
||||
|
||||
Auto-installs when ``documents`` and ``fusion_accounting_core`` are both
|
||||
present.
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'account',
|
||||
'documents',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizards/create_invoice_from_document_views.xml',
|
||||
'views/documents_document_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'data/server_actions_data.xml',
|
||||
],
|
||||
'auto_install': ['documents', 'fusion_accounting_core'],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'icon': '/fusion_accounting_documents/static/description/icon.png',
|
||||
}
|
||||
25
fusion_accounting_documents/data/server_actions_data.xml
Normal file
25
fusion_accounting_documents/data/server_actions_data.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Server action bound to documents.document so the
|
||||
"Create Vendor Invoice" workflow appears in the cog/Actions
|
||||
menu of the Documents kanban + list views.
|
||||
|
||||
We dispatch through ``action_create_invoice`` so the same
|
||||
validation runs whether the user clicks the action or calls
|
||||
the method programmatically.
|
||||
-->
|
||||
<record id="action_create_invoice_from_document" model="ir.actions.server">
|
||||
<field name="name">Create Vendor Invoice (Fusion)</field>
|
||||
<field name="model_id" ref="documents.model_documents_document"/>
|
||||
<field name="binding_model_id" ref="documents.model_documents_document"/>
|
||||
<field name="binding_view_types">list,kanban</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
if records and len(records) == 1:
|
||||
action = records.action_create_invoice()
|
||||
else:
|
||||
raise UserError(_("Select exactly one document to convert into a vendor invoice."))
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
fusion_accounting_documents/models/__init__.py
Normal file
2
fusion_accounting_documents/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import documents_document
|
||||
from . import account_move
|
||||
33
fusion_accounting_documents/models/account_move.py
Normal file
33
fusion_accounting_documents/models/account_move.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Reverse linkage from account.move back to source documents."""
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
source_document_ids = fields.One2many(
|
||||
'documents.document',
|
||||
'move_id',
|
||||
string='Source Documents',
|
||||
readonly=True,
|
||||
help="Documents in the Documents app that were used to create this move.",
|
||||
)
|
||||
source_document_count = fields.Integer(
|
||||
string='Source Document Count',
|
||||
compute='_compute_source_document_count',
|
||||
)
|
||||
|
||||
def _compute_source_document_count(self):
|
||||
for m in self:
|
||||
m.source_document_count = len(m.source_document_ids)
|
||||
|
||||
def action_open_source_documents(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Source Documents'),
|
||||
'res_model': 'documents.document',
|
||||
'view_mode': 'kanban,list',
|
||||
'domain': [('move_id', '=', self.id)],
|
||||
}
|
||||
71
fusion_accounting_documents/models/documents_document.py
Normal file
71
fusion_accounting_documents/models/documents_document.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Bridge documents.document to accounting moves.
|
||||
|
||||
Adds a Many2one link to the created invoice/move, a computed
|
||||
``is_invoice_candidate`` flag for PDFs/images that have not yet been
|
||||
turned into a vendor bill, and the ``action_create_invoice`` entry
|
||||
point used by both the form button and the server action.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
INVOICE_CANDIDATE_MIMETYPES = (
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
)
|
||||
|
||||
|
||||
class DocumentsDocument(models.Model):
|
||||
_inherit = 'documents.document'
|
||||
|
||||
move_id = fields.Many2one(
|
||||
'account.move',
|
||||
string='Linked Invoice/Move',
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
help="The accounting move this document was used to create.",
|
||||
)
|
||||
is_invoice_candidate = fields.Boolean(
|
||||
string='Is Invoice Candidate',
|
||||
compute='_compute_is_invoice_candidate',
|
||||
store=True,
|
||||
help="True when this document looks like a vendor bill "
|
||||
"(PDF/image binary) and has not yet been linked to a move.",
|
||||
)
|
||||
|
||||
@api.depends('mimetype', 'type', 'move_id')
|
||||
def _compute_is_invoice_candidate(self):
|
||||
for d in self:
|
||||
d.is_invoice_candidate = (
|
||||
d.type == 'binary'
|
||||
and (d.mimetype or '') in INVOICE_CANDIDATE_MIMETYPES
|
||||
and not d.move_id
|
||||
)
|
||||
|
||||
def action_create_invoice(self):
|
||||
"""Open the wizard to create a vendor invoice from this document."""
|
||||
self.ensure_one()
|
||||
if self.move_id:
|
||||
raise UserError(_(
|
||||
"This document is already linked to invoice %s.",
|
||||
self.move_id.display_name,
|
||||
))
|
||||
if self.type == 'folder':
|
||||
raise UserError(_(
|
||||
"Folders cannot be turned into invoices."
|
||||
))
|
||||
if (self.mimetype or '') not in INVOICE_CANDIDATE_MIMETYPES:
|
||||
raise UserError(_(
|
||||
"Only PDF or image documents can be turned into invoices."
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Create Invoice from Document'),
|
||||
'res_model': 'fusion.create.invoice.from.document.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_document_id': self.id},
|
||||
}
|
||||
2
fusion_accounting_documents/security/ir.model.access.csv
Normal file
2
fusion_accounting_documents/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_create_invoice_wizard_user,fusion.create.invoice.wizard.user,model_fusion_create_invoice_from_document_wizard,base.group_user,1,1,1,1
|
||||
|
BIN
fusion_accounting_documents/static/description/icon.png
Normal file
BIN
fusion_accounting_documents/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
1
fusion_accounting_documents/tests/__init__.py
Normal file
1
fusion_accounting_documents/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_document_to_invoice
|
||||
140
fusion_accounting_documents/tests/test_document_to_invoice.py
Normal file
140
fusion_accounting_documents/tests/test_document_to_invoice.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for the documents.document <-> account.move bridge."""
|
||||
|
||||
import base64
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_accounting_documents')
|
||||
class TestDocumentToInvoice(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.vendor = cls.env['res.partner'].create({
|
||||
'name': 'Test Doc Vendor',
|
||||
'supplier_rank': 1,
|
||||
})
|
||||
cls.purchase_journal = cls.env['account.journal'].search(
|
||||
[('type', '=', 'purchase'),
|
||||
('company_id', '=', cls.env.company.id)],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
def _make_document(self, name='Test Bill PDF',
|
||||
mimetype='application/pdf',
|
||||
payload=b'%PDF-fake-bill-content'):
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': name,
|
||||
'datas': base64.b64encode(payload),
|
||||
'mimetype': mimetype,
|
||||
})
|
||||
Document = self.env['documents.document']
|
||||
doc_vals = {
|
||||
'name': name,
|
||||
'attachment_id': attachment.id,
|
||||
'mimetype': mimetype,
|
||||
'type': 'binary',
|
||||
}
|
||||
if 'folder_id' in Document._fields:
|
||||
folder = Document.search(
|
||||
[('type', '=', 'folder')], limit=1,
|
||||
)
|
||||
if folder:
|
||||
doc_vals['folder_id'] = folder.id
|
||||
return Document.create(doc_vals)
|
||||
|
||||
def test_invoice_candidate_flag_pdf(self):
|
||||
doc = self._make_document()
|
||||
self.assertTrue(doc.is_invoice_candidate)
|
||||
|
||||
def test_invoice_candidate_flag_image(self):
|
||||
doc = self._make_document(
|
||||
name='scan.png',
|
||||
mimetype='image/png',
|
||||
payload=b'\x89PNG\r\n\x1a\nfake',
|
||||
)
|
||||
self.assertTrue(doc.is_invoice_candidate)
|
||||
|
||||
def test_invoice_candidate_flag_text_excluded(self):
|
||||
doc = self._make_document(
|
||||
name='note.txt',
|
||||
mimetype='text/plain',
|
||||
payload=b'just a note',
|
||||
)
|
||||
self.assertFalse(doc.is_invoice_candidate)
|
||||
|
||||
def test_action_create_invoice_opens_wizard(self):
|
||||
doc = self._make_document()
|
||||
action = doc.action_create_invoice()
|
||||
self.assertEqual(action['type'], 'ir.actions.act_window')
|
||||
self.assertEqual(
|
||||
action['res_model'],
|
||||
'fusion.create.invoice.from.document.wizard',
|
||||
)
|
||||
self.assertEqual(action['target'], 'new')
|
||||
self.assertEqual(action['context']['default_document_id'], doc.id)
|
||||
|
||||
def test_wizard_creates_invoice_and_links(self):
|
||||
doc = self._make_document()
|
||||
wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
|
||||
'document_id': doc.id,
|
||||
'partner_id': self.vendor.id,
|
||||
'move_type': 'in_invoice',
|
||||
})
|
||||
self.assertTrue(wizard.journal_id, "Default journal should resolve")
|
||||
action = wizard.action_create_invoice()
|
||||
|
||||
self.assertEqual(action['res_model'], 'account.move')
|
||||
move = self.env['account.move'].browse(action['res_id'])
|
||||
self.assertEqual(move.move_type, 'in_invoice')
|
||||
self.assertEqual(move.partner_id, self.vendor)
|
||||
|
||||
self.assertEqual(doc.move_id, move)
|
||||
self.assertFalse(doc.is_invoice_candidate,
|
||||
"Linked docs should no longer be candidates")
|
||||
|
||||
self.assertEqual(move.source_document_count, 1)
|
||||
self.assertIn(doc, move.source_document_ids)
|
||||
|
||||
attachments = self.env['ir.attachment'].search([
|
||||
('res_model', '=', 'account.move'),
|
||||
('res_id', '=', move.id),
|
||||
])
|
||||
self.assertTrue(
|
||||
attachments,
|
||||
"An attachment copy should land on the new move",
|
||||
)
|
||||
|
||||
def test_action_create_invoice_already_linked_raises(self):
|
||||
doc = self._make_document()
|
||||
existing_move = self.env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': self.vendor.id,
|
||||
})
|
||||
doc.move_id = existing_move.id
|
||||
with self.assertRaises(UserError):
|
||||
doc.action_create_invoice()
|
||||
|
||||
def test_action_create_invoice_non_candidate_raises(self):
|
||||
doc = self._make_document(
|
||||
name='note.txt',
|
||||
mimetype='text/plain',
|
||||
payload=b'hello',
|
||||
)
|
||||
with self.assertRaises(UserError):
|
||||
doc.action_create_invoice()
|
||||
|
||||
def test_wizard_creates_credit_note(self):
|
||||
doc = self._make_document(name='credit-note.pdf')
|
||||
wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
|
||||
'document_id': doc.id,
|
||||
'partner_id': self.vendor.id,
|
||||
'move_type': 'in_refund',
|
||||
})
|
||||
action = wizard.action_create_invoice()
|
||||
move = self.env['account.move'].browse(action['res_id'])
|
||||
self.assertEqual(move.move_type, 'in_refund')
|
||||
self.assertEqual(doc.move_id, move)
|
||||
21
fusion_accounting_documents/views/account_move_views.xml
Normal file
21
fusion_accounting_documents/views/account_move_views.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_move_form_inherit_fusion_documents" model="ir.ui.view">
|
||||
<field name="name">account.move.form.inherit.fusion.documents</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button"
|
||||
type="object"
|
||||
name="action_open_source_documents"
|
||||
icon="fa-file-text-o"
|
||||
invisible="source_document_count == 0">
|
||||
<field name="source_document_count"
|
||||
widget="statinfo"
|
||||
string="Source Docs"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
The Documents app does not ship a regular form view for
|
||||
documents.document; editing happens in the side panel of the
|
||||
kanban/list views. We therefore add the new fields to the
|
||||
kanban + list views and rely on a server action (defined in
|
||||
data/server_actions_data.xml) to expose the "Create Invoice"
|
||||
workflow from the Actions menu.
|
||||
-->
|
||||
|
||||
<record id="view_documents_document_kanban_inherit_fusion_acc"
|
||||
model="ir.ui.view">
|
||||
<field name="name">documents.document.kanban.inherit.fusion.acc</field>
|
||||
<field name="model">documents.document</field>
|
||||
<field name="inherit_id" ref="documents.document_view_kanban"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="is_invoice_candidate"/>
|
||||
<field name="move_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_documents_document_list_inherit_fusion_acc"
|
||||
model="ir.ui.view">
|
||||
<field name="name">documents.document.list.inherit.fusion.acc</field>
|
||||
<field name="model">documents.document</field>
|
||||
<field name="inherit_id" ref="documents.documents_view_list_main"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="is_invoice_candidate" optional="hide"/>
|
||||
<field name="move_id"
|
||||
string="Linked Invoice"
|
||||
optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
fusion_accounting_documents/wizards/__init__.py
Normal file
1
fusion_accounting_documents/wizards/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import create_invoice_from_document
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Wizard to create a vendor invoice from a Documents document.
|
||||
|
||||
The wizard creates an empty draft ``account.move`` of the chosen
|
||||
move type, copies the document's binary attachment onto the new
|
||||
move, posts a chatter note linking back to the source document,
|
||||
and finally stores the move on ``documents.document.move_id`` so
|
||||
the source no longer appears as an invoice candidate.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
MOVE_TYPE_LABELS = {
|
||||
'in_invoice': _('Vendor Bill'),
|
||||
'in_refund': _('Vendor Credit Note'),
|
||||
}
|
||||
|
||||
|
||||
class CreateInvoiceFromDocumentWizard(models.TransientModel):
|
||||
_name = 'fusion.create.invoice.from.document.wizard'
|
||||
_description = 'Create Vendor Invoice from Document'
|
||||
|
||||
document_id = fields.Many2one(
|
||||
'documents.document',
|
||||
string='Source Document',
|
||||
required=True,
|
||||
readonly=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
document_name = fields.Char(related='document_id.name', readonly=True)
|
||||
document_mimetype = fields.Char(related='document_id.mimetype', readonly=True)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Vendor',
|
||||
domain="[('supplier_rank', '>', 0)]",
|
||||
)
|
||||
move_type = fields.Selection(
|
||||
[
|
||||
('in_invoice', 'Vendor Bill'),
|
||||
('in_refund', 'Vendor Credit Note'),
|
||||
],
|
||||
string='Type',
|
||||
default='in_invoice',
|
||||
required=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Journal',
|
||||
domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
|
||||
default=lambda self: self._default_journal(),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _default_journal(self):
|
||||
return self.env['account.journal'].search(
|
||||
[('type', '=', 'purchase'),
|
||||
('company_id', '=', self.env.company.id)],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
@api.onchange('company_id')
|
||||
def _onchange_company_id(self):
|
||||
if self.journal_id and self.journal_id.company_id != self.company_id:
|
||||
self.journal_id = self.env['account.journal'].search(
|
||||
[('type', '=', 'purchase'),
|
||||
('company_id', '=', self.company_id.id)],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
def action_create_invoice(self):
|
||||
self.ensure_one()
|
||||
if not self.document_id:
|
||||
raise UserError(_("No document selected."))
|
||||
if self.document_id.move_id:
|
||||
raise UserError(_(
|
||||
"Document %(doc)s is already linked to invoice %(inv)s.",
|
||||
doc=self.document_id.display_name,
|
||||
inv=self.document_id.move_id.display_name,
|
||||
))
|
||||
if not self.journal_id:
|
||||
raise UserError(_(
|
||||
"No purchase journal configured for company %s.",
|
||||
self.company_id.display_name,
|
||||
))
|
||||
|
||||
move_vals = {
|
||||
'move_type': self.move_type,
|
||||
'journal_id': self.journal_id.id,
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
if self.partner_id:
|
||||
move_vals['partner_id'] = self.partner_id.id
|
||||
|
||||
move = self.env['account.move'].create(move_vals)
|
||||
|
||||
attachment = self.document_id.attachment_id
|
||||
if attachment:
|
||||
attachment_copy = attachment.copy({
|
||||
'res_model': 'account.move',
|
||||
'res_id': move.id,
|
||||
})
|
||||
move.message_post(
|
||||
body=_(
|
||||
"Created from Documents source: <strong>%s</strong>",
|
||||
self.document_id.name,
|
||||
),
|
||||
attachment_ids=[attachment_copy.id],
|
||||
)
|
||||
else:
|
||||
move.message_post(body=_(
|
||||
"Created from Documents source: <strong>%s</strong> "
|
||||
"(no attachment to copy).",
|
||||
self.document_id.name,
|
||||
))
|
||||
|
||||
self.document_id.move_id = move.id
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': MOVE_TYPE_LABELS.get(self.move_type, _('Invoice')),
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'form',
|
||||
'res_id': move.id,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_create_invoice_from_document_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.create.invoice.from.document.wizard.form</field>
|
||||
<field name="model">fusion.create.invoice.from.document.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Invoice from Document">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="document_id" invisible="1"/>
|
||||
<field name="document_name" readonly="1"/>
|
||||
<field name="document_mimetype" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="move_type"/>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="journal_id" options="{'no_create': True}"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_create_invoice"
|
||||
string="Create Invoice"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
data-hotkey="x"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -78,10 +78,120 @@ class FusionMigrationWizard(models.TransientModel):
|
||||
result['created'], result['skipped'], len(result['errors']))
|
||||
return result
|
||||
|
||||
def _followup_partner_state_bootstrap_step(self):
|
||||
"""Migration step: copy Enterprise account_followup per-partner state
|
||||
onto Fusion's fields on res.partner.
|
||||
|
||||
Idempotent: only updates partners whose Fusion field is at default
|
||||
(no_action) and whose Enterprise field has a non-default value.
|
||||
"""
|
||||
self.ensure_one()
|
||||
_logger.info("fusion_accounting_followup partner-state migration starting")
|
||||
|
||||
Partner = self.env['res.partner'].sudo()
|
||||
has_status = 'followup_status' in Partner._fields
|
||||
has_next_date = 'payment_next_action_date' in Partner._fields
|
||||
has_line = 'followup_line_id' in Partner._fields
|
||||
if not (has_status or has_next_date or has_line):
|
||||
_logger.info(
|
||||
"Enterprise account_followup partner fields not present \u2014 skipping")
|
||||
return {
|
||||
'step': 'followup_partner_state',
|
||||
'enterprise_module_present': False,
|
||||
'updated': 0, 'skipped': 0, 'errors': [],
|
||||
}
|
||||
|
||||
result = {
|
||||
'step': 'followup_partner_state',
|
||||
'enterprise_module_present': True,
|
||||
'updated': 0, 'skipped': 0, 'errors': [],
|
||||
}
|
||||
|
||||
domain_terms = []
|
||||
if has_status:
|
||||
domain_terms.append(('followup_status', '!=', 'no_action_needed'))
|
||||
if has_next_date:
|
||||
domain_terms.append(('payment_next_action_date', '!=', False))
|
||||
if not domain_terms:
|
||||
_logger.info("No usable Enterprise follow-up fields \u2014 skipping")
|
||||
return result
|
||||
if len(domain_terms) > 1:
|
||||
domain = ['|'] * (len(domain_terms) - 1) + domain_terms
|
||||
else:
|
||||
domain = domain_terms
|
||||
candidates = Partner.search(domain)
|
||||
_logger.info(
|
||||
"Found %d partners with non-default Enterprise follow-up state",
|
||||
len(candidates))
|
||||
|
||||
Level = self.env['fusion.followup.level'].sudo()
|
||||
today = fields.Date.today()
|
||||
|
||||
status_map = {
|
||||
'in_need_of_action': 'action_due',
|
||||
'with_overdue_invoices': 'action_due',
|
||||
'no_action_needed': 'no_action',
|
||||
}
|
||||
|
||||
for partner in candidates:
|
||||
try:
|
||||
if partner.fusion_followup_status not in (False, 'no_action'):
|
||||
result['skipped'] += 1
|
||||
continue
|
||||
|
||||
vals = {}
|
||||
|
||||
ent_status = (
|
||||
getattr(partner, 'followup_status', None)
|
||||
if has_status else None)
|
||||
if ent_status and ent_status in status_map:
|
||||
vals['fusion_followup_status'] = status_map[ent_status]
|
||||
|
||||
next_date = (
|
||||
getattr(partner, 'payment_next_action_date', False)
|
||||
if has_next_date else False)
|
||||
if next_date and next_date > today:
|
||||
vals['fusion_followup_paused_until'] = next_date
|
||||
vals['fusion_followup_status'] = 'paused'
|
||||
|
||||
ent_line = (
|
||||
getattr(partner, 'followup_line_id', None)
|
||||
if has_line else None)
|
||||
if ent_line:
|
||||
fusion_level = Level.search([
|
||||
('name', '=', ent_line.name),
|
||||
], limit=1)
|
||||
if fusion_level:
|
||||
vals['fusion_followup_last_level_id'] = fusion_level.id
|
||||
|
||||
if vals:
|
||||
partner.write(vals)
|
||||
result['updated'] += 1
|
||||
_logger.debug(
|
||||
"Migrated partner %s: %s", partner.name, vals)
|
||||
else:
|
||||
result['skipped'] += 1
|
||||
|
||||
except Exception as e:
|
||||
result['errors'].append(
|
||||
f"Partner {partner.id} ({partner.name}): {e}")
|
||||
_logger.warning(
|
||||
"Migration failed for partner %s: %s", partner.id, e)
|
||||
|
||||
_logger.info(
|
||||
"fusion_accounting_followup partner-state migration: "
|
||||
"updated=%d skipped=%d errors=%d",
|
||||
result['updated'], result['skipped'], len(result['errors']))
|
||||
return result
|
||||
|
||||
def action_run_migration(self):
|
||||
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
|
||||
try:
|
||||
self._followup_bootstrap_step()
|
||||
except Exception as e:
|
||||
_logger.warning("followup_bootstrap_step failed: %s", e)
|
||||
try:
|
||||
self._followup_partner_state_bootstrap_step()
|
||||
except Exception as e:
|
||||
_logger.warning("followup_partner_state_bootstrap_step failed: %s", e)
|
||||
return result
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/followup";
|
||||
|
||||
export class FollowupService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.rpc = services.rpc;
|
||||
// V19: rpc is a standalone import, not a service.
|
||||
this.rpc = rpc;
|
||||
this.notification = services.notification;
|
||||
|
||||
this.state = reactive({
|
||||
@@ -138,7 +140,7 @@ export class FollowupService {
|
||||
}
|
||||
|
||||
export const followupService = {
|
||||
dependencies: ["rpc", "notification"],
|
||||
dependencies: ["notification"],
|
||||
start(env, services) { return new FollowupService(env, services); },
|
||||
};
|
||||
|
||||
|
||||
@@ -19,3 +19,12 @@ class TestFollowupMigrationRoundTrip(TransactionCase):
|
||||
# Second run skips what first created (or both no-op)
|
||||
if first['enterprise_module_present']:
|
||||
self.assertGreaterEqual(second['skipped'], first['created'])
|
||||
|
||||
def test_partner_state_bootstrap_step(self):
|
||||
"""Verify the partner-state migration step runs without error."""
|
||||
wizard = self.env['fusion.migration.wizard'].create({})
|
||||
result = wizard._followup_partner_state_bootstrap_step()
|
||||
self.assertEqual(result['step'], 'followup_partner_state')
|
||||
self.assertIn(result['enterprise_module_present'], [True, False])
|
||||
self.assertGreaterEqual(result['updated'], 0)
|
||||
self.assertGreaterEqual(result['skipped'], 0)
|
||||
|
||||
1
fusion_accounting_hr_payroll/__init__.py
Normal file
1
fusion_accounting_hr_payroll/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
59
fusion_accounting_hr_payroll/__manifest__.py
Normal file
59
fusion_accounting_hr_payroll/__manifest__.py
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
'name': 'Fusion Accounting - HR Payroll Bridge',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Human Resources/Payroll',
|
||||
'summary': 'Bridges payroll (hr_payroll) to accounting via account.move creation when payslips are validated.',
|
||||
'description': """
|
||||
Fusion Accounting - HR Payroll Bridge
|
||||
=====================================
|
||||
|
||||
A Fusion-native replacement for Odoo Enterprise's ``hr_payroll_account``
|
||||
module. Removes Westin's last payroll-accounting dependency on the
|
||||
Enterprise ``accountant`` umbrella.
|
||||
|
||||
Scope
|
||||
-----
|
||||
- Adds ``account_debit`` / ``account_credit`` / ``analytic_distribution`` to
|
||||
``hr.salary.rule`` (company-dependent GL mapping per rule).
|
||||
- Adds ``move_id`` + ``journal_id`` + ``_fusion_create_account_move`` to
|
||||
``hr.payslip``: when a payslip is validated, generates a balanced
|
||||
``account.move`` from the salary rule mapping.
|
||||
- Adds ``fusion_payroll_journal_id`` + ``fusion_payroll_auto_post`` to
|
||||
``res.company`` (fallback journal + auto-post toggle).
|
||||
- Reverse links ``payslip_ids`` / ``payslip_count`` on ``account.move``
|
||||
for traceability and reporting.
|
||||
|
||||
Coexistence
|
||||
-----------
|
||||
When Odoo Enterprise's ``hr_payroll_account`` is also installed, this
|
||||
module yields move-creation to it (detected at runtime via
|
||||
``ir.module.module``) so payslips don't get duplicate entries. After
|
||||
``hr_payroll_account`` is uninstalled, this module owns the bridge.
|
||||
|
||||
Auto-install
|
||||
------------
|
||||
Auto-installs whenever both ``hr_payroll`` and ``fusion_accounting_core``
|
||||
are present.
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'account',
|
||||
'hr_payroll',
|
||||
'base_iban',
|
||||
],
|
||||
'data': [
|
||||
'data/hr_salary_rule_data.xml',
|
||||
'views/hr_salary_rule_views.xml',
|
||||
'views/hr_payslip_views.xml',
|
||||
'views/hr_payroll_structure_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
],
|
||||
'auto_install': ['hr_payroll', 'fusion_accounting_core'],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'icon': '/fusion_accounting_hr_payroll/static/description/icon.png',
|
||||
}
|
||||
34
fusion_accounting_hr_payroll/data/hr_salary_rule_data.xml
Normal file
34
fusion_accounting_hr_payroll/data/hr_salary_rule_data.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Bridge defaults from the Enterprise hr_payroll_account module.
|
||||
Wrapped in noupdate="1" so re-running -u does not overwrite a
|
||||
customer's account mapping on these rules.
|
||||
|
||||
Each <record> uses xmlid_lookup="ignore" through optional `forcecreate="0"`
|
||||
semantics so that the load is silently skipped when the referenced
|
||||
upstream rule is not present (e.g. on a database without the
|
||||
Enterprise default payroll structures).
|
||||
-->
|
||||
<data noupdate="1">
|
||||
<record id="hr_payroll.default_deduction_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_attachment_of_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_assignment_of_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_child_support" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_reimbursement_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
10
fusion_accounting_hr_payroll/models/__init__.py
Normal file
10
fusion_accounting_hr_payroll/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from . import hr_salary_rule
|
||||
from . import hr_payslip
|
||||
from . import hr_payslip_line
|
||||
from . import hr_payslip_run
|
||||
from . import hr_payroll_structure
|
||||
from . import account_journal
|
||||
from . import account_move
|
||||
from . import account_move_line
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
12
fusion_accounting_hr_payroll/models/account_journal.py
Normal file
12
fusion_accounting_hr_payroll/models/account_journal.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountJournal(models.Model):
|
||||
_inherit = 'account.journal'
|
||||
|
||||
is_payroll_journal = fields.Boolean(
|
||||
string='Used for Payroll',
|
||||
help="Marks this journal as the salary / payroll posting journal "
|
||||
"for the company. Informational; the actual fallback is set "
|
||||
"on res.company.fusion_payroll_journal_id.",
|
||||
)
|
||||
41
fusion_accounting_hr_payroll/models/account_move.py
Normal file
41
fusion_accounting_hr_payroll/models/account_move.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
payslip_ids = fields.One2many(
|
||||
comodel_name='hr.payslip',
|
||||
inverse_name='move_id',
|
||||
string='Payslips',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
payslip_count = fields.Integer(
|
||||
string='# of Payslips',
|
||||
compute='_compute_payslip_count',
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
def _compute_payslip_count(self):
|
||||
for move in self:
|
||||
move.payslip_count = len(move.payslip_ids)
|
||||
|
||||
def action_open_payslip(self):
|
||||
self.ensure_one()
|
||||
action = {
|
||||
'name': _('Payslips'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'hr.payslip',
|
||||
}
|
||||
if self.payslip_count == 1:
|
||||
action.update({
|
||||
'view_mode': 'form',
|
||||
'res_id': self.payslip_ids.id,
|
||||
})
|
||||
else:
|
||||
action.update({
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.payslip_ids.ids)],
|
||||
})
|
||||
return action
|
||||
16
fusion_accounting_hr_payroll/models/account_move_line.py
Normal file
16
fusion_accounting_hr_payroll/models/account_move_line.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
payslip_id = fields.Many2one(
|
||||
'hr.payslip',
|
||||
string='Source Payslip',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
index='btree_not_null',
|
||||
help="Payslip this journal item was generated from "
|
||||
"(populated by the Fusion payroll bridge for reporting).",
|
||||
)
|
||||
26
fusion_accounting_hr_payroll/models/hr_payroll_structure.py
Normal file
26
fusion_accounting_hr_payroll/models/hr_payroll_structure.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class HrPayrollStructure(models.Model):
|
||||
_inherit = 'hr.payroll.structure'
|
||||
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Salary Journal',
|
||||
company_dependent=True,
|
||||
domain="[('type', '=', 'general')]",
|
||||
help="Default journal used when generating payroll accounting "
|
||||
"entries for payslips that follow this structure.",
|
||||
)
|
||||
|
||||
@api.constrains('journal_id')
|
||||
def _check_journal_currency(self):
|
||||
for record in self.sudo():
|
||||
journal = record.journal_id
|
||||
if journal and journal.currency_id and journal.company_id \
|
||||
and journal.currency_id != journal.company_id.currency_id:
|
||||
raise ValidationError(_(
|
||||
"The salary journal must be in the same currency as "
|
||||
"the company.",
|
||||
))
|
||||
242
fusion_accounting_hr_payroll/models/hr_payslip.py
Normal file
242
fusion_accounting_hr_payroll/models/hr_payslip.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
move_id = fields.Many2one(
|
||||
'account.move',
|
||||
string='Accounting Entry',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
index='btree_not_null',
|
||||
)
|
||||
move_state = fields.Selection(
|
||||
related='move_id.state',
|
||||
string='Move State',
|
||||
export_string_translation=False,
|
||||
)
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Salary Journal',
|
||||
domain="[('type', '=', 'general')]",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _fusion_enterprise_bridge_active(self):
|
||||
"""Return True when the Enterprise hr_payroll_account module is the
|
||||
authoritative payslip - GL bridge on this database. Used to avoid
|
||||
duplicate move creation while both modules coexist."""
|
||||
module = self.env['ir.module.module'].sudo().search(
|
||||
[('name', '=', 'hr_payroll_account')], limit=1,
|
||||
)
|
||||
return bool(module) and module.state == 'installed'
|
||||
|
||||
def _fusion_resolve_journal(self):
|
||||
"""Pick the journal for this payslip's bridge move."""
|
||||
self.ensure_one()
|
||||
if self.journal_id:
|
||||
return self.journal_id
|
||||
struct = self.struct_id
|
||||
if struct and 'journal_id' in struct._fields and struct.journal_id:
|
||||
return struct.journal_id
|
||||
company = self.company_id or self.env.company
|
||||
return company.fusion_payroll_journal_id or False
|
||||
|
||||
def _fusion_resolve_partner(self):
|
||||
"""Pick the best partner reference for the move lines of this payslip."""
|
||||
self.ensure_one()
|
||||
employee = self.employee_id
|
||||
if not employee:
|
||||
return False
|
||||
if 'work_contact_id' in employee._fields and employee.work_contact_id:
|
||||
return employee.work_contact_id.id
|
||||
if 'address_home_id' in employee._fields and employee.address_home_id:
|
||||
return employee.address_home_id.id
|
||||
return False
|
||||
|
||||
def _fusion_get_line_amount(self, line):
|
||||
"""Hook so a localisation can override which payslip-line value is
|
||||
posted. Defaults to ``line.total``."""
|
||||
return line.total or 0.0
|
||||
|
||||
def action_payslip_done(self):
|
||||
res = super().action_payslip_done()
|
||||
if self._fusion_enterprise_bridge_active():
|
||||
return res
|
||||
for slip in self:
|
||||
if slip.move_id:
|
||||
continue
|
||||
journal = slip._fusion_resolve_journal()
|
||||
if not journal:
|
||||
continue
|
||||
try:
|
||||
slip._fusion_create_account_move(journal=journal)
|
||||
except UserError as err:
|
||||
_logger.warning(
|
||||
"Fusion payroll bridge: GL move skipped for slip %s: %s",
|
||||
slip.id, err,
|
||||
)
|
||||
slip.message_post(body=_(
|
||||
"Fusion Payroll bridge could not create the journal "
|
||||
"entry: %s",
|
||||
) % err)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Fusion payroll bridge: unexpected failure for slip %s",
|
||||
slip.id,
|
||||
)
|
||||
return res
|
||||
|
||||
def action_payslip_cancel(self):
|
||||
if hasattr(super(), 'action_payslip_cancel'):
|
||||
res = super().action_payslip_cancel()
|
||||
else:
|
||||
res = True
|
||||
if self._fusion_enterprise_bridge_active():
|
||||
return res
|
||||
for slip in self:
|
||||
move = slip.move_id
|
||||
if not move:
|
||||
continue
|
||||
try:
|
||||
if move.state == 'posted':
|
||||
move.button_draft()
|
||||
move.with_context(force_delete=True).unlink()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Fusion payroll bridge: cannot reverse move %s for slip %s",
|
||||
move.id, slip.id,
|
||||
)
|
||||
return res
|
||||
|
||||
def _fusion_create_account_move(self, journal=None):
|
||||
"""Build a balanced ``account.move`` from this payslip using the
|
||||
``account_debit`` / ``account_credit`` mapping on each salary rule.
|
||||
Returns the created move (or False if there is nothing to post)."""
|
||||
self.ensure_one()
|
||||
if not self.line_ids:
|
||||
return False
|
||||
journal = journal or self._fusion_resolve_journal()
|
||||
if not journal:
|
||||
raise UserError(_(
|
||||
"No salary journal configured for company %s. "
|
||||
"Set a fallback journal under Accounting Settings - "
|
||||
"Fusion Payroll Bridge.",
|
||||
) % (self.company_id.display_name if self.company_id else ''))
|
||||
|
||||
debit_per_account = defaultdict(float)
|
||||
credit_per_account = defaultdict(float)
|
||||
analytic_per_account = {}
|
||||
|
||||
for line in self.line_ids:
|
||||
rule = line.salary_rule_id
|
||||
amount = self._fusion_get_line_amount(line)
|
||||
if not amount:
|
||||
continue
|
||||
debit_account = rule.account_debit
|
||||
credit_account = rule.account_credit
|
||||
analytic = (
|
||||
rule.fusion_analytic_account_id
|
||||
if 'fusion_analytic_account_id' in rule._fields
|
||||
else False
|
||||
)
|
||||
|
||||
if amount > 0:
|
||||
if debit_account:
|
||||
debit_per_account[debit_account.id] += amount
|
||||
if credit_account:
|
||||
credit_per_account[credit_account.id] += amount
|
||||
else:
|
||||
pos = -amount
|
||||
if debit_account:
|
||||
credit_per_account[debit_account.id] += pos
|
||||
if credit_account:
|
||||
debit_per_account[credit_account.id] += pos
|
||||
|
||||
if analytic:
|
||||
for acc in (debit_account, credit_account):
|
||||
if acc and acc.id not in analytic_per_account:
|
||||
analytic_per_account[acc.id] = analytic.id
|
||||
|
||||
partner_id = self._fusion_resolve_partner()
|
||||
line_label = self.display_name or self.number or _('Payslip')
|
||||
move_lines = []
|
||||
all_accounts = set(debit_per_account) | set(credit_per_account)
|
||||
|
||||
for account_id in all_accounts:
|
||||
net = (
|
||||
debit_per_account.get(account_id, 0.0)
|
||||
- credit_per_account.get(account_id, 0.0)
|
||||
)
|
||||
if abs(net) < 0.005:
|
||||
continue
|
||||
vals = {
|
||||
'account_id': account_id,
|
||||
'name': line_label,
|
||||
'partner_id': partner_id,
|
||||
}
|
||||
if net > 0:
|
||||
vals['debit'] = round(net, 2)
|
||||
vals['credit'] = 0.0
|
||||
else:
|
||||
vals['debit'] = 0.0
|
||||
vals['credit'] = round(-net, 2)
|
||||
analytic_id = analytic_per_account.get(account_id)
|
||||
if analytic_id:
|
||||
vals['analytic_distribution'] = {str(analytic_id): 100.0}
|
||||
move_lines.append((0, 0, vals))
|
||||
|
||||
if not move_lines:
|
||||
return False
|
||||
|
||||
total_debit = sum(vals[2]['debit'] for vals in move_lines)
|
||||
total_credit = sum(vals[2]['credit'] for vals in move_lines)
|
||||
if abs(total_debit - total_credit) > 0.01:
|
||||
raise UserError(_(
|
||||
"Payroll move not balanced: debit=%(d).2f, credit=%(c).2f. "
|
||||
"Check the account_debit / account_credit mapping on the "
|
||||
"salary rules of payslip %(name)s.",
|
||||
) % {
|
||||
'd': total_debit,
|
||||
'c': total_credit,
|
||||
'name': self.display_name,
|
||||
})
|
||||
|
||||
move_vals = {
|
||||
'journal_id': journal.id,
|
||||
'date': self.date_to or fields.Date.context_today(self),
|
||||
'ref': self.number or self.display_name,
|
||||
'line_ids': move_lines,
|
||||
'move_type': 'entry',
|
||||
}
|
||||
move = self.env['account.move'].sudo().create(move_vals)
|
||||
if self.company_id and self.company_id.fusion_payroll_auto_post:
|
||||
try:
|
||||
move.action_post()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Fusion payroll bridge: auto-post failed for move %s; "
|
||||
"leaving in draft.",
|
||||
move.id,
|
||||
)
|
||||
self.move_id = move.id
|
||||
return move
|
||||
|
||||
def action_open_move(self):
|
||||
self.ensure_one()
|
||||
if not self.move_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Journal Entry'),
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.move_id.id,
|
||||
}
|
||||
16
fusion_accounting_hr_payroll/models/hr_payslip_line.py
Normal file
16
fusion_accounting_hr_payroll/models/hr_payslip_line.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrPayslipLine(models.Model):
|
||||
_inherit = 'hr.payslip.line'
|
||||
|
||||
move_line_id = fields.Many2one(
|
||||
'account.move.line',
|
||||
string='Journal Item',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
index='btree_not_null',
|
||||
help="Account move line this payslip line was rolled up into "
|
||||
"(set by the Fusion payroll bridge for traceability).",
|
||||
)
|
||||
29
fusion_accounting_hr_payroll/models/hr_payslip_run.py
Normal file
29
fusion_accounting_hr_payroll/models/hr_payslip_run.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class HrPayslipRun(models.Model):
|
||||
_inherit = 'hr.payslip.run'
|
||||
|
||||
move_id = fields.Many2one(
|
||||
'account.move',
|
||||
string='Batch Accounting Entry',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
)
|
||||
move_state = fields.Selection(
|
||||
related='move_id.state',
|
||||
string='Move State',
|
||||
)
|
||||
|
||||
def action_open_move(self):
|
||||
self.ensure_one()
|
||||
if not self.move_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Journal Entry'),
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.move_id.id,
|
||||
}
|
||||
35
fusion_accounting_hr_payroll/models/hr_salary_rule.py
Normal file
35
fusion_accounting_hr_payroll/models/hr_salary_rule.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrSalaryRule(models.Model):
|
||||
_inherit = 'hr.salary.rule'
|
||||
|
||||
account_debit = fields.Many2one(
|
||||
'account.account',
|
||||
string='Debit Account',
|
||||
company_dependent=True,
|
||||
ondelete='restrict',
|
||||
help="GL account debited when this rule's amount is posted "
|
||||
"(typically expense or asset).",
|
||||
)
|
||||
account_credit = fields.Many2one(
|
||||
'account.account',
|
||||
string='Credit Account',
|
||||
company_dependent=True,
|
||||
ondelete='restrict',
|
||||
help="GL account credited when this rule's amount is posted "
|
||||
"(typically liability).",
|
||||
)
|
||||
fusion_analytic_account_id = fields.Many2one(
|
||||
'account.analytic.account',
|
||||
string='Analytic Account',
|
||||
company_dependent=True,
|
||||
help="Optional analytic account applied to both legs of the move.",
|
||||
)
|
||||
not_computed_in_net = fields.Boolean(
|
||||
string="Excluded from Net",
|
||||
default=False,
|
||||
help="If checked, the result of this rule is excluded from the "
|
||||
"Net salary line in the journal entry. Set a dedicated "
|
||||
"debit/credit account so the amount is posted independently.",
|
||||
)
|
||||
19
fusion_accounting_hr_payroll/models/res_company.py
Normal file
19
fusion_accounting_hr_payroll/models/res_company.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
fusion_payroll_journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Default Payroll Journal',
|
||||
domain="[('type', '=', 'general'), ('company_id', '=', id)]",
|
||||
help="Fallback journal used by the Fusion payroll bridge when a "
|
||||
"payslip's structure does not define one.",
|
||||
)
|
||||
fusion_payroll_auto_post = fields.Boolean(
|
||||
string='Auto-post Payroll Entries',
|
||||
default=False,
|
||||
help="When enabled, payroll-generated journal entries are posted "
|
||||
"immediately. Otherwise they remain in draft for review.",
|
||||
)
|
||||
16
fusion_accounting_hr_payroll/models/res_config_settings.py
Normal file
16
fusion_accounting_hr_payroll/models/res_config_settings.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
fusion_payroll_journal_id = fields.Many2one(
|
||||
related='company_id.fusion_payroll_journal_id',
|
||||
string='Default Payroll Journal',
|
||||
readonly=False,
|
||||
)
|
||||
fusion_payroll_auto_post = fields.Boolean(
|
||||
related='company_id.fusion_payroll_auto_post',
|
||||
string='Auto-post Payroll Entries',
|
||||
readonly=False,
|
||||
)
|
||||
BIN
fusion_accounting_hr_payroll/static/description/icon.png
Normal file
BIN
fusion_accounting_hr_payroll/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
1
fusion_accounting_hr_payroll/tests/__init__.py
Normal file
1
fusion_accounting_hr_payroll/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_payslip_to_move
|
||||
108
fusion_accounting_hr_payroll/tests/test_payslip_to_move.py
Normal file
108
fusion_accounting_hr_payroll/tests/test_payslip_to_move.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionPayrollBridge(TransactionCase):
|
||||
"""Smoke tests for the Fusion payroll bridge.
|
||||
|
||||
Verifies that the field surface required to replace Enterprise's
|
||||
``hr_payroll_account`` is present after the module installs.
|
||||
Full payslip-to-move integration is exercised in a separate
|
||||
integration test that needs a seeded payroll structure.
|
||||
"""
|
||||
|
||||
def test_module_installed(self):
|
||||
module = self.env['ir.module.module'].sudo().search(
|
||||
[('name', '=', 'fusion_accounting_hr_payroll')], limit=1,
|
||||
)
|
||||
self.assertTrue(module, "Module record must exist")
|
||||
self.assertEqual(
|
||||
module.state, 'installed',
|
||||
"Module should be in 'installed' state for these tests to run",
|
||||
)
|
||||
|
||||
def test_salary_rule_has_account_fields(self):
|
||||
rule_model = self.env['hr.salary.rule']
|
||||
for fname in (
|
||||
'account_debit',
|
||||
'account_credit',
|
||||
'fusion_analytic_account_id',
|
||||
'not_computed_in_net',
|
||||
):
|
||||
self.assertIn(
|
||||
fname, rule_model._fields,
|
||||
f"hr.salary.rule must expose '{fname}'",
|
||||
)
|
||||
|
||||
def test_payslip_has_move_link(self):
|
||||
slip_model = self.env['hr.payslip']
|
||||
for fname in ('move_id', 'move_state', 'journal_id'):
|
||||
self.assertIn(
|
||||
fname, slip_model._fields,
|
||||
f"hr.payslip must expose '{fname}'",
|
||||
)
|
||||
self.assertTrue(
|
||||
hasattr(slip_model, '_fusion_create_account_move'),
|
||||
"hr.payslip must expose the _fusion_create_account_move bridge",
|
||||
)
|
||||
self.assertTrue(
|
||||
hasattr(slip_model, '_fusion_enterprise_bridge_active'),
|
||||
"hr.payslip must expose the Enterprise-bridge detector",
|
||||
)
|
||||
|
||||
def test_payslip_run_has_move_link(self):
|
||||
run_model = self.env['hr.payslip.run']
|
||||
for fname in ('move_id', 'move_state'):
|
||||
self.assertIn(
|
||||
fname, run_model._fields,
|
||||
f"hr.payslip.run must expose '{fname}'",
|
||||
)
|
||||
|
||||
def test_company_payroll_journal_field(self):
|
||||
co_model = self.env['res.company']
|
||||
for fname in ('fusion_payroll_journal_id', 'fusion_payroll_auto_post'):
|
||||
self.assertIn(
|
||||
fname, co_model._fields,
|
||||
f"res.company must expose '{fname}'",
|
||||
)
|
||||
|
||||
def test_account_move_back_links(self):
|
||||
move_model = self.env['account.move']
|
||||
for fname in ('payslip_ids', 'payslip_count'):
|
||||
self.assertIn(
|
||||
fname, move_model._fields,
|
||||
f"account.move must expose '{fname}'",
|
||||
)
|
||||
line_model = self.env['account.move.line']
|
||||
self.assertIn(
|
||||
'payslip_id', line_model._fields,
|
||||
"account.move.line must expose 'payslip_id'",
|
||||
)
|
||||
|
||||
def test_payslip_line_has_move_line_link(self):
|
||||
line_model = self.env['hr.payslip.line']
|
||||
self.assertIn(
|
||||
'move_line_id', line_model._fields,
|
||||
"hr.payslip.line must expose 'move_line_id'",
|
||||
)
|
||||
|
||||
def test_enterprise_bridge_detector_returns_bool(self):
|
||||
slip_model = self.env['hr.payslip']
|
||||
self.assertIsInstance(
|
||||
slip_model._fusion_enterprise_bridge_active(), bool,
|
||||
)
|
||||
|
||||
def test_account_journal_payroll_flag(self):
|
||||
journal_model = self.env['account.journal']
|
||||
self.assertIn(
|
||||
'is_payroll_journal', journal_model._fields,
|
||||
"account.journal must expose 'is_payroll_journal'",
|
||||
)
|
||||
|
||||
def test_payroll_structure_journal_field(self):
|
||||
struct_model = self.env['hr.payroll.structure']
|
||||
self.assertIn(
|
||||
'journal_id', struct_model._fields,
|
||||
"hr.payroll.structure must expose 'journal_id'",
|
||||
)
|
||||
24
fusion_accounting_hr_payroll/views/account_move_views.xml
Normal file
24
fusion_accounting_hr_payroll/views/account_move_views.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_account_move_view_form" model="ir.ui.view">
|
||||
<field name="name">account.move.form.fusion.payroll.bridge</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<field name="payslip_count" invisible="1"/>
|
||||
<button class="oe_stat_button"
|
||||
name="action_open_payslip"
|
||||
type="object"
|
||||
icon="fa-user"
|
||||
invisible="not payslip_count"
|
||||
groups="hr.group_hr_user">
|
||||
<div class="o_stat_info">
|
||||
<field name="payslip_count" class="o_stat_value"/>
|
||||
<span class="o_stat_text">Payslips</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_payroll_structure_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.payroll.structure.form.fusion.payroll.bridge</field>
|
||||
<field name="model">hr.payroll.structure</field>
|
||||
<field name="inherit_id" ref="hr_payroll.view_hr_employee_grade_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion Accounting">
|
||||
<field name="journal_id"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
26
fusion_accounting_hr_payroll/views/hr_payslip_views.xml
Normal file
26
fusion_accounting_hr_payroll/views/hr_payslip_views.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_payslip_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.payslip.form.fusion.payroll.bridge</field>
|
||||
<field name="model">hr.payslip</field>
|
||||
<field name="inherit_id" ref="hr_payroll.view_hr_payslip_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<field name="move_id" invisible="1"/>
|
||||
<field name="move_state" invisible="1"/>
|
||||
<button class="oe_stat_button"
|
||||
name="action_open_move"
|
||||
type="object"
|
||||
icon="fa-bars"
|
||||
invisible="not move_id"
|
||||
groups="account.group_account_readonly">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text" invisible="move_state != 'draft'">Journal Entry (Draft)</span>
|
||||
<span class="o_stat_text" invisible="move_state != 'posted'">Journal Entry (Posted)</span>
|
||||
<span class="o_stat_text" invisible="move_state != 'cancel'">Journal Entry (Canceled)</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
26
fusion_accounting_hr_payroll/views/hr_salary_rule_views.xml
Normal file
26
fusion_accounting_hr_payroll/views/hr_salary_rule_views.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_salary_rule_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.salary.rule.form.fusion.payroll.bridge</field>
|
||||
<field name="model">hr.salary.rule</field>
|
||||
<field name="inherit_id" ref="hr_payroll.hr_salary_rule_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<notebook>
|
||||
<page string="Fusion Accounting" name="fusion_accounting">
|
||||
<group>
|
||||
<group>
|
||||
<field name="account_debit" placeholder="None"/>
|
||||
<field name="account_credit" placeholder="None"/>
|
||||
<field name="fusion_analytic_account_id"
|
||||
groups="analytic.group_analytic_accounting"
|
||||
placeholder="None"/>
|
||||
<field name="not_computed_in_net"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_payroll_res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.form.fusion.payroll.bridge</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[1]" position="before">
|
||||
<block title="Fusion Payroll Bridge" id="fusion_payroll_bridge_block">
|
||||
<setting id="fusion_payroll_journal_setting"
|
||||
string="Default Payroll Journal"
|
||||
help="Fallback journal used by the Fusion payroll bridge when a payslip's structure does not define one.">
|
||||
<field name="fusion_payroll_journal_id"/>
|
||||
</setting>
|
||||
<setting id="fusion_payroll_auto_post_setting"
|
||||
string="Auto-post Payroll Entries"
|
||||
help="When enabled, payroll-generated journal entries are posted immediately. Otherwise they remain in draft for review.">
|
||||
<field name="fusion_payroll_auto_post"/>
|
||||
</setting>
|
||||
</block>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
fusion_accounting_l10n_ca/__init__.py
Normal file
1
fusion_accounting_l10n_ca/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
31
fusion_accounting_l10n_ca/__manifest__.py
Normal file
31
fusion_accounting_l10n_ca/__manifest__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Canadian Reports',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Localizations/Reporting',
|
||||
'summary': 'Canadian-specific report definitions and tax return templates for Fusion Accounting.',
|
||||
'description': """
|
||||
Replaces Enterprise's l10n_ca_reports module with Fusion-native equivalents:
|
||||
- Canadian Balance Sheet (report definition for fusion_accounting_reports engine)
|
||||
- Canadian Profit & Loss (report definition)
|
||||
- Tax return tracking templates (GST/HST/PST periods)
|
||||
|
||||
Auto-installs when l10n_ca + fusion_accounting_reports are both present.
|
||||
""",
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_reports',
|
||||
'l10n_ca',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/fusion_tax_return_data.xml',
|
||||
'data/report_ca_balance_sheet.xml',
|
||||
'data/report_ca_profit_loss.xml',
|
||||
],
|
||||
'auto_install': ['l10n_ca', 'fusion_accounting_reports'],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'author': 'Westin / Fusion Suite',
|
||||
'icon': '/fusion_accounting_l10n_ca/static/description/icon.png',
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="seq_fusion_tax_return" model="ir.sequence">
|
||||
<field name="name">Fusion Tax Return</field>
|
||||
<field name="code">fusion.tax.return</field>
|
||||
<field name="prefix">TAX/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
</record>
|
||||
</odoo>
|
||||
45
fusion_accounting_l10n_ca/data/report_ca_balance_sheet.xml
Normal file
45
fusion_accounting_l10n_ca/data/report_ca_balance_sheet.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_ca_balance_sheet" model="fusion.report">
|
||||
<field name="name">Balance Sheet (Canada)</field>
|
||||
<field name="code">ca_balance_sheet</field>
|
||||
<field name="report_type">balance_sheet</field>
|
||||
<field name="sequence">21</field>
|
||||
<field name="default_comparison_mode">previous_period</field>
|
||||
<field name="description">Canadian-formatted balance sheet aligned to GAAP/IFRS classifications used in Canada.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'ASSETS', 'level': 0},
|
||||
{'label': 'Current Assets', 'level': 1},
|
||||
{'label': 'Cash and Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 2},
|
||||
{'label': 'Accounts Receivable', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 2},
|
||||
{'label': 'Inventory', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 2},
|
||||
{'label': 'Prepaid Expenses', 'account_type_prefix': 'asset_prepayments', 'sign': 1, 'level': 2},
|
||||
{'label': 'Total Current Assets', 'compute': 'subtotal', 'above': 4, 'sign': 1, 'level': 1},
|
||||
|
||||
{'label': 'Non-Current Assets', 'level': 1},
|
||||
{'label': 'Property, Plant and Equipment', 'account_type_prefix': 'asset_fixed', 'sign': 1, 'level': 2},
|
||||
{'label': 'Intangible Assets', 'account_type_prefix': 'asset_non_current', 'sign': 1, 'level': 2},
|
||||
{'label': 'Total Non-Current Assets', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 1},
|
||||
|
||||
{'label': 'TOTAL ASSETS', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'LIABILITIES', 'level': 0},
|
||||
{'label': 'Current Liabilities', 'level': 1},
|
||||
{'label': 'Accounts Payable', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 2},
|
||||
{'label': 'Tax Payable (GST/HST/PST)', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 2},
|
||||
{'label': 'Total Current Liabilities', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 1},
|
||||
|
||||
{'label': 'Long-Term Liabilities', 'account_type_prefix': 'liability_non_current', 'sign': -1, 'level': 1},
|
||||
|
||||
{'label': 'TOTAL LIABILITIES', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'EQUITY', 'level': 0},
|
||||
{'label': 'Share Capital', 'account_type_prefix': 'equity', 'sign': -1, 'level': 1},
|
||||
{'label': 'Retained Earnings', 'account_type_prefix': 'equity_unaffected', 'sign': -1, 'level': 1},
|
||||
{'label': 'TOTAL EQUITY', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'TOTAL LIABILITIES + EQUITY', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
31
fusion_accounting_l10n_ca/data/report_ca_profit_loss.xml
Normal file
31
fusion_accounting_l10n_ca/data/report_ca_profit_loss.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_ca_profit_loss" model="fusion.report">
|
||||
<field name="name">Profit and Loss (Canada)</field>
|
||||
<field name="code">ca_profit_loss</field>
|
||||
<field name="report_type">pnl</field>
|
||||
<field name="sequence">12</field>
|
||||
<field name="default_comparison_mode">previous_year</field>
|
||||
<field name="description">Canadian-formatted income statement.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'OPERATING REVENUE', 'level': 0},
|
||||
{'label': 'Sales Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
|
||||
{'label': 'Other Operating Revenue', 'account_type_prefix': 'income_other', 'sign': -1, 'level': 1},
|
||||
{'label': 'Total Revenue', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'COST OF GOODS SOLD', 'level': 0},
|
||||
{'label': 'Direct Costs', 'account_type_prefix': 'expense_direct_cost', 'sign': -1, 'level': 1},
|
||||
|
||||
{'label': 'GROSS PROFIT', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'OPERATING EXPENSES', 'level': 0},
|
||||
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
|
||||
{'label': 'Depreciation', 'account_type_prefix': 'expense_depreciation', 'sign': -1, 'level': 1},
|
||||
|
||||
{'label': 'OPERATING INCOME', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'NET INCOME BEFORE TAX', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
1
fusion_accounting_l10n_ca/models/__init__.py
Normal file
1
fusion_accounting_l10n_ca/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import fusion_tax_return
|
||||
92
fusion_accounting_l10n_ca/models/fusion_tax_return.py
Normal file
92
fusion_accounting_l10n_ca/models/fusion_tax_return.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Fusion-native tax return tracking.
|
||||
|
||||
A simpler replacement for Enterprise's `account.return` model: a tax
|
||||
return is a (return_type, period_from, period_to, status) record. Filers
|
||||
mark them filed once submitted to CRA / Revenu Quebec / provincial
|
||||
authorities.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FusionTaxReturn(models.Model):
|
||||
_name = "fusion.tax.return"
|
||||
_inherit = ["mail.thread"]
|
||||
_description = "Fusion Tax Return Filing"
|
||||
_order = "date_to desc, id desc"
|
||||
|
||||
name = fields.Char(
|
||||
string="Reference",
|
||||
required=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
default=lambda self: _("New"),
|
||||
)
|
||||
return_type = fields.Selection(
|
||||
[
|
||||
("gst_hst", "GST/HST Return"),
|
||||
("pst", "PST Return"),
|
||||
("qst", "QST Return"),
|
||||
("t4", "T4 Slip"),
|
||||
("t5018", "T5018 Statement"),
|
||||
("payroll_remittance", "Payroll Source Deductions"),
|
||||
("other", "Other"),
|
||||
],
|
||||
required=True,
|
||||
default="gst_hst",
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
"res.company",
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
currency_id = fields.Many2one(related="company_id.currency_id")
|
||||
|
||||
date_from = fields.Date(string="Period Start", required=True)
|
||||
date_to = fields.Date(string="Period End", required=True)
|
||||
|
||||
state = fields.Selection(
|
||||
[
|
||||
("draft", "Draft"),
|
||||
("to_file", "To File"),
|
||||
("filed", "Filed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
default="draft",
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
filing_date = fields.Date(string="Filed On")
|
||||
filing_reference = fields.Char(string="Confirmation #")
|
||||
notes = fields.Text()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get("name", _("New")) == _("New"):
|
||||
vals["name"] = self.env["ir.sequence"].next_by_code(
|
||||
"fusion.tax.return"
|
||||
) or _("New")
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.constrains("date_from", "date_to")
|
||||
def _check_period(self):
|
||||
for r in self:
|
||||
if r.date_from and r.date_to and r.date_from > r.date_to:
|
||||
raise UserError(_("Period start must precede period end."))
|
||||
|
||||
def action_mark_filed(self):
|
||||
self.ensure_one()
|
||||
if self.state != "to_file":
|
||||
raise UserError(_("Can only mark 'To File' returns as filed."))
|
||||
self.write(
|
||||
{
|
||||
"state": "filed",
|
||||
"filing_date": fields.Date.context_today(self),
|
||||
}
|
||||
)
|
||||
return True
|
||||
3
fusion_accounting_l10n_ca/security/ir.model.access.csv
Normal file
3
fusion_accounting_l10n_ca/security/ir.model.access.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_tax_return_user,fusion.tax.return.user,model_fusion_tax_return,base.group_user,1,0,0,0
|
||||
access_fusion_tax_return_manager,fusion.tax.return.manager,model_fusion_tax_return,account.group_account_manager,1,1,1,1
|
||||
|
BIN
fusion_accounting_l10n_ca/static/description/icon.png
Normal file
BIN
fusion_accounting_l10n_ca/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
1
fusion_accounting_l10n_ca/tests/__init__.py
Normal file
1
fusion_accounting_l10n_ca/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_l10n_ca
|
||||
36
fusion_accounting_l10n_ca/tests/test_l10n_ca.py
Normal file
36
fusion_accounting_l10n_ca/tests/test_l10n_ca.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestL10nCa(TransactionCase):
|
||||
|
||||
def test_canadian_reports_seeded(self):
|
||||
Report = self.env["fusion.report"].sudo()
|
||||
ca_bs = Report.search([("code", "=", "ca_balance_sheet")], limit=1)
|
||||
ca_pl = Report.search([("code", "=", "ca_profit_loss")], limit=1)
|
||||
self.assertTrue(ca_bs, "ca_balance_sheet not seeded")
|
||||
self.assertTrue(ca_pl, "ca_profit_loss not seeded")
|
||||
self.assertEqual(ca_bs.report_type, "balance_sheet")
|
||||
self.assertEqual(ca_pl.report_type, "pnl")
|
||||
|
||||
def test_canadian_pnl_runs_via_engine(self):
|
||||
from odoo.addons.fusion_accounting_reports.services.date_periods import Period
|
||||
|
||||
period = Period(date(2025, 1, 1), date(2025, 12, 31), "FY 2025")
|
||||
result = self.env["fusion.report.engine"].compute_pnl(
|
||||
period, report_code="ca_profit_loss",
|
||||
)
|
||||
self.assertEqual(result["report_name"], "Profit and Loss (Canada)")
|
||||
self.assertGreater(len(result["rows"]), 0)
|
||||
|
||||
def test_tax_return_create(self):
|
||||
ret = self.env["fusion.tax.return"].create({
|
||||
"return_type": "gst_hst",
|
||||
"date_from": date(2025, 1, 1),
|
||||
"date_to": date(2025, 3, 31),
|
||||
})
|
||||
self.assertNotEqual(ret.name, "New")
|
||||
self.assertEqual(ret.state, "draft")
|
||||
2
fusion_accounting_ocr/__init__.py
Normal file
2
fusion_accounting_ocr/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
39
fusion_accounting_ocr/__manifest__.py
Normal file
39
fusion_accounting_ocr/__manifest__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Invoice OCR',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'OCR for vendor bills via tesseract + LLM-driven field extraction.',
|
||||
'description': """
|
||||
Fusion Accounting — Invoice OCR
|
||||
================================
|
||||
Replaces Enterprise's account_invoice_extract with a Fusion-native pipeline:
|
||||
|
||||
1. Tesseract OCRs the bill attachment (PDF or image) into raw text
|
||||
2. The fusion_accounting_ai LLMProvider parses the raw text into structured
|
||||
fields (vendor, invoice number, dates, amounts, line items)
|
||||
3. Draft invoice fields are populated for the AP user to confirm
|
||||
|
||||
Pluggable backend architecture: future Mindee, Google Document AI, or
|
||||
Ollama-vision adapters can be dropped in alongside the default tesseract
|
||||
adapter.
|
||||
""",
|
||||
'icon': '/fusion_accounting_ocr/static/description/icon.png',
|
||||
'author': 'Westin / Fusion Suite',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'account',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['pytesseract', 'pdf2image', 'PIL'],
|
||||
},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/account_move_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
],
|
||||
'auto_install': False,
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
1
fusion_accounting_ocr/controllers/__init__.py
Normal file
1
fusion_accounting_ocr/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import ocr_controller
|
||||
21
fusion_accounting_ocr/controllers/ocr_controller.py
Normal file
21
fusion_accounting_ocr/controllers/ocr_controller.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FusionOcrController(http.Controller):
|
||||
|
||||
@http.route('/fusion/ocr/request_for_invoice', type='jsonrpc', auth='user')
|
||||
def request_for_invoice(self, move_id):
|
||||
move = request.env['account.move'].browse(int(move_id))
|
||||
move.check_access('write')
|
||||
try:
|
||||
move.action_request_ocr()
|
||||
return {
|
||||
'status': 'ok',
|
||||
'state': move.ocr_state,
|
||||
'backend': move.ocr_backend,
|
||||
'confidence': move.ocr_confidence,
|
||||
'extracted': move.ocr_extracted_data,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
4
fusion_accounting_ocr/models/__init__.py
Normal file
4
fusion_accounting_ocr/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import fusion_ocr_log
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import account_move
|
||||
180
fusion_accounting_ocr/models/account_move.py
Normal file
180
fusion_accounting_ocr/models/account_move.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""account.move OCR extension.
|
||||
|
||||
Adds an OCR pipeline triggered manually (or, optionally, automatically when
|
||||
a PDF/image is attached). Stage 1 is tesseract text extraction; stage 2 is
|
||||
LLM field parsing through the existing fusion_accounting_ai adapter stack.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ..services.ocr_providers.tesseract_adapter import TesseractAdapter
|
||||
from ..services.ocr_providers.manual_adapter import ManualAdapter
|
||||
from ..services.invoice_field_parser import parse_invoice_fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SUPPORTED_MIMETYPES = (
|
||||
'application/pdf', 'image/png', 'image/jpeg', 'image/jpg',
|
||||
)
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
ocr_state = fields.Selection(
|
||||
[
|
||||
('not_requested', 'Not Requested'),
|
||||
('pending', 'Pending'),
|
||||
('processing', 'Processing'),
|
||||
('done', 'Done'),
|
||||
('failed', 'Failed'),
|
||||
('manual', 'Manual Entry'),
|
||||
],
|
||||
default='not_requested',
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
ocr_raw_text = fields.Text(
|
||||
string='OCR Raw Text', readonly=True, copy=False,
|
||||
help="Raw text extracted by the OCR backend.",
|
||||
)
|
||||
ocr_extracted_data = fields.Json(
|
||||
string='OCR Extracted Fields', readonly=True, copy=False,
|
||||
help="Structured invoice fields parsed from the OCR text by the LLM.",
|
||||
)
|
||||
ocr_backend = fields.Char(string='OCR Backend Used', readonly=True, copy=False)
|
||||
ocr_confidence = fields.Float(string='OCR Confidence', readonly=True, copy=False)
|
||||
ocr_log_ids = fields.One2many('fusion.ocr.log', 'move_id', string='OCR Runs')
|
||||
|
||||
def action_request_ocr(self):
|
||||
"""Run OCR on the most recent supported attachment of each move."""
|
||||
for move in self:
|
||||
if move.move_type not in ('in_invoice', 'in_refund'):
|
||||
raise UserError(_("OCR currently supports vendor bills only."))
|
||||
attachment = self.env['ir.attachment'].sudo().search(
|
||||
[
|
||||
('res_model', '=', 'account.move'),
|
||||
('res_id', '=', move.id),
|
||||
('mimetype', 'in', SUPPORTED_MIMETYPES),
|
||||
],
|
||||
order='create_date desc',
|
||||
limit=1,
|
||||
)
|
||||
if not attachment:
|
||||
raise UserError(
|
||||
_("No PDF or image attachment found on %s") % (move.name or move.id)
|
||||
)
|
||||
move._fusion_run_ocr(attachment)
|
||||
return True
|
||||
|
||||
def _fusion_run_ocr(self, attachment):
|
||||
self.ensure_one()
|
||||
self.ocr_state = 'processing'
|
||||
|
||||
backend_name = (
|
||||
self.company_id.fusion_ocr_default_backend
|
||||
if 'fusion_ocr_default_backend' in self.company_id._fields
|
||||
else 'tesseract'
|
||||
)
|
||||
provider = self._fusion_get_ocr_provider(backend_name)
|
||||
if not provider:
|
||||
self.ocr_state = 'manual'
|
||||
self.message_post(
|
||||
body=_("No OCR backend available; falling back to manual entry.")
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
data = base64.b64decode(attachment.datas)
|
||||
result = provider.extract(
|
||||
data, mimetype=attachment.mimetype or 'application/pdf'
|
||||
)
|
||||
|
||||
self.write({
|
||||
'ocr_raw_text': result.raw_text,
|
||||
'ocr_backend': result.backend,
|
||||
'ocr_confidence': result.confidence,
|
||||
})
|
||||
self.env['fusion.ocr.log'].sudo().create({
|
||||
'move_id': self.id,
|
||||
'backend': result.backend,
|
||||
'confidence': result.confidence,
|
||||
'raw_text_length': len(result.raw_text or ''),
|
||||
'pages': result.pages,
|
||||
'error': result.error,
|
||||
})
|
||||
|
||||
if not result.raw_text and result.error:
|
||||
self.ocr_state = 'failed'
|
||||
self.message_post(body=_("OCR failed: %s") % result.error)
|
||||
return False
|
||||
|
||||
parsed = parse_invoice_fields(self.env, result.raw_text)
|
||||
self.ocr_extracted_data = parsed
|
||||
self.ocr_state = 'done'
|
||||
|
||||
self._fusion_apply_ocr_fields(parsed)
|
||||
self.message_post(
|
||||
body=_("OCR complete: %s confidence %.0f%%") % (
|
||||
result.backend, (result.confidence or 0) * 100,
|
||||
)
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.exception("OCR run failed for move %s", self.id)
|
||||
self.ocr_state = 'failed'
|
||||
self.message_post(body=_("OCR error: %s") % e)
|
||||
return False
|
||||
|
||||
def _fusion_get_ocr_provider(self, backend_name):
|
||||
if backend_name == 'tesseract' and TesseractAdapter.is_available():
|
||||
return TesseractAdapter()
|
||||
if backend_name == 'manual':
|
||||
return ManualAdapter()
|
||||
# Future adapters (mindee, google_doc_ai, ollama_vision) plug in
|
||||
# here. Fall back to whichever adapter is actually usable.
|
||||
if TesseractAdapter.is_available():
|
||||
return TesseractAdapter()
|
||||
return ManualAdapter()
|
||||
|
||||
def _fusion_apply_ocr_fields(self, parsed):
|
||||
"""Apply parsed fields to a draft invoice without overwriting any
|
||||
user-entered data. No-op on posted/cancelled invoices."""
|
||||
if self.state != 'draft':
|
||||
return
|
||||
|
||||
vals = {}
|
||||
if parsed.get('invoice_date') and not self.invoice_date:
|
||||
try:
|
||||
vals['invoice_date'] = parsed['invoice_date']
|
||||
except Exception:
|
||||
pass
|
||||
if parsed.get('due_date') and not self.invoice_date_due:
|
||||
try:
|
||||
vals['invoice_date_due'] = parsed['due_date']
|
||||
except Exception:
|
||||
pass
|
||||
if parsed.get('invoice_number') and not self.ref:
|
||||
vals['ref'] = parsed['invoice_number']
|
||||
|
||||
# Vendor: best-effort name match against existing supplier partners.
|
||||
# Never auto-create a partner; AP user confirms ambiguous matches.
|
||||
if parsed.get('vendor_name') and not self.partner_id:
|
||||
partner = self.env['res.partner'].sudo().search(
|
||||
[
|
||||
('name', '=ilike', parsed['vendor_name']),
|
||||
('supplier_rank', '>', 0),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
if partner:
|
||||
vals['partner_id'] = partner.id
|
||||
|
||||
if vals:
|
||||
self.write(vals)
|
||||
17
fusion_accounting_ocr/models/fusion_ocr_log.py
Normal file
17
fusion_accounting_ocr/models/fusion_ocr_log.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionOcrLog(models.Model):
|
||||
_name = 'fusion.ocr.log'
|
||||
_description = 'Fusion OCR Run Log'
|
||||
_order = 'create_date desc'
|
||||
|
||||
move_id = fields.Many2one(
|
||||
'account.move', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
backend = fields.Char(required=True)
|
||||
confidence = fields.Float()
|
||||
raw_text_length = fields.Integer()
|
||||
pages = fields.Integer()
|
||||
error = fields.Text()
|
||||
create_date = fields.Datetime(readonly=True)
|
||||
26
fusion_accounting_ocr/models/res_company.py
Normal file
26
fusion_accounting_ocr/models/res_company.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
fusion_ocr_enabled = fields.Boolean(
|
||||
string='Enable Invoice OCR',
|
||||
default=False,
|
||||
help="When enabled, vendor bill attachments can be OCR'd via the "
|
||||
"configured backend.",
|
||||
)
|
||||
fusion_ocr_default_backend = fields.Selection(
|
||||
[
|
||||
('tesseract', 'Tesseract (local, free)'),
|
||||
('manual', 'Manual entry only'),
|
||||
],
|
||||
default='tesseract',
|
||||
string='Default OCR Backend',
|
||||
)
|
||||
fusion_ocr_auto_run = fields.Boolean(
|
||||
string='Auto-run OCR on attachment',
|
||||
default=False,
|
||||
help="When enabled, OCR runs automatically when a PDF/image is "
|
||||
"attached to a vendor bill.",
|
||||
)
|
||||
15
fusion_accounting_ocr/models/res_config_settings.py
Normal file
15
fusion_accounting_ocr/models/res_config_settings.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
fusion_ocr_enabled = fields.Boolean(
|
||||
related='company_id.fusion_ocr_enabled', readonly=False,
|
||||
)
|
||||
fusion_ocr_default_backend = fields.Selection(
|
||||
related='company_id.fusion_ocr_default_backend', readonly=False,
|
||||
)
|
||||
fusion_ocr_auto_run = fields.Boolean(
|
||||
related='company_id.fusion_ocr_auto_run', readonly=False,
|
||||
)
|
||||
3
fusion_accounting_ocr/security/ir.model.access.csv
Normal file
3
fusion_accounting_ocr/security/ir.model.access.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_ocr_log_user,fusion.ocr.log.user,model_fusion_ocr_log,base.group_user,1,0,0,0
|
||||
access_fusion_ocr_log_manager,fusion.ocr.log.manager,model_fusion_ocr_log,account.group_account_manager,1,1,1,1
|
||||
|
3
fusion_accounting_ocr/services/__init__.py
Normal file
3
fusion_accounting_ocr/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import ocr_providers
|
||||
from . import attachment_to_image
|
||||
from . import invoice_field_parser
|
||||
43
fusion_accounting_ocr/services/attachment_to_image.py
Normal file
43
fusion_accounting_ocr/services/attachment_to_image.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Helper: turn an ir.attachment into a list of PIL.Image pages.
|
||||
|
||||
Kept separate from the adapters so future backends (Ollama-vision, Mindee)
|
||||
that want PIL images directly don't have to re-implement the PDF rendering.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def attachment_to_pages(attachment):
|
||||
"""Decode an ir.attachment into a list of PIL.Image pages.
|
||||
|
||||
Returns ``[]`` on failure (caller should treat as no pages).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
from pdf2image import convert_from_bytes
|
||||
except ImportError as e:
|
||||
_logger.warning("attachment_to_pages requires PIL + pdf2image: %s", e)
|
||||
return []
|
||||
|
||||
if not attachment or not attachment.datas:
|
||||
return []
|
||||
|
||||
try:
|
||||
data = base64.b64decode(attachment.datas)
|
||||
except Exception as e:
|
||||
_logger.warning("Could not decode attachment %s: %s", attachment.id, e)
|
||||
return []
|
||||
|
||||
mimetype = attachment.mimetype or ''
|
||||
is_pdf = mimetype == 'application/pdf' or data[:4] == b'%PDF'
|
||||
try:
|
||||
if is_pdf:
|
||||
return convert_from_bytes(data, dpi=200)
|
||||
return [Image.open(io.BytesIO(data))]
|
||||
except Exception as e:
|
||||
_logger.warning("Could not render attachment %s: %s", attachment.id, e)
|
||||
return []
|
||||
150
fusion_accounting_ocr/services/invoice_field_parser.py
Normal file
150
fusion_accounting_ocr/services/invoice_field_parser.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Stage-2 of the OCR pipeline: parse raw OCR text into structured invoice
|
||||
fields via the configured LLM provider.
|
||||
|
||||
Mirrors the pattern in fusion_accounting_followup/services/followup_text_generator.py:
|
||||
look up an adapter by ir.config_parameter, fall back gracefully when no
|
||||
provider is configured, and never let an LLM hiccup nuke the OCR result.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are an invoice field extraction assistant. You read raw OCR text "
|
||||
"from vendor bills and return a strict JSON object with the requested "
|
||||
"fields. You never include commentary or markdown fences. When a field "
|
||||
"cannot be determined from the text you return null for that field."
|
||||
)
|
||||
|
||||
USER_PROMPT = """Given the raw OCR text of a vendor bill, return a JSON object
|
||||
with these fields (use null when unclear):
|
||||
|
||||
{{
|
||||
"vendor_name": <string, the seller/vendor company name>,
|
||||
"invoice_number": <string, the bill or invoice reference number>,
|
||||
"invoice_date": <string, ISO format YYYY-MM-DD>,
|
||||
"due_date": <string or null, ISO format YYYY-MM-DD>,
|
||||
"currency": <string, ISO 4217 code like CAD/USD/EUR>,
|
||||
"subtotal": <number or null>,
|
||||
"tax_total": <number or null>,
|
||||
"total": <number, the grand total amount due>,
|
||||
"line_items": [
|
||||
{{"description": <string>, "quantity": <number or null>,
|
||||
"unit_price": <number or null>, "amount": <number or null>}}
|
||||
]
|
||||
}}
|
||||
|
||||
Return ONLY valid JSON, no commentary, no markdown fences.
|
||||
|
||||
Raw OCR text:
|
||||
---
|
||||
{text}
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
def parse_invoice_fields(env, raw_text: str, *, provider=None) -> dict:
|
||||
"""Use the configured LLM provider to extract structured invoice fields.
|
||||
|
||||
Returns a dict with the schema above. On any failure (no provider, bad
|
||||
JSON, network error, etc.) returns an all-null result so the OCR raw
|
||||
text is still preserved for the AP user.
|
||||
"""
|
||||
if not raw_text or not raw_text.strip():
|
||||
return _empty_result()
|
||||
|
||||
if provider is None:
|
||||
provider = _get_provider(env)
|
||||
if provider is None:
|
||||
_logger.info(
|
||||
"No LLM provider configured for OCR field parsing; "
|
||||
"raw OCR text preserved, fields left empty."
|
||||
)
|
||||
return _empty_result()
|
||||
|
||||
try:
|
||||
truncated = raw_text[:12000]
|
||||
user = USER_PROMPT.format(text=truncated)
|
||||
response = provider.complete(
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[{'role': 'user', 'content': user}],
|
||||
max_tokens=1000,
|
||||
temperature=0.1,
|
||||
)
|
||||
content = response.get('content') if isinstance(response, dict) else response
|
||||
if not content:
|
||||
return _empty_result()
|
||||
|
||||
# LLMs sometimes wrap JSON in ```json ... ``` despite instructions.
|
||||
content = content.strip()
|
||||
if content.startswith('```'):
|
||||
content = content.split('```', 2)[1]
|
||||
if content.startswith('json'):
|
||||
content = content[4:]
|
||||
content = content.rsplit('```', 1)[0]
|
||||
|
||||
parsed = json.loads(content.strip())
|
||||
return {
|
||||
'vendor_name': parsed.get('vendor_name'),
|
||||
'invoice_number': parsed.get('invoice_number'),
|
||||
'invoice_date': parsed.get('invoice_date'),
|
||||
'due_date': parsed.get('due_date'),
|
||||
'currency': parsed.get('currency'),
|
||||
'subtotal': parsed.get('subtotal'),
|
||||
'tax_total': parsed.get('tax_total'),
|
||||
'total': parsed.get('total'),
|
||||
'line_items': parsed.get('line_items') or [],
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
_logger.warning("LLM returned non-JSON for OCR field parsing: %s", e)
|
||||
return _empty_result()
|
||||
except Exception as e:
|
||||
_logger.warning("OCR field parsing failed: %s", e)
|
||||
return _empty_result()
|
||||
|
||||
|
||||
def _empty_result():
|
||||
return {
|
||||
'vendor_name': None,
|
||||
'invoice_number': None,
|
||||
'invoice_date': None,
|
||||
'due_date': None,
|
||||
'currency': None,
|
||||
'subtotal': None,
|
||||
'tax_total': None,
|
||||
'total': None,
|
||||
'line_items': [],
|
||||
}
|
||||
|
||||
|
||||
def _get_provider(env):
|
||||
"""Look up the LLM adapter via ir.config_parameter.
|
||||
|
||||
Honours a feature-specific override
|
||||
(``fusion_accounting.provider.ocr_field_parsing``) before falling back
|
||||
to the suite-wide default (``fusion_accounting.provider.default``).
|
||||
Returns None when no adapter is configured/importable.
|
||||
"""
|
||||
param = env['ir.config_parameter'].sudo()
|
||||
name = param.get_param('fusion_accounting.provider.ocr_field_parsing')
|
||||
if not name:
|
||||
name = param.get_param('fusion_accounting.provider.default')
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
if name.startswith('openai'):
|
||||
return OpenAIAdapter(env)
|
||||
if name.startswith('claude'):
|
||||
return ClaudeAdapter(env)
|
||||
except Exception as e:
|
||||
_logger.warning("OCR field parser could not instantiate %s: %s", name, e)
|
||||
return None
|
||||
return None
|
||||
3
fusion_accounting_ocr/services/ocr_providers/__init__.py
Normal file
3
fusion_accounting_ocr/services/ocr_providers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import base
|
||||
from . import tesseract_adapter
|
||||
from . import manual_adapter
|
||||
40
fusion_accounting_ocr/services/ocr_providers/base.py
Normal file
40
fusion_accounting_ocr/services/ocr_providers/base.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""OCRProvider contract - every backend must conform.
|
||||
|
||||
Mirrors the LLMProvider pattern in fusion_accounting_ai. Future adapters
|
||||
(Mindee, Google Document AI, Ollama-vision) drop in alongside the default
|
||||
tesseract adapter without touching account.move.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCRResult:
|
||||
raw_text: str = ''
|
||||
confidence: float = 0.0 # 0.0–1.0
|
||||
pages: int = 0
|
||||
backend: str = ''
|
||||
error: str = ''
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class OCRProvider(ABC):
|
||||
"""Abstract OCR backend. Subclasses implement extract()."""
|
||||
|
||||
name: str = 'base'
|
||||
|
||||
@abstractmethod
|
||||
def extract(self, image_or_pdf_bytes: bytes, *, mimetype: str = 'application/pdf') -> OCRResult:
|
||||
"""Extract text from raw bytes.
|
||||
|
||||
``mimetype`` hints whether to PDF-render (poppler) or image-decode
|
||||
(PIL) the bytes. Implementations should still inspect the byte
|
||||
signature for safety.
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
"""Return True if the backend's runtime deps are present."""
|
||||
return True
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Manual fallback adapter - no real OCR, just marks the document as
|
||||
'awaiting manual entry'. Used when no real OCR backend is available
|
||||
or when the user explicitly disables OCR.
|
||||
"""
|
||||
|
||||
from .base import OCRProvider, OCRResult
|
||||
|
||||
|
||||
class ManualAdapter(OCRProvider):
|
||||
name = 'manual'
|
||||
|
||||
def extract(self, image_or_pdf_bytes, *, mimetype='application/pdf'):
|
||||
return OCRResult(raw_text='', confidence=0.0, pages=0, backend='manual')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tesseract OCR adapter.
|
||||
|
||||
Uses the system tesseract binary via pytesseract, with poppler-backed
|
||||
PDF rendering via pdf2image. Inside the container these are pre-installed:
|
||||
- tesseract-ocr 5.3.4
|
||||
- pytesseract 0.3.13
|
||||
- pdf2image 1.17.0
|
||||
- poppler-utils
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
|
||||
from .base import OCRProvider, OCRResult
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TesseractAdapter(OCRProvider):
|
||||
name = 'tesseract'
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
try:
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_bytes # noqa: F401
|
||||
from PIL import Image # noqa: F401
|
||||
pytesseract.get_tesseract_version()
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.debug("TesseractAdapter not available: %s", e)
|
||||
return False
|
||||
|
||||
def extract(self, image_or_pdf_bytes, *, mimetype='application/pdf'):
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_bytes
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
is_pdf = (
|
||||
mimetype == 'application/pdf'
|
||||
or (image_or_pdf_bytes[:4] == b'%PDF')
|
||||
)
|
||||
if is_pdf:
|
||||
pages = convert_from_bytes(image_or_pdf_bytes, dpi=200)
|
||||
else:
|
||||
img = Image.open(io.BytesIO(image_or_pdf_bytes))
|
||||
pages = [img]
|
||||
|
||||
texts = []
|
||||
for p in pages:
|
||||
texts.append(pytesseract.image_to_string(p))
|
||||
full_text = '\n\f\n'.join(texts)
|
||||
|
||||
# Heuristic confidence - tesseract has a per-word conf in
|
||||
# image_to_data, but a length proxy is fine for routing
|
||||
# decisions. Future: use pytesseract.image_to_data for a real
|
||||
# average word-level confidence.
|
||||
conf = min(1.0, len(full_text) / 1000.0)
|
||||
return OCRResult(
|
||||
raw_text=full_text,
|
||||
confidence=conf,
|
||||
pages=len(pages),
|
||||
backend='tesseract',
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning("Tesseract OCR failed: %s", e)
|
||||
return OCRResult(
|
||||
raw_text='', confidence=0.0, pages=0,
|
||||
backend='tesseract', error=str(e),
|
||||
)
|
||||
BIN
fusion_accounting_ocr/static/description/icon.png
Normal file
BIN
fusion_accounting_ocr/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
3
fusion_accounting_ocr/tests/__init__.py
Normal file
3
fusion_accounting_ocr/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import test_tesseract_adapter
|
||||
from . import test_invoice_ocr_flow
|
||||
from . import test_field_parser
|
||||
74
fusion_accounting_ocr/tests/test_field_parser.py
Normal file
74
fusion_accounting_ocr/tests/test_field_parser.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.addons.fusion_accounting_ocr.services.invoice_field_parser import (
|
||||
parse_invoice_fields,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFieldParser(TransactionCase):
|
||||
|
||||
def test_parser_handles_empty_text(self):
|
||||
result = parse_invoice_fields(self.env, '')
|
||||
self.assertIsNone(result['total'])
|
||||
self.assertEqual(result['line_items'], [])
|
||||
|
||||
def test_parser_handles_no_provider_gracefully(self):
|
||||
# Without an LLM provider configured, parse should return an empty
|
||||
# result dict rather than crashing.
|
||||
result = parse_invoice_fields(self.env, 'INVOICE 12345 Total $100')
|
||||
self.assertIn('total', result)
|
||||
self.assertIn('line_items', result)
|
||||
self.assertIsInstance(result['line_items'], list)
|
||||
|
||||
def test_parser_consumes_clean_json(self):
|
||||
provider = MagicMock()
|
||||
provider.complete.return_value = {
|
||||
'content': (
|
||||
'{"vendor_name": "Acme Co", "invoice_number": "INV-1",'
|
||||
' "invoice_date": "2026-04-20", "due_date": null,'
|
||||
' "currency": "CAD", "subtotal": 90.0, "tax_total": 10.0,'
|
||||
' "total": 100.0, "line_items": ['
|
||||
'{"description": "Widget", "quantity": 1, "unit_price": 90.0,'
|
||||
' "amount": 90.0}]}'
|
||||
),
|
||||
}
|
||||
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
|
||||
self.assertEqual(result['vendor_name'], 'Acme Co')
|
||||
self.assertEqual(result['invoice_number'], 'INV-1')
|
||||
self.assertEqual(result['total'], 100.0)
|
||||
self.assertEqual(len(result['line_items']), 1)
|
||||
self.assertEqual(result['line_items'][0]['description'], 'Widget')
|
||||
|
||||
def test_parser_strips_markdown_fences(self):
|
||||
provider = MagicMock()
|
||||
provider.complete.return_value = {
|
||||
'content': (
|
||||
'```json\n'
|
||||
'{"vendor_name": "Beta Ltd", "invoice_number": "B-2",'
|
||||
' "invoice_date": null, "due_date": null, "currency": null,'
|
||||
' "subtotal": null, "tax_total": null, "total": 5.5,'
|
||||
' "line_items": []}\n'
|
||||
'```'
|
||||
),
|
||||
}
|
||||
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
|
||||
self.assertEqual(result['vendor_name'], 'Beta Ltd')
|
||||
self.assertEqual(result['total'], 5.5)
|
||||
|
||||
def test_parser_returns_empty_on_invalid_json(self):
|
||||
provider = MagicMock()
|
||||
provider.complete.return_value = {'content': 'not json at all'}
|
||||
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
|
||||
self.assertIsNone(result['total'])
|
||||
self.assertEqual(result['line_items'], [])
|
||||
|
||||
def test_parser_returns_empty_on_provider_exception(self):
|
||||
provider = MagicMock()
|
||||
provider.complete.side_effect = RuntimeError('boom')
|
||||
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
|
||||
self.assertIsNone(result['total'])
|
||||
self.assertEqual(result['line_items'], [])
|
||||
117
fusion_accounting_ocr/tests/test_invoice_ocr_flow.py
Normal file
117
fusion_accounting_ocr/tests/test_invoice_ocr_flow.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import base64
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestInvoiceOcrFlow(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'Test Vendor',
|
||||
'supplier_rank': 1,
|
||||
})
|
||||
self.move = self.env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
def test_ocr_state_default(self):
|
||||
self.assertEqual(self.move.ocr_state, 'not_requested')
|
||||
|
||||
def test_action_request_ocr_no_attachment_raises(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.move.action_request_ocr()
|
||||
|
||||
def test_action_request_ocr_with_image(self):
|
||||
img = Image.new('RGB', (800, 120), color='white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
from PIL import ImageFont
|
||||
font = ImageFont.truetype(
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36,
|
||||
)
|
||||
except Exception:
|
||||
font = None
|
||||
draw.text((20, 30), "TOTAL $50.00 INV-9999", fill='black', font=font)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
|
||||
self.env['ir.attachment'].create({
|
||||
'name': 'test_invoice.png',
|
||||
'datas': base64.b64encode(buf.getvalue()),
|
||||
'res_model': 'account.move',
|
||||
'res_id': self.move.id,
|
||||
'mimetype': 'image/png',
|
||||
})
|
||||
|
||||
# Mock the LLM call to avoid a real API roundtrip.
|
||||
with patch(
|
||||
'odoo.addons.fusion_accounting_ocr.models.account_move.parse_invoice_fields',
|
||||
return_value={
|
||||
'vendor_name': None,
|
||||
'invoice_number': 'INV-9999',
|
||||
'invoice_date': None,
|
||||
'due_date': None,
|
||||
'currency': None,
|
||||
'subtotal': None,
|
||||
'tax_total': None,
|
||||
'total': 50.0,
|
||||
'line_items': [],
|
||||
},
|
||||
):
|
||||
self.move.action_request_ocr()
|
||||
|
||||
self.assertEqual(self.move.ocr_state, 'done')
|
||||
self.assertEqual(self.move.ocr_backend, 'tesseract')
|
||||
self.assertGreater(self.move.ocr_confidence, 0)
|
||||
self.assertIsNotNone(self.move.ocr_extracted_data)
|
||||
# Parsed invoice_number should land on the invoice's ref field.
|
||||
self.assertEqual(self.move.ref, 'INV-9999')
|
||||
# OCR log row was created.
|
||||
self.assertEqual(len(self.move.ocr_log_ids), 1)
|
||||
log = self.move.ocr_log_ids
|
||||
self.assertEqual(log.backend, 'tesseract')
|
||||
self.assertGreater(log.raw_text_length, 0)
|
||||
|
||||
def test_apply_does_not_overwrite_user_entered_ref(self):
|
||||
self.move.ref = 'USER-SET-REF'
|
||||
img = Image.new('RGB', (400, 80), color='white')
|
||||
ImageDraw.Draw(img).text((10, 30), "INV-7777", fill='black')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
self.env['ir.attachment'].create({
|
||||
'name': 't.png',
|
||||
'datas': base64.b64encode(buf.getvalue()),
|
||||
'res_model': 'account.move',
|
||||
'res_id': self.move.id,
|
||||
'mimetype': 'image/png',
|
||||
})
|
||||
with patch(
|
||||
'odoo.addons.fusion_accounting_ocr.models.account_move.parse_invoice_fields',
|
||||
return_value={
|
||||
'vendor_name': None, 'invoice_number': 'INV-7777',
|
||||
'invoice_date': None, 'due_date': None, 'currency': None,
|
||||
'subtotal': None, 'tax_total': None, 'total': None,
|
||||
'line_items': [],
|
||||
},
|
||||
):
|
||||
self.move.action_request_ocr()
|
||||
|
||||
# User-entered ref must not be overwritten.
|
||||
self.assertEqual(self.move.ref, 'USER-SET-REF')
|
||||
|
||||
def test_only_vendor_bills_supported(self):
|
||||
customer_invoice = self.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
with self.assertRaises(UserError):
|
||||
customer_invoice.action_request_ocr()
|
||||
47
fusion_accounting_ocr/tests/test_tesseract_adapter.py
Normal file
47
fusion_accounting_ocr/tests/test_tesseract_adapter.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.addons.fusion_accounting_ocr.services.ocr_providers.tesseract_adapter import (
|
||||
TesseractAdapter,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTesseractAdapter(TransactionCase):
|
||||
|
||||
def test_is_available(self):
|
||||
# In our container tesseract + pytesseract + pdf2image are pre-installed.
|
||||
self.assertTrue(TesseractAdapter.is_available())
|
||||
|
||||
def test_extract_simple_text_image(self):
|
||||
# Generate a tiny PNG with the text "INVOICE 12345 Total $100".
|
||||
# Use a slightly larger image and try to load a TTF font for
|
||||
# tesseract reliability; fall back to default bitmap font otherwise.
|
||||
img = Image.new('RGB', (800, 120), color='white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
from PIL import ImageFont
|
||||
font = ImageFont.truetype(
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36,
|
||||
)
|
||||
except Exception:
|
||||
font = None
|
||||
draw.text((20, 30), "INVOICE 12345 Total $100", fill='black', font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
png_bytes = buf.getvalue()
|
||||
|
||||
adapter = TesseractAdapter()
|
||||
result = adapter.extract(png_bytes, mimetype='image/png')
|
||||
|
||||
self.assertEqual(result.backend, 'tesseract')
|
||||
self.assertEqual(result.error, '')
|
||||
self.assertEqual(result.pages, 1)
|
||||
self.assertGreater(len(result.raw_text), 0)
|
||||
# Tesseract should pick up the digits at minimum.
|
||||
self.assertIn('12345', result.raw_text.replace(' ', ''))
|
||||
45
fusion_accounting_ocr/views/account_move_views.xml
Normal file
45
fusion_accounting_ocr/views/account_move_views.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_move_form_inherit_fusion_ocr" model="ir.ui.view">
|
||||
<field name="name">account.move.form.inherit.fusion_ocr</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_request_ocr"
|
||||
type="object"
|
||||
string="Request OCR"
|
||||
class="oe_highlight"
|
||||
invisible="move_type not in ('in_invoice', 'in_refund') or ocr_state in ('processing', 'done')"/>
|
||||
<button name="action_request_ocr"
|
||||
type="object"
|
||||
string="Re-run OCR"
|
||||
invisible="move_type not in ('in_invoice', 'in_refund') or ocr_state not in ('done', 'failed', 'manual')"/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion OCR"
|
||||
invisible="move_type not in ('in_invoice', 'in_refund') or ocr_state == 'not_requested'">
|
||||
<group>
|
||||
<field name="ocr_state" widget="badge"
|
||||
decoration-success="ocr_state == 'done'"
|
||||
decoration-info="ocr_state == 'processing'"
|
||||
decoration-warning="ocr_state == 'manual'"
|
||||
decoration-danger="ocr_state == 'failed'"/>
|
||||
<field name="ocr_backend" readonly="1"/>
|
||||
<field name="ocr_confidence" readonly="1" widget="percentage"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="ocr_extracted_data" readonly="1" widget="text"/>
|
||||
</group>
|
||||
<field name="ocr_raw_text" readonly="1" nolabel="1"
|
||||
placeholder="Raw OCR text..."/>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
35
fusion_accounting_ocr/views/res_config_settings_views.xml
Normal file
35
fusion_accounting_ocr/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form_inherit_fusion_ocr" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.fusion_ocr</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//block[@id='account_vendor_bills']" position="after">
|
||||
<block title="Fusion Invoice OCR" id="fusion_ocr_section">
|
||||
<setting id="fusion_ocr_enabled_setting"
|
||||
string="Enable Invoice OCR"
|
||||
help="OCR vendor bill attachments via the configured backend.">
|
||||
<field name="fusion_ocr_enabled"/>
|
||||
<div class="content-group" invisible="not fusion_ocr_enabled">
|
||||
<div class="mt16">
|
||||
<label for="fusion_ocr_default_backend"
|
||||
string="Default OCR Backend" class="o_light_label"/>
|
||||
<field name="fusion_ocr_default_backend"/>
|
||||
</div>
|
||||
<div class="mt16">
|
||||
<field name="fusion_ocr_auto_run"/>
|
||||
<label for="fusion_ocr_auto_run"
|
||||
string="Auto-run OCR on attachment"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -36,6 +36,13 @@ menu hides; the engine and AI tools remain available for the chat.
|
||||
'data/report_balance_sheet.xml',
|
||||
'data/report_trial_balance.xml',
|
||||
'data/report_general_ledger.xml',
|
||||
'data/report_cash_flow.xml',
|
||||
'data/report_executive_summary.xml',
|
||||
'data/report_tax_report.xml',
|
||||
'data/report_annual_statements.xml',
|
||||
'data/report_aged_receivable.xml',
|
||||
'data/report_aged_payable.xml',
|
||||
'data/report_partner_ledger.xml',
|
||||
'data/cron.xml',
|
||||
'reports/report_pdf_template.xml',
|
||||
'wizards/xlsx_export_wizard_views.xml',
|
||||
|
||||
@@ -18,7 +18,16 @@ from ..services.date_periods import Period
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'}
|
||||
REPORT_TYPES = {
|
||||
'pnl', 'balance_sheet', 'trial_balance', 'general_ledger',
|
||||
'aged_receivable', 'aged_payable', 'partner_ledger',
|
||||
}
|
||||
|
||||
PARTNER_GROUPED_ACCOUNT_TYPE = {
|
||||
'aged_receivable': 'asset_receivable',
|
||||
'aged_payable': 'liability_payable',
|
||||
'partner_ledger': 'asset_receivable',
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
@@ -56,7 +65,7 @@ class FusionReportsController(http.Controller):
|
||||
|
||||
@http.route('/fusion/reports/run', type='jsonrpc', auth='user')
|
||||
def run(self, report_type, date_from=None, date_to=None,
|
||||
comparison='none', company_id=None):
|
||||
comparison='none', company_id=None, report_code=None):
|
||||
if report_type not in REPORT_TYPES:
|
||||
raise ValidationError(_("Unknown report type: %s") % report_type)
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
@@ -66,19 +75,33 @@ class FusionReportsController(http.Controller):
|
||||
period = _build_period(date_from, date_to)
|
||||
return engine.compute_pnl(
|
||||
period, comparison=comparison, company_id=company_id,
|
||||
report_code=report_code,
|
||||
)
|
||||
if report_type == 'balance_sheet':
|
||||
return engine.compute_balance_sheet(
|
||||
_parse_date(date_to),
|
||||
comparison=comparison,
|
||||
company_id=company_id,
|
||||
report_code=report_code,
|
||||
)
|
||||
if report_type == 'trial_balance':
|
||||
period = _build_period(date_from, date_to)
|
||||
return engine.compute_trial_balance(period, company_id=company_id)
|
||||
return engine.compute_trial_balance(
|
||||
period, company_id=company_id, report_code=report_code,
|
||||
)
|
||||
if report_type in PARTNER_GROUPED_ACCOUNT_TYPE:
|
||||
period = _build_period(date_from, date_to)
|
||||
return engine.compute_partner_grouped(
|
||||
period,
|
||||
account_type=PARTNER_GROUPED_ACCOUNT_TYPE[report_type],
|
||||
comparison=comparison,
|
||||
company_id=company_id,
|
||||
)
|
||||
# general_ledger
|
||||
period = _build_period(date_from, date_to)
|
||||
return engine.compute_gl(period, company_id=company_id)
|
||||
return engine.compute_gl(
|
||||
period, company_id=company_id, report_code=report_code,
|
||||
)
|
||||
|
||||
@http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user')
|
||||
def drill_down(self, account_id, date_from, date_to, company_id=None):
|
||||
|
||||
14
fusion_accounting_reports/data/report_aged_payable.xml
Normal file
14
fusion_accounting_reports/data/report_aged_payable.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_aged_payable" model="fusion.report">
|
||||
<field name="name">Aged Payable</field>
|
||||
<field name="code">aged_payable</field>
|
||||
<field name="report_type">aged_payable</field>
|
||||
<field name="sequence">36</field>
|
||||
<field name="description">Per-vendor outstanding payables, bucketed by aging.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Aged Payable', 'account_type_for_grouping': 'liability_payable'}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
14
fusion_accounting_reports/data/report_aged_receivable.xml
Normal file
14
fusion_accounting_reports/data/report_aged_receivable.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_aged_receivable" model="fusion.report">
|
||||
<field name="name">Aged Receivable</field>
|
||||
<field name="code">aged_receivable</field>
|
||||
<field name="report_type">aged_receivable</field>
|
||||
<field name="sequence">35</field>
|
||||
<field name="description">Per-customer outstanding receivables, bucketed by aging.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Aged Receivable', 'account_type_for_grouping': 'asset_receivable'}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
19
fusion_accounting_reports/data/report_annual_statements.xml
Normal file
19
fusion_accounting_reports/data/report_annual_statements.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_annual_statements" model="fusion.report">
|
||||
<field name="name">Annual Statements</field>
|
||||
<field name="code">annual_statements</field>
|
||||
<field name="report_type">pnl</field>
|
||||
<field name="sequence">11</field>
|
||||
<field name="default_comparison_mode">previous_year</field>
|
||||
<field name="description">Year-over-year P&L comparison for annual reporting.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
|
||||
{'label': 'Cost of Goods Sold', 'account_type_prefix': 'expense_direct_cost', 'sign': -1, 'level': 1},
|
||||
{'label': 'Gross Profit', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
|
||||
{'label': 'OPERATING INCOME', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
29
fusion_accounting_reports/data/report_cash_flow.xml
Normal file
29
fusion_accounting_reports/data/report_cash_flow.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_cash_flow" model="fusion.report">
|
||||
<field name="name">Cash Flow Statement</field>
|
||||
<field name="code">cash_flow</field>
|
||||
<field name="report_type">pnl</field>
|
||||
<field name="sequence">15</field>
|
||||
<field name="default_comparison_mode">previous_year</field>
|
||||
<field name="description">Cash flow by activity (operating, investing, financing).</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Operating Activities', 'level': 0},
|
||||
{'label': 'Net Income (from operations)', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
|
||||
{'label': 'Depreciation Add-back', 'account_type_prefix': 'expense_depreciation', 'sign': 1, 'level': 1},
|
||||
{'label': 'Operating Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'Investing Activities', 'level': 0},
|
||||
{'label': 'Fixed Asset Purchases', 'account_type_prefix': 'asset_fixed', 'sign': -1, 'level': 1},
|
||||
{'label': 'Investing Cash Flow', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'Financing Activities', 'level': 0},
|
||||
{'label': 'Liabilities (long-term)', 'account_type_prefix': 'liability_non_current', 'sign': 1, 'level': 1},
|
||||
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': 1, 'level': 1},
|
||||
{'label': 'Financing Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'NET CHANGE IN CASH', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
24
fusion_accounting_reports/data/report_executive_summary.xml
Normal file
24
fusion_accounting_reports/data/report_executive_summary.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_executive_summary" model="fusion.report">
|
||||
<field name="name">Executive Summary</field>
|
||||
<field name="code">executive_summary</field>
|
||||
<field name="report_type">pnl</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="default_comparison_mode">previous_year</field>
|
||||
<field name="description">Top-level KPI summary: revenue, expenses, net income, key balance positions.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'PROFIT & LOSS', 'level': 0},
|
||||
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
|
||||
{'label': 'Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
|
||||
{'label': 'Net Income', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
|
||||
{'label': 'BALANCE POSITIONS', 'level': 0},
|
||||
{'label': 'Cash & Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 1},
|
||||
{'label': 'Receivables', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 1},
|
||||
{'label': 'Payables', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 1},
|
||||
{'label': 'Net Working Position', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
14
fusion_accounting_reports/data/report_partner_ledger.xml
Normal file
14
fusion_accounting_reports/data/report_partner_ledger.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_partner_ledger" model="fusion.report">
|
||||
<field name="name">Partner Ledger</field>
|
||||
<field name="code">partner_ledger</field>
|
||||
<field name="report_type">partner_ledger</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="description">Per-partner ledger combining receivable and payable activity.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Partner Ledger', 'account_type_for_grouping': 'asset_receivable'}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
16
fusion_accounting_reports/data/report_tax_report.xml
Normal file
16
fusion_accounting_reports/data/report_tax_report.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="report_tax_summary" model="fusion.report">
|
||||
<field name="name">Tax Summary</field>
|
||||
<field name="code">tax_summary</field>
|
||||
<field name="report_type">trial_balance</field>
|
||||
<field name="sequence">25</field>
|
||||
<field name="description">Tax liability + asset positions. v1: aggregate-level only; per-tax-code breakdown is Phase 2.5.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Tax Asset (recoverable)', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 0},
|
||||
{'label': 'Tax Liability (collected)', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 0},
|
||||
{'label': 'NET TAX POSITION', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -13,6 +13,9 @@ REPORT_TYPES = [
|
||||
('balance_sheet', 'Balance Sheet'),
|
||||
('trial_balance', 'Trial Balance'),
|
||||
('general_ledger', 'General Ledger'),
|
||||
('aged_receivable', 'Aged Receivable'),
|
||||
('aged_payable', 'Aged Payable'),
|
||||
('partner_ledger', 'Partner Ledger'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Internal pipeline (per report run):
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import ValidationError
|
||||
@@ -39,10 +39,17 @@ class FusionReportEngine(models.AbstractModel):
|
||||
@api.model
|
||||
def compute_pnl(
|
||||
self, period: Period, *, comparison: str = 'none',
|
||||
company_id: int | None = None,
|
||||
company_id: int | None = None, report_code: str | None = None,
|
||||
) -> dict:
|
||||
"""Income statement (P&L) for the given period."""
|
||||
report = self._get_report('pnl', company_id=company_id)
|
||||
"""Income statement (P&L) for the given period.
|
||||
|
||||
``report_code`` selects between multiple PnL-typed report definitions
|
||||
(``pnl``, ``cash_flow``, ``executive_summary``, ``annual_statements``).
|
||||
When omitted, falls back to the canonical ``pnl`` definition.
|
||||
"""
|
||||
report = self._get_report(
|
||||
'pnl', company_id=company_id, code=report_code,
|
||||
)
|
||||
return self._compute(
|
||||
report, period, comparison=comparison, company_id=company_id,
|
||||
)
|
||||
@@ -50,11 +57,13 @@ class FusionReportEngine(models.AbstractModel):
|
||||
@api.model
|
||||
def compute_balance_sheet(
|
||||
self, date_to: date, *, comparison: str = 'none',
|
||||
company_id: int | None = None,
|
||||
company_id: int | None = None, report_code: str | None = None,
|
||||
) -> dict:
|
||||
"""Balance sheet AS OF date_to. Period.date_from is set to a
|
||||
far-past date so balances are cumulative-since-inception."""
|
||||
report = self._get_report('balance_sheet', company_id=company_id)
|
||||
report = self._get_report(
|
||||
'balance_sheet', company_id=company_id, code=report_code,
|
||||
)
|
||||
period = Period(
|
||||
date_from=date(1970, 1, 1),
|
||||
date_to=date_to,
|
||||
@@ -67,10 +76,17 @@ class FusionReportEngine(models.AbstractModel):
|
||||
@api.model
|
||||
def compute_trial_balance(
|
||||
self, period: Period, *, company_id: int | None = None,
|
||||
report_code: str | None = None,
|
||||
) -> dict:
|
||||
"""Trial balance for the given period - every account with
|
||||
non-zero balance."""
|
||||
report = self._get_report('trial_balance', company_id=company_id)
|
||||
non-zero balance.
|
||||
|
||||
``report_code`` selects between multiple TB-typed reports (e.g.
|
||||
``trial_balance``, ``tax_summary``).
|
||||
"""
|
||||
report = self._get_report(
|
||||
'trial_balance', company_id=company_id, code=report_code,
|
||||
)
|
||||
return self._compute(
|
||||
report, period, comparison='none', company_id=company_id,
|
||||
)
|
||||
@@ -78,12 +94,14 @@ class FusionReportEngine(models.AbstractModel):
|
||||
@api.model
|
||||
def compute_gl(
|
||||
self, period: Period, *, account_ids: list | None = None,
|
||||
company_id: int | None = None,
|
||||
company_id: int | None = None, report_code: str | None = None,
|
||||
) -> dict:
|
||||
"""General ledger for the given period.
|
||||
|
||||
Returns per-account move-line listings rather than aggregated rows."""
|
||||
report = self._get_report('general_ledger', company_id=company_id)
|
||||
report = self._get_report(
|
||||
'general_ledger', company_id=company_id, code=report_code,
|
||||
)
|
||||
company_id = company_id or self.env.company.id
|
||||
result = self._compute(
|
||||
report, period, comparison='none', company_id=company_id,
|
||||
@@ -118,27 +136,188 @@ class FusionReportEngine(models.AbstractModel):
|
||||
limit=500,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def compute_partner_grouped(
|
||||
self, period: Period, *, account_type: str = 'asset_receivable',
|
||||
comparison: str = 'none', company_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Per-partner aggregation report (Aged Receivable, Aged Payable,
|
||||
Partner Ledger).
|
||||
|
||||
Returns a dict with ``rows`` = list of partner-level aggregates.
|
||||
Each row has the partner_id, partner_name, total residual, and
|
||||
aging buckets: current / 1-30 / 31-60 / 61-90 / 90+ days past
|
||||
``period.date_to``.
|
||||
|
||||
SQL-direct for performance: a single GROUP BY query with conditional
|
||||
sum per bucket. Only un-reconciled, posted lines with non-zero
|
||||
residual at the as-of date are included.
|
||||
"""
|
||||
company_id = company_id or self.env.company.id
|
||||
|
||||
accounts = self.env['account.account'].sudo().search([
|
||||
('account_type', '=', account_type),
|
||||
('company_ids', 'in', company_id),
|
||||
])
|
||||
if not accounts:
|
||||
return {
|
||||
'report_type': 'partner_grouped',
|
||||
'account_type': account_type,
|
||||
'period': {
|
||||
'date_from': str(period.date_from),
|
||||
'date_to': str(period.date_to),
|
||||
'label': period.label,
|
||||
},
|
||||
'rows': [],
|
||||
'total': 0.0,
|
||||
'partner_count': 0,
|
||||
}
|
||||
|
||||
as_of = period.date_to
|
||||
d30 = as_of - timedelta(days=30)
|
||||
d60 = as_of - timedelta(days=60)
|
||||
d90 = as_of - timedelta(days=90)
|
||||
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(p.id, 0) AS partner_id,
|
||||
COALESCE(p.name, '(no partner)') AS partner_name,
|
||||
SUM(aml.amount_residual) AS total_residual,
|
||||
SUM(CASE
|
||||
WHEN aml.date_maturity >= %s
|
||||
OR aml.date_maturity IS NULL
|
||||
THEN aml.amount_residual ELSE 0
|
||||
END) AS bucket_current,
|
||||
SUM(CASE
|
||||
WHEN aml.date_maturity < %s
|
||||
AND aml.date_maturity >= %s
|
||||
THEN aml.amount_residual ELSE 0
|
||||
END) AS bucket_1_30,
|
||||
SUM(CASE
|
||||
WHEN aml.date_maturity < %s
|
||||
AND aml.date_maturity >= %s
|
||||
THEN aml.amount_residual ELSE 0
|
||||
END) AS bucket_31_60,
|
||||
SUM(CASE
|
||||
WHEN aml.date_maturity < %s
|
||||
AND aml.date_maturity >= %s
|
||||
THEN aml.amount_residual ELSE 0
|
||||
END) AS bucket_61_90,
|
||||
SUM(CASE
|
||||
WHEN aml.date_maturity < %s
|
||||
THEN aml.amount_residual ELSE 0
|
||||
END) AS bucket_90_plus,
|
||||
COUNT(*) AS line_count
|
||||
FROM account_move_line aml
|
||||
LEFT JOIN res_partner p ON p.id = aml.partner_id
|
||||
WHERE aml.account_id = ANY(%s)
|
||||
AND aml.parent_state = 'posted'
|
||||
AND aml.reconciled = false
|
||||
AND aml.amount_residual != 0
|
||||
AND aml.company_id = %s
|
||||
AND aml.date <= %s
|
||||
GROUP BY p.id, p.name
|
||||
HAVING SUM(aml.amount_residual) != 0
|
||||
ORDER BY total_residual DESC
|
||||
""",
|
||||
(
|
||||
as_of,
|
||||
as_of, d30,
|
||||
d30, d60,
|
||||
d60, d90,
|
||||
d90,
|
||||
list(accounts.ids), company_id, as_of,
|
||||
),
|
||||
)
|
||||
|
||||
rows = []
|
||||
for r in self.env.cr.dictfetchall():
|
||||
rows.append({
|
||||
'partner_id': r['partner_id'] or False,
|
||||
'partner_name': r['partner_name'] or '(no partner)',
|
||||
'total': float(r['total_residual'] or 0),
|
||||
'bucket_current': float(r['bucket_current'] or 0),
|
||||
'bucket_1_30': float(r['bucket_1_30'] or 0),
|
||||
'bucket_31_60': float(r['bucket_31_60'] or 0),
|
||||
'bucket_61_90': float(r['bucket_61_90'] or 0),
|
||||
'bucket_90_plus': float(r['bucket_90_plus'] or 0),
|
||||
'line_count': r['line_count'],
|
||||
})
|
||||
|
||||
total = sum(r['total'] for r in rows)
|
||||
return {
|
||||
'report_type': 'partner_grouped',
|
||||
'account_type': account_type,
|
||||
'period': {
|
||||
'date_from': str(period.date_from),
|
||||
'date_to': str(period.date_to),
|
||||
'label': period.label,
|
||||
},
|
||||
'company_id': company_id,
|
||||
'rows': rows,
|
||||
'total': total,
|
||||
'partner_count': len(rows),
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# PRIVATE HELPERS
|
||||
# ============================================================
|
||||
|
||||
def _get_report(self, report_type: str, *, company_id: int | None = None):
|
||||
"""Look up the active fusion.report definition for a given
|
||||
type+company. If no per-company override, falls back to global
|
||||
(company_id=False)."""
|
||||
def _get_report(
|
||||
self, report_type: str, *, company_id: int | None = None,
|
||||
code: str | None = None,
|
||||
):
|
||||
"""Look up the active fusion.report definition.
|
||||
|
||||
When ``code`` is provided, prefer the report with that exact code
|
||||
(validating its ``report_type`` matches). Otherwise fall back to
|
||||
the canonical-by-type lookup: prefer code == report_type, then any
|
||||
report of that type. Per-company overrides win over global.
|
||||
"""
|
||||
Report = self.env['fusion.report'].sudo()
|
||||
company_id = company_id or self.env.company.id
|
||||
company_domain = [
|
||||
('active', '=', True),
|
||||
'|',
|
||||
('company_id', '=', company_id),
|
||||
('company_id', '=', False),
|
||||
]
|
||||
if code:
|
||||
report = Report.search(
|
||||
[('code', '=', code)] + company_domain,
|
||||
order='company_id desc nulls last',
|
||||
limit=1,
|
||||
)
|
||||
if not report:
|
||||
raise ValidationError(
|
||||
_("No active fusion.report definition with code '%s'") % code
|
||||
)
|
||||
if report.report_type != report_type:
|
||||
raise ValidationError(
|
||||
_("Report '%(code)s' has type '%(actual)s' but '%(expected)s' was expected.")
|
||||
% {
|
||||
'code': code,
|
||||
'actual': report.report_type,
|
||||
'expected': report_type,
|
||||
}
|
||||
)
|
||||
return report
|
||||
|
||||
# No code: prefer the canonical (code == report_type), then any
|
||||
# other report of that type.
|
||||
report = Report.search(
|
||||
[
|
||||
('report_type', '=', report_type),
|
||||
('active', '=', True),
|
||||
'|',
|
||||
('company_id', '=', company_id),
|
||||
('company_id', '=', False),
|
||||
],
|
||||
[('code', '=', report_type), ('report_type', '=', report_type)] + company_domain,
|
||||
order='company_id desc nulls last',
|
||||
limit=1,
|
||||
)
|
||||
if report:
|
||||
return report
|
||||
report = Report.search(
|
||||
[('report_type', '=', report_type)] + company_domain,
|
||||
order='company_id desc nulls last, sequence',
|
||||
limit=1,
|
||||
)
|
||||
if not report:
|
||||
raise ValidationError(
|
||||
_("No active fusion.report definition for type '%s'") % report_type
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "variables";
|
||||
// Variables come from _variables.scss via manifest concatenation order.
|
||||
|
||||
[data-color-scheme="dark"] .o_fusion_reports {
|
||||
background: #1f2937;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "variables";
|
||||
// Variables come from _variables.scss via manifest concatenation order.
|
||||
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
|
||||
|
||||
.o_fusion_reports {
|
||||
background: $report-bg-secondary;
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/reports";
|
||||
|
||||
export class ReportsService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.rpc = services.rpc;
|
||||
// V19: rpc is a standalone import, not a service.
|
||||
this.rpc = rpc;
|
||||
this.notification = services.notification;
|
||||
|
||||
this.state = reactive({
|
||||
@@ -140,7 +142,7 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
export const reportsService = {
|
||||
dependencies: ["rpc", "notification"],
|
||||
dependencies: ["notification"],
|
||||
start(env, services) { return new ReportsService(env, services); },
|
||||
};
|
||||
|
||||
|
||||
@@ -90,6 +90,75 @@ class TestFusionReportEngine(TransactionCase):
|
||||
)
|
||||
self.assertIsInstance(rows, list)
|
||||
|
||||
def test_compute_partner_grouped_receivable(self):
|
||||
period = Period(date(2025, 1, 1), date(2025, 12, 31), 'Test')
|
||||
result = self.env['fusion.report.engine'].compute_partner_grouped(
|
||||
period, account_type='asset_receivable',
|
||||
)
|
||||
self.assertEqual(result['report_type'], 'partner_grouped')
|
||||
self.assertEqual(result['account_type'], 'asset_receivable')
|
||||
self.assertIn('rows', result)
|
||||
self.assertIn('total', result)
|
||||
self.assertIn('partner_count', result)
|
||||
if result['rows']:
|
||||
for key in (
|
||||
'partner_name', 'total', 'bucket_current', 'bucket_1_30',
|
||||
'bucket_31_60', 'bucket_61_90', 'bucket_90_plus',
|
||||
):
|
||||
self.assertIn(key, result['rows'][0])
|
||||
|
||||
def test_report_code_disambiguates_same_report_type(self):
|
||||
"""Multiple reports of report_type='pnl' must each be addressable
|
||||
by code so the engine returns the requested definition's line_specs
|
||||
(not whichever was first by company_id)."""
|
||||
spec_one = [
|
||||
{'label': 'A', 'account_type_prefix': 'income_', 'sign': 1},
|
||||
]
|
||||
spec_two = [
|
||||
{'label': 'X', 'account_type_prefix': 'income_', 'sign': 1},
|
||||
{'label': 'Y', 'account_type_prefix': 'expense_', 'sign': -1},
|
||||
{'label': 'Z', 'account_type_prefix': 'asset_', 'sign': 1},
|
||||
]
|
||||
self.env['fusion.report'].create({
|
||||
'name': 'Variant One', 'code': 'variant_one',
|
||||
'report_type': 'pnl', 'line_specs': spec_one,
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
self.env['fusion.report'].create({
|
||||
'name': 'Variant Two', 'code': 'variant_two',
|
||||
'report_type': 'pnl', 'line_specs': spec_two,
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
|
||||
engine = self.env['fusion.report.engine']
|
||||
r1 = engine.compute_pnl(
|
||||
period, company_id=self.env.company.id,
|
||||
report_code='variant_one',
|
||||
)
|
||||
r2 = engine.compute_pnl(
|
||||
period, company_id=self.env.company.id,
|
||||
report_code='variant_two',
|
||||
)
|
||||
self.assertEqual(r1['report_name'], 'Variant One')
|
||||
self.assertEqual(r2['report_name'], 'Variant Two')
|
||||
self.assertEqual(len(r1['rows']), 1)
|
||||
self.assertEqual(len(r2['rows']), 3)
|
||||
|
||||
def test_report_code_validates_type_match(self):
|
||||
"""Asking for a 'pnl' computation but giving a balance_sheet code
|
||||
should raise ValidationError, not silently mis-render."""
|
||||
self.env['fusion.report'].create({
|
||||
'name': 'Wrong Type', 'code': 'wrong_type_test',
|
||||
'report_type': 'balance_sheet', 'line_specs': [],
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['fusion.report.engine'].compute_pnl(
|
||||
period, company_id=self.env.company.id,
|
||||
report_code='wrong_type_test',
|
||||
)
|
||||
|
||||
def test_no_report_raises_validation_error(self):
|
||||
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
|
||||
# Inactivate any pre-existing GL definitions so the lookup
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user