Skip to content

Part 2 of 7 · Purchase order approver series ~6 min read

How a purchase request arrives

The fastest way to kill a purchase process is to make people use a system they do not already live in. So this one does not pick a channel. It takes an email, a short web form, or a Slack shortcut, and turns all three into the same five fields. This post is about that conversion, and about the two things that make it trustworthy: it leaves a field blank rather than guessing, and it will not create a second request for the same ask.

Key takeaways

  • Three lanes — an inbox, a five-field form, and a Slack shortcut — all land as one request record.
  • The record is always the same five fields: requester, item, budget line, amount, vendor.
  • A field the reader cannot fill confidently stays blank. Blank is a question; a guess is a bug.
  • A fingerprint on requester, vendor, amount and day makes a resent email land on the same request.
  • Attachments — a quote PDF, a screenshot of a cart — are kept and shown to the approver.

Three ways in, one shape out

People ask for things the way they already talk. Forcing everyone through one form is how you end up with a form nobody uses and a purchase process that lives in your inbox anyway. So there are three lanes, and they all end in the same place.

Three request lanes converging on one intake queueThree boxes stacked on the left, each an outside source. The first is a reply-to inbox: somebody emails a dedicated address in plain prose, worded however they like. The second is a five-field form on the company intranet, which arrives already structured. The third is a Slack shortcut, typed as slash-buy in a channel, which opens a small modal. Each has an arrow labelled with what it carries -- prose, fields, and modal respectively -- converging on a single box on the right, One request queue, where every ask becomes the same record. Below that queue, connected by a downward arrow, is the Reader, which fills in what it can read confidently and leaves the rest blank. A note says whichever lane it came from, what leaves the queue looks identical.Reply-to inboxplain email, any wordingproseFive-field formon the intranetfieldsSlack shortcut/buy, in the channelmodalOne request queueevery ask becomesthe same recordReaderfills what it can,leaves the rest blankWhichever lane it came from, what leaves the queue looks identical.
Fig 1. Three lanes into one queue. An email, a form and a Slack shortcut all become the same request record before anything is checked, which is why the rest of the system only has to understand one shape.
  • App integration
  • Machine learning
  • Front-end & mobile

The five fields

Everything downstream — the budget check, the approval message, the purchase order — reads exactly five fields. Keeping the list this short is deliberate. Every extra field is another thing the reader can get wrong and another thing a requester can leave out.

  • Requester. Who asked. From the email sender, the form session, or the Slack user. This one is never guessed — it comes from the channel itself, which is also why a forwarded email is treated as a request from the forwarder, not the original sender.
  • Item. What they want, in their words, trimmed to one line. “Two boxes of blue nitrile gloves, size L.” Not normalised, not looked up in a catalogue. The approver is a human and reads human sentences faster than SKUs.
  • Budget line. Which row in the sheet this belongs to. This is the field the reader gets wrong most often, and so it is the field it is most willing to leave blank.
  • Amount. The number, in your currency. If a quote PDF is attached the reader prefers the number on the quote over the number in the email body, because people round in prose and quotes do not.
  • Vendor. Who it is being bought from. Matched against the approved-vendor tab by name, loosely — “Medline”, “Medline Industries” and “medline.com” are the same vendor — but a name with no match stays as typed and becomes a question later.

What happens to one email

The email lane is the messy one, so it is worth following end to end.

How one email becomes a complete purchase requestA vertical chain of five steps inside the AWS account, entered from the left by a box labelled Email arrives, to the requests address. Step one, Store the raw message in S3 along with any attachments. Step two, Seen this ask before, which fingerprints the requester, vendor, amount and day and writes it conditionally to a DynamoDB requests table shown to the right; if that write is rejected the request is a duplicate and the branch exits to Same request, reply on the original. Step three, Read into five fields with a single Bedrock call, grounded by the vendor list read from the sheet. Step four, All five filled, which exits to Ask the requester, naming the missing field, if any field is blank. Step five, Hand to the checker as one complete request. A note says the duplicate test runs before the model, so a resent email never costs a second read.AWS ACCOUNTEmail arrivesto the requests addressStore the raw messageS3, with attachmentsSeen this ask before?requester + vendor +amount + dayDynamoDB requestsconditional writeSame requestreply on the originalduplicateRead into five fieldsone Bedrock callVendor listfrom the sheetAll five filled?Ask the requestername the missing fieldgapHand to the checkerone complete requestThe duplicate test runs before the model, so a resent email never costs a second read.
Fig 2. One email, end to end. The raw message is kept, the duplicate test runs before any model call, the read fills what it can, and a missing field becomes a question rather than a guess.
  • Storage
  • Database
  • App integration
  • Machine learning
  • Security & identity
  • People

Why the duplicate test comes first

People resend. They forward the same request to a second address in case the first one was missed, they reply “any update?” on the same thread, they tap the form submit button twice on a bad connection. Every one of those is the same ask, and every one of them would otherwise become a second purchase order.

So before anything expensive or consequential happens, the intake builds a fingerprint — the requester, the vendor as typed, the amount, and the calendar day — and tries to write it to DynamoDB with a condition that the key does not already exist. The database decides, not a query-then-write in the function, because two copies of the same email arriving a second apart will both pass a query and both write. A conditional write cannot both succeed. The loser reads the winner’s request id and replies on that thread instead.

Putting the test before the model read has a second benefit: a resent email costs nothing. The read is the only line on the bill that scales with volume, so not paying it twice for the same ask matters more than it looks.

Blank beats plausible

The reader is allowed to say it does not know. If the email is “can we get the thing we talked about”, the item field will be the sentence as typed, and the budget line and the amount will be empty. The system does not guess a budget line from the requester’s department, and it does not guess an amount from last month’s order. It replies: “Which budget line, and roughly how much?”

This looks like a worse system on a demo and is a much better one in practice. A guessed budget line is a wrong number in a real budget, discovered at month end, after the money is gone. A blank field is a fifteen-second reply.

What the intake guarantees

  • Every request that reaches the checker has all five fields filled, or it never reached the checker.
  • One ask produces one request record, even if the same email arrives four times.
  • The original message and every attachment are kept, so the approver can open the quote.
  • The requester always hears back — a request id, a question, or a purchase order. Never silence.

Next: what the checker actually does with those five fields, and why none of it is the model’s job.

All posts