Skip to content

Part 7 of 7 · Packing slip checker series ~7 min read

Engineering reference: the packing slip checker architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the offline sync, and where the model is used.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The packing slip checker drawn with AWS service namesThree boxes across the top outside the AWS account. Packing slips arriving by email ahead of the van. The receiving device, which is offline capable. And Purchasing and invoicing, which read records and receive flags. Inside the account, three groups. S3 holding slips and photographs alongside an API providing a sync endpoint. Three Lambda functions named match, receive and aggregate. And two DynamoDB tables named receipts and discrepancies. A note gives the region as us-east-1, one account, and states that the device works offline and that counts and photographs sync on return.AWS ACCOUNTPacking slipsemail, ahead of the vanThe receiving deviceoffline capablePurchasing and invoicingread and flagS3 + APIslips, photographs,sync endpointLambda x3match, receive, aggregateDynamoDB x2receipts, discrepanciesingroundsoutus-east-1. One account. The device works offline; counts and photographs sync on return.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Database
  • People

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
ps-matchS3 put on the slips prefixReads the slip, matches it to a purchase order, builds the check list120s / 1024 MB
ps-receiveAPI, from the deviceAccepts counts and photographs, generates the annotation wording, records the receipt30s / 1024 MB
ps-aggregateEventBridge, weeklyGroups discrepancies by supplier, product and unit mapping; computes acceptance rates120s / 1024 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
ps-match-roles3:GetObject, bedrock:InvokeModel, dynamodb:PutItem, dynamodb:QueryThe slips prefix; one model id; receipts; read-only on purchase orders
ps-receive-roles3:PutObject, dynamodb:UpdateItem, ses:SendEmailThe photographs prefix; receipts and discrepancies; one verified identity
ps-aggregate-roledynamodb:QueryRead-only across both tables

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: receipts

PK   po_number         S
SK   line_no           N
     sku               S
     ordered_qty       N
     slip_qty          N   what the packing slip claimed
     counted_qty       N   null if not counted
     status            S   counted | accepted | substituted
     unit_mapping      S   ’box of 12’ — the mapping in force at the time
     photos            L   s3 keys, wide and close
     received_at       S
     annotated         BOOL true if written on the note before signing
     received_by       S   a named person

`status` distinguishes counted from accepted. A single ’received’ value
cannot answer the first question anybody asks two months later.

Table: discrepancies

PK   supplier          S
SK   found_at#po#line  S
     kind              S   short | over | substituted | wrong_item
     sku               S
     expected          N
     found             N
     value_pence       N
     unit_mapping      S   copied, so a later mapping change cannot rewrite history
     notified_at       S   same day, or the reason it was not
     resolved_at       S   credit or redelivery
     resolution        S

Over-shipments are the same record type as shortages, so both appear
in the same pattern analysis. They usually share a cause.

Inbound and outbound

  • Slips arrive by email into an S3 prefix. Suppliers who send structured files are parsed directly; only unstructured slips reach the model.
  • The device downloads the check list when the match completes, so nothing at the door needs a network round trip.
  • Counts and photographs queue locally and sync when signal returns. The sync is idempotent on a device-generated id.
  • The receipt record feeds the invoice match, so a held line cannot be quietly paid. That integration is the main one worth doing properly.

The model call

  • One read per unstructured packing slip. Extracting product codes, quantities and units into lines.
  • It never decides the check list. That is three rules: any difference, high value or history, plus the rotation.
  • It never interprets a substitution. Whether a substituted product is acceptable is a judgement recorded against a person’s name.
  • The annotation wording is a template, not generated. A sentence written on a legal document is not a place for variation.
  • Structured slips skip the model entirely, which over time is most of them and most of the bill.

Things worth knowing before you build it

  • Record counted and accepted as different statuses. It is one field and it answers the question every dispute starts with.
  • Copy the unit mapping onto the discrepancy record. A mapping corrected next month must not retroactively make old discrepancies disappear.
  • Make the device work offline before anything else. A receiving app that needs signal in a loading bay is a receiving app that records everything as correct.
  • Generate the annotation wording and show it before the signature step, not after. The order is the whole point.
  • Put the acceptance rate on the discrepancy report. Without it, the least-checked supplier always looks like the best one.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts