Skip to content

Part 7 of 7 · Stock transfer planner series ~7 min read

Engineering reference: the stock transfer planner 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 in-transit location, and the economic test.

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 stock transfer planner drawn with AWS service namesThree boxes across the top outside the AWS account. Stock and sales per site, read only. The vehicle schedule of planned movements. And Pick lists, one per movement. Inside the account, three groups. EventBridge running a nightly scan and a batch at cut-off. Three Lambda functions named scan, evaluate and batch. And two DynamoDB tables named proposals and transfers. A note gives the region as us-east-1, one account, and states that in-transit is a site id like any other, with a real balance.AWS ACCOUNTStock and salesper site, read onlyVehicle scheduleplanned movementsPick listsone per movementEventBridge nightlyscan, thenbatch at cut-offLambda x3scan, evaluate, batchDynamoDB x2proposals, transfersingroundsoutus-east-1. One account. In-transit is a site id like any other, with a real balance.
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
  • Management
  • Front-end & mobile

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
st-scanEventBridge, nightlyFinds imbalances among pairs whose stock or sales changed; applies ranging and cover rules300s / 2048 MB
st-evaluateDynamoDB stream on proposalsRuns the economic test, the ping-pong check and the source protection rule60s / 1024 MB
st-batchEventBridge, at each movement cut-offRechecks need, assembles the load, emits one consolidated pick list120s / 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
st-scan-roledynamodb:Query, dynamodb:PutItemRead-only on stock and sales; writes proposals
st-evaluate-roledynamodb:Query, dynamodb:UpdateItemProposals and transfers
st-batch-roledynamodb:Query, dynamodb:UpdateItem, s3:PutObjectBoth tables; the pick list prefix

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: proposals

PK   sku               S
SK   from#to#made_on   S   br3#br1#2026-08-24
     qty               N   the smaller of surplus and need
     benefit_pence     N   margin protected, after both discounts
     cost_pence        N   pick + pack + transport share + receive
     on_movement       BOOL true if a scheduled movement exists
     source_cover_wks  N   what A keeps, after the transfer
     expires_at        S   B’s own replenishment lead time
     rejected_reason   S   too_small | ping_pong | source_cover | uneconomic

Rejected proposals are counted, not stored. The reason distribution is
the useful output: mostly `uneconomic` means the detector is too loose.

Table: transfers

PK   transfer_id       S
     sku               S
     from_site         S
     to_site           S
     qty_despatched    N
     qty_received      N   confirmed at the far end, not assumed
     despatched_at     S   stock moves to site id ’in_transit’ here
     expected_at       S
     received_at       S   stock moves to to_site here
     urgent            BOOL bypassed the economic test
     discrepancy       N   despatched minus received, if non-zero

`in_transit` is an ordinary site id, so the company-wide total is
correct at every instant and the balance can be reconciled monthly.

Inbound and outbound

  • Stock and sales are read, never written. This system proposes; the stock system records the movement.
  • Ranging and display minimums are per product per site, set by somebody who knows the range. Without them the scan proposes transfers for products the site never sells.
  • The vehicle schedule is an input, not something this system creates. A transfer never triggers a journey.
  • Need is rechecked at the movement cut-off, because a proposal made nine days ago may have been overtaken by a normal replenishment.

The model call

  • There is no model in this system. The imbalance test is a comparison of cover weeks and the economic test is arithmetic.
  • The tempting use is demand forecasting per site per product. At single-site granularity most products sell in ones and a trailing rate is as good as anything.
  • A second tempting use is estimating substitution probability. It is a judgement about the range, better set per category by a person and visible in configuration.
  • Explainability matters here because somebody is being asked to spend twenty minutes picking. The arithmetic goes on the proposal.
  • The cost page assumes none, which is why the scan is the only variable.

Things worth knowing before you build it

  • Model in-transit as a real location. Deducting at despatch with nowhere to put it makes stock vanish; deducting at receipt promises stock that is in a van.
  • Compute the benefit from margin protected, not stock value. Stock value makes every transfer look worthwhile and none of them are.
  • Check for a reverse transfer within the last quarter before proposing. Both legs of a ping-pong pass the test individually.
  • Keep a display minimum per product per site. A transfer that takes the last one off a shelf is a merchandising failure no inventory rule will catch.
  • Recheck need at the cut-off, not only when the proposal is made. Normal replenishment frequently solves it in the intervening week.

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

All posts