diff --git a/account_journal_import/__init__.py b/account_journal_import/__init__.py new file mode 100644 index 0000000..9a7e03e --- /dev/null +++ b/account_journal_import/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/account_journal_import/__manifest__.py b/account_journal_import/__manifest__.py new file mode 100644 index 0000000..8764d67 --- /dev/null +++ b/account_journal_import/__manifest__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +{ + "name": "Journal Entry Import (CSV/XLSX) - Misc Operations", + "version": "19.0.1.0.0", + "category": "Accounting", + "summary": "Import journal items into Miscellaneous Operations with validation preview", + "depends": ["account"], + "data": [ + "security/ir.model.access.csv", + "views/account_move_view.xml", + "views/misc_import_wizard_view.xml", + ], + "installable": True, + "application": False, + "license": "LGPL-3", +} diff --git a/account_journal_import/__pycache__/__init__.cpython-312.pyc b/account_journal_import/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..07f1fcd Binary files /dev/null and b/account_journal_import/__pycache__/__init__.cpython-312.pyc differ diff --git a/account_journal_import/models/__init__.py b/account_journal_import/models/__init__.py new file mode 100644 index 0000000..460e034 --- /dev/null +++ b/account_journal_import/models/__init__.py @@ -0,0 +1 @@ +from . import misc_import_wizard diff --git a/account_journal_import/models/__pycache__/__init__.cpython-312.pyc b/account_journal_import/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0f69077 Binary files /dev/null and b/account_journal_import/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/account_journal_import/models/__pycache__/account_move.cpython-312.pyc b/account_journal_import/models/__pycache__/account_move.cpython-312.pyc new file mode 100644 index 0000000..b1899f0 Binary files /dev/null and b/account_journal_import/models/__pycache__/account_move.cpython-312.pyc differ diff --git a/account_journal_import/models/__pycache__/error_table_wizard.cpython-312.pyc b/account_journal_import/models/__pycache__/error_table_wizard.cpython-312.pyc new file mode 100644 index 0000000..bb5901e Binary files /dev/null and b/account_journal_import/models/__pycache__/error_table_wizard.cpython-312.pyc differ diff --git a/account_journal_import/models/__pycache__/misc_import_wizard.cpython-312.pyc b/account_journal_import/models/__pycache__/misc_import_wizard.cpython-312.pyc new file mode 100644 index 0000000..b945e4c Binary files /dev/null and b/account_journal_import/models/__pycache__/misc_import_wizard.cpython-312.pyc differ diff --git a/account_journal_import/models/misc_import_wizard.py b/account_journal_import/models/misc_import_wizard.py new file mode 100644 index 0000000..f3c467f --- /dev/null +++ b/account_journal_import/models/misc_import_wizard.py @@ -0,0 +1,424 @@ +# -*- coding: utf-8 -*- +import base64 +import csv +import io +from collections import defaultdict + +from odoo import models, fields, _, api +from odoo.exceptions import UserError + +try: + import openpyxl +except Exception: + openpyxl = None + +from logging import getLogger +_logger = getLogger(__name__) + + +REQUIRED_COLUMNS = { + "date", "journal", "account", "partner", "number", + "debit", "credit", "description", "currency", "amount_currency", + "debit2", "credit2", +} +PRODUCT_COLUMNS = {"product", "product_id", "product_code", "product_name", "sku"} + + +def _to_str(v): + if v is None: + return "" + return str(v).strip() + + +def _to_float(v): + if v in (None, "", False): + return 0.0 + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip().replace(",", "") + return float(s) if s else 0.0 + + + +# Result Wizard (Errors) + +class AccountMiscImportResultWizard(models.TransientModel): + _name = "account.misc.import.result.wizard" + _description = "Import Validation Results" + + error_count = fields.Integer(string="Number of validation issues founded", compute="_compute_error_count", store=False) + line_ids = fields.One2many( + "account.misc.import.result.line", + "wizard_id", + string="Issues", + readonly=True, + ) + + @api.depends("line_ids") + def _compute_error_count(self): + for w in self: + w.error_count = len(w.line_ids) + + @api.model + def action_open(self, issues): + + wizard = self.create({}) + line_vals = [] + for it in issues or []: + line_vals.append((0, 0, { + "severity": it.get("severity") or "error", + "row_no": it.get("row_no") or 0, + "field_name": it.get("field_name") or "", + "message": it.get("message") or "", + })) + wizard.write({"line_ids": line_vals}) + + return { + "type": "ir.actions.act_window", + "name": _("Import Validation Results"), + "res_model": "account.misc.import.result.wizard", + "view_mode": "form", + "target": "new", + "res_id": wizard.id, + } + + +class AccountMiscImportResultLine(models.TransientModel): + _name = "account.misc.import.result.line" + _description = "Import Validation Issue" + _order = "severity desc, row_no asc, id asc" + + wizard_id = fields.Many2one("account.misc.import.result.wizard", required=True, ondelete="cascade") + severity = fields.Selection( + [("error", "Error"), ("warning", "Warning")], + default="error", + required=True, + readonly=True, + ) + row_no = fields.Integer(string="Row", readonly=True) + field_name = fields.Char(string="Field", readonly=True) + message = fields.Char(string="Message", readonly=True) + + + +# Main Import Wizard + +class AccountMiscImportWizard(models.TransientModel): + _name = "account.misc.import.wizard" + _description = "Import Journal Lines (CSV/XLSX)" + + file_data = fields.Binary(string="File", required=True) + file_name = fields.Char(string="Filename", required=True) + type_file = fields.Char(string="File Type", readonly=True) + + # --------------------------------------------------------- + # Main entry + # --------------------------------------------------------- + def action_import(self): + self.ensure_one() + + rows = self._read_file() + issues = self._validate_rows(rows) + + # If validation returned issues => open result wizard + if issues: + return self.env["account.misc.import.result.wizard"].action_open(issues) + + # group by entry number (each number => one move) + groups = defaultdict(list) + for r in rows: + key = _to_str(r.get("number")) or "__no_number__" + groups[key].append(r) + + for number, group_rows in groups.items(): + move = self._create_move(number=number, rows=group_rows) + self._create_move_lines(move, group_rows) + + return { + "type": "ir.actions.act_window", + "name": _("Journal Entries (Miscellaneous)"), + "res_model": "account.move", + "view_mode": "list,form", + "domain": [("move_type", "=", "entry")], + "context": {"search_default_group_by_journal": 1}, + } + + + # Create move + + def _create_move(self, number=None, rows=None): + rows = rows or [{}] + file_date = _to_str(rows[0].get("date")) + journal_id = rows[0].get("_journal_id") + + if not journal_id: + + raise UserError(_("Missing journal for entry '%s'.") % (number or "")) + + return self.env["account.move"].create({ + "move_type": "entry", + "journal_id": journal_id, + "date": file_date or fields.Date.context_today(self), + "ref": number or _("Imported Journal Entry"), + }) + + + # File reading + + def _read_file(self): + raw = base64.b64decode(self.file_data or b"") + name = (self.file_name or "").lower() + + if name.endswith(".csv"): + self.write({"type_file": "CSV"}) + return self._read_csv(raw) + + if name.endswith(".xlsx"): + if not openpyxl: + raise UserError(_("XLSX import requires python library 'openpyxl'.")) + self.write({"type_file": "XLSX"}) + return self._read_xlsx(raw) + + raise UserError(_("Only CSV or XLSX files are supported.")) + + def _read_csv(self, raw): + text = raw.decode("utf-8-sig", errors="ignore") + reader = csv.DictReader(io.StringIO(text)) + + if not reader.fieldnames: + raise UserError(_("CSV must have a header row.")) + + headers = [_to_str(h).lower() for h in reader.fieldnames] + headers_set = set(headers) + + missing = REQUIRED_COLUMNS - headers_set + if missing: + raise UserError(_("Missing columns: %s") % ", ".join(sorted(missing))) + + for pcol in PRODUCT_COLUMNS: + if pcol in headers_set: + raise UserError(_("Product data detected in column '%s' -> not Misc.") % pcol) + + rows = [] + for i, row in enumerate(reader, start=2): + normalized = {(_to_str(k).lower()): row.get(k) for k in row.keys()} + normalized["_row"] = i + rows.append(normalized) + return rows + + def _read_xlsx(self, raw): + wb = openpyxl.load_workbook(io.BytesIO(raw), data_only=True) + ws = wb.active + + headers = [_to_str(c.value).lower() for c in ws[1]] + if not any(headers): + raise UserError(_("XLSX first row must contain headers.")) + + headers_set = set(headers) + missing = REQUIRED_COLUMNS - headers_set + if missing: + raise UserError(_("Missing columns: %s") % ", ".join(sorted(missing))) + + for pcol in PRODUCT_COLUMNS: + if pcol in headers_set: + raise UserError(_("Product data detected in column '%s' -> not Misc.") % pcol) + + rows = [] + for i, values in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + row = dict(zip(headers, values)) + row["_row"] = i + rows.append(row) + return rows + + + # Validation + + def _validate_rows(self, rows): + issues = [] + + def add_issue(severity, row_no, field_name, message): + issues.append({ + "severity": severity or "error", + "row_no": int(row_no or 0), + "field_name": field_name or "", + "message": message or "", + }) + + if not rows: + raise UserError(_("The file is empty.")) + + company = self.env.company + Currency = self.env["res.currency"] + Partner = self.env["res.partner"] + Journal = self.env["account.journal"] + Account = self.env["account.account"] + + # group numbers + unique_numbers = set(_to_str(r.get("number")) for r in rows) + + # Must have at least 2 lines per entry number + for num in unique_numbers: + count = sum(1 for r in rows if _to_str(r.get("number")) == num) + if count < 2: + add_issue( + "error", 0, "number", + _("Entry '%(num)s' has only %(count)d line(s). Must be at least 2.") + % {"num": num, "count": count} + ) + + # Validate each entry group + for num in unique_numbers: + entry_rows = [r for r in rows if _to_str(r.get("number")) == num] + + total_debit = 0.0 + total_credit = 0.0 + + + journal_id_for_entry = None + + for r in entry_rows: + row_no = r.get("_row", 0) + + # debit/credit rules + debit = _to_float(r.get("debit")) + credit = _to_float(r.get("credit")) + + if debit < 0 or credit < 0: + add_issue("error", row_no, "debit/credit", _("Debit/Credit cannot be negative.")) + + if debit > 0 and credit > 0: + add_issue("error", row_no, "debit/credit", _("Cannot have both debit and credit.")) + + if debit == 0 and credit == 0: + add_issue("error", row_no, "debit/credit", _("Debit and credit cannot both be zero.")) + + total_debit += debit + total_credit += credit + + # currency check + cur_code = _to_str(r.get("currency")) + if cur_code: + currency = Currency.search( + [("name", "=", cur_code), ("active", "=", True)], + limit=1 + ) + if not currency: + add_issue("error", row_no, "currency", _("Currency '%s' not found.") % cur_code) + + # partner check (optional) - using ref as you do + partner_val = _to_str(r.get("partner")) + if partner_val: + partner = Partner.search([("ref", "=", partner_val)], limit=1) + if partner: + r["_partner_id"] = partner.id + else: + add_issue("error", row_no, "partner", _("Partner '%s' not found.") % partner_val) + + # journal by code (required) + journal_code = _to_str(r.get("journal")) + if not journal_code: + add_issue("error", row_no, "journal", _("Journal is empty.")) + else: + journal = Journal.search( + [("company_id", "=", company.id), ("code", "=", journal_code)], + limit=1 + ) + if not journal: + add_issue("error", row_no, "journal", _("Journal code '%s' not found.") % journal_code) + else: + r["_journal_id"] = journal.id + if journal_id_for_entry is None: + journal_id_for_entry = journal.id + elif journal_id_for_entry != journal.id: + add_issue( + "error", row_no, "journal", + _("Entry '%s' has multiple journals. Use one journal per entry number.") % num + ) + + # account by code (required) + acc_code = _to_str(r.get("account")) + if not acc_code: + add_issue("error", row_no, "account", _("Account is empty.")) + else: + acc = Account.search( + [("company_ids", "in", company.id), ("code", "=", acc_code)], + limit=1 + ) + if not acc: + add_issue("error", row_no, "account", _("Account code '%s' not found.") % acc_code) + else: + r["_account_id"] = acc.id + + # Balanced totals per entry + rounding = company.currency_id.rounding or 0.01 + if abs(total_debit - total_credit) > rounding: + add_issue( + "error", + entry_rows[-1].get("_row", 0), + "balance", + _("Entry '%(n)s' not balanced: debit=%(d)s credit=%(c)s") + % {"n": num, "d": total_debit, "c": total_credit} + ) + + + return issues + + + # Create move lines + + def _create_move_lines(self, move, rows): + aml = self.env["account.move.line"] + + Currency = self.env["res.currency"] + vals_list = [] + + for r in rows: + row_no = r.get("_row", 0) + + account_id = r.get("_account_id") + if not account_id: + # Should not happen if validation is correct + raise UserError(_("Row %s: Account not resolved.") % row_no) + + name = _to_str(r.get("description")) or "/" + debit = _to_float(r.get("debit")) + credit = _to_float(r.get("credit")) + partner_id = r.get("_partner_id") or False + + currency_id = False + cur_code = _to_str(r.get("currency")) + if cur_code: + currency = Currency.search([("name", "=", cur_code)], limit=1) + if currency: + currency_id = currency.id + + amount_currency = 0.0 + if r.get("amount_currency") not in (None, ""): + amount_currency = _to_float(r.get("amount_currency")) + + debit2 = _to_float(r.get("debit2")) if r.get("debit2") not in (None, "") else 0.0 + credit2 = _to_float(r.get("credit2")) if r.get("credit2") not in (None, "") else 0.0 + + vals_list.append({ + "move_id": move.id, + "account_id": account_id, + "name": name, + "debit": debit, + "credit": credit, + "partner_id": partner_id, + "product_id": False, + "currency_id": currency_id, + "amount_currency": amount_currency, + "debit2": debit2, + "credit2": credit2, + }) + + + lines = aml.create(vals_list) + for r, line in zip(rows, lines): + + line.write({ + "debit2": _to_float(r.get("debit2")), + "credit2": _to_float(r.get("credit2")), + "balance2": _to_float(r.get("debit2")) - _to_float(r.get("credit2")), + }) diff --git a/account_journal_import/security/ir.model.access.csv b/account_journal_import/security/ir.model.access.csv new file mode 100644 index 0000000..73005a3 --- /dev/null +++ b/account_journal_import/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_misc_import_wizard,access_account_misc_import_wizard,model_account_misc_import_wizard,account.group_account_user,1,1,1,1 +access_account_misc_import_result_wizard,access_account_misc_import_result_wizard,model_account_misc_import_result_wizard,account.group_account_user,1,1,1,1 +access_account_misc_import_result_line,access_account_misc_import_result_line,model_account_misc_import_result_line,account.group_account_user,1,1,1,1 diff --git a/account_journal_import/views/account_move_view.xml b/account_journal_import/views/account_move_view.xml new file mode 100644 index 0000000..cc73d1b --- /dev/null +++ b/account_journal_import/views/account_move_view.xml @@ -0,0 +1,26 @@ + + + + Import Journal Items (CSV/XLSX) + account.misc.import.wizard + form + new + {} + + + + + + + + + \ No newline at end of file diff --git a/account_journal_import/views/misc_import_wizard_view.xml b/account_journal_import/views/misc_import_wizard_view.xml new file mode 100644 index 0000000..07d9016 --- /dev/null +++ b/account_journal_import/views/misc_import_wizard_view.xml @@ -0,0 +1,76 @@ + + + + + + account.misc.import.wizard.form + account.misc.import.wizard + + +
+ + + + + + + + +
+
+ +
+
+ + + + account.misc.import.result.wizard.form + account.misc.import.result.wizard + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ +