diff --git a/fusion_accounting_documents/__init__.py b/fusion_accounting_documents/__init__.py new file mode 100644 index 00000000..aee8895e --- /dev/null +++ b/fusion_accounting_documents/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/fusion_accounting_documents/__manifest__.py b/fusion_accounting_documents/__manifest__.py new file mode 100644 index 00000000..945929dc --- /dev/null +++ b/fusion_accounting_documents/__manifest__.py @@ -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', +} diff --git a/fusion_accounting_documents/data/server_actions_data.xml b/fusion_accounting_documents/data/server_actions_data.xml new file mode 100644 index 00000000..da604f5c --- /dev/null +++ b/fusion_accounting_documents/data/server_actions_data.xml @@ -0,0 +1,25 @@ + + + + + Create Vendor Invoice (Fusion) + + + list,kanban + 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.")) + + + diff --git a/fusion_accounting_documents/models/__init__.py b/fusion_accounting_documents/models/__init__.py new file mode 100644 index 00000000..c0ebbc10 --- /dev/null +++ b/fusion_accounting_documents/models/__init__.py @@ -0,0 +1,2 @@ +from . import documents_document +from . import account_move diff --git a/fusion_accounting_documents/models/account_move.py b/fusion_accounting_documents/models/account_move.py new file mode 100644 index 00000000..f8924ca4 --- /dev/null +++ b/fusion_accounting_documents/models/account_move.py @@ -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)], + } diff --git a/fusion_accounting_documents/models/documents_document.py b/fusion_accounting_documents/models/documents_document.py new file mode 100644 index 00000000..f6e27985 --- /dev/null +++ b/fusion_accounting_documents/models/documents_document.py @@ -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}, + } diff --git a/fusion_accounting_documents/security/ir.model.access.csv b/fusion_accounting_documents/security/ir.model.access.csv new file mode 100644 index 00000000..6f7cce5d --- /dev/null +++ b/fusion_accounting_documents/security/ir.model.access.csv @@ -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 diff --git a/fusion_accounting_documents/static/description/icon.png b/fusion_accounting_documents/static/description/icon.png new file mode 100644 index 00000000..6773c627 Binary files /dev/null and b/fusion_accounting_documents/static/description/icon.png differ diff --git a/fusion_accounting_documents/tests/__init__.py b/fusion_accounting_documents/tests/__init__.py new file mode 100644 index 00000000..4e32d8e5 --- /dev/null +++ b/fusion_accounting_documents/tests/__init__.py @@ -0,0 +1 @@ +from . import test_document_to_invoice diff --git a/fusion_accounting_documents/tests/test_document_to_invoice.py b/fusion_accounting_documents/tests/test_document_to_invoice.py new file mode 100644 index 00000000..42991c4c --- /dev/null +++ b/fusion_accounting_documents/tests/test_document_to_invoice.py @@ -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) diff --git a/fusion_accounting_documents/views/account_move_views.xml b/fusion_accounting_documents/views/account_move_views.xml new file mode 100644 index 00000000..fb650931 --- /dev/null +++ b/fusion_accounting_documents/views/account_move_views.xml @@ -0,0 +1,21 @@ + + + + account.move.form.inherit.fusion.documents + account.move + + + + + + + + diff --git a/fusion_accounting_documents/views/documents_document_views.xml b/fusion_accounting_documents/views/documents_document_views.xml new file mode 100644 index 00000000..93b439a4 --- /dev/null +++ b/fusion_accounting_documents/views/documents_document_views.xml @@ -0,0 +1,39 @@ + + + + + + documents.document.kanban.inherit.fusion.acc + documents.document + + + + + + + + + + + documents.document.list.inherit.fusion.acc + documents.document + + + + + + + + + diff --git a/fusion_accounting_documents/wizards/__init__.py b/fusion_accounting_documents/wizards/__init__.py new file mode 100644 index 00000000..12d3d897 --- /dev/null +++ b/fusion_accounting_documents/wizards/__init__.py @@ -0,0 +1 @@ +from . import create_invoice_from_document diff --git a/fusion_accounting_documents/wizards/create_invoice_from_document.py b/fusion_accounting_documents/wizards/create_invoice_from_document.py new file mode 100644 index 00000000..187b5edd --- /dev/null +++ b/fusion_accounting_documents/wizards/create_invoice_from_document.py @@ -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: %s", + self.document_id.name, + ), + attachment_ids=[attachment_copy.id], + ) + else: + move.message_post(body=_( + "Created from Documents source: %s " + "(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, + } diff --git a/fusion_accounting_documents/wizards/create_invoice_from_document_views.xml b/fusion_accounting_documents/wizards/create_invoice_from_document_views.xml new file mode 100644 index 00000000..c9ffa2e4 --- /dev/null +++ b/fusion_accounting_documents/wizards/create_invoice_from_document_views.xml @@ -0,0 +1,37 @@ + + + + fusion.create.invoice.from.document.wizard.form + fusion.create.invoice.from.document.wizard + +
+ + + + + + + + + + + + + + +
+
+
+