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):
|
||||
"""Create a bank statement. Auto-creates a bank journal if not provided."""
|
||||
"""Create a bank statement.
|
||||
|
||||
NOTE: in V19 Community, ``account.bank.statement.journal_id`` is a
|
||||
read-only computed field derived from ``line_ids.journal_id`` — direct
|
||||
writes are silently dropped. Enterprise's ``account_accountant`` used to
|
||||
override this to make it writable; without Enterprise we have to derive
|
||||
the journal from a line. We attach a single token line at create time
|
||||
(later removed/replaced by the test) to bootstrap the journal.
|
||||
"""
|
||||
journal = journal or make_bank_journal(env)
|
||||
return env['account.bank.statement'].create({
|
||||
'name': name,
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
'line_ids': [(0, 0, {
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
'payment_ref': 'Statement bootstrap line',
|
||||
'amount': 0.0,
|
||||
})],
|
||||
})
|
||||
|
||||
|
||||
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
|
||||
partner=None, memo='Test line', date_=None):
|
||||
"""Create a bank statement line. Creates statement if not provided.
|
||||
"""Create a bank statement line. Creates a journal (and optionally a
|
||||
statement) if not provided.
|
||||
|
||||
Most-common factory in tests. Defaults give a $100 line with no partner."""
|
||||
if not statement:
|
||||
statement = make_bank_statement(env, journal=journal, date_=date_)
|
||||
return env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': statement.journal_id.id,
|
||||
In V19 Community, lines can exist standalone — a statement is not
|
||||
required. We create one only if the test explicitly passes ``statement=``.
|
||||
"""
|
||||
if statement and not journal:
|
||||
journal = statement.journal_id
|
||||
if not journal:
|
||||
journal = make_bank_journal(env)
|
||||
vals = {
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
'payment_ref': memo,
|
||||
'amount': amount,
|
||||
'partner_id': partner.id if partner else False,
|
||||
})
|
||||
}
|
||||
if statement:
|
||||
vals['statement_id'] = statement.id
|
||||
return env['account.bank.statement.line'].create(vals)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
2
fusion_accounting_documents/__init__.py
Normal file
2
fusion_accounting_documents/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizards
|
||||
48
fusion_accounting_documents/__manifest__.py
Normal file
48
fusion_accounting_documents/__manifest__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Documents Bridge',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'Bridges the Documents app to Accounting: route scanned bills into vendor invoices.',
|
||||
'description': """
|
||||
Fusion Accounting — Documents Bridge
|
||||
====================================
|
||||
|
||||
A Fusion-native replacement for Enterprise's ``documents_account`` module.
|
||||
|
||||
Adds:
|
||||
|
||||
- ``documents.document.move_id`` — Many2one to the linked accounting move.
|
||||
- ``documents.document.is_invoice_candidate`` — computed flag for PDFs/images
|
||||
not yet linked to a move.
|
||||
- ``documents.document.action_create_invoice()`` — opens a wizard that
|
||||
creates a draft vendor bill and copies the document's binary as an
|
||||
attachment on the new ``account.move``.
|
||||
- ``account.move.source_document_ids`` — reverse linkage with a stat button
|
||||
on the invoice form.
|
||||
- A ``fusion.create.invoice.from.document.wizard`` model + form view.
|
||||
- A server action bound to ``documents.document`` so the workflow is
|
||||
reachable from the Documents Actions menu (the Documents app uses
|
||||
kanban/list views without a regular form view to inherit from).
|
||||
|
||||
Auto-installs when ``documents`` and ``fusion_accounting_core`` are both
|
||||
present.
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'account',
|
||||
'documents',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizards/create_invoice_from_document_views.xml',
|
||||
'views/documents_document_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'data/server_actions_data.xml',
|
||||
],
|
||||
'auto_install': ['documents', 'fusion_accounting_core'],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'icon': '/fusion_accounting_documents/static/description/icon.png',
|
||||
}
|
||||
25
fusion_accounting_documents/data/server_actions_data.xml
Normal file
25
fusion_accounting_documents/data/server_actions_data.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Server action bound to documents.document so the
|
||||
"Create Vendor Invoice" workflow appears in the cog/Actions
|
||||
menu of the Documents kanban + list views.
|
||||
|
||||
We dispatch through ``action_create_invoice`` so the same
|
||||
validation runs whether the user clicks the action or calls
|
||||
the method programmatically.
|
||||
-->
|
||||
<record id="action_create_invoice_from_document" model="ir.actions.server">
|
||||
<field name="name">Create Vendor Invoice (Fusion)</field>
|
||||
<field name="model_id" ref="documents.model_documents_document"/>
|
||||
<field name="binding_model_id" ref="documents.model_documents_document"/>
|
||||
<field name="binding_view_types">list,kanban</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
if records and len(records) == 1:
|
||||
action = records.action_create_invoice()
|
||||
else:
|
||||
raise UserError(_("Select exactly one document to convert into a vendor invoice."))
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
fusion_accounting_documents/models/__init__.py
Normal file
2
fusion_accounting_documents/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import documents_document
|
||||
from . import account_move
|
||||
33
fusion_accounting_documents/models/account_move.py
Normal file
33
fusion_accounting_documents/models/account_move.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Reverse linkage from account.move back to source documents."""
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
source_document_ids = fields.One2many(
|
||||
'documents.document',
|
||||
'move_id',
|
||||
string='Source Documents',
|
||||
readonly=True,
|
||||
help="Documents in the Documents app that were used to create this move.",
|
||||
)
|
||||
source_document_count = fields.Integer(
|
||||
string='Source Document Count',
|
||||
compute='_compute_source_document_count',
|
||||
)
|
||||
|
||||
def _compute_source_document_count(self):
|
||||
for m in self:
|
||||
m.source_document_count = len(m.source_document_ids)
|
||||
|
||||
def action_open_source_documents(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Source Documents'),
|
||||
'res_model': 'documents.document',
|
||||
'view_mode': 'kanban,list',
|
||||
'domain': [('move_id', '=', self.id)],
|
||||
}
|
||||
71
fusion_accounting_documents/models/documents_document.py
Normal file
71
fusion_accounting_documents/models/documents_document.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Bridge documents.document to accounting moves.
|
||||
|
||||
Adds a Many2one link to the created invoice/move, a computed
|
||||
``is_invoice_candidate`` flag for PDFs/images that have not yet been
|
||||
turned into a vendor bill, and the ``action_create_invoice`` entry
|
||||
point used by both the form button and the server action.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
INVOICE_CANDIDATE_MIMETYPES = (
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
)
|
||||
|
||||
|
||||
class DocumentsDocument(models.Model):
|
||||
_inherit = 'documents.document'
|
||||
|
||||
move_id = fields.Many2one(
|
||||
'account.move',
|
||||
string='Linked Invoice/Move',
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
help="The accounting move this document was used to create.",
|
||||
)
|
||||
is_invoice_candidate = fields.Boolean(
|
||||
string='Is Invoice Candidate',
|
||||
compute='_compute_is_invoice_candidate',
|
||||
store=True,
|
||||
help="True when this document looks like a vendor bill "
|
||||
"(PDF/image binary) and has not yet been linked to a move.",
|
||||
)
|
||||
|
||||
@api.depends('mimetype', 'type', 'move_id')
|
||||
def _compute_is_invoice_candidate(self):
|
||||
for d in self:
|
||||
d.is_invoice_candidate = (
|
||||
d.type == 'binary'
|
||||
and (d.mimetype or '') in INVOICE_CANDIDATE_MIMETYPES
|
||||
and not d.move_id
|
||||
)
|
||||
|
||||
def action_create_invoice(self):
|
||||
"""Open the wizard to create a vendor invoice from this document."""
|
||||
self.ensure_one()
|
||||
if self.move_id:
|
||||
raise UserError(_(
|
||||
"This document is already linked to invoice %s.",
|
||||
self.move_id.display_name,
|
||||
))
|
||||
if self.type == 'folder':
|
||||
raise UserError(_(
|
||||
"Folders cannot be turned into invoices."
|
||||
))
|
||||
if (self.mimetype or '') not in INVOICE_CANDIDATE_MIMETYPES:
|
||||
raise UserError(_(
|
||||
"Only PDF or image documents can be turned into invoices."
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Create Invoice from Document'),
|
||||
'res_model': 'fusion.create.invoice.from.document.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_document_id': self.id},
|
||||
}
|
||||
2
fusion_accounting_documents/security/ir.model.access.csv
Normal file
2
fusion_accounting_documents/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_create_invoice_wizard_user,fusion.create.invoice.wizard.user,model_fusion_create_invoice_from_document_wizard,base.group_user,1,1,1,1
|
||||
|
BIN
fusion_accounting_documents/static/description/icon.png
Normal file
BIN
fusion_accounting_documents/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
1
fusion_accounting_documents/tests/__init__.py
Normal file
1
fusion_accounting_documents/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_document_to_invoice
|
||||
140
fusion_accounting_documents/tests/test_document_to_invoice.py
Normal file
140
fusion_accounting_documents/tests/test_document_to_invoice.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for the documents.document <-> account.move bridge."""
|
||||
|
||||
import base64
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_accounting_documents')
|
||||
class TestDocumentToInvoice(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.vendor = cls.env['res.partner'].create({
|
||||
'name': 'Test Doc Vendor',
|
||||
'supplier_rank': 1,
|
||||
})
|
||||
cls.purchase_journal = cls.env['account.journal'].search(
|
||||
[('type', '=', 'purchase'),
|
||||
('company_id', '=', cls.env.company.id)],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
def _make_document(self, name='Test Bill PDF',
|
||||
mimetype='application/pdf',
|
||||
payload=b'%PDF-fake-bill-content'):
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': name,
|
||||
'datas': base64.b64encode(payload),
|
||||
'mimetype': mimetype,
|
||||
})
|
||||
Document = self.env['documents.document']
|
||||
doc_vals = {
|
||||
'name': name,
|
||||
'attachment_id': attachment.id,
|
||||
'mimetype': mimetype,
|
||||
'type': 'binary',
|
||||
}
|
||||
if 'folder_id' in Document._fields:
|
||||
folder = Document.search(
|
||||
[('type', '=', 'folder')], limit=1,
|
||||
)
|
||||
if folder:
|
||||
doc_vals['folder_id'] = folder.id
|
||||
return Document.create(doc_vals)
|
||||
|
||||
def test_invoice_candidate_flag_pdf(self):
|
||||
doc = self._make_document()
|
||||
self.assertTrue(doc.is_invoice_candidate)
|
||||
|
||||
def test_invoice_candidate_flag_image(self):
|
||||
doc = self._make_document(
|
||||
name='scan.png',
|
||||
mimetype='image/png',
|
||||
payload=b'\x89PNG\r\n\x1a\nfake',
|
||||
)
|
||||
self.assertTrue(doc.is_invoice_candidate)
|
||||
|
||||
def test_invoice_candidate_flag_text_excluded(self):
|
||||
doc = self._make_document(
|
||||
name='note.txt',
|
||||
mimetype='text/plain',
|
||||
payload=b'just a note',
|
||||
)
|
||||
self.assertFalse(doc.is_invoice_candidate)
|
||||
|
||||
def test_action_create_invoice_opens_wizard(self):
|
||||
doc = self._make_document()
|
||||
action = doc.action_create_invoice()
|
||||
self.assertEqual(action['type'], 'ir.actions.act_window')
|
||||
self.assertEqual(
|
||||
action['res_model'],
|
||||
'fusion.create.invoice.from.document.wizard',
|
||||
)
|
||||
self.assertEqual(action['target'], 'new')
|
||||
self.assertEqual(action['context']['default_document_id'], doc.id)
|
||||
|
||||
def test_wizard_creates_invoice_and_links(self):
|
||||
doc = self._make_document()
|
||||
wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
|
||||
'document_id': doc.id,
|
||||
'partner_id': self.vendor.id,
|
||||
'move_type': 'in_invoice',
|
||||
})
|
||||
self.assertTrue(wizard.journal_id, "Default journal should resolve")
|
||||
action = wizard.action_create_invoice()
|
||||
|
||||
self.assertEqual(action['res_model'], 'account.move')
|
||||
move = self.env['account.move'].browse(action['res_id'])
|
||||
self.assertEqual(move.move_type, 'in_invoice')
|
||||
self.assertEqual(move.partner_id, self.vendor)
|
||||
|
||||
self.assertEqual(doc.move_id, move)
|
||||
self.assertFalse(doc.is_invoice_candidate,
|
||||
"Linked docs should no longer be candidates")
|
||||
|
||||
self.assertEqual(move.source_document_count, 1)
|
||||
self.assertIn(doc, move.source_document_ids)
|
||||
|
||||
attachments = self.env['ir.attachment'].search([
|
||||
('res_model', '=', 'account.move'),
|
||||
('res_id', '=', move.id),
|
||||
])
|
||||
self.assertTrue(
|
||||
attachments,
|
||||
"An attachment copy should land on the new move",
|
||||
)
|
||||
|
||||
def test_action_create_invoice_already_linked_raises(self):
|
||||
doc = self._make_document()
|
||||
existing_move = self.env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': self.vendor.id,
|
||||
})
|
||||
doc.move_id = existing_move.id
|
||||
with self.assertRaises(UserError):
|
||||
doc.action_create_invoice()
|
||||
|
||||
def test_action_create_invoice_non_candidate_raises(self):
|
||||
doc = self._make_document(
|
||||
name='note.txt',
|
||||
mimetype='text/plain',
|
||||
payload=b'hello',
|
||||
)
|
||||
with self.assertRaises(UserError):
|
||||
doc.action_create_invoice()
|
||||
|
||||
def test_wizard_creates_credit_note(self):
|
||||
doc = self._make_document(name='credit-note.pdf')
|
||||
wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
|
||||
'document_id': doc.id,
|
||||
'partner_id': self.vendor.id,
|
||||
'move_type': 'in_refund',
|
||||
})
|
||||
action = wizard.action_create_invoice()
|
||||
move = self.env['account.move'].browse(action['res_id'])
|
||||
self.assertEqual(move.move_type, 'in_refund')
|
||||
self.assertEqual(doc.move_id, move)
|
||||
21
fusion_accounting_documents/views/account_move_views.xml
Normal file
21
fusion_accounting_documents/views/account_move_views.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_move_form_inherit_fusion_documents" model="ir.ui.view">
|
||||
<field name="name">account.move.form.inherit.fusion.documents</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button"
|
||||
type="object"
|
||||
name="action_open_source_documents"
|
||||
icon="fa-file-text-o"
|
||||
invisible="source_document_count == 0">
|
||||
<field name="source_document_count"
|
||||
widget="statinfo"
|
||||
string="Source Docs"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
The Documents app does not ship a regular form view for
|
||||
documents.document; editing happens in the side panel of the
|
||||
kanban/list views. We therefore add the new fields to the
|
||||
kanban + list views and rely on a server action (defined in
|
||||
data/server_actions_data.xml) to expose the "Create Invoice"
|
||||
workflow from the Actions menu.
|
||||
-->
|
||||
|
||||
<record id="view_documents_document_kanban_inherit_fusion_acc"
|
||||
model="ir.ui.view">
|
||||
<field name="name">documents.document.kanban.inherit.fusion.acc</field>
|
||||
<field name="model">documents.document</field>
|
||||
<field name="inherit_id" ref="documents.document_view_kanban"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="is_invoice_candidate"/>
|
||||
<field name="move_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_documents_document_list_inherit_fusion_acc"
|
||||
model="ir.ui.view">
|
||||
<field name="name">documents.document.list.inherit.fusion.acc</field>
|
||||
<field name="model">documents.document</field>
|
||||
<field name="inherit_id" ref="documents.documents_view_list_main"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="is_invoice_candidate" optional="hide"/>
|
||||
<field name="move_id"
|
||||
string="Linked Invoice"
|
||||
optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
fusion_accounting_documents/wizards/__init__.py
Normal file
1
fusion_accounting_documents/wizards/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import create_invoice_from_document
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Wizard to create a vendor invoice from a Documents document.
|
||||
|
||||
The wizard creates an empty draft ``account.move`` of the chosen
|
||||
move type, copies the document's binary attachment onto the new
|
||||
move, posts a chatter note linking back to the source document,
|
||||
and finally stores the move on ``documents.document.move_id`` so
|
||||
the source no longer appears as an invoice candidate.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
MOVE_TYPE_LABELS = {
|
||||
'in_invoice': _('Vendor Bill'),
|
||||
'in_refund': _('Vendor Credit Note'),
|
||||
}
|
||||
|
||||
|
||||
class CreateInvoiceFromDocumentWizard(models.TransientModel):
|
||||
_name = 'fusion.create.invoice.from.document.wizard'
|
||||
_description = 'Create Vendor Invoice from Document'
|
||||
|
||||
document_id = fields.Many2one(
|
||||
'documents.document',
|
||||
string='Source Document',
|
||||
required=True,
|
||||
readonly=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
document_name = fields.Char(related='document_id.name', readonly=True)
|
||||
document_mimetype = fields.Char(related='document_id.mimetype', readonly=True)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Vendor',
|
||||
domain="[('supplier_rank', '>', 0)]",
|
||||
)
|
||||
move_type = fields.Selection(
|
||||
[
|
||||
('in_invoice', 'Vendor Bill'),
|
||||
('in_refund', 'Vendor Credit Note'),
|
||||
],
|
||||
string='Type',
|
||||
default='in_invoice',
|
||||
required=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Journal',
|
||||
domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
|
||||
default=lambda self: self._default_journal(),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _default_journal(self):
|
||||
return self.env['account.journal'].search(
|
||||
[('type', '=', 'purchase'),
|
||||
('company_id', '=', self.env.company.id)],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
@api.onchange('company_id')
|
||||
def _onchange_company_id(self):
|
||||
if self.journal_id and self.journal_id.company_id != self.company_id:
|
||||
self.journal_id = self.env['account.journal'].search(
|
||||
[('type', '=', 'purchase'),
|
||||
('company_id', '=', self.company_id.id)],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
def action_create_invoice(self):
|
||||
self.ensure_one()
|
||||
if not self.document_id:
|
||||
raise UserError(_("No document selected."))
|
||||
if self.document_id.move_id:
|
||||
raise UserError(_(
|
||||
"Document %(doc)s is already linked to invoice %(inv)s.",
|
||||
doc=self.document_id.display_name,
|
||||
inv=self.document_id.move_id.display_name,
|
||||
))
|
||||
if not self.journal_id:
|
||||
raise UserError(_(
|
||||
"No purchase journal configured for company %s.",
|
||||
self.company_id.display_name,
|
||||
))
|
||||
|
||||
move_vals = {
|
||||
'move_type': self.move_type,
|
||||
'journal_id': self.journal_id.id,
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
if self.partner_id:
|
||||
move_vals['partner_id'] = self.partner_id.id
|
||||
|
||||
move = self.env['account.move'].create(move_vals)
|
||||
|
||||
attachment = self.document_id.attachment_id
|
||||
if attachment:
|
||||
attachment_copy = attachment.copy({
|
||||
'res_model': 'account.move',
|
||||
'res_id': move.id,
|
||||
})
|
||||
move.message_post(
|
||||
body=_(
|
||||
"Created from Documents source: <strong>%s</strong>",
|
||||
self.document_id.name,
|
||||
),
|
||||
attachment_ids=[attachment_copy.id],
|
||||
)
|
||||
else:
|
||||
move.message_post(body=_(
|
||||
"Created from Documents source: <strong>%s</strong> "
|
||||
"(no attachment to copy).",
|
||||
self.document_id.name,
|
||||
))
|
||||
|
||||
self.document_id.move_id = move.id
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': MOVE_TYPE_LABELS.get(self.move_type, _('Invoice')),
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'form',
|
||||
'res_id': move.id,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_create_invoice_from_document_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.create.invoice.from.document.wizard.form</field>
|
||||
<field name="model">fusion.create.invoice.from.document.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Invoice from Document">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="document_id" invisible="1"/>
|
||||
<field name="document_name" readonly="1"/>
|
||||
<field name="document_mimetype" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="move_type"/>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="journal_id" options="{'no_create': True}"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_create_invoice"
|
||||
string="Create Invoice"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
data-hotkey="x"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
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_add_from_so_wizard_views.xml',
|
||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||
'report/report_so_acknowledgement.xml',
|
||||
'wizard/fp_part_catalog_import_wizard_views.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',
|
||||
# Quote-to-cash reports (portrait + landscape)
|
||||
'report/report_fp_sale.xml',
|
||||
'report/report_fp_so_acknowledgement.xml',
|
||||
'report/report_fp_work_order.xml',
|
||||
'report/report_fp_job_traveller.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