kempo-inventory 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -321,7 +321,7 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
321
321
  | `unregisterFields(owner)` | Remove every field your extension owns, and their values |
322
322
  | `FIELD_TYPES`, `EVENTS` | The field types and the hook event names |
323
323
 
324
- An item looks like `{ id, sku, name, description, category, tags, quantity, fields: { ...custom values }, owner, created, updated }`.
324
+ An item looks like `{ id, sku, name, description, category, tags, quantity, held, fields: { ...custom values }, owner, created, updated }`.
325
325
 
326
326
  ### Several items at once
327
327
 
@@ -339,6 +339,32 @@ const [error, { items }] = await adjustStockMany(
339
339
 
340
340
  Changes to the same item are added together and one that nets to nothing is left out. Every item goes through the same `before_adjust` guard as `adjustStock`, and each fires `stock:adjusted` once everything is committed. `ref` is written on every stock movement (as `[order-1042]` in its note) so the change can be found, and reversed, later.
341
341
 
342
+ ### Held stock
343
+
344
+ An item has two figures: **`quantity`**, what is *available* now, and **`held`**, what is set aside for something that has not finished — an order waiting for its payment, a job waiting for its materials. The stock the item really has is the two added together.
345
+
346
+ `quantity` stays the figure everything already reads, so holding stock lowers what can be sold or used everywhere without anything else having to know about holds. Three moves, each all or nothing across every item in the call, and each recorded as a movement (`heldDelta`, `heldAfter`):
347
+
348
+ | Function | Moves | When |
349
+ |---|---|---|
350
+ | `holdStock(changes, options)` | available → **held** | an order is placed |
351
+ | `releaseStock(changes, options)` | held → **available** | it was cancelled, expired or failed |
352
+ | `consumeHeld(changes, options)` | held → **gone** | it was fulfilled: the units have left |
353
+
354
+ ```javascript
355
+ import { holdStock, consumeHeld, releaseStock } from 'kempo-inventory/sdk';
356
+
357
+ const [error, { items }] = await holdStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
358
+ // error.code 409 and "Not enough of Resin (g) is available to hold": nothing changed
359
+
360
+ await consumeHeld([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' }); // paid
361
+ await releaseStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' }); // or not
362
+ ```
363
+
364
+ `changes` is `[{ id, quantity }]` (or one `{ id, quantity }`), with `quantity` a whole number above zero; the same item twice is added together. Every move is a single conditional update, so concurrent callers cannot hold what is not available, release what is not held, or take either figure below zero. `ref` is written on every movement so what was held for an order can be found and given back later.
365
+
366
+ Holding and releasing change what is available, so they also fire `stock:adjusted` (with `reason` `hold` or `release`), and anything that reacts to availability — such as kempo-products-inventory — stays correct. `stock:held` fires for all three. `adjustStock` leaves `held` alone, and an item with stock on hold cannot be deleted until it is given back. The admin list shows an **On hold** column once anything is held.
367
+
342
368
  ### Adding to the item page
343
369
 
344
370
  An extension can add to the admin item page by supplying a fragment named `inventory-item-actions`, in `admin/inventory-item-actions.fragment.html` in its own package. It appears under the item form, and reads the item from `?id=` in the address. (kempo-products-inventory uses it for "Make product from this item".)
@@ -355,6 +381,7 @@ Declare these in your `kempo-config.json` `hooks`. Every payload also carries `a
355
381
  | `kempo-inventory:item:before_update` | `{ item, changes: { sku?, name?, description?, fields? }, userId }` | edit `changes` |
356
382
  | `kempo-inventory:item:before_delete` | `{ item, userId }` | refuse only |
357
383
  | `kempo-inventory:stock:before_adjust` | `{ item, draft: { delta, reason, note }, userId }` | edit `draft` |
384
+ | `kempo-inventory:stock:before_hold` | `{ item, draft: { kind, quantity, reason, note }, userId }` (`kind` is `hold`, `release` or `consume`) | edit `draft` |
358
385
  | `kempo-inventory:category:before_update` | `{ name, newName, changes: { description?, tags?, image? }, userId }` | refuse only |
359
386
  | `kempo-inventory:category:before_delete` | `{ category, name, userId }` (`category` is `null` for one with no record) | refuse only |
360
387
  | `kempo-inventory:field:before_create` | `{ draft }` | refuse only |
@@ -371,6 +398,7 @@ Ownership is checked first, so these only run for changes that would otherwise g
371
398
  | `kempo-inventory:item:updated` | `{ item, previous, userId }` |
372
399
  | `kempo-inventory:item:deleted` | `{ item, userId }` |
373
400
  | `kempo-inventory:stock:adjusted` | `{ item, previousQuantity, delta, reason, note, movement, userId }` |
401
+ | `kempo-inventory:stock:held` | `{ item, kind, quantity, previousQuantity, previousHeld, movement, userId }` |
374
402
  | `kempo-inventory:category:updated` | `{ category, previous, moved, userId }` |
375
403
  | `kempo-inventory:category:deleted` | `{ category, userId }` |
376
404
  | `kempo-inventory:field:created` | `{ field }` |
@@ -105,13 +105,15 @@ locked: true
105
105
  let fields = [];
106
106
  /* The Managed by column only appears once an extension owns an item. */
107
107
  let owners = false;
108
+ /* The Held column only appears once something is set aside. */
109
+ let holds = false;
108
110
 
109
111
  /*
110
112
  Display-only values are precomputed as plain strings so the table never formats
111
113
  user-supplied text itself.
112
114
  */
113
115
  const decorate = (item, ctx) => {
114
- const record = { ...item, qtyText: String(item.quantity), tagsText: item.tags.join(', '), ownerText: item.owner };
116
+ const record = { ...item, qtyText: String(item.quantity), heldText: String(item.held ?? 0), tagsText: item.tags.join(', '), ownerText: item.owner };
115
117
  /* Only the fields that apply to this item's category; a column another category's items fill stays blank here. */
116
118
  for(const field of applicableFields(fields, item.category).filter(f => f.listed)) record[`f_${field.key}`] = displayValue(field, item.fields[field.key], ctx);
117
119
  return record;
@@ -122,6 +124,7 @@ locked: true
122
124
  { name: 'name', label: 'Name', size: 240, editable: false },
123
125
  { name: 'category', label: 'Category', size: 140, editable: false },
124
126
  { name: 'qtyText', label: 'Quantity', size: 100, editable: false },
127
+ ...(holds ? [{ name: 'heldText', label: 'On hold', size: 100, editable: false }] : []),
125
128
  { name: 'tagsText', label: 'Tags', size: 160, editable: false },
126
129
  ...(owners ? [{ name: 'ownerText', label: 'Managed by', size: 170, editable: false }] : []),
127
130
  /* One column per key: a key two categories both use is one column. */
@@ -151,6 +154,7 @@ locked: true
151
154
  return;
152
155
  }
153
156
  owners = data.items.some(i => i.owner);
157
+ holds = data.items.some(i => i.held > 0);
154
158
  $table.setData({ records: data.items.map(i => decorate(i, data.context)), fields: columns() });
155
159
  };
156
160
 
package/kempo-config.json CHANGED
@@ -1,4 +1,6 @@
1
1
  {
2
+ "docs": "https://github.com/dustinpoissant/kempo-inventory#readme",
3
+ "git": "https://github.com/dustinpoissant/kempo-inventory",
2
4
  "public-scope": "inventory",
3
5
  "schema": "./server/db/schema.js",
4
6
  "widgets": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-inventory",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "A basic inventory management system for kempo cms",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
@@ -15,15 +15,12 @@
15
15
  "type": "module",
16
16
  "main": "sdk.js",
17
17
  "scripts": {
18
- "test": "node scripts/run-tests.js unit node --test",
19
- "test:integration": "node scripts/run-tests.js integration node --test",
18
+ "test": "node scripts/run-tests.js unit node --test --test-concurrency=1",
19
+ "test:integration": "node scripts/run-tests.js integration node --test --test-concurrency=1",
20
20
  "link:local": "npm install ../kempo ../kempo-server ../kempo-ui --no-save --no-legacy-peer-deps --force",
21
21
  "docs:build": "node scripts/build-docs.js",
22
22
  "docs:dev": "npx kempo-server --root docs --port 4050"
23
23
  },
24
- "kempo": {
25
- "public-scope": "inventory"
26
- },
27
24
  "exports": {
28
25
  "./install": "./install.js",
29
26
  "./update": "./update.js",
@@ -42,7 +39,7 @@
42
39
  "drizzle-orm": "^0.45.2"
43
40
  },
44
41
  "peerDependencies": {
45
- "kempo": ">=4.3.0",
42
+ "kempo": ">=4.5.8",
46
43
  "kempo-ui": ">=0.5.0",
47
44
  "kempo-media": ">=0.3.0",
48
45
  "kempo-files": ">=0.1.3",
package/sdk.js CHANGED
@@ -36,6 +36,8 @@ export {
36
36
  getMovements,
37
37
  } from './server/utils/items.js';
38
38
 
39
+ export { holdStock, releaseStock, consumeHeld } from './server/utils/holds.js';
40
+
39
41
  export {
40
42
  getFields,
41
43
  getField,
@@ -10,7 +10,8 @@ export const kempoInventoryItem = pgTable('kempoInventoryItem', {
10
10
  name: text('name').notNull(),
11
11
  description: text('description').notNull().default(''),
12
12
  category: text('category').notNull().default(''), // built in and optional; '' means uncategorised
13
- quantity: integer('quantity').notNull().default(0),
13
+ quantity: integer('quantity').notNull().default(0), // what is AVAILABLE; held units are not in it
14
+ held: integer('held').notNull().default(0), // set aside for something unfinished (an order awaiting payment); see holds.js
14
15
  tags: jsonb('tags').notNull().default([]), // built in: short lower-case labels search finds
15
16
  data: jsonb('data').notNull().default({}),
16
17
  owner: text('owner').notNull().default(''), // '' for items people manage, or the extension that created it; see ownership.js
@@ -49,6 +50,8 @@ export const kempoInventoryMovement = pgTable('kempoInventoryMovement', {
49
50
  itemId: text('itemId').notNull(),
50
51
  delta: integer('delta').notNull(),
51
52
  quantityAfter: integer('quantityAfter').notNull(),
53
+ heldDelta: integer('heldDelta').notNull().default(0), // the change to `held`: + when stock is set aside, - when it is given back or used
54
+ heldAfter: integer('heldAfter').notNull().default(0),
52
55
  reason: text('reason').notNull().default('adjustment'),
53
56
  note: text('note').notNull().default(''),
54
57
  userId: text('userId').notNull().default(''),
@@ -18,6 +18,8 @@ export const EVENTS = {
18
18
  itemDeleted: 'kempo-inventory:item:deleted',
19
19
  stockBeforeAdjust: 'kempo-inventory:stock:before_adjust',
20
20
  stockAdjusted: 'kempo-inventory:stock:adjusted',
21
+ stockBeforeHold: 'kempo-inventory:stock:before_hold',
22
+ stockHeld: 'kempo-inventory:stock:held',
21
23
  categoryBeforeUpdate: 'kempo-inventory:category:before_update',
22
24
  categoryUpdated: 'kempo-inventory:category:updated',
23
25
  categoryBeforeDelete: 'kempo-inventory:category:before_delete',
@@ -0,0 +1,154 @@
1
+ import db from 'kempo/server/db/index.js';
2
+ import { and, eq, inArray, sql } from 'drizzle-orm';
3
+ import crypto from 'crypto';
4
+ import { kempoInventoryItem, kempoInventoryMovement } from '../db/schema.js';
5
+ import { EVENTS, guard, notify } from './hooks.js';
6
+
7
+ /*
8
+ Held stock: units that are set aside for something that has not finished — an order waiting for
9
+ its payment, a job waiting for its materials.
10
+
11
+ An item has two figures:
12
+
13
+ quantity what is **available**: what can be sold or used now. This is the figure everything
14
+ else already reads, so a hold lowers availability everywhere without anything else
15
+ having to know holds exist.
16
+ held what is set aside. Nobody can take it as available, and it is still the store's.
17
+
18
+ The stock an item really has is `quantity + held`. Three moves, each all-or-nothing across every
19
+ item in the call, each recorded as a movement:
20
+
21
+ holdStock available → held (an order is placed)
22
+ releaseStock held → available (it was cancelled, expired or failed)
23
+ consumeHeld held → gone (it was fulfilled: the units have left)
24
+
25
+ Every move is one conditional UPDATE, so concurrent callers cannot hold what is not there, release
26
+ what is not held, or take a figure below zero.
27
+
28
+ const [error, { items }] = await holdStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
29
+ // ...paid:
30
+ await consumeHeld([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
31
+ // ...or not:
32
+ await releaseStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
33
+
34
+ `ref` is written on every movement, so what was held for an order can be found and given back
35
+ later. A refusal is a 409 that names the item, and nothing has changed.
36
+ */
37
+
38
+ const newId = () => crypto.randomBytes(8).toString('hex');
39
+
40
+ class Refusal {
41
+ constructor(error){
42
+ this.error = error;
43
+ }
44
+ }
45
+
46
+ /* What each move does to `quantity` and to `held`, per unit. */
47
+ const MOVES = {
48
+ hold: { available: -1, held: 1 },
49
+ release: { available: 1, held: -1 },
50
+ consume: { available: 0, held: -1 },
51
+ };
52
+
53
+ const move = async (kind, input, { reason = '', note = '', ref = '', userId = '', owner = '' } = {}) => {
54
+ const list = Array.isArray(input) ? input : [input];
55
+ if(!list.length || list.some(entry => !entry || typeof entry !== 'object')) return [{ code: 400, msg: 'changes must be a non-empty list of { id, quantity }' }, null];
56
+
57
+ const totals = new Map();
58
+ for(const entry of list){
59
+ const id = String(entry.id ?? '');
60
+ const quantity = Number(entry.quantity);
61
+ if(!id) return [{ code: 400, msg: 'Every change needs an item id' }, null];
62
+ if(!Number.isInteger(quantity) || quantity <= 0) return [{ code: 400, msg: 'Every quantity must be a whole number above zero' }, null];
63
+ totals.set(id, (totals.get(id) ?? 0) + quantity);
64
+ }
65
+ /* In a fixed order, so two overlapping calls lock rows the same way round and cannot deadlock. */
66
+ const wanted = [...totals.entries()].map(([id, quantity]) => ({ id, quantity })).sort((a, b) => a.id.localeCompare(b.id));
67
+
68
+ let rows;
69
+ try {
70
+ rows = await db.select().from(kempoInventoryItem).where(inArray(kempoInventoryItem.id, wanted.map(entry => entry.id)));
71
+ } catch {
72
+ return [{ code: 500, msg: `Failed to ${kind} stock` }, null];
73
+ }
74
+ const existing = new Map(rows.map(row => [row.id, row]));
75
+ if(wanted.some(entry => !existing.has(entry.id))) return [{ code: 404, msg: 'An item in this change no longer exists' }, null];
76
+
77
+ const drafts = new Map();
78
+ for(const entry of wanted){
79
+ const draft = { kind, quantity: entry.quantity, reason: reason || kind, note };
80
+ const refused = await guard(EVENTS.stockBeforeHold, { item: existing.get(entry.id), draft, userId, actor: owner });
81
+ if(refused) return [refused, null];
82
+ const quantity = Number(draft.quantity);
83
+ if(!Number.isInteger(quantity) || quantity <= 0) return [{ code: 400, msg: 'quantity must be a whole number above zero' }, null];
84
+ drafts.set(entry.id, { quantity, reason: String(draft.reason || kind), note: String(draft.note || '') });
85
+ }
86
+
87
+ const { available, held } = MOVES[kind];
88
+
89
+ try {
90
+ const applied = await db.transaction(async tx => {
91
+ const results = [];
92
+ for(const entry of wanted){
93
+ const { quantity, reason: why, note: text } = drafts.get(entry.id);
94
+ const availableDelta = available * quantity;
95
+ const heldDelta = held * quantity;
96
+ const [row] = await tx.update(kempoInventoryItem)
97
+ .set({
98
+ quantity: sql`${kempoInventoryItem.quantity} + ${availableDelta}`,
99
+ held: sql`${kempoInventoryItem.held} + ${heldDelta}`,
100
+ updated: new Date(),
101
+ })
102
+ .where(and(
103
+ eq(kempoInventoryItem.id, entry.id),
104
+ sql`${kempoInventoryItem.quantity} + ${availableDelta} >= 0`,
105
+ sql`${kempoInventoryItem.held} + ${heldDelta} >= 0`,
106
+ ))
107
+ .returning();
108
+ if(!row){
109
+ const name = existing.get(entry.id).name;
110
+ throw new Refusal({
111
+ code: 409,
112
+ msg: kind === 'hold' ? `Not enough of ${name} is available to hold` : `${name} does not have that much on hold`,
113
+ });
114
+ }
115
+ const [movement] = await tx.insert(kempoInventoryMovement).values({
116
+ id: newId(), itemId: entry.id, delta: availableDelta, quantityAfter: row.quantity, heldDelta, heldAfter: row.held,
117
+ reason: why, note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
118
+ }).returning();
119
+ results.push({ row, movement, availableDelta, heldDelta, quantity });
120
+ }
121
+ return results;
122
+ });
123
+
124
+ const items = [];
125
+ for(const { row, movement, availableDelta, heldDelta, quantity } of applied){
126
+ const item = { id: row.id, sku: row.sku, name: row.name, quantity: row.quantity, held: row.held, category: row.category, owner: row.owner };
127
+ items.push(item);
128
+ await notify(EVENTS.stockHeld, {
129
+ item, kind, quantity, previousQuantity: row.quantity - availableDelta, previousHeld: row.held - heldDelta,
130
+ movement, userId, actor: owner,
131
+ });
132
+ /* What is available changed, so everything listening for that is told in the usual way. */
133
+ if(availableDelta !== 0){
134
+ await notify(EVENTS.stockAdjusted, {
135
+ item, previousQuantity: row.quantity - availableDelta, delta: availableDelta,
136
+ reason: movement.reason, note: movement.note, movement, userId, actor: owner,
137
+ });
138
+ }
139
+ }
140
+ return [null, { items }];
141
+ } catch(error) {
142
+ if(error instanceof Refusal) return [error.error, null];
143
+ return [{ code: 500, msg: `Failed to ${kind} stock` }, null];
144
+ }
145
+ };
146
+
147
+ /* Sets units aside: they stop being available and are counted as held. */
148
+ export const holdStock = (changes, options) => move('hold', changes, options);
149
+
150
+ /* Gives held units back: they are available again. */
151
+ export const releaseStock = (changes, options) => move('release', changes, options);
152
+
153
+ /* Finishes with held units: they were used or sold, so they are no longer held and not available. */
154
+ export const consumeHeld = (changes, options) => move('consume', changes, options);
@@ -30,6 +30,7 @@ const shape = row => row && ({
30
30
  description: row.description,
31
31
  category: row.category,
32
32
  quantity: row.quantity,
33
+ held: row.held ?? 0,
33
34
  tags: row.tags ?? [],
34
35
  fields: row.data ?? {},
35
36
  owner: row.owner,
@@ -317,6 +318,7 @@ export const deleteItem = async (id, { userId = '', owner = '' } = {}) => {
317
318
  const [lookupError, existing] = await getItem(id);
318
319
  if(lookupError) return [lookupError, null];
319
320
  if(existing.owner !== owner) return [notYours('item', existing, owner), null];
321
+ if(existing.held > 0) return [{ code: 409, msg: `${existing.held} of this item are on hold for something unfinished, so it cannot be deleted yet` }, null];
320
322
 
321
323
  const refused = await guard(EVENTS.itemBeforeDelete, { item: existing, userId, actor: owner });
322
324
  if(refused) return [refused, null];
@@ -359,7 +361,7 @@ export const adjustStock = async (id, { delta, reason = 'adjustment', note = '',
359
361
  .returning();
360
362
  if(!row) return null;
361
363
  const [movement] = await tx.insert(kempoInventoryMovement).values({
362
- id: newId(), itemId: id, delta: change, quantityAfter: row.quantity,
364
+ id: newId(), itemId: id, delta: change, quantityAfter: row.quantity, heldAfter: row.held,
363
365
  reason: String(draft.reason || 'adjustment'), note: String(draft.note || ''), userId, created: new Date(),
364
366
  }).returning();
365
367
  return { row, movement };
@@ -451,7 +453,7 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
451
453
  .returning();
452
454
  if(!row) throw new Refusal({ code: 409, msg: `Insufficient stock of ${existing.get(entry.id).name}` });
453
455
  const [movement] = await tx.insert(kempoInventoryMovement).values({
454
- id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, reason: why,
456
+ id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, heldAfter: row.held, reason: why,
455
457
  note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
456
458
  }).returning();
457
459
  results.push({ row, movement, delta });
package/update.js CHANGED
@@ -8,6 +8,11 @@ import { getSetting, setSetting, deleteSetting } from 'kempo/server/sdk.js';
8
8
  this on a database that already has the change is harmless.
9
9
  */
10
10
  export default async () => {
11
+ // Next: held stock. `quantity` stays what is available; `held` is what is set aside (server/utils/holds.js)
12
+ await db.execute(sql`ALTER TABLE "kempoInventoryItem" ADD COLUMN IF NOT EXISTS "held" integer NOT NULL DEFAULT 0`);
13
+ await db.execute(sql`ALTER TABLE "kempoInventoryMovement" ADD COLUMN IF NOT EXISTS "heldDelta" integer NOT NULL DEFAULT 0`);
14
+ await db.execute(sql`ALTER TABLE "kempoInventoryMovement" ADD COLUMN IF NOT EXISTS "heldAfter" integer NOT NULL DEFAULT 0`);
15
+
11
16
  // Next: media_provider is retired. kempo-media sits on kempo-files, so new uploads go through it whenever it is installed.
12
17
  await deleteSetting('kempo-inventory', 'media_provider');
13
18