# Reviewing AI-generated ERP code: a checklist

Canonical: https://erpfly.com/blog/ai-generated-erp-code-review/
Last updated: September 11, 2026

Published May 12, 2026 in Guides. A reviewer's checklist for AI-written ERPNext and Odoo code: permission bypasses, SQL injection, sudo(), N+1 queries, commits in loops, tests and migrations.

Review AI-generated ERP code the way you'd review a confident new hire's first pull request: assume it runs, and assume it takes shortcuts wherever the shortcut made an error go away. In ERPNext and Odoo those shortcuts cluster in a few places: skipped permission checks, raw SQL built with string formatting, `sudo()` sprinkled over access errors, queries inside loops, manual commits, and hardcoded company or currency values. Check those first, then look for tests and migrations, which AI tools tend to leave out unless asked.

We generate ERP code for a living, so this list includes mistakes we've caught in our own output. Language models are very good at producing code that looks like the framework. They're less good at knowing why the framework makes you do things the slow way.

### Why AI code fails in predictable ways

A model learns from public code, and public ERP code is full of forum answers, quick fixes and snippets written for versions that no longer exist. It also optimises for "this works when I run it", and the fastest route to that in an ERP is usually to bypass a check.

So the failures aren't random. They're the same handful of patterns, which makes them easy to review for once you know where to look.

### 1. Permission bypasses

#### Frappe: `ignore_permissions` and `frappe.get_all`

The classic pattern: the generated code hit a `PermissionError` during testing and the fix was `ignore_permissions=True`. That fix is sometimes right (a system-level background job creating a log record) and often wrong (a whitelisted method any logged-in user can call).

Look hard at every `@frappe.whitelist()` function. It's a public API endpoint. If it reads or writes documents on behalf of the caller, it should check the caller is allowed to:

```python
import frappe


@frappe.whitelist()
def approve_rental(booking):
    doc = frappe.get_doc("Rental Booking", booking)
    doc.check_permission("write")  # raises if the user can't write this record
    doc.status = "Approved"
    doc.save()
```

Also watch for `frappe.get_all` in user-facing code. It ignores permissions by design. `frappe.get_list` applies them. Models mix these up constantly, and `frappe.qb` queries skip permissions too. Anything with `allow_guest=True` deserves a second reviewer.

#### Odoo: `sudo()` as an error silencer

In Odoo the equivalent is `sudo()`. The model gets an `AccessError`, adds `sudo()`, and the error is gone, along with record rules and multi-company isolation. A `sudo().search()` will happily return another company's records.

```python
from odoo import models
from odoo.exceptions import AccessError


class RentalBooking(models.Model):
    _name = "rental.booking"
    _description = "Rental Booking"

    # fields omitted

    def action_approve(self):
        # Not: self.sudo().write(...)
        if not self.env.user.has_group("purchase.group_purchase_manager"):
            raise AccessError("Only purchase managers can approve bookings.")
        self.write({"state": "approved"})
```

Every `sudo()` in a review should have a comment explaining why the current user legitimately can't do this themselves. If nobody can write that comment, the `sudo()` goes.

### 2. Missing access rules on new models

A generated Odoo addon that defines a model but has no line for it in `ir.model.access.csv` will install, log a warning that the model has no access rules, and then be usable only by the superuser. The AI "fixes" this by suggesting you test as admin. Check the CSV has a row per model and group, and that the permissions are what the business wants, not all ones:

```csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_rental_booking_user,rental.booking.user,model_rental_booking,base.group_user,1,1,1,0
access_rental_booking_manager,rental.booking.manager,model_rental_booking,purchase.group_purchase_manager,1,1,1,1
```

For multi-company setups, check there's an `ir.rule` restricting records to `company_ids`. Models almost never add one unprompted.

On the Frappe side, check the DocType JSON's permissions table. Generated DocTypes often ship with only System Manager, which means no other role can use the feature, or with every role ticked, which is worse.

### 3. SQL built with string formatting

This one shows up in both frameworks and it's a real injection risk, not a style issue.

```python
# Wrong: user input goes straight into the query
frappe.db.sql(f"select name from `tabSales Invoice` where customer = '{customer}'")

# Right: let the driver handle the value
frappe.db.sql(
    "select name, grand_total from `tabSales Invoice` where customer = %s and docstatus = 1",
    (customer,),
    as_dict=True,
)
```

Better still, don't use raw SQL at all when `frappe.get_list` or `frappe.qb` will do. In Odoo, the rule is the same for `self.env.cr.execute`: pass parameters as the second argument, never through an f-string or `%` on the string itself. Odoo 17 and later also have `odoo.tools.SQL` for composing queries safely.

Raw SQL also skips the ORM's permission checks in both frameworks, which brings you back to item 1.

### 4. N+1 queries

AI code reads beautifully and queries horribly. The typical shape is a loop that fetches a full document per row:

```python
# One query for the list, then one more per invoice
for name in frappe.get_all("Sales Invoice", filters={"docstatus": 1}, pluck="name"):
    doc = frappe.get_doc("Sales Invoice", name)
    totals[doc.customer] = totals.get(doc.customer, 0) + doc.outstanding_amount
```

Fine with fifty invoices on a test site. Painful with a few years of real data. One grouped query does the same job:

```python
import frappe
from frappe.query_builder.functions import Sum

si = frappe.qb.DocType("Sales Invoice")
rows = (
    frappe.qb.from_(si)
    .select(si.customer, Sum(si.outstanding_amount).as_("outstanding"))
    .where((si.docstatus == 1) & (si.company == company))
    .groupby(si.customer)
    .run(as_dict=True)
)
```

In Odoo, the ORM prefetches related fields across a recordset, so `for order in orders: order.partner_id.name` is usually fine. What isn't fine is a `search()` or `search_count()` inside the loop. Move the search out, search once with an `in` domain, and group in Python.

### 5. Commits inside loops

```python
for row in rows:
    je = frappe.get_doc({"doctype": "Journal Entry", **row})
    je.insert()
    je.submit()
    frappe.db.commit()  # the problem
```

Frappe commits at the end of a successful request and rolls back on error. A manual `frappe.db.commit()` inside a loop breaks that. If row 40 fails, rows 1 to 39 are already posted, and re-running the import posts them twice. Odoo's `self.env.cr.commit()` has the same effect and Odoo's own guidelines warn against calling it yourself.

There are legitimate cases, mostly long background jobs that commit in batches deliberately. Those should be idempotent (safe to re-run) and should say so in a comment. If the generated code commits and you can't see why, remove it.

### 6. Hardcoded company, currency and precision

Generated code loves `"company": "My Company Ltd"` and `currency = "USD"`, because the prompt's example had them. It passes every test on a single-company site and breaks the day you add a second entity.

Look for:

- Company names as string literals. In ERPNext, the company should come from the document, or `erpnext.get_default_company()` as a fallback. In Odoo, from the record's `company_id` or `self.env.company`.
- Currency literals. ERPNext: read `default_currency` from the Company. Odoo: `company.currency_id`, and `Monetary` fields with a proper `currency_field`.
- `round(x, 2)`. Use `flt(value, doc.precision("fieldname"))` in Frappe and the currency's own rounding in Odoo, or totals drift by a cent against the standard documents.
- Account names, warehouse names and cost centres typed in as strings. These belong in a settings DocType or a configuration field.

### 7. Wrong-version APIs

Models blend versions. Watch for `@api.multi` (gone since Odoo 13), `attrs` and `states` in views (removed in Odoo 17 in favour of expressions like `invisible="state != 'draft'"`), and `name_get` overrides where Odoo 17 and later expect `_compute_display_name`. On Frappe, check imports against the version you run; test base classes, for example, moved to `frappe.tests` in v16.

The code often still loads, which is what makes these dangerous. It just doesn't do anything.

### 8. Tests and migrations

Ask two questions of any generated module.

**Where are the tests?** At minimum, one test per business rule that matters: the deposit can't be refunded before inspection, the discount can't exceed the approval limit. In Frappe that's a test class in the DocType folder run with `bench run-tests --app`. In Odoo, a `TransactionCase` tagged with `@tagged("post_install", "-at_install")`. Check that the tests assert outcomes, not just that no exception was raised. AI-written tests are prone to testing that the code does what the code does.

**What happens to existing data?** If the change adds a mandatory field or changes a field's meaning, there has to be a Frappe patch in `patches.txt` or an Odoo script under `migrations/<version>/`, with the module version bumped in the manifest so it actually runs. Generated code is usually written as if the database were empty. Yours isn't.

### Our opinion: review the diff, not the demo

The most common way AI-generated ERP code gets into production unreviewed is a good demo. The form looks right, the workflow moves, someone says ship it. None of the problems above show up in a demo, because demos run as Administrator on a site with twelve records and one company.

So insist on reading the pull request, even if you only check it against this list. Log in as a restricted user. Load a copy of production data. That hour is cheaper than finding out through a month-end close that doesn't balance.

### How we handle this at erpfly

We don't claim our generator avoids all of this. It doesn't, which is why generated [ERPNext apps](https://erpfly.com/erpnext-custom-module-development/) and [Odoo modules](https://erpfly.com/odoo-module-development/) come as pull requests with tests you can run, and why we'd rather you review them than trust them. If you're weighing generated code against a developer on payroll, our [AI vs hiring an ERP developer comparison](https://erpfly.com/compare/ai-vs-hiring-erp-developer/) is candid about the trade-offs. For small tweaks where a full app is overkill, see [Server Scripts vs a custom app](https://erpfly.com/blog/server-scripts-vs-custom-app-erpnext/).

### Sources

- [Database API, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/api/database)
- [Security in Odoo, Odoo 19 developer documentation](https://www.odoo.com/documentation/19.0/developer/reference/backend/security.html)
- [Coding guidelines, Odoo 19 documentation](https://www.odoo.com/documentation/19.0/contributing/development/coding_guidelines.html)
- [ORM Changelog, Odoo 19 developer documentation](https://www.odoo.com/documentation/19.0/developer/reference/backend/orm/changelog.html)
- [Testing Odoo, Odoo 19 developer documentation](https://www.odoo.com/documentation/19.0/developer/reference/backend/testing.html)