kempo-inventory 0.2.0 → 0.2.2

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
@@ -314,7 +314,8 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
314
314
  | `createItem({ sku, name, description, quantity, fields, userId }, { owner })` | Create. `owner` makes it your extension's |
315
315
  | `updateItem(id, { sku, name, description, fields, userId }, { owner })` | Update; `fields` is merged, and an empty value clears a field. SKU, name and category of an owned item need its `owner` |
316
316
  | `deleteItem(id, { userId, owner })` | Delete an item and its history (owned items need their `owner`) |
317
- | `adjustStock(id, { delta, reason, note, userId })` | The only way quantity changes, for anyone. Atomic; refuses to go below zero |
317
+ | `adjustStock(id, { delta, reason, note, userId })` | The way quantity changes, for anyone. Atomic; refuses to go below zero |
318
+ | `adjustStockMany(changes, { reason, note, ref, userId, owner })` | Several stock changes together: every one happens or none does. `changes` is `[{ id, delta }]`. Use it when one event touches several items, such as the materials of a recipe |
318
319
  | `unregisterItems(owner, { release })` | Delete every item you own, or with `release` hand them back to people |
319
320
  | `getMovements({ itemId, limit, offset })` | Stock history |
320
321
  | `getFields({ category, owner })`, `getField(key, category)` | Read field definitions. `getFields()` is every field (each with its `category` key and `categoryName`); with `category` it is what an item in that category shows |
@@ -328,6 +329,26 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
328
329
 
329
330
  An item looks like `{ id, sku, name, description, category, tags, quantity, fields: { ...custom values }, owner, created, updated }`.
330
331
 
332
+ ### Several items at once
333
+
334
+ `adjustStock` changes one item in its own transaction, so a sale that uses resin, paint and filament could take the resin and then find the paint short. `adjustStockMany` applies the whole set together:
335
+
336
+ ```javascript
337
+ import { adjustStockMany } from 'kempo-inventory/sdk';
338
+
339
+ const [error, { items }] = await adjustStockMany(
340
+ [{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
341
+ { reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
342
+ );
343
+ // error.code 409 and error.msg "Insufficient stock of Paint (ml)": nothing changed
344
+ ```
345
+
346
+ 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.
347
+
348
+ ### Adding to the item page
349
+
350
+ 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".)
351
+
331
352
  ### Hooks
332
353
 
333
354
  Declare these in your `kempo-config.json` `hooks`. Every payload also carries `actor`: the extension that made the change through the SDK, or `''` for a person using the admin.
@@ -12,6 +12,12 @@ locked: true
12
12
 
13
13
  <div id="editItem" style="max-width: 44rem;"></div>
14
14
 
15
+ <!--
16
+ An extension can add to this page by supplying a fragment with this name in its own admin/
17
+ directory (inventory-item-actions.fragment.html). It reads the item from ?id= in the address.
18
+ -->
19
+ <fragment name="inventory-item-actions"></fragment>
20
+
15
21
  <script type="module">
16
22
  import { getItem, getFields } from '/inventory/sdk.js';
17
23
  import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-inventory",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A basic inventory management system for kempo cms",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
package/sdk.js CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  deleteItem,
33
33
  unregisterItems,
34
34
  adjustStock,
35
+ adjustStockMany,
35
36
  getMovements,
36
37
  } from './server/utils/items.js';
37
38
 
@@ -377,6 +377,102 @@ export const adjustStock = async (id, { delta, reason = 'adjustment', note = '',
377
377
  }
378
378
  };
379
379
 
380
+ /*
381
+ Thrown inside a transaction to roll everything back with a message the caller can show.
382
+ */
383
+ class Refusal {
384
+ constructor(error){
385
+ this.error = error;
386
+ }
387
+ }
388
+
389
+ /*
390
+ Applies several stock changes together: every one happens or none does. This is what makes
391
+ taking the materials for a whole recipe safe, where one adjustStock per item could take the resin
392
+ and then fail on the paint. `changes` is [{ id, delta, reason?, note? }]; changes to the same item
393
+ are added together, and one that nets to nothing is left out. Each item runs through the same
394
+ before_adjust guard as adjustStock, and fires the same stock:adjusted notification once everything
395
+ is committed.
396
+
397
+ const [error, result] = await adjustStockMany(
398
+ [{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
399
+ { reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
400
+ );
401
+
402
+ `ref` is recorded on every movement so the change can be found and reversed later. A refusal for
403
+ lack of stock is a 409 that names the item, and nothing has changed. Resolves to
404
+ [null, { items }] with the items as they are now.
405
+ */
406
+ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '', ref = '', userId = '', owner = '' } = {}) => {
407
+ if(!Array.isArray(changes) || !changes.length) return [{ code: 400, msg: 'changes must be a non-empty list' }, null];
408
+
409
+ const totals = new Map();
410
+ for(const change of changes){
411
+ const id = String(change?.id ?? '');
412
+ const delta = toInt(change?.delta, NaN);
413
+ if(!id) return [{ code: 400, msg: 'Every change needs an item id' }, null];
414
+ if(Number.isNaN(delta)) return [{ code: 400, msg: 'Every delta must be a whole number' }, null];
415
+ const entry = totals.get(id) ?? { id, delta: 0, reason: change.reason, note: change.note };
416
+ entry.delta += delta;
417
+ totals.set(id, entry);
418
+ }
419
+ /* In a fixed order, so two overlapping calls lock rows the same way round and cannot deadlock. */
420
+ const wanted = [...totals.values()].filter(entry => entry.delta !== 0).sort((a, b) => a.id.localeCompare(b.id));
421
+ if(!wanted.length) return [null, { items: [] }];
422
+
423
+ let rows;
424
+ try {
425
+ rows = await db.select().from(kempoInventoryItem).where(inArray(kempoInventoryItem.id, wanted.map(entry => entry.id)));
426
+ } catch {
427
+ return [{ code: 500, msg: 'Failed to adjust stock' }, null];
428
+ }
429
+ const existing = new Map(rows.map(row => [row.id, shape(row)]));
430
+ const missing = wanted.find(entry => !existing.has(entry.id));
431
+ if(missing) return [{ code: 404, msg: 'An item in this change no longer exists' }, null];
432
+
433
+ const drafts = new Map();
434
+ for(const entry of wanted){
435
+ const draft = { delta: entry.delta, reason: entry.reason ?? reason, note: entry.note ?? note };
436
+ const refused = await guard(EVENTS.stockBeforeAdjust, { item: existing.get(entry.id), draft, userId, actor: owner });
437
+ if(refused) return [refused, null];
438
+ const delta = toInt(draft.delta, NaN);
439
+ if(Number.isNaN(delta) || delta === 0) return [{ code: 400, msg: 'delta must be a non-zero whole number' }, null];
440
+ drafts.set(entry.id, { delta, reason: String(draft.reason || 'adjustment'), note: String(draft.note || '') });
441
+ }
442
+
443
+ try {
444
+ const applied = await db.transaction(async tx => {
445
+ const results = [];
446
+ for(const entry of wanted){
447
+ const { delta, reason: why, note: text } = drafts.get(entry.id);
448
+ const [row] = await tx.update(kempoInventoryItem)
449
+ .set({ quantity: sql`${kempoInventoryItem.quantity} + ${delta}`, updated: new Date() })
450
+ .where(and(eq(kempoInventoryItem.id, entry.id), sql`${kempoInventoryItem.quantity} + ${delta} >= 0`))
451
+ .returning();
452
+ if(!row) throw new Refusal({ code: 409, msg: `Insufficient stock of ${existing.get(entry.id).name}` });
453
+ const [movement] = await tx.insert(kempoInventoryMovement).values({
454
+ id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, reason: why,
455
+ note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
456
+ }).returning();
457
+ results.push({ row, movement, delta });
458
+ }
459
+ return results;
460
+ });
461
+ const items = [];
462
+ for(const { row, movement, delta } of applied){
463
+ const item = shape(row);
464
+ items.push(item);
465
+ await notify(EVENTS.stockAdjusted, {
466
+ item, previousQuantity: item.quantity - delta, delta, reason: movement.reason, note: movement.note, movement, userId, actor: owner,
467
+ });
468
+ }
469
+ return [null, { items }];
470
+ } catch(error) {
471
+ if(error instanceof Refusal) return [error.error, null];
472
+ return [{ code: 500, msg: 'Failed to adjust stock' }, null];
473
+ }
474
+ };
475
+
380
476
  export const getMovements = async ({ itemId, limit = 50, offset = 0 } = {}) => {
381
477
  try {
382
478
  const where = itemId ? eq(kempoInventoryMovement.itemId, itemId) : undefined;