Write a "what's the status of this PO" query against Yardi Voyager's MM2 purchasing module, and it will run. It will also, in a specific and repeatable set of ways, tell you the wrong answer. MM2 doesn't fail loudly — it fails by returning a plausible number from the wrong column.
Here's where the traps actually are, at the table and column level.
The purchase order tables aren't where people guess
The Yardi Voyager purchase order table is MM2PO at the header level and MM2PODET at the line level — one PO, many lines, standard header/detail shape. Purchase requisitions sit one layer upstream in MM2PURREQ / MM2PURREQDET, and the PR→PO link is optional: a PO line carries MM2PODET.HPRDET → MM2PURREQDET.HMY only when the client actually routes purchases through requisitions. Plenty of installs cut POs directly, and that column sits null — that's normal, not a data-quality problem.
That optionality matters for query correctness. If you inner-join PO lines to requisitions expecting every PO to have one, you silently drop every PO that was created without a PR — and the missing rows won't throw an error, they'll just not be in your result set.
There is no receiving table — and the two tables that look like one aren't it
The instinct, coming from most other ERPs, is to look for a goods-receipt table. Voyager doesn't have one, and two tables in the schema actively look like candidates and are not: MM2RECPO and MM2RECWO. Despite the naming, these are recurring PO/WO templates (they carry ObjectType codes 29 and 42), not receiving records. Join a query to either expecting GRN rows and you'll get either nothing or the wrong rows back.
Receipt state instead lives directly on the PO line itself — no separate table at all:
MM2PODET.IQTYRECEIVEDvsIQTYORDEREDDTRECEIVEDDATEhUserReceivedBy
Derive a status from the pair of quantity columns: 0 received against a nonzero ordered quantity is not received, received < ordered is partial, received >= ordered is fully received. This is exactly what a three-way-match exception query needs to flag — ordered-not-invoiced, invoiced-without-receipt, and partial-receipt lines — and it only works if you know receiving isn't a join target, it's a comparison.
Space-padded codes will break your equality filters
MM2PO.SCODE and MM2PURREQ.SCODE are nchar(8) — fixed-width, space-padded. A filter like WHERE SCODE = 'PO-1042' will silently fail to match the padded value sitting in the column. RTRIM() on both sides of any comparison against these codes, every time, or trim at write time in whatever tool built the query.
The obvious amount columns aren't always populated
MM2PO and MM2PODET carry header/line amount fields that look authoritative — dNet, dGrossTotal, iApprovalState — and at a given install, any of them can simply be unpopulated. Prefer DTOTAL at the header and DTOTALCOST at the line, and profile the rest before trusting them in a report. This is a module where "the column exists" and "the column is populated at this install" are two different questions, and treating them as the same one produces reports that are quietly wrong rather than reports that fail.
Invoice-to-PO matching happens at the line, not the header
Once a PO line is invoiced, the natural next join is back to the AP invoice register. The header-level link (MM2PO.HPO / HWO on the invoice register header) is commonly dead — don't match invoices to POs there. The real link is line-level: GLInvRegDetail.hPODet → MM2PODET.HMY. A query that joins at the header will either match nothing or match the wrong invoice entirely on a PO with multiple lines from different invoices.
It also means payable and payment amounts, once you follow the chain further downstream, sit at invoice-header grain — they repeat across every PO line a single invoice happens to cover. Summing those repeated header amounts alongside line-level PO costs double-counts. Aggregate at the grain the amount actually lives at, not the grain of the row you're looking at.
When MM2 is just empty
Some Voyager installs run procurement through an external ERP entirely and use MM2 minimally or not at all. Before treating a thin or empty MM2 result as a query bug, check the row counts on MM2PO and MM2PURREQ directly — an empty module is a legitimate finding, not a broken join.
Putting it together
None of these traps are exotic — they're the kind of thing that shows up the first time someone reconciles a purchasing report against what procurement actually knows happened. A correct three-way-match query needs: line-level receiving status derived from quantity columns, line-level invoice matching via GLInvRegDetail.hPODet, trimmed codes, and amount columns chosen for actual population rather than name. Get any one of those wrong and the query still runs — it just tells you PO 1042 was never received when it was, three weeks ago, in full.
This is exactly the kind of tribal knowledge the PropETL SQL Query Assistant carries as execution-verified query patterns, not guesses — the join keys above, the receiving-status derivation, and a ready-made three-way-match exception pattern, all queryable in plain English inside Claude. It's included in every tier, including the free 7-day trial. See what else it knows about Voyager's procure-to-pay chain on the SQL Assistant page.
