Guides 6 min read Updated
Reviewing AI-generated ERP code: a checklist
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.
Written by the erpfly team, people who build on Frappe and Odoo for a living.
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:
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.
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:
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.
# 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:
# 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:
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
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’scompany_idorself.env.company. - Currency literals. ERPNext: read
default_currencyfrom the Company. Odoo:company.currency_id, andMonetaryfields with a propercurrency_field. round(x, 2). Useflt(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 and Odoo modules 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 is candid about the trade-offs. For small tweaks where a full app is overkill, see Server Scripts vs a custom app.
Sources
The official documentation and source code this page was checked against.
- 01 Database API, Frappe Framework documentation docs.frappe.io
- 02 Security in Odoo, Odoo 19 developer documentation odoo.com
- 03 Coding guidelines, Odoo 19 documentation odoo.com
- 04 ORM Changelog, Odoo 19 developer documentation odoo.com
- 05 Testing Odoo, Odoo 19 developer documentation odoo.com
Terms in this post