6 Commits

Author SHA1 Message Date
gsinghpal
068a654c2b fix(fusion_accounting_bank_rec): test factory adapts to V19 Community semantics
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
After Enterprise's account_accountant is uninstalled,
account.bank.statement.journal_id reverts to its V19 Community definition
\u2014 a read-only computed field derived from line_ids.journal_id. Direct
writes are silently dropped (which is what was happening: 55 tests
errored with 'null value in column journal_id' because the test's
statement had no journal, and the line factory was reading
statement.journal_id (False) and passing that to the line create).

Fix:
- make_bank_statement now bootstraps the statement with one zero-amount
  line carrying journal_id, so the computed journal_id resolves correctly.
- make_bank_line no longer routes journal through the statement \u2014
  journal_id is set directly on the line (which is V19 Community's
  intended path; lines can exist standalone without a statement).

This is a test-only change; runtime behaviour is unchanged. Real users
creating bank lines via the UI already use the correct path.

Made-with: Cursor
2026-04-20 00:52:02 -04:00
gsinghpal
71f39c8d33 feat(fusion_accounting_documents): Documents app <-> invoice bridge
Replaces Enterprise's documents_account with a Fusion-native bridge.
When a PDF/image lands in the Documents app, users can convert it
into a draft vendor bill via a wizard that copies the document's
binary onto the new account.move and posts a chatter note linking
back to the source document.

Adds:
- documents.document.move_id (Many2one to the linked invoice)
- documents.document.is_invoice_candidate (computed; True for
  unlinked PDF/image binaries)
- documents.document.action_create_invoice() opens the wizard
- account.move.source_document_ids reverse linkage + statinfo button
- fusion.create.invoice.from.document.wizard (TransientModel + form)
- ir.actions.server bound to documents.document so the workflow
  appears in the kanban/list Actions menu (the Documents app has
  no regular form view to inherit from in v19)

The wizard:
- defaults to the company's first purchase journal
- supports vendor bill or vendor credit note
- copies the source attachment onto the new move
- posts a chatter note linking back
- marks the document linked so it stops appearing as a candidate

Auto-installs when documents + fusion_accounting_core are both
present. 8 unit tests cover the candidate flag, wizard happy path,
attachment copy, reverse linkage, already-linked guard, non-PDF
guard, and credit-note creation.

Made-with: Cursor
2026-04-20 00:34:50 -04:00
gsinghpal
125f48377a feat(fusion_accounting_ocr): pluggable OCR for vendor bills
Replaces Enterprise's account_invoice_extract with a Fusion-native pipeline:

Stage 1 (text extraction): Tesseract OCRs the bill attachment via
pytesseract + pdf2image. Pluggable OCRProvider adapter pattern allows
future Mindee / Google Document AI / Ollama-vision backends.

Stage 2 (field parsing): The fusion_accounting_ai LLMProvider reads the
raw OCR text and returns structured invoice fields (vendor, invoice
number, dates, amounts, line items) as JSON.

Draft invoice fields are auto-populated for empty-only fields (never
overwriting user-entered data). Vendor matching by name against
res.partner with supplier_rank > 0.

Adds:
- account.move.ocr_state (selection: not_requested/pending/processing/
  done/failed/manual)
- account.move.ocr_raw_text, ocr_extracted_data (Json), ocr_backend,
  ocr_confidence
- fusion.ocr.log (audit trail per OCR run)
- res.company.fusion_ocr_enabled / fusion_ocr_default_backend / auto_run
- /fusion/ocr/request_for_invoice JSON-RPC endpoint

Backend availability detected at runtime via OCRProvider.is_available()
classmethods. Tesseract 5.3.4 + pytesseract 0.3.13 + pdf2image 1.17.0
are installed in the container.

Tests: 13 (TesseractAdapter availability + image OCR; flow tests for
draft autofill, no-attachment guard, customer-invoice guard, ref-not-
overwritten; field parser empty/clean-json/markdown-fence/bad-JSON/
provider-exception). All pass on westin-v19 OrbStack VM.

Made-with: Cursor
2026-04-20 00:32:50 -04:00
gsinghpal
a730942d24 feat(fusion_accounting_hr_payroll): payroll -> GL bridge
Replaces Enterprise's hr_payroll_account with a Fusion-native bridge:
- Adds account_debit / account_credit / fusion_analytic_account_id /
  not_computed_in_net to hr.salary.rule (company-dependent GL mapping)
- Adds move_id + move_state + journal_id + _fusion_create_account_move
  to hr.payslip (validated payslip -> balanced account.move)
- Adds move_id + move_state + action_open_move to hr.payslip.run
- Adds journal_id (company-dependent) to hr.payroll.structure
- Adds is_payroll_journal flag to account.journal
- Adds payslip_ids / payslip_count + action_open_payslip on account.move
- Adds payslip_id reverse link on account.move.line
- Adds move_line_id reverse link on hr.payslip.line
- Adds fusion_payroll_journal_id + fusion_payroll_auto_post to res.company
  (with res.config.settings exposure)

Coexistence: detects Enterprise hr_payroll_account at runtime via
ir.module.module and yields move creation to it while both modules are
installed, so payslips do not get duplicate entries. Once the Enterprise
module is uninstalled, this module owns the bridge.

Auto-installs whenever both hr_payroll and fusion_accounting_core are
present on the database.

10 smoke tests verifying field surface + bridge entrypoints all pass on
westin-v19. Full payslip-to-move integration test deferred (needs
seeded payroll structure).

Removes Westin's last payroll-accounting dependency on Enterprise's
accountant umbrella module (Phase 6b of the Fusion Accounting suite).

Made-with: Cursor
2026-04-20 00:18:08 -04:00
gsinghpal
aab4b5e958 feat(fusion_accounting_l10n_ca): Canadian reports + tax return tracking
Replaces Enterprise's l10n_ca_reports with Fusion-native equivalents:
- ca_balance_sheet, ca_profit_loss as fusion.report definitions
- fusion.tax.return model for GST/HST/PST/T4/T5018 filing tracking
- Auto-installs when l10n_ca + fusion_accounting_reports both present

Removes Westin's last Canadian-compliance dependency on Enterprise's
account_reports.

Made-with: Cursor
2026-04-20 00:12:59 -04:00
gsinghpal
c8ca37099b refactor(reports): move SO Acknowledgement into fusion_plating_reports with house style
D7 template was originally in fusion_plating_configurator with a
Bootstrap-only look-and-feel that didn't match the other Fusion
Plating reports. Re-styled and relocated:

- Moved to fusion_plating_reports/report/report_fp_so_acknowledgement.xml
  alongside sale / work-order / job-traveller / invoice templates.
- Uses fp_portrait_styles (company primary colour for headers, .bordered
  tables, .info-header row, .totals-table, .highlight-box, .sig-box /
  .sig-line / .small-muted).
- Layout now mirrors report_fp_sale.xml: Billing / Shipping address
  pair, references row (Customer PO / Customer Job / Order Date /
  Salesperson), scheduling row (Planned Start / Internal / Customer
  Deadline / Ship Via), blanket-order callout, order line table
  (PART / DESCRIPTION / TREATMENT / QTY / UNIT PRICE / SUBTOTAL),
  totals table with subtotal / taxes / grand total, and a two-column
  signature block.

fusion_plating_configurator no longer ships report/ files — it
depends on fusion_plating_reports transitively via installed modules
order. Report XML ID changed from
'fusion_plating_configurator.report_fp_so_acknowledgement_doc' to
'fusion_plating_reports.report_fp_so_acknowledgement_doc'.

UAT on S00066: PDF renders cleanly with ENTECH branding, contact
footer, subtotal \$3,025 / taxes \$393.25 / grand total \$3,418.25,
signature lines — visually identical to the Quotation/Sales Order
report.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:10:33 -04:00
77 changed files with 2840 additions and 180 deletions

View File

@@ -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)
# ============================================================

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizards

View 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',
}

View 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>

View File

@@ -0,0 +1,2 @@
from . import documents_document
from . import account_move

View 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)],
}

View 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},
}

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1 @@
from . import test_document_to_invoice

View 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)

View 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>

View File

@@ -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>

View File

@@ -0,0 +1 @@
from . import create_invoice_from_document

View File

@@ -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,
}

View File

@@ -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>

View File

@@ -0,0 +1 @@
from . import models

View 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',
}

View 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>

View 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

View 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.",
)

View 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

View 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).",
)

View 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.",
))

View 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,
}

View 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).",
)

View 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,
}

View 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.",
)

View 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.",
)

View 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,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1 @@
from . import test_payslip_to_move

View 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'",
)

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -0,0 +1 @@
from . import models

View 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',
}

View File

@@ -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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
from . import fusion_tax_return

View 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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_tax_return_user fusion.tax.return.user model_fusion_tax_return base.group_user 1 0 0 0
3 access_fusion_tax_return_manager fusion.tax.return.manager model_fusion_tax_return account.group_account_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1 @@
from . import test_l10n_ca

View 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")

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View 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',
}

View File

@@ -0,0 +1 @@
from . import ocr_controller

View 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)}

View File

@@ -0,0 +1,4 @@
from . import fusion_ocr_log
from . import res_company
from . import res_config_settings
from . import account_move

View 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)

View 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)

View 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.",
)

View 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,
)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_ocr_log_user fusion.ocr.log.user model_fusion_ocr_log base.group_user 1 0 0 0
3 access_fusion_ocr_log_manager fusion.ocr.log.manager model_fusion_ocr_log account.group_account_manager 1 1 1 1

View File

@@ -0,0 +1,3 @@
from . import ocr_providers
from . import attachment_to_image
from . import invoice_field_parser

View 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 []

View 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

View File

@@ -0,0 +1,3 @@
from . import base
from . import tesseract_adapter
from . import manual_adapter

View 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.01.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

View File

@@ -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')

View File

@@ -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),
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,3 @@
from . import test_tesseract_adapter
from . import test_invoice_ocr_flow
from . import test_field_parser

View 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'], [])

View 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()

View 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(' ', ''))

View 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>

View 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>

View File

@@ -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',
],

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>