DEV Community

life
life

Posted on

Prep requirements are a rules table, not an if-statement

Every fulfillment system ends up with a function that decides whether an item needs a poly bag, a label, a bubble wrap layer, or a "Sold as set" marking. It usually starts as ten lines of conditionals and it ends as the place where bugs go to hide, because the rules it encodes are not ours. They belong to the channel, and the channel changes them.

When the rules live in code, a change in the channel's requirement becomes a deploy. When they live in a table, it becomes a row with an effective date, and the difference shows up the first time you have to explain what a shipment that moved in March was prepped under.

What the rules actually look like

Strip away the per-channel naming and a prep rule has the same shape everywhere:

when  <item attributes>
require <one or more prep actions>
from    <date>
to      <date or open>
source  <where this came from>
Enter fullscreen mode Exit fullscreen mode

The attributes are things like whether the item is liquid, whether it ships in its own packaging, whether the manufacturer barcode is scannable, whether it is a multi-pack that must not be separated, whether it is fragile, whether it has a shelf life. The actions are things like bag it, label it, overlabel the old barcode, bundle it with a set marker, add a suffocation warning, or reject it as unshippable until the seller changes the packaging.

Two of those are easy to miss and cause most of the rework. A bag can be required while the warning text is not, or the warning can be required only above a certain opening size. And an item that already carries a scannable barcode may still need an overlay label if that barcode encodes something the channel cannot resolve. Encoding either of those as a single boolean is how a shipment gets rejected at the door with a reason nobody in the building can reproduce.

The schema

CREATE TABLE prep_rule (
  id            bigserial PRIMARY KEY,
  channel       text        NOT NULL,   -- 'amazon-fba', 'walmart-wfs', 'dtc-marketplace'
  rule_code     text        NOT NULL,   -- stable name: 'poly_bag', 'suffocation_warning'
  predicate     jsonb       NOT NULL,   -- attribute constraints
  actions       jsonb       NOT NULL,   -- ordered list of prep actions to apply
  params        jsonb       NOT NULL DEFAULT '{}',  -- thresholds for this rule version
  effective_from date       NOT NULL,
  effective_to   date,                  -- NULL means still in force
  source_url    text        NOT NULL,   -- where the channel published it
  source_seen   date        NOT NULL,   -- when we read it
  UNIQUE (channel, rule_code, effective_from)
);
Enter fullscreen mode Exit fullscreen mode

effective_to is never edited once a successor row exists. When a channel changes a rule, you close the old row by inserting a new one with a later effective_from, and you leave the old row alone. The history is the point. A seller disputing a March rejection needs the March rule, not today's rule with a comment attached.

params holds the thresholds. Keeping them out of the predicate is deliberate: the predicate decides whether the rule applies, the params decide how the action is carried out. "Bag it" is the action; the opening-size threshold that triggers the extra warning text is a parameter of a different rule that may also apply.

CREATE TABLE prep_action (
  code        text PRIMARY KEY,
  label       text NOT NULL,
  labor_secs  integer NOT NULL,   -- for costing, not for the customer-facing quote
  consumable  text                -- 'poly_bag_6x8', 'thermal_label', 'bubble_wrap'
);
Enter fullscreen mode Exit fullscreen mode

Resolving a SKU at a point in time

type ItemAttrs = {
  isLiquid: boolean
  shipsInOwnPackaging: boolean
  barcodeScannable: boolean
  isSet: boolean
  fragile: boolean
  shelfLifeDays: number | null
  longestOpeningCm: number | null
}

export function requiredPrep(
  channel: string,
  item: ItemAttrs,
  asOf: Date
): { ruleCode: string; actions: string[]; params: Record<string, unknown> }[] {
  const day = asOf.toISOString().slice(0, 10)
  const rows = db.any(
    `SELECT rule_code, actions, params FROM prep_rule
      WHERE channel = $1
        AND effective_from <= $2
        AND (effective_to IS NULL OR effective_to > $2)
      ORDER BY rule_code`, [channel, day])

  return rows
    .filter(r => matches(r.predicate, item))
    .map(r => ({ ruleCode: r.rule_code, actions: r.actions, params: r.params }))
}
Enter fullscreen mode Exit fullscreen mode

matches is a small predicate evaluator, not an expression engine. Something like { "isLiquid": true, "shipsInOwnPackaging": false } meaning both must hold, with arrays allowed on the right side for "any of". Resist the urge to let the predicate be arbitrary SQL or JavaScript. The moment it is, a bad row can do more than misclassify a SKU.

The asOf parameter is the whole design. Every caller from the receiving bench to the invoicing job passes the same date, and the answer is stable for that date forever.

The three bugs this shape prevents

Silent drift. A channel updates its requirement and nobody tells engineering. With rules in code, the system keeps applying the old rule and the failure surfaces as rejected shipments weeks later. With rules in a table plus a source_seen date, a periodic check that re-reads the published page and flags anything older than, say, ninety days is a fifteen-line job. It does not decide what the new rule is. It says "this one is old, go look".

Unreproducible decisions. "Why did we bag and label this twice?" is answerable when the resolution is a query: which rows were in force, what the item attributes were, what the actions produced. It is not answerable when the logic was an if-chain that has since been edited twice.

Costing that disagrees with the bench. labor_secs and consumable per action mean the prep charge derived from the same rules the operators follow. When the two come from different places, the invoice is always the one that turns out wrong.

Tests

test('rule that expired is not applied', () => {
  const r = requiredPrep('amazon-fba', liquidItem, new Date('2026-06-01'))
  expect(r.map(x => x.ruleCode)).not.toContain('poly_bag_old_threshold')
})

test('two rules can apply to one item', () => {
  const r = requiredPrep('amazon-fba', wideOpeningLiquidItem, new Date('2026-10-06'))
  expect(r.map(x => x.ruleCode).sort()).toEqual(['poly_bag', 'suffocation_warning'])
})

test('closing a rule does not change historical answers', () => {
  const before = requiredPrep('amazon-fba', liquidItem, new Date('2026-03-01'))
  insertNewRuleVersion('poly_bag', new Date('2026-10-01'))
  const after = requiredPrep('amazon-fba', liquidItem, new Date('2026-03-01'))
  expect(after).toEqual(before)
})
Enter fullscreen mode Exit fullscreen mode

The last one is the test that protects the design. If closing a rule can change what March resolves to, the table is a cache of today's opinion rather than a record.

Where the numbers come from

One honest caveat about params. The thresholds belong to the channel and they move. Nothing in this schema makes your numbers correct; it only makes them dated, attributable and re-runnable. Every row carries a source_url and a source_seen date precisely so that a wrong number is a row you can find and replace, rather than a constant buried in a function nobody remembers touching.

FulfillNexa by SBT (fulfillnexa.com) runs inbound, storage and outbound across Shenzhen, Suzhou and Dongguan, 24,000 square meters combined and 13,000 of it in Suzhou, and the prep rules we apply are kept in exactly this shape because our operators need to see why an item was treated a certain way, not just that it was. We do not publish rates or transit commitments, and none of the above depends on either.

Top comments (0)