DEV Community

jason
jason

Posted on

urning emailed spreadsheets into Magento orders without asking customers to change

Every B2B Magento store I've worked on has the same quiet leak. A chunk of the orders never touch the storefront. They arrive as an .xlsx exported from the buyer's purchasing system, a PDF purchase order, or a photo of a list someone wrote by hand. A person on the seller's side opens the admin, clicks Create New Order, and retypes it.

Nobody logs this as a problem because nothing is broken. The order gets placed. It just costs a few minutes of a salesperson's day, every day, and it never shows up in any conversion report because as far as Magento is concerned that order was created by an admin.

The usual answer is to push the buyer into the storefront. Better UX, saved carts, quick order forms, a requisition list. I've built some of that. It doesn't work, and there's a comment in a r/Magento thread that explains why better than I can: "Portals rarely stop manual orders. Most buyers won't leave their internal workflow just to click through a UI." The buyer already has the order in their ERP. Retyping it into your portal is work for them, not a convenience.

So I stopped trying to move the buyer and started building the opposite: the order arrives however they already send it, and Magento does the retyping.

Here's the shape of it, and the parts that turned out to matter.

The pipeline
Cron polls a shared mailbox. Each message that looks like an order goes through an extraction step. The result lands in an admin grid as a draft. A human clicks Confirm and a real order is created.

<!-- etc/crontab.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="default">
<job name="vendor_orderinbox_poll"
instance="Vendor\OrderInbox\Cron\PollMailbox"
method="execute">
<schedule>*/10 * * * *</schedule>
</job>
</group>
</config>

Nothing surprising there. The interesting decisions are all downstream.

Never create the order automatically
This is the part I'd argue about with anyone. It is technically easy to place the order straight from the parsed lines:

`$quote = $this->quoteFactory->create();
$quote->setStoreId($storeId);
$quote->assignCustomer($customer);

foreach ($lines as $line) {
$product = $this->productRepository->get($line->getSku());
$quote->addProduct($product, $line->getQty());
}

$quote->getShippingAddress()
->addData($shipping)
->setCollectShippingRates(true)
->collectShippingRates()
->setShippingMethod('flatrate_flatrate');

$quote->getPayment()->importData(['method' => 'checkmo']);
$quote->collectTotals();
$this->quoteRepository->save($quote);`

$orderId = $this->cartManagement->placeOrder($quote->getId());
Don't. In B2C, a bad automated order is a refund. In B2B it's a pallet on a truck to the wrong country, and the relationship with an account worth five figures a year. The whole value of the feature is removing the typing, not removing the human. Confirming a pre-filled draft takes fifteen seconds. Typing it takes four minutes. That's the win, and it survives being wrong occasionally.

So the parsed result goes into the module's own table, and the code above only runs after someone clicks Confirm.

Idempotency is not optional
IMAP will hand you the same message twice. Cron overlaps with itself the day someone sets the schedule to every minute. Whatever you key on, key on something stable:

<!-- etc/db_schema.xml, abridged -->
<table name="vendor_orderinbox_message">
<column xsi:type="varchar" name="message_id" nullable="false" length="255"/>
<constraint xsi:type="unique" referenceId="VENDOR_ORDERINBOX_MESSAGE_ID">
<column name="message_id"/>
</constraint>
</table>

The RFC 5322 Message-ID header is the right key. Insert first, process second, and let the unique constraint be the lock. I lost an afternoon to a duplicate-draft bug before doing it this way.

Identifying the customer is harder than parsing the order
The From address is the obvious key, and it's wrong often enough to matter. Purchasing assistants forward from their own mailbox. Companies have four people who send orders. Someone replies from their phone with a different alias.

What works: match on the address first, fall back to the domain, and when the domain maps to more than one customer account, don't guess. Put the draft in the grid unassigned with the candidates listed and let the human pick. Getting this wrong doesn't produce a visibly broken draft, it produces a plausible one billed to the wrong company, which is worse.

Give the model the customer's catalog, not yours
The naive version dumps the email text into an LLM and asks for SKUs and quantities. It works in a demo and falls apart on a real mailbox, because a buyer writes "the black ones, same as March" and no amount of prompt engineering recovers a SKU from that without knowing what they bought in March.

So before the extraction call, I pull that customer's own history:

`$criteria = $this->searchCriteriaBuilder
->addFilter('customer_id', $customerId)
->setPageSize(200)
->create();

$previousSkus = [];
foreach ($this->orderRepository->getList($criteria)->getItems() as $order) {
foreach ($order->getItems() as $item) {
$previousSkus[$item->getSku()] = $item->getName();
}
}`
Those SKUs go into the prompt as the candidate set. Two things fall out of this. Accuracy goes up a lot, because most B2B orders are re-orders. And the token bill stays flat, because you're sending a couple of hundred SKUs instead of a catalog of ninety thousand.

Constrain the output, then validate it anyway
The extraction returns structured JSON, not prose. Every provider worth using supports a schema or tool-call for this now, so there's no reason to be parsing free text in 2026.

But a schema only guarantees the shape, not the truth. A model will happily return a well-formed SKU that doesn't exist. So every returned SKU is checked against the repository, and anything that misses goes into the draft as an unresolved line with the original text next to it:

try {
$product = $this->productRepository->get($extracted->getSku());
} catch (NoSuchEntityException $e) {
$line->setStatus(Line::STATUS_UNRESOLVED);
$line->setRawText($extracted->getRawText());
continue;
}

Unresolved lines are a feature. They're the module admitting it doesn't know, which is the only behaviour that makes the human's fifteen-second review meaningful.

Never take the price from the email
The buyer's PO has prices on it. They're the prices the buyer thinks they have. Use Magento's own pricing for that customer group and compare. If they differ, flag it on the draft.

That comparison turned out to be worth more than the parsing. It surfaces expired agreements and stale price lists on the buyer's side, which is a conversation you want to have before you ship, not after you invoice.

Keep the original
The source email, headers and attachments, stays linked to the draft and gets copied onto the order as a comment when it's confirmed. Three months later somebody will ask why 200 units shipped, and the answer needs to be a file, not a memory.

What I still don't know
Scanned PDFs are the weak spot. A clean text PDF extracts fine; a photo of a fax needs OCR first and accuracy drops in a way I haven't measured properly yet. Multi-page tables that split a line item across a page break are another one. And I have no data on how this behaves across languages beyond English and German.

That's honestly why I'm writing this before the module is finished rather than after. If you run B2B on Magento, I'd like to know two things: how much of your order volume actually arrives outside the storefront, and in what format. The formats are what decides whether this is a product or just my own workaround.

Notes and a signup if you want to try it when it's ready: https://jasonyang6688.github.io/solo-forge-landing/order-inbox/?ref=devto

Top comments (0)