Compare commits
6 Commits
fusion_acc
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068a654c2b | ||
|
|
71f39c8d33 | ||
|
|
125f48377a | ||
|
|
a730942d24 | ||
|
|
aab4b5e958 | ||
|
|
c8ca37099b |
@@ -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):
|
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)
|
journal = journal or make_bank_journal(env)
|
||||||
return env['account.bank.statement'].create({
|
return env['account.bank.statement'].create({
|
||||||
'name': name,
|
'name': name,
|
||||||
'journal_id': journal.id,
|
|
||||||
'date': date_ or date.today(),
|
'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,
|
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
|
||||||
partner=None, memo='Test line', date_=None):
|
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."""
|
In V19 Community, lines can exist standalone — a statement is not
|
||||||
if not statement:
|
required. We create one only if the test explicitly passes ``statement=``.
|
||||||
statement = make_bank_statement(env, journal=journal, date_=date_)
|
"""
|
||||||
return env['account.bank.statement.line'].create({
|
if statement and not journal:
|
||||||
'statement_id': statement.id,
|
journal = statement.journal_id
|
||||||
'journal_id': statement.journal_id.id,
|
if not journal:
|
||||||
|
journal = make_bank_journal(env)
|
||||||
|
vals = {
|
||||||
|
'journal_id': journal.id,
|
||||||
'date': date_ or date.today(),
|
'date': date_ or date.today(),
|
||||||
'payment_ref': memo,
|
'payment_ref': memo,
|
||||||
'amount': amount,
|
'amount': amount,
|
||||||
'partner_id': partner.id if partner else False,
|
'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>
|
||||||
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>
|
||||||
@@ -52,7 +52,6 @@ Provides:
|
|||||||
'wizard/fp_direct_order_wizard_views.xml',
|
'wizard/fp_direct_order_wizard_views.xml',
|
||||||
'wizard/fp_add_from_so_wizard_views.xml',
|
'wizard/fp_add_from_so_wizard_views.xml',
|
||||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||||
'report/report_so_acknowledgement.xml',
|
|
||||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||||
'data/fp_sale_description_template_data.xml',
|
'data/fp_sale_description_template_data.xml',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2026 Nexa Systems Inc.
|
|
||||||
License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
Part of the Fusion Plating product family.
|
|
||||||
|
|
||||||
Sales Order Acknowledgement PDF (Phase D7) — a customer-facing
|
|
||||||
confirmation sent shortly after action_confirm. Includes external
|
|
||||||
notes, deadlines, and a signature block.
|
|
||||||
-->
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<record id="action_report_fp_so_acknowledgement" model="ir.actions.report">
|
|
||||||
<field name="name">Sales Order Acknowledgement</field>
|
|
||||||
<field name="model">sale.order</field>
|
|
||||||
<field name="report_type">qweb-pdf</field>
|
|
||||||
<field name="report_name">fusion_plating_configurator.report_fp_so_acknowledgement_doc</field>
|
|
||||||
<field name="report_file">fusion_plating_configurator.report_fp_so_acknowledgement_doc</field>
|
|
||||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
|
||||||
<field name="binding_type">report</field>
|
|
||||||
<field name="print_report_name">'Acknowledgement - %s' % object.name</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<template id="report_fp_so_acknowledgement_doc">
|
|
||||||
<t t-call="web.html_container">
|
|
||||||
<t t-foreach="docs" t-as="doc">
|
|
||||||
<t t-call="web.external_layout">
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<h2 class="mb-4">
|
|
||||||
<span>Sales Order Acknowledgement - </span>
|
|
||||||
<span t-field="doc.name"/>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-6">
|
|
||||||
<strong>Customer</strong><br/>
|
|
||||||
<span t-field="doc.partner_id"/><br/>
|
|
||||||
<span t-if="doc.x_fc_contact_phone"
|
|
||||||
t-field="doc.x_fc_contact_phone"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<strong>References</strong><br/>
|
|
||||||
<span>Customer PO: </span>
|
|
||||||
<span t-field="doc.x_fc_po_number"/><br/>
|
|
||||||
<t t-if="doc.x_fc_customer_job_number">
|
|
||||||
<span>Customer Job #: </span>
|
|
||||||
<span t-field="doc.x_fc_customer_job_number"/><br/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-6">
|
|
||||||
<strong>Bill To</strong><br/>
|
|
||||||
<div t-field="doc.partner_invoice_id"
|
|
||||||
t-options='{"widget": "contact", "fields": ["address"], "no_marker": true}'/>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<strong>Ship To</strong><br/>
|
|
||||||
<div t-field="doc.partner_shipping_id"
|
|
||||||
t-options='{"widget": "contact", "fields": ["address"], "no_marker": true}'/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-4">
|
|
||||||
<strong>Planned Start:</strong>
|
|
||||||
<span t-field="doc.x_fc_planned_start_date"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<strong>Customer Deadline:</strong>
|
|
||||||
<span t-field="doc.commitment_date"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<strong>Ship Via:</strong>
|
|
||||||
<span t-field="doc.x_fc_ship_via"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="table table-sm table-bordered">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Part</th>
|
|
||||||
<th>Treatment</th>
|
|
||||||
<th class="text-end">Qty</th>
|
|
||||||
<th class="text-end">Unit Price</th>
|
|
||||||
<th class="text-end">Subtotal</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr t-foreach="doc.order_line.filtered(lambda l: not l.x_fc_archived)"
|
|
||||||
t-as="line">
|
|
||||||
<td>
|
|
||||||
<span t-field="line.x_fc_part_catalog_id.part_number"/>
|
|
||||||
<br/>
|
|
||||||
<small t-field="line.name"/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span t-field="line.x_fc_coating_config_id"/>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<span t-field="line.product_uom_qty"/>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<span t-field="line.price_unit"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<span t-field="line.price_subtotal"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-end">
|
|
||||||
<strong>Total</strong>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<strong>
|
|
||||||
<span t-field="doc.amount_total"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
|
||||||
</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div t-if="doc.x_fc_external_note" class="mt-4">
|
|
||||||
<strong>Notes</strong>
|
|
||||||
<div t-field="doc.x_fc_external_note"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div t-if="doc.x_fc_is_blanket_order" class="alert alert-info mt-3">
|
|
||||||
<strong>Blanket Order.</strong>
|
|
||||||
Parts will be released in quantities over time.
|
|
||||||
<span t-if="doc.x_fc_block_partial_shipments">
|
|
||||||
Partial shipments are blocked; the order ships
|
|
||||||
as one complete batch.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<tr>
|
|
||||||
<td style="width: 50%;">
|
|
||||||
<strong>Customer Signature</strong><br/>
|
|
||||||
<div style="border-bottom: 1px solid #333; height: 40px;"/>
|
|
||||||
<small>Signed name / date</small>
|
|
||||||
</td>
|
|
||||||
<td style="width: 50%;">
|
|
||||||
<strong>Nexa Systems / EN Technologies</strong><br/>
|
|
||||||
<div style="border-bottom: 1px solid #333; height: 40px;"/>
|
|
||||||
<small>
|
|
||||||
<span t-field="doc.user_id"/>
|
|
||||||
</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
</t>
|
|
||||||
</t>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
'report/report_wo_margin.xml',
|
'report/report_wo_margin.xml',
|
||||||
# Quote-to-cash reports (portrait + landscape)
|
# Quote-to-cash reports (portrait + landscape)
|
||||||
'report/report_fp_sale.xml',
|
'report/report_fp_sale.xml',
|
||||||
|
'report/report_fp_so_acknowledgement.xml',
|
||||||
'report/report_fp_work_order.xml',
|
'report/report_fp_work_order.xml',
|
||||||
'report/report_fp_job_traveller.xml',
|
'report/report_fp_job_traveller.xml',
|
||||||
'report/report_fp_packing_slip.xml',
|
'report/report_fp_packing_slip.xml',
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Sales Order Acknowledgement (Phase D7) — customer-facing
|
||||||
|
confirmation sent shortly after action_confirm. Styled to match
|
||||||
|
the rest of the Fusion Plating report family (portrait; bordered
|
||||||
|
tables; company primary-colour header; totals-table footer;
|
||||||
|
sig-box signature pair).
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="action_report_fp_so_acknowledgement" model="ir.actions.report">
|
||||||
|
<field name="name">Sales Order Acknowledgement</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">fusion_plating_reports.report_fp_so_acknowledgement_doc</field>
|
||||||
|
<field name="report_file">fusion_plating_reports.report_fp_so_acknowledgement_doc</field>
|
||||||
|
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
<field name="print_report_name">'Acknowledgement - %s' % object.name</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<template id="report_fp_so_acknowledgement_doc">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="doc">
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||||
|
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||||
|
<div class="fp-report">
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h4>
|
||||||
|
<span>Sales Order Acknowledgement </span>
|
||||||
|
<span t-field="doc.name"/>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<!-- Billing / Shipping -->
|
||||||
|
<table class="bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50%;">BILLING ADDRESS</th>
|
||||||
|
<th style="width: 50%;">SHIPPING ADDRESS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 70px;">
|
||||||
|
<div t-field="doc.partner_invoice_id"
|
||||||
|
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone', 'email'], 'no_marker': True}"/>
|
||||||
|
</td>
|
||||||
|
<td style="height: 70px;">
|
||||||
|
<div t-field="doc.partner_shipping_id"
|
||||||
|
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone'], 'no_marker': True}"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- References -->
|
||||||
|
<table class="bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="info-header" style="width: 25%;">CUSTOMER PO #</th>
|
||||||
|
<th class="info-header" style="width: 25%;">CUSTOMER JOB #</th>
|
||||||
|
<th class="info-header" style="width: 25%;">ORDER DATE</th>
|
||||||
|
<th class="info-header" style="width: 25%;">SALESPERSON</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-esc="doc.x_fc_po_number or '-'"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-esc="doc.x_fc_customer_job_number or '-'"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-field="doc.date_order"
|
||||||
|
t-options="{'widget': 'date'}"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-field="doc.user_id"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Scheduling -->
|
||||||
|
<table class="bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="info-header" style="width: 25%;">PLANNED START</th>
|
||||||
|
<th class="info-header" style="width: 25%;">INTERNAL DEADLINE</th>
|
||||||
|
<th class="info-header" style="width: 25%;">CUSTOMER DEADLINE</th>
|
||||||
|
<th class="info-header" style="width: 25%;">SHIP VIA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-field="doc.x_fc_planned_start_date"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-field="doc.x_fc_internal_deadline"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-field="doc.commitment_date"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-esc="doc.x_fc_ship_via or '-'"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Blanket / Block Partial callout -->
|
||||||
|
<t t-if="doc.x_fc_is_blanket_order or doc.x_fc_block_partial_shipments">
|
||||||
|
<div class="highlight-box">
|
||||||
|
<t t-if="doc.x_fc_is_blanket_order">
|
||||||
|
<strong>Blanket Order.</strong>
|
||||||
|
Parts will be released in quantities over time.
|
||||||
|
</t>
|
||||||
|
<t t-if="doc.x_fc_block_partial_shipments">
|
||||||
|
<strong>Partial shipments blocked.</strong>
|
||||||
|
The order ships as one complete batch.
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Order lines -->
|
||||||
|
<table class="bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 14%;">PART</th>
|
||||||
|
<th class="text-start" style="width: 36%;">DESCRIPTION</th>
|
||||||
|
<th style="width: 18%;">TREATMENT</th>
|
||||||
|
<th style="width: 8%;">QTY</th>
|
||||||
|
<th style="width: 12%;">UNIT PRICE</th>
|
||||||
|
<th style="width: 12%;">SUBTOTAL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="doc.order_line.filtered(lambda l: not l.x_fc_archived and (not l.display_type or l.display_type in ('line_section', 'line_note', 'product')))"
|
||||||
|
t-as="line">
|
||||||
|
<t t-if="line.display_type == 'line_section'">
|
||||||
|
<tr class="section-row">
|
||||||
|
<td colspan="6"><strong t-field="line.name"/></td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<t t-elif="line.display_type == 'line_note'">
|
||||||
|
<tr class="note-row">
|
||||||
|
<td colspan="6"><span t-field="line.name"/></td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-esc="line.x_fc_part_catalog_id.part_number or '-'"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<t t-set="clean_name" t-value="line.name"/>
|
||||||
|
<t t-if="line.name and '] ' in line.name">
|
||||||
|
<t t-set="clean_name" t-value="line.name.split('] ', 1)[1]"/>
|
||||||
|
</t>
|
||||||
|
<span t-esc="clean_name"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-field="line.x_fc_coating_config_id"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="line.price_unit"
|
||||||
|
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="line.price_subtotal"
|
||||||
|
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Terms + Totals -->
|
||||||
|
<div class="row" style="margin-top: 15px;">
|
||||||
|
<div class="col-6">
|
||||||
|
<t t-if="doc.x_fc_invoice_strategy">
|
||||||
|
<strong>Invoice Strategy: </strong>
|
||||||
|
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
|
||||||
|
<span t-esc="inv_strat"/>
|
||||||
|
<t t-if="doc.x_fc_invoice_strategy == 'deposit' and doc.x_fc_deposit_percent">
|
||||||
|
(<span t-esc="doc.x_fc_deposit_percent"/>%)
|
||||||
|
</t>
|
||||||
|
<br/>
|
||||||
|
</t>
|
||||||
|
<t t-if="doc.payment_term_id.note">
|
||||||
|
<strong>Payment Terms:</strong><br/>
|
||||||
|
<span t-field="doc.payment_term_id.note"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="col-6" style="text-align: right;">
|
||||||
|
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="min-width: 150px;">Subtotal</td>
|
||||||
|
<td class="text-end" style="min-width: 110px;">
|
||||||
|
<span t-field="doc.amount_untaxed"
|
||||||
|
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Taxes</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="doc.amount_tax"
|
||||||
|
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #eaf2f8;">
|
||||||
|
<td><strong>Grand Total</strong></td>
|
||||||
|
<td class="text-end"><strong>
|
||||||
|
<span t-field="doc.amount_total"
|
||||||
|
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
|
</strong></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External (customer-visible) notes -->
|
||||||
|
<t t-if="doc.x_fc_external_note">
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<strong>Notes:</strong>
|
||||||
|
<div t-field="doc.x_fc_external_note"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Signature block -->
|
||||||
|
<div class="row" style="margin-top: 25px;">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="sig-box">
|
||||||
|
<div class="sig-line"/>
|
||||||
|
<div class="small-muted">Customer Acceptance (Signature / Date)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="sig-box">
|
||||||
|
<t t-if="doc.signature">
|
||||||
|
<img t-att-src="image_data_uri(doc.signature)"
|
||||||
|
style="max-height: 3cm; max-width: 8cm;"/><br/>
|
||||||
|
<span t-field="doc.signed_by"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="sig-line"/>
|
||||||
|
</t>
|
||||||
|
<div class="small-muted">Authorized Representative</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user