kempo-inventory 0.3.6 → 0.4.1

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  The core of an inventory system for [kempo](https://www.npmjs.com/package/kempo) CMS. It is deliberately small: every item has just a **SKU**, **name**, **quantity** and **description**. Everything else is a *custom field* that a person adds in the admin, or that another extension registers, so the same base can model anything: parts, books, rentals, supplies.
4
4
 
5
- - Stock changes go through one audited action, with a reason, a note and a movement history
5
+ - Quantity changes go through one audited action, with a reason, a note and a movement history
6
6
  - Custom fields of seven types, managed in **Admin > Inventory > Fields**
7
7
  - A server SDK and a set of hooks so other extensions can extend it (reorder levels, pricing, suppliers, categories...), and fields, categories and items can be owned by an extension
8
8
  - Admin page at **Admin > Inventory**, JSON API under `/inventory/api`
@@ -77,15 +77,15 @@ A photo field (type "Photos / files") can **force how photos are saved**. When y
77
77
  - **Force photo shape**: an aspect ratio such as `1:1`, `4:3` or `16:9`. Each photo added to the field opens in Kempo UI's `<k-image-crop>`, locked to that shape, so only that shape can be saved; **Skip** leaves a photo out.
78
78
  - **Max size (px)**: photos are kept within this many pixels on a side (scaled down, never up). With only a max size and no shape, photos are simply scaled down, with no cropper.
79
79
 
80
- Leave both empty and photos are uploaded as they are. The stored file is the cropped one, so the shape holds everywhere it is shown. A category's picture is always cropped to the `category_image_ratio` setting below. Anything the browser cannot edit (GIF, SVG, HEIC, video) is uploaded untouched. The same two properties (`imageRatio`, `imageMax`) are on the field API, and `<inventory-media-field crop-ratio="1 / 1" crop-max="1600">` applies them in your own forms. Needs kempo-ui 0.5 or newer.
80
+ Leave both empty and photos are uploaded as they are. The stored file is the cropped one, so the shape holds everywhere it is shown. A category's picture is always cropped to the `category_image_ratio` setting below. Anything the browser cannot edit (GIF, SVG, HEIC, video) is uploaded untouched. The same two properties (`imageRatio`, `imageMax`) are on the field API, and `<k-inventory-media-field crop-ratio="1 / 1" crop-max="1600">` applies them in your own forms. Needs kempo-ui 0.5 or newer.
81
81
 
82
82
  ## Image shape
83
83
 
84
- Two settings (**Admin > Settings**) set the aspect ratio images are *shown* at (a display crop; a photo field's forced shape above changes what is stored): `category_image_ratio` and `item_image_ratio`, written `width:height` (`4:3` by default, or `1:1`, `16:9`...). A blank or invalid value falls back to `4:3`. Images are **cropped to fit** the shape, never stretched, and the originals are not changed, so changing the setting reshapes every image at once. The admin's category and item pickers use them, and `GET /inventory/api/fields` returns them as `imageRatios` (CSS, e.g. `"4 / 3"`) so a site can do the same; `<inventory-media-field ratio-for="category">` previews at the category shape.
84
+ Two settings (**Admin > Settings**) set the aspect ratio images are *shown* at (a display crop; a photo field's forced shape above changes what is stored): `category_image_ratio` and `item_image_ratio`, written `width:height` (`4:3` by default, or `1:1`, `16:9`...). A blank or invalid value falls back to `4:3`. Images are **cropped to fit** the shape, never stretched, and the originals are not changed, so changing the setting reshapes every image at once. The admin's category and item pickers use them, and `GET /inventory/api/fields` returns them as `imageRatios` (CSS, e.g. `"4 / 3"`) so a site can do the same; `<k-inventory-media-field ratio-for="category">` previews at the category shape.
85
85
 
86
86
  ## Import and export
87
87
 
88
- **Admin > Inventory** has **Export** and **Import** buttons (admin portal only; the public site has none). Both are limited to the **top tier**: the `kempo-inventory:fields:manage` permission, held by the `kempo-inventory:admin` group. A manager, a viewer or a signed-out visitor gets a 403 (or 401) and does not see the buttons, because an export carries the whole inventory out and an import can rewrite and re-stock all of it.
88
+ **Admin > Inventory** has **Export** and **Import** buttons (admin portal only; the public site has none). Both are limited to the **top tier**: the `kempo-inventory:fields:manage` permission, held by the `kempo-inventory:admin` group. A manager, a viewer or a signed-out visitor gets a 403 (or 401) and does not see the buttons, because an export carries the whole inventory out and an import can rewrite and re-quantity all of it.
89
89
 
90
90
  **Export** downloads the whole inventory in one of two forms:
91
91
 
@@ -95,7 +95,7 @@ Two settings (**Admin > Settings**) set the aspect ratio images are *shown* at (
95
95
  **Import** takes either one (or any zip with an `inventory.json` in this format, including one re-zipped with compression). It is **done on the server**, in two steps:
96
96
 
97
97
  1. **Check.** The file is uploaded once and checked. You see how many items are new, how many already exist (matched by **SKU**; categories by name), what would be ignored, and any rows that cannot be imported and why. Nothing in the inventory has changed yet.
98
- 2. **Import.** For the ones that already exist you choose **Ignore them** (leave what is here exactly as it is; the default) or **Update them** with the file's values (a value the file leaves empty never clears anything, and a changed quantity is recorded as a stock adjustment, "import", so the history stays honest).
98
+ 2. **Import.** For the ones that already exist you choose **Ignore them** (leave what is here exactly as it is; the default) or **Update them** with the file's values (a value the file leaves empty never clears anything, and a changed quantity is recorded as a quantity adjustment, "import", so the history stays honest).
99
99
 
100
100
  Things worth knowing: an import never creates or changes **fields**; a value only comes across when a field with the same key exists and applies to the item's category (the rest are ignored and counted). Rows are independent: one that fails (a missing name, a bad number, a required field with no value) does not stop the others. A field linking to another item is exported as that item's SKU and resolved by SKU on import, including to items created in the same import. Photos only come from the zip; each distinct photo is stored once, to the library new uploads normally go to (as the person importing, with its own file-library rules), at its original size.
101
101
 
@@ -122,11 +122,11 @@ Items and categories can be **tagged**: short labels such as `acrylic`, `red`, `
122
122
  - **Inheritance:** an item does not inherit its category's tags. Searching a category's tag finds the category, and the category is how you reach its items.
123
123
  - The admin lists show a Tags column. To reuse the control in your own forms, `ItemFields.js` exports `tagsValue(tags)`, `suggestTags` and `readTags($tags)` for use with `<k-tags .value=${tagsValue(tags)} .getSuggestions=${suggestTags}>`; `readTags` includes text typed but not yet added.
124
124
 
125
- ## Who can read, and who can see stock counts
125
+ ## Who can read, and who can see quantity counts
126
126
 
127
127
  Reading the inventory needs the `items:read` permission. Turn on the `public_read` setting (off by default) and **anyone can read it, signed in or not**, so it can be shared as a catalogue. Public reading covers items, categories and fields (and photos, see below); writing always needs the matching permission.
128
128
 
129
- **Stock counts are only for people who manage stock.** A caller without `stock:adjust` or `items:update`, whether signed out, signed in with only `items:read`, or reading publicly, gets items **without `quantity`** and no stock history (`GET /inventory/api/movements` is refused). Callers who do manage stock get everything as before.
129
+ **Quantity counts are only for people who manage quantity.** A caller without `quantity:adjust` or `items:update`, whether signed out, signed in with only `items:read`, or reading publicly, gets items **without `quantity`** and no stock history (`GET /inventory/api/movements` is refused). Callers who do manage quantity get everything as before.
130
130
 
131
131
  ## Who can see photos
132
132
 
@@ -186,12 +186,12 @@ scanner.stop(); // release the camera
186
186
  This extension ships the data and the API, plus an admin screen for managing it. A site that wants its own screens (a dashboard, product pages, a storefront) builds them itself, and can reuse the same browser modules the admin page uses:
187
187
 
188
188
  ```javascript
189
- import { getItems, getFields, adjustStock } from '/inventory/sdk.js'; // the API client
189
+ import { getItems, getFields, adjustQuantity } from '/inventory/sdk.js'; // the API client
190
190
  import { openItemDialog, openAdjustDialog } from '/inventory/components/ItemDialog.js';
191
191
  import { fieldTemplate, readField, displayValue } from '/inventory/components/ItemFields.js';
192
192
 
193
193
  openItemDialog({ item, fields, onSaved: item => reload() }); // the form in a dialog, for a screen that really wants one
194
- openAdjustDialog({ item, onSaved: item => reload() }); // add or remove stock
194
+ openAdjustDialog({ item, onSaved: item => reload() }); // add or remove quantity
195
195
  ```
196
196
 
197
197
  **Adding and editing an item are pages, not dialogs.** A dialog closes with a stray tap outside it and takes everything entered with it (photos taken with the camera included), so each gets a page of its own that keeps the work and warns before it is left. In the admin they are **Inventory > New Item** (`/admin/extension/kempo-inventory/new`) and the edit action on a row (`/admin/extension/kempo-inventory/edit?id=…`). A site gets the same from `NewItemPage.js`:
@@ -219,7 +219,7 @@ An extension that adds to inventory declares the dependency, registers its field
219
219
  "dependencies": ["kempo-inventory"],
220
220
  "hooks": {
221
221
  "kempo-inventory:item:before_create": "./hooks/before-create.js",
222
- "kempo-inventory:stock:adjusted": "./hooks/stock-adjusted.js"
222
+ "kempo-inventory:quantity:adjusted": "./hooks/quantity-adjusted.js"
223
223
  }
224
224
  }
225
225
  ```
@@ -254,7 +254,7 @@ Fields, categories and items can each belong to an extension. `owner` is `''` fo
254
254
  |---|---|---|
255
255
  | **Field** | delete it, change its key, type (within the safe conversions), requirement or options | rename it, change its description, list/suggest settings and position |
256
256
  | **Category** | rename it (which rewrites its items), merge into or out of it, delete it | change its image, description and tags, and put items in it |
257
- | **Item** | change its SKU, name and category, delete it | edit description, tags and custom field values, and **adjust stock** |
257
+ | **Item** | change its SKU, name and category, delete it | edit description, tags and custom field values, and **adjust quantity** |
258
258
 
259
259
  A category cannot be renamed or merged by anyone but its owner while it holds items or fields another extension owns, because that would rewrite them. Sending an owned item's current SKU, name and category back unchanged is not a change, so the admin form keeps working.
260
260
 
@@ -295,7 +295,7 @@ To protect more than this (say, the *values* of a field you own), subscribe to t
295
295
  ### Server SDK
296
296
 
297
297
  ```javascript
298
- import { getItems, createItem, adjustStock, registerFields } from 'kempo-inventory/sdk';
298
+ import { getItems, createItem, adjustQuantity, registerFields } from 'kempo-inventory/sdk';
299
299
  ```
300
300
 
301
301
  Every function resolves to `[error, result]`, where `error` is `{ code, msg }` or `null`.
@@ -308,10 +308,10 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
308
308
  | `createItem({ sku, name, description, quantity, fields, userId }, { owner })` | Create. `owner` makes it your extension's |
309
309
  | `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` |
310
310
  | `deleteItem(id, { userId, owner })` | Delete an item and its history (owned items need their `owner`) |
311
- | `adjustStock(id, { delta, reason, note, userId })` | The way quantity changes, for anyone. Atomic; refuses to go below zero |
312
- | `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 |
311
+ | `adjustQuantity(id, { delta, reason, note, userId })` | The way quantity changes, for anyone. Atomic; refuses to go below zero |
312
+ | `adjustQuantityMany(changes, { reason, note, ref, userId, owner })` | Several quantity 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 |
313
313
  | `unregisterItems(owner, { release })` | Delete every item you own, or with `release` hand them back to people |
314
- | `getMovements({ itemId, limit, offset })` | Stock history |
314
+ | `getMovements({ itemId, limit, offset })` | Quantity history |
315
315
  | `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 |
316
316
  | `createField(definition)`, `updateField(key, changes)`, `deleteField(key)` | Manage user-defined fields |
317
317
  | `getCategories({ includeEmpty, owner })`, `saveCategory(name, { newName, image, description, tags }, { owner })`, `deleteCategory(name, { owner })` | The categories, and managing them |
@@ -321,49 +321,23 @@ 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, held, fields: { ...custom values }, owner, created, updated }`.
324
+ An item looks like `{ id, sku, name, description, category, tags, quantity, fields: { ...custom values }, owner, created, updated }`.
325
325
 
326
326
  ### Several items at once
327
327
 
328
- `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:
328
+ `adjustQuantity` 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. `adjustQuantityMany` applies the whole set together:
329
329
 
330
330
  ```javascript
331
- import { adjustStockMany } from 'kempo-inventory/sdk';
331
+ import { adjustQuantityMany } from 'kempo-inventory/sdk';
332
332
 
333
- const [error, { items }] = await adjustStockMany(
333
+ const [error, { items }] = await adjustQuantityMany(
334
334
  [{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
335
335
  { reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
336
336
  );
337
- // error.code 409 and error.msg "Insufficient stock of Paint (ml)": nothing changed
337
+ // error.code 409 and error.msg "Insufficient quantity of Paint (ml)": nothing changed
338
338
  ```
339
339
 
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
-
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.
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 `adjustQuantity`, and each fires `quantity:adjusted` once everything is committed. `ref` is written on every quantity movement (as `[order-1042]` in its note) so the change can be found, and reversed, later.
367
341
 
368
342
  ### Adding to the item page
369
343
 
@@ -380,8 +354,7 @@ Declare these in your `kempo-config.json` `hooks`. Every payload also carries `a
380
354
  | `kempo-inventory:item:before_create` | `{ draft: { sku, name, description, quantity, fields }, userId }` | edit `draft` |
381
355
  | `kempo-inventory:item:before_update` | `{ item, changes: { sku?, name?, description?, fields? }, userId }` | edit `changes` |
382
356
  | `kempo-inventory:item:before_delete` | `{ item, userId }` | refuse only |
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` |
357
+ | `kempo-inventory:quantity:before_adjust` | `{ item, draft: { delta, reason, note }, userId }` | edit `draft` |
385
358
  | `kempo-inventory:category:before_update` | `{ name, newName, changes: { description?, tags?, image? }, userId }` | refuse only |
386
359
  | `kempo-inventory:category:before_delete` | `{ category, name, userId }` (`category` is `null` for one with no record) | refuse only |
387
360
  | `kempo-inventory:field:before_create` | `{ draft }` | refuse only |
@@ -397,8 +370,7 @@ Ownership is checked first, so these only run for changes that would otherwise g
397
370
  | `kempo-inventory:item:created` | `{ item, userId }` |
398
371
  | `kempo-inventory:item:updated` | `{ item, previous, userId }` |
399
372
  | `kempo-inventory:item:deleted` | `{ item, userId }` |
400
- | `kempo-inventory:stock:adjusted` | `{ item, previousQuantity, delta, reason, note, movement, userId }` |
401
- | `kempo-inventory:stock:held` | `{ item, kind, quantity, previousQuantity, previousHeld, movement, userId }` |
373
+ | `kempo-inventory:quantity:adjusted` | `{ item, previousQuantity, delta, reason, note, movement, userId }` |
402
374
  | `kempo-inventory:category:updated` | `{ category, previous, moved, userId }` |
403
375
  | `kempo-inventory:category:deleted` | `{ category, userId }` |
404
376
  | `kempo-inventory:field:created` | `{ field }` |
@@ -408,7 +380,7 @@ Ownership is checked first, so these only run for changes that would otherwise g
408
380
  For example, a low-stock check that reacts to every adjustment:
409
381
 
410
382
  ```javascript
411
- // hooks/stock-adjusted.js
383
+ // hooks/quantity-adjusted.js
412
384
  export default async ({ item }) => {
413
385
  const level = item.fields.reorderLevel;
414
386
  if(level !== undefined && item.quantity <= level){
@@ -427,7 +399,7 @@ Hooks run one at a time, in registration order, and are awaited, so a slow handl
427
399
  | `kempo-inventory:items:create` | Add items |
428
400
  | `kempo-inventory:items:update` | Edit items |
429
401
  | `kempo-inventory:items:delete` | Delete items (and their history) |
430
- | `kempo-inventory:stock:adjust` | Add or remove stock |
402
+ | `kempo-inventory:quantity:adjust` | Add or remove quantity |
431
403
  | `kempo-inventory:fields:manage` | Create, edit and delete custom fields |
432
404
 
433
405
  Groups, each including everything in the one before it:
@@ -435,7 +407,7 @@ Groups, each including everything in the one before it:
435
407
  | Group | Can |
436
408
  |---|---|
437
409
  | `kempo-inventory:viewer` | View items, fields and movements |
438
- | `kempo-inventory:manager` | Also create, edit and delete items and adjust stock |
410
+ | `kempo-inventory:manager` | Also create, edit and delete items and adjust quantity |
439
411
  | `kempo-inventory:admin` | Also set up the custom fields (`fields:manage`) |
440
412
 
441
413
  Every group also includes `system:admin:access`, because kempo serves extension pages inside the admin panel and redirects anyone without it. That only opens the panel itself: its other sections stay locked behind their own permissions.
@@ -477,4 +449,4 @@ The documentation site is written in `docs-src/` and built into `docs/` (served
477
449
 
478
450
  ## Dashboard widget
479
451
 
480
- Adds a **Inventory** widget to the kempo admin dashboard showing item, unit and out-of-stock counts and the latest stock movements, with a **New item** button. It is declared in `widgets` in `kempo-config.json` and registered when the extension is installed or updated; people only see it if they hold its permission, and can hide, reorder or resize it from the dashboard's Customize dialog. Needs a kempo with the dashboard (see kempo's `docs/dashboard.md`); on an older kempo the entry is ignored.
452
+ Adds a **Inventory** widget to the kempo admin dashboard showing item, unit and out-of-stock counts and the latest quantity movements, with a **New item** button. It is declared in `widgets` in `kempo-config.json` and registered when the extension is installed or updated; people only see it if they hold its permission, and can hide, reorder or resize it from the dashboard's Customize dialog. Needs a kempo with the dashboard (see kempo's `docs/dashboard.md`); on an older kempo the entry is ignored.
@@ -63,7 +63,7 @@ locked: true
63
63
  const [, permData] = await getAllCurrentUserPermissions();
64
64
  const canEdit = new Set(permData?.permissions ?? []).has('kempo-inventory:items:update');
65
65
 
66
- const manageFields = document.createElement('inventory-row-action');
66
+ const manageFields = document.createElement('k-inventory-row-action');
67
67
  manageFields.setAttribute('slot', 'before');
68
68
  manageFields.setAttribute('action', 'fields');
69
69
  manageFields.setAttribute('icon', 'settings');
@@ -71,7 +71,7 @@ locked: true
71
71
  $table.appendChild(manageFields);
72
72
 
73
73
  if(canEdit){
74
- const edit = document.createElement('inventory-row-action');
74
+ const edit = document.createElement('k-inventory-row-action');
75
75
  edit.setAttribute('slot', 'before');
76
76
  edit.setAttribute('action', 'edit');
77
77
  edit.setAttribute('icon', 'edit');
@@ -142,7 +142,7 @@ locked: true
142
142
  </div>
143
143
  <div class="mb">
144
144
  <label class="d-b mb-sm">Image</label>
145
- <inventory-media-field id="cat-image" ratio-for="category" .max=${1} .value=${category?.image ? [category.image.id] : []} .assets=${category?.image ? { [category.image.id]: category.image } : {}}></inventory-media-field>
145
+ <k-inventory-media-field id="cat-image" ratio-for="category" .max=${1} .value=${category?.image ? [category.image.id] : []} .assets=${category?.image ? { [category.image.id]: category.image } : {}}></k-inventory-media-field>
146
146
  </div>
147
147
  </div>
148
148
  `, {
@@ -63,7 +63,7 @@ locked: true
63
63
  for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
64
64
  $table.appendChild($control);
65
65
  };
66
- addControl('inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
66
+ addControl('k-inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
67
67
  $table.setAttribute('request-delete', '');
68
68
  addControl('kc-tc-delete-record', 'after');
69
69
 
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 960 960" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M228.283,855.577l-156.434,-89.949c-7.077,-4.097 -12.571,-9.498 -16.481,-16.202c-3.911,-6.704 -5.866,-14.154 -5.866,-22.348l0,-177.664c0,-8.194 1.955,-15.643 5.866,-22.348c3.911,-6.704 9.405,-12.105 16.481,-16.202l156.434,-89.949c7.077,-4.097 14.526,-6.146 22.348,-6.146c7.822,0 15.271,2.049 22.348,6.146l156.434,89.949c7.077,4.097 12.571,9.498 16.481,16.202c3.911,6.704 5.866,14.154 5.866,22.348l0,177.664c0,8.194 -1.955,15.643 -5.866,22.348c-3.911,6.704 -9.405,12.105 -16.481,16.202l-156.434,89.949c-7.077,4.097 -14.526,6.146 -22.348,6.146c-7.822,0 -15.271,-2.049 -22.348,-6.146Zm111.738,-294.99l43.019,-24.582l-132.41,-76.541l-43.578,25.141l132.969,75.982Zm-111.738,243.59l0,-153.082l-134.086,-77.658l0,153.082l134.086,77.658Zm44.695,0l134.086,-77.658l0,-153.082l-134.086,77.658l0,153.082Zm-22.348,-191.631l43.578,-25.141l-132.41,-76.541l-43.578,25.141l132.41,76.541Zm391.07,243.031l-156.434,-89.949c-7.077,-4.097 -12.571,-9.498 -16.481,-16.202c-3.911,-6.704 -5.866,-14.154 -5.866,-22.348l0,-177.664c0,-8.194 1.955,-15.643 5.866,-22.348c3.911,-6.704 9.405,-12.105 16.481,-16.202l156.434,-89.949c7.077,-4.097 14.526,-6.146 22.348,-6.146c7.822,0 15.271,2.049 22.348,6.146l156.434,89.949c7.077,4.097 12.571,9.498 16.481,16.202c3.911,6.704 5.866,14.154 5.866,22.348l0,177.664c0,8.194 -1.955,15.643 -5.866,22.348c-3.911,6.704 -9.405,12.105 -16.481,16.202l-156.434,89.949c-7.077,4.097 -14.526,6.146 -22.348,6.146c-7.822,0 -15.271,-2.049 -22.348,-6.146Zm44.695,-51.4l134.086,-77.658l0,-153.082l-134.086,77.658l0,153.082Zm-44.695,0l0,-153.082l-134.086,-77.658l0,153.082l134.086,77.658Zm22.348,-191.631l43.578,-25.141l-132.41,-76.541l-43.578,25.141l132.41,76.541Zm89.391,-51.958l43.019,-24.582l-132.41,-76.541l-43.578,25.141l132.969,75.982Zm-324.027,-55.763l-156.434,-89.949c-7.077,-4.097 -12.571,-9.498 -16.481,-16.202c-3.911,-6.704 -5.866,-14.154 -5.866,-22.348l0,-177.664c0,-8.194 1.955,-15.643 5.866,-22.348c3.911,-6.704 9.405,-12.105 16.481,-16.202l156.434,-89.949c7.077,-4.097 14.526,-6.146 22.348,-6.146c7.822,0 15.271,2.049 22.348,6.146l156.434,89.949c7.077,4.097 12.571,9.498 16.481,16.202c3.911,6.704 5.866,14.154 5.866,22.348l0,177.664c0,8.194 -1.955,15.643 -5.866,22.348c-3.911,6.704 -9.405,12.105 -16.481,16.202l-156.434,89.949c-7.077,4.097 -14.526,6.146 -22.348,6.146c-7.822,0 -15.271,-2.049 -22.348,-6.146Zm111.738,-294.99l43.019,-24.582l-132.866,-77.453l-43.122,26.054l132.969,75.982Zm-111.738,243.59l0,-153.082l-134.086,-77.658l0,153.082l134.086,77.658Zm22.348,-191.631l43.578,-25.141l-132.41,-76.541l-43.578,25.141l132.41,76.541Zm22.348,191.631l134.086,-77.658l0,-153.082l-134.086,77.658l0,153.082Z"/></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 960 960" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:affinity="https://www.affinity.studio/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M440,777l0,-274l-240,-139l0,274l240,139Zm80,0l240,-139l0,-274l-240,139l0,274Zm-80,92l-280,-161c-12.667,-7.333 -22.5,-17 -29.5,-29c-7,-12 -10.5,-25.333 -10.5,-40l0,-318c0,-14.667 3.5,-28 10.5,-40c7,-12 16.833,-21.667 29.5,-29l280,-161c12.667,-7.333 26,-11 40,-11c14,0 27.333,3.667 40,11l280,161c12.667,7.333 22.5,17 29.5,29c7,12 10.5,25.333 10.5,40l0,318c0,14.667 -3.5,28 -10.5,40c-7,12 -16.833,21.667 -29.5,29l-280,161c-12.667,7.333 -26,11 -40,11c-14,0 -27.333,-3.667 -40,-11Zm200,-528l77,-44l-237,-137l-78,45l238,136Zm-160,93l78,-45l-237,-137l-78,45l237,137Z" style="fill-rule:nonzero;"/></svg>
@@ -17,6 +17,7 @@ locked: true
17
17
  <script type="module" src="/kempo-ui/components/Toggle.js"></script>
18
18
  <script type="module" src="/kempo-ui/components/Combobox.js"></script>
19
19
  <script type="module" src="/kempo-ui/components/Icon.js"></script>
20
+ <script type="module" src="/kempo-ui/components/controls/TcOpen.js"></script>
20
21
  <script type="module" src="/kempo/components/Permission.js"></script>
21
22
  <script type="module" src="/inventory/components/RowAction.js"></script>
22
23
 
@@ -28,6 +29,7 @@ locked: true
28
29
  -->
29
30
  <k-table
30
31
  id="itemsTable"
32
+ open-on-click
31
33
  enable-pages
32
34
  page-size="25"
33
35
  page-size-options="[10,25,50,100]"
@@ -40,14 +42,14 @@ locked: true
40
42
  <kc-tc-field-sort-hide></kc-tc-field-sort-hide>
41
43
  <div class="flex"></div>
42
44
  <k-permission has="kempo-inventory:fields:manage">
43
- <button id="importBtn" class="btn m0"><k-icon name="arrow" direction="up"></k-icon> Import</button>
45
+ <button id="importBtn" class="btn m0"><k-icon name="download" rotation="180"></k-icon> Import</button>
44
46
  </k-permission>
45
47
  <k-permission has="kempo-inventory:fields:manage">
46
48
  <button id="exportBtn" class="btn m0"><k-icon name="download"></k-icon> Export</button>
47
49
  </k-permission>
48
50
  <a href="/admin/extension/kempo-inventory/categories" class="btn"><k-icon name="folder"></k-icon> Categories</a>
49
51
  <k-permission has="kempo-inventory:fields:manage">
50
- <a href="/admin/extension/kempo-inventory/fields" class="btn"><k-icon name="settings"></k-icon> Fields</a>
52
+ <a href="/admin/extension/kempo-inventory/fields" class="btn"><k-icon name="fields"></k-icon> Fields</a>
51
53
  </k-permission>
52
54
  <k-permission has="kempo-inventory:items:create">
53
55
  <button id="newItemBtn" class="primary m0"><k-icon name="add"></k-icon> New Item</button>
@@ -90,9 +92,10 @@ locked: true
90
92
  $control.setAttribute('slot', slot);
91
93
  for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
92
94
  $table.appendChild($control);
95
+ return $control;
93
96
  };
94
- if(can('items:update')) addControl('inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
95
- if(can('stock:adjust')) addControl('inventory-row-action', 'before', { action: 'adjust', icon: 'repeat', title: 'Adjust stock' });
97
+ if(can('items:update')) addControl('kc-tc-open', 'before', { title: 'Edit' }).appendChild(Object.assign(document.createElement('k-icon'), { name: 'edit' }));
98
+ if(can('quantity:adjust')) addControl('k-inventory-row-action', 'before', { action: 'adjust', icon: 'repeat', title: 'Adjust quantity' });
96
99
  if(can('items:delete')){
97
100
  $table.setAttribute('request-delete', '');
98
101
  addControl('kc-tc-delete-record', 'after');
@@ -105,15 +108,13 @@ locked: true
105
108
  let fields = [];
106
109
  /* The Managed by column only appears once an extension owns an item. */
107
110
  let owners = false;
108
- /* The Held column only appears once something is set aside. */
109
- let holds = false;
110
111
 
111
112
  /*
112
113
  Display-only values are precomputed as plain strings so the table never formats
113
114
  user-supplied text itself.
114
115
  */
115
116
  const decorate = (item, ctx) => {
116
- const record = { ...item, qtyText: String(item.quantity), heldText: String(item.held ?? 0), tagsText: item.tags.join(', '), ownerText: item.owner };
117
+ const record = { ...item, qtyText: String(item.quantity), tagsText: item.tags.join(', '), ownerText: item.owner, ...(can('items:update') && { _url: `/admin/extension/kempo-inventory/edit?id=${encodeURIComponent(item.id)}` }) };
117
118
  /* Only the fields that apply to this item's category; a column another category's items fill stays blank here. */
118
119
  for(const field of applicableFields(fields, item.category).filter(f => f.listed)) record[`f_${field.key}`] = displayValue(field, item.fields[field.key], ctx);
119
120
  return record;
@@ -121,10 +122,9 @@ locked: true
121
122
 
122
123
  const columns = () => [
123
124
  { name: 'sku', label: 'SKU', size: 130, editable: false },
124
- { name: 'name', label: 'Name', size: 240, editable: false },
125
+ { name: 'name', label: 'Name', editable: false },
125
126
  { name: 'category', label: 'Category', size: 140, editable: false },
126
127
  { name: 'qtyText', label: 'Quantity', size: 100, editable: false },
127
- ...(holds ? [{ name: 'heldText', label: 'On hold', size: 100, editable: false }] : []),
128
128
  { name: 'tagsText', label: 'Tags', size: 160, editable: false },
129
129
  ...(owners ? [{ name: 'ownerText', label: 'Managed by', size: 170, editable: false }] : []),
130
130
  /* One column per key: a key two categories both use is one column. */
@@ -154,7 +154,6 @@ locked: true
154
154
  return;
155
155
  }
156
156
  owners = data.items.some(i => i.owner);
157
- holds = data.items.some(i => i.held > 0);
158
157
  $table.setData({ records: data.items.map(i => decorate(i, data.context)), fields: columns() });
159
158
  };
160
159
 
@@ -174,7 +173,7 @@ locked: true
174
173
  Toast.error(`"${record.name}" is managed by the ${record.owner} extension and cannot be deleted here`);
175
174
  return;
176
175
  }
177
- Dialog.confirm(`Delete "${record.name}" and its stock history? This action cannot be undone.`, async confirmed => {
176
+ Dialog.confirm(`Delete "${record.name}" and its quantity history? This action cannot be undone.`, async confirmed => {
178
177
  if(!confirmed){ reject(); return; }
179
178
  const [error] = await deleteItem(record.id);
180
179
  if(error){
@@ -189,8 +188,6 @@ locked: true
189
188
  });
190
189
 
191
190
  $table.addEventListener('itemAction', ({ detail: { action, record } }) => {
192
- /* Editing is a page of its own, like adding: a dialog is too easy to dismiss and loses what was entered. */
193
- if(action === 'edit') location.href = `/admin/extension/kempo-inventory/edit?id=${encodeURIComponent(record.id)}`;
194
191
  if(action === 'adjust') openAdjustDialog({ item: record, onSaved: load });
195
192
  });
196
193
 
@@ -1,3 +1,6 @@
1
- <content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="30">
2
- <k-aside-item icon="archive" group="Commerce" href="/admin/extension/kempo-inventory/" no-expand>Inventory</k-aside-item>
1
+ <content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="87">
2
+ <k-aside-item icon-src="/admin/extension/kempo-inventory/icons/inventory.svg" group="Products" href="/admin/extension/kempo-inventory/" no-expand>Inventory</k-aside-item>
3
+ </content>
4
+ <content id="kempo-inventory-categories-nav" name="Inventory Categories" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="86">
5
+ <k-aside-item icon-src="/admin/extension/kempo-inventory/icons/inventory-categories.svg" group="Products" href="/admin/extension/kempo-inventory/categories/" no-expand>Inventory Categories</k-aside-item>
3
6
  </content>
package/kempo-config.json CHANGED
@@ -5,12 +5,12 @@
5
5
  "schema": "./server/db/schema.js",
6
6
  "widgets": [
7
7
  {
8
- "key": "stock",
8
+ "key": "quantity",
9
9
  "title": "Inventory",
10
- "description": "Item and unit counts, what is out of stock and the latest stock movements.",
11
- "icon": "archive",
12
- "element": "k-inv-widget",
13
- "module": "/inventory/components/StockWidget.js",
10
+ "description": "Item and unit counts, what is out of stock and the latest quantity movements.",
11
+ "iconSrc": "/admin/extension/kempo-inventory/icons/inventory.svg",
12
+ "element": "k-inventory-widget",
13
+ "module": "/inventory/components/QuantityWidget.js",
14
14
  "defaultWidth": 2,
15
15
  "defaultHeight": 2,
16
16
  "minWidth": 1,
@@ -22,7 +22,7 @@
22
22
  "permissions": [
23
23
  {
24
24
  "name": "kempo-inventory:items:read",
25
- "description": "View inventory items, fields and stock movements"
25
+ "description": "View inventory items, fields and quantity movements"
26
26
  },
27
27
  {
28
28
  "name": "kempo-inventory:items:create",
@@ -37,8 +37,8 @@
37
37
  "description": "Delete inventory items"
38
38
  },
39
39
  {
40
- "name": "kempo-inventory:stock:adjust",
41
- "description": "Add or remove stock on inventory items"
40
+ "name": "kempo-inventory:quantity:adjust",
41
+ "description": "Add or remove quantity on inventory items"
42
42
  },
43
43
  {
44
44
  "name": "kempo-inventory:fields:manage",
@@ -56,26 +56,26 @@
56
56
  },
57
57
  {
58
58
  "name": "kempo-inventory:manager",
59
- "description": "Can view and manage items and stock levels",
59
+ "description": "Can view and manage items and quantity levels",
60
60
  "permissions": [
61
61
  "system:admin:access",
62
62
  "kempo-inventory:items:read",
63
63
  "kempo-inventory:items:create",
64
64
  "kempo-inventory:items:update",
65
65
  "kempo-inventory:items:delete",
66
- "kempo-inventory:stock:adjust"
66
+ "kempo-inventory:quantity:adjust"
67
67
  ]
68
68
  },
69
69
  {
70
70
  "name": "kempo-inventory:admin",
71
- "description": "Can manage items and stock levels, and set up the custom fields items have",
71
+ "description": "Can manage items and quantity levels, and set up the custom fields items have",
72
72
  "permissions": [
73
73
  "system:admin:access",
74
74
  "kempo-inventory:items:read",
75
75
  "kempo-inventory:items:create",
76
76
  "kempo-inventory:items:update",
77
77
  "kempo-inventory:items:delete",
78
- "kempo-inventory:stock:adjust",
78
+ "kempo-inventory:quantity:adjust",
79
79
  "kempo-inventory:fields:manage"
80
80
  ]
81
81
  }
@@ -91,7 +91,7 @@
91
91
  "name": "public_read",
92
92
  "value": false,
93
93
  "type": "boolean",
94
- "description": "Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see stock counts. Off by default: reading otherwise needs the items:read permission."
94
+ "description": "Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see quantity counts. Off by default: reading otherwise needs the items:read permission."
95
95
  },
96
96
  {
97
97
  "name": "public_photos",
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "kempo-inventory",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
4
4
  "description": "A basic inventory management system for kempo cms",
5
5
  "license": "MIT",
6
- "author": "Dustin Poissant",
6
+ "author": "Dustin Poissant <dustinpoissant@gmail.com>",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/dustinpoissant/kempo-inventory.git"
@@ -39,8 +39,8 @@
39
39
  "drizzle-orm": "^0.45.2"
40
40
  },
41
41
  "peerDependencies": {
42
- "kempo": ">=4.5.8",
43
- "kempo-ui": ">=0.5.0",
42
+ "kempo": ">=4.9.7",
43
+ "kempo-ui": ">=0.7.0",
44
44
  "kempo-media": ">=0.3.0",
45
45
  "kempo-files": ">=0.1.3",
46
46
  "kempo-thumbs": ">=0.1.3"
@@ -7,8 +7,8 @@ export default async (request, response) => {
7
7
 
8
8
  const [error, item] = await getItem(request.params.id);
9
9
  if(error) return response.status(error.code).json({ error: error.msg });
10
- /* The stock history is all counts, so it is only for people who manage stock. */
11
- const [, movements] = access.canSeeStock ? await getMovements({ itemId: item.id, limit: 100 }) : [null, []];
10
+ /* The quantity history is all counts, so it is only for people who manage quantity. */
11
+ const [, movements] = access.canSeeQuantity ? await getMovements({ itemId: item.id, limit: 100 }) : [null, []];
12
12
  const [, linked] = await getLinked(item);
13
13
  const [, media] = await getMedia(item);
14
14
  response.json({ item: forCaller(item, access), movements: movements || [], linked: linked || {}, media: media || {} });
@@ -1,12 +1,12 @@
1
1
  import authorize from '../../../../../server/utils/authorize.js';
2
- import { adjustStock } from '../../../../../server/utils/items.js';
2
+ import { adjustQuantity } from '../../../../../server/utils/items.js';
3
3
 
4
4
  export default async (request, response) => {
5
- const [authError, auth] = await authorize(request, 'stock:adjust');
5
+ const [authError, auth] = await authorize(request, 'quantity:adjust');
6
6
  if(authError) return response.status(authError.code).json({ error: authError.msg });
7
7
 
8
8
  const { delta, reason, note } = request.body || {};
9
- const [error, item] = await adjustStock(request.params.id, { delta, reason, note, userId: auth.userId });
9
+ const [error, item] = await adjustQuantity(request.params.id, { delta, reason, note, userId: auth.userId });
10
10
  if(error) return response.status(error.code).json({ error: error.msg });
11
11
  response.json({ item });
12
12
  };
@@ -4,7 +4,7 @@ import { getMovements } from '../../../server/utils/items.js';
4
4
  export default async (request, response) => {
5
5
  const [authError, access] = await authorizeRead(request);
6
6
  if(authError) return response.status(authError.code).json({ error: authError.msg });
7
- if(!access.canSeeStock) return response.status(403).json({ error: 'Insufficient permissions' });
7
+ if(!access.canSeeQuantity) return response.status(403).json({ error: 'Insufficient permissions' });
8
8
 
9
9
  const { itemId, limit, offset } = request.query;
10
10
  const [error, movements] = await getMovements({
@@ -118,7 +118,7 @@ export const openImportDialog = ({ onDone = () => {} } = {}) => {
118
118
  </label>
119
119
  <label class="d-f mb" style="gap: var(--spacer_h); align-items: flex-start; cursor: pointer;">
120
120
  <input type="radio" name="onMatch" value="update" ?checked=${state.onMatch === 'update'} @change=${() => { state.onMatch = 'update'; draw(); }}>
121
- <span><strong>Update them</strong> with the values in the file<br><small class="tc-muted">A value the file leaves empty never clears anything. A changed quantity is recorded as a stock adjustment.</small></span>
121
+ <span><strong>Update them</strong> with the values in the file<br><small class="tc-muted">A value the file leaves empty never clears anything. A changed quantity is recorded as a quantity adjustment.</small></span>
122
122
  </label>` : ''}
123
123
  ${problemList(preview.results)}` : ''}
124
124
  ${state.phase === 'importing' ? html`<p><k-spinner size="sm"></k-spinner> Importing. With photos this can take a while; keep this window open.</p>` : ''}
@@ -2,7 +2,7 @@ import { html } from '/kempo-ui/lit-all.min.js';
2
2
  import Toast from '/kempo-ui/components/Toast.js';
3
3
  import Dialog from '/kempo-ui/components/Dialog.js';
4
4
  import '/kempo-ui/components/Table.js';
5
- import { getItem, adjustStock } from '/inventory/sdk.js';
5
+ import { getItem, adjustQuantity } from '/inventory/sdk.js';
6
6
  import { createItemForm } from '/inventory/components/ItemForm.js';
7
7
 
8
8
  /*
@@ -17,7 +17,7 @@ import { createItemForm } from '/inventory/components/ItemForm.js';
17
17
  openItemDialog({ fields, defaults: { fields: { brand: 'Vallejo' } }, onSaved: ... }); // custom fields filled in
18
18
  openItemDialog({ item, fields, onSaved: item => reload() }); // edit an item
19
19
  openItemDialog({ fields, askQuantity: false, defaults: { quantity: 1 }, onSaved }); // no quantity box: the item just exists
20
- openAdjustDialog({ item, onSaved: item => reload() }); // add or remove stock
20
+ openAdjustDialog({ item, onSaved: item => reload() }); // add or remove quantity
21
21
 
22
22
  `fields` is the full field list from getFields(). The form shows the default fields plus those of the
23
23
  item's category, and swaps them as the Category box changes. `onSaved` runs after the change is stored.
@@ -71,7 +71,7 @@ export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
71
71
  <div class="p" style=${SCROLL}>
72
72
  <p class="mb">Current quantity: <strong>${data.item.quantity}</strong></p>
73
73
  <div class="mb">
74
- <label class="d-b mb-sm" for="dlg-delta">Change (negative removes stock)</label>
74
+ <label class="d-b mb-sm" for="dlg-delta">Change (negative removes quantity)</label>
75
75
  <input type="number" id="dlg-delta" class="full" step="1" required>
76
76
  </div>
77
77
  <div class="mb">
@@ -92,7 +92,7 @@ export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
92
92
  <k-table id="historyTable" placeholder="No movements yet."></k-table>
93
93
  </div>
94
94
  `, {
95
- title: `Adjust stock: ${data.item.name}`,
95
+ title: `Adjust quantity: ${data.item.name}`,
96
96
  confirmText: 'Apply',
97
97
  cancelText: 'Close',
98
98
  confirmAction: async event => {
@@ -102,17 +102,17 @@ export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
102
102
  Toast.error('Enter a non-zero whole number');
103
103
  return;
104
104
  }
105
- const [adjustError, adjusted] = await adjustStock(item.id, {
105
+ const [adjustError, adjusted] = await adjustQuantity(item.id, {
106
106
  delta,
107
107
  reason: $dialog.querySelector('#dlg-reason').value,
108
108
  note: $dialog.querySelector('#dlg-note').value,
109
109
  });
110
110
  if(adjustError){
111
- Toast.error(adjustError.msg || 'Failed to adjust stock');
111
+ Toast.error(adjustError.msg || 'Failed to adjust quantity');
112
112
  return;
113
113
  }
114
114
  $dialog.close();
115
- Toast.success('Stock updated');
115
+ Toast.success('Quantity updated');
116
116
  onSaved(adjusted.item);
117
117
  },
118
118
  });
@@ -201,7 +201,7 @@ export class InventoryMediaField extends LitElement {
201
201
  }
202
202
  }
203
203
 
204
- if(!customElements.get('inventory-media-field')) customElements.define('inventory-media-field', InventoryMediaField);
204
+ if(!customElements.get('k-inventory-media-field')) customElements.define('k-inventory-media-field', InventoryMediaField);
205
205
 
206
206
  /*
207
207
  A colour field's control. Colour is optional, so with nothing chosen it offers a button, because a
@@ -312,7 +312,7 @@ class InventoryColorField extends LitElement {
312
312
  }
313
313
  }
314
314
 
315
- if(!customElements.get('inventory-color-field')) customElements.define('inventory-color-field', InventoryColorField);
315
+ if(!customElements.get('k-inventory-color-field')) customElements.define('k-inventory-color-field', InventoryColorField);
316
316
 
317
317
  const searchItems = async (event, ownId) => {
318
318
  const $combobox = event.currentTarget;
@@ -361,7 +361,7 @@ export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = n
361
361
  control = html`<input type="date" id=${id} class="full" .value=${value ?? ''}>`;
362
362
  break;
363
363
  case 'color':
364
- control = html`<inventory-color-field id=${id} .value=${value ?? ''}></inventory-color-field>`;
364
+ control = html`<k-inventory-color-field id=${id} .value=${value ?? ''}></k-inventory-color-field>`;
365
365
  break;
366
366
  case 'rating':
367
367
  control = html`<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
@@ -385,7 +385,7 @@ export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = n
385
385
  @select=${e => { e.currentTarget.dataset.itemId = e.detail.value; e.currentTarget.dataset.label = e.detail.label; }}></k-combobox>`;
386
386
  break;
387
387
  case 'media':
388
- control = html`<inventory-media-field id=${id} .value=${value ?? []} .assets=${media} .cropRatio=${field.imageRatio ?? ''} .cropMax=${field.imageMax ?? 0}></inventory-media-field>`;
388
+ control = html`<k-inventory-media-field id=${id} .value=${value ?? []} .assets=${media} .cropRatio=${field.imageRatio ?? ''} .cropMax=${field.imageMax ?? 0}></k-inventory-media-field>`;
389
389
  break;
390
390
  default:
391
391
  control = field.type === 'text' && field.suggest
@@ -93,7 +93,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
93
93
  };
94
94
 
95
95
  render(html`
96
- ${locked ? html`<p class="mb tc-muted">Managed by the ${item.owner} extension. Its SKU, name and category can only be changed there; everything else, including stock, you can edit here.</p>` : ''}
96
+ ${locked ? html`<p class="mb tc-muted">Managed by the ${item.owner} extension. Its SKU, name and category can only be changed there; everything else, including quantity, you can edit here.</p>` : ''}
97
97
  <div class="d-f mb" style="gap: var(--spacer);">
98
98
  <div class="flex">
99
99
  <label class="d-b mb-sm" for="dlg-sku">SKU *</label>
@@ -4,10 +4,10 @@ import { getItems, getMovements } from '/inventory/sdk.js';
4
4
  const stat = (value, label, classes = '') => html`<div class="flex"><div class="h2 m0 ${classes}">${value}</div><div class="small tc-muted">${label}</div></div>`;
5
5
 
6
6
  /*
7
- Stock at a glance. Unit and out-of-stock figures are shown only when the page fetched holds every
7
+ Quantity at a glance. Unit and out-of-stock figures are shown only when the page fetched holds every
8
8
  item, so they are never an under-count.
9
9
  */
10
- export default class StockWidget extends DashboardWidget {
10
+ export default class QuantityWidget extends DashboardWidget {
11
11
  static properties = {
12
12
  ...DashboardWidget.properties,
13
13
  total: { state: true },
@@ -68,4 +68,4 @@ export default class StockWidget extends DashboardWidget {
68
68
  }
69
69
  }
70
70
 
71
- customElements.define('k-inv-widget', StockWidget);
71
+ customElements.define('k-inventory-widget', QuantityWidget);
@@ -7,7 +7,7 @@ import '/kempo-ui/components/Icon.js';
7
7
  The page owns the dialog and the request; this only reports the choice, as an `itemAction`
8
8
  event on the table: { action, record }.
9
9
 
10
- <inventory-row-action action="edit" icon="edit" title="Edit"></inventory-row-action>
10
+ <k-inventory-row-action action="edit" icon="edit" title="Edit"></k-inventory-row-action>
11
11
  */
12
12
  export default class RowAction extends AdminTableControl {
13
13
  static properties = {
@@ -31,4 +31,4 @@ export default class RowAction extends AdminTableControl {
31
31
  render(){ return html`<slot><k-icon name="${this.icon}"></k-icon></slot>`; }
32
32
  }
33
33
 
34
- customElements.define('inventory-row-action', RowAction);
34
+ customElements.define('k-inventory-row-action', RowAction);
package/public/sdk.js CHANGED
@@ -32,7 +32,7 @@ export const getItem = id => req('GET', `${BASE}/items/${enc(id)}`);
32
32
  export const createItem = data => req('POST', `${BASE}/items`, data);
33
33
  export const updateItem = (id, data) => req('PATCH', `${BASE}/items/${enc(id)}`, data);
34
34
  export const deleteItem = id => req('DELETE', `${BASE}/items/${enc(id)}`);
35
- export const adjustStock = (id, data) => req('POST', `${BASE}/items/${enc(id)}/adjust`, data);
35
+ export const adjustQuantity = (id, data) => req('POST', `${BASE}/items/${enc(id)}/adjust`, data);
36
36
  export const getMovements = (params = {}) => req('GET', `${BASE}/movements${buildQuery(params)}`);
37
37
 
38
38
  /*
package/sdk.js CHANGED
@@ -13,7 +13,7 @@
13
13
  };
14
14
 
15
15
  Fields, categories and items can all be owned. Pass your extension name as `owner` and only you can
16
- delete them or change the parts that define them; people keep editing everything else (stock
16
+ delete them or change the parts that define them; people keep editing everything else (quantity
17
17
  included) in the admin. Owner is never read from an HTTP request, only from these server calls:
18
18
 
19
19
  registerCategory(owner, { name, description }) registerField(owner, { ..., category })
@@ -31,12 +31,11 @@ export {
31
31
  updateItem,
32
32
  deleteItem,
33
33
  unregisterItems,
34
- adjustStock,
35
- adjustStockMany,
34
+ adjustQuantity,
35
+ adjustQuantityMany,
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
40
  export {
42
41
  getFields,
@@ -10,8 +10,7 @@ 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), // 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
13
+ quantity: integer('quantity').notNull().default(0),
15
14
  tags: jsonb('tags').notNull().default([]), // built in: short lower-case labels search finds
16
15
  data: jsonb('data').notNull().default({}),
17
16
  owner: text('owner').notNull().default(''), // '' for items people manage, or the extension that created it; see ownership.js
@@ -50,8 +49,6 @@ export const kempoInventoryMovement = pgTable('kempoInventoryMovement', {
50
49
  itemId: text('itemId').notNull(),
51
50
  delta: integer('delta').notNull(),
52
51
  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),
55
52
  reason: text('reason').notNull().default('adjustment'),
56
53
  note: text('note').notNull().default(''),
57
54
  userId: text('userId').notNull().default(''),
@@ -2,14 +2,14 @@ import { currentUserHasPermission, getSession, getSetting } from 'kempo/server/s
2
2
  import authorize from './authorize.js';
3
3
 
4
4
  /*
5
- Who may read the inventory, and whether they may see stock counts.
5
+ Who may read the inventory, and whether they may see quantity counts.
6
6
 
7
7
  Normally reading needs the `items:read` permission. With the `public_read` setting on, anyone can
8
- read, signed in or not, so the inventory can be shared as a catalogue. Either way, **stock counts
9
- are only for people who manage stock**: callers without `stock:adjust` or `items:update` get items
8
+ read, signed in or not, so the inventory can be shared as a catalogue. Either way, **quantity counts
9
+ are only for people who manage quantity**: callers without `quantity:adjust` or `items:update` get items
10
10
  with no `quantity` and no stock history.
11
11
 
12
- Resolves to [null, { userId, canSeeStock, anonymous }] or [{ code, msg }, null].
12
+ Resolves to [null, { userId, canSeeQuantity, anonymous }] or [{ code, msg }, null].
13
13
  */
14
14
  export const publicReadEnabled = async () => {
15
15
  const [, value] = await getSetting('kempo-inventory', 'public_read', false);
@@ -25,19 +25,19 @@ export const authorizeRead = async request => {
25
25
  const token = request.cookies?.session_token;
26
26
  const [error, auth] = await authorize(request, 'items:read');
27
27
  if(!error){
28
- const canSeeStock = (await holds(token, 'stock:adjust')) || (await holds(token, 'items:update'));
29
- return [null, { userId: auth.userId, canSeeStock, anonymous: false }];
28
+ const canSeeQuantity = (await holds(token, 'quantity:adjust')) || (await holds(token, 'items:update'));
29
+ return [null, { userId: auth.userId, canSeeQuantity, anonymous: false }];
30
30
  }
31
31
  if(await publicReadEnabled()){
32
32
  const [sessionError, session] = token ? await getSession({ token }) : [true, null];
33
- return [null, { userId: !sessionError && session?.user ? session.user.id : null, canSeeStock: false, anonymous: true }];
33
+ return [null, { userId: !sessionError && session?.user ? session.user.id : null, canSeeQuantity: false, anonymous: true }];
34
34
  }
35
35
  return [error, null];
36
36
  };
37
37
 
38
- /* An item as the caller may see it: no stock count unless they manage stock. */
38
+ /* An item as the caller may see it: no stock count unless they manage quantity. */
39
39
  export const forCaller = (item, access) => {
40
- if(access.canSeeStock) return item;
40
+ if(access.canSeeQuantity) return item;
41
41
  const { quantity, ...rest } = item;
42
42
  return rest;
43
43
  };
@@ -16,10 +16,8 @@ export const EVENTS = {
16
16
  itemUpdated: 'kempo-inventory:item:updated',
17
17
  itemBeforeDelete: 'kempo-inventory:item:before_delete',
18
18
  itemDeleted: 'kempo-inventory:item:deleted',
19
- stockBeforeAdjust: 'kempo-inventory:stock:before_adjust',
20
- stockAdjusted: 'kempo-inventory:stock:adjusted',
21
- stockBeforeHold: 'kempo-inventory:stock:before_hold',
22
- stockHeld: 'kempo-inventory:stock:held',
19
+ quantityBeforeAdjust: 'kempo-inventory:quantity:before_adjust',
20
+ quantityAdjusted: 'kempo-inventory:quantity:adjusted',
23
21
  categoryBeforeUpdate: 'kempo-inventory:category:before_update',
24
22
  categoryUpdated: 'kempo-inventory:category:updated',
25
23
  categoryBeforeDelete: 'kempo-inventory:category:before_delete',
@@ -2,7 +2,7 @@ import db from 'kempo/server/db/index.js';
2
2
  import { asc } from 'drizzle-orm';
3
3
  import { kempoInventoryItem, kempoInventoryCategory } from '../db/schema.js';
4
4
  import { getFields } from './fields.js';
5
- import { getItemBySku, createItem, updateItem, adjustStock } from './items.js';
5
+ import { getItemBySku, createItem, updateItem, adjustQuantity } from './items.js';
6
6
  import { getCategories, saveCategory } from './categories.js';
7
7
  import { applicableFields, coerceValues } from './fieldTypes.js';
8
8
  import { normalizeTags } from './tags.js';
@@ -111,13 +111,13 @@ const given = value => value !== undefined && value !== null && value !== '' &&
111
111
 
112
112
  onMatch 'skip' an item or category that already exists (same SKU / same name) is left alone
113
113
  'update' it is updated with the values the file provides; a value the file leaves empty
114
- is never cleared. A changed quantity is applied as a stock adjustment, so the
114
+ is never cleared. A changed quantity is applied as a quantity adjustment, so the
115
115
  history shows it.
116
116
 
117
117
  Rows are independent: one that fails does not stop the others. Resolves to
118
118
  [null, { results, categoryResults, summary }].
119
119
  */
120
- export const importData = async ({ items = [], categories = [], onMatch = 'skip', dryRun = false } = {}, { userId = '', canUpdate = false, canAdjustStock = false, maxRows = MAX_IMPORT_ROWS } = {}) => {
120
+ export const importData = async ({ items = [], categories = [], onMatch = 'skip', dryRun = false } = {}, { userId = '', canUpdate = false, canAdjustQuantity = false, maxRows = MAX_IMPORT_ROWS } = {}) => {
121
121
  if(!['skip', 'update'].includes(onMatch)) return [{ code: 400, msg: 'onMatch must be "skip" or "update"' }, null];
122
122
  if(!Array.isArray(items) || !Array.isArray(categories)) return [{ code: 400, msg: 'items and categories must be lists' }, null];
123
123
  if(items.length > maxRows || categories.length > maxRows) return [{ code: 413, msg: `Send at most ${maxRows} rows at a time` }, null];
@@ -199,7 +199,7 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
199
199
  if(invalid){ fail(result, invalid.msg); continue; }
200
200
 
201
201
  if(existing){
202
- if(quantity !== undefined && quantity !== existing.quantity && !canAdjustStock){ fail(result, 'Changing a quantity needs the stock:adjust permission'); continue; }
202
+ if(quantity !== undefined && quantity !== existing.quantity && !canAdjustQuantity){ fail(result, 'Changing a quantity needs the quantity:adjust permission'); continue; }
203
203
  result.action = 'update';
204
204
  if(dryRun){ summary.updated++; continue; }
205
205
  const changes = { fields: coerced };
@@ -210,7 +210,7 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
210
210
  const [error] = await updateItem(existing.id, { ...changes, userId });
211
211
  if(error && !(error.code === 400 && error.msg === 'No changes provided')){ fail(result, error.msg); continue; }
212
212
  if(quantity !== undefined && quantity !== existing.quantity){
213
- const [adjustError] = await adjustStock(existing.id, { delta: quantity - existing.quantity, reason: 'import', note: 'Imported', userId });
213
+ const [adjustError] = await adjustQuantity(existing.id, { delta: quantity - existing.quantity, reason: 'import', note: 'Imported', userId });
214
214
  if(adjustError){ fail(result, adjustError.msg); continue; }
215
215
  }
216
216
  summary.updated++;
@@ -73,7 +73,7 @@ const mediaIn = ({ items, categories }) => {
73
73
  return paths;
74
74
  };
75
75
 
76
- const PERMISSIONS = { canUpdate: true, canAdjustStock: true, maxRows: MAX_ITEMS }; // the caller is the top tier, which holds all of these
76
+ const PERMISSIONS = { canUpdate: true, canAdjustQuantity: true, maxRows: MAX_ITEMS }; // the caller is the top tier, which holds all of these
77
77
 
78
78
  /* What importing this file would do, changing nothing: { results, categoryResults, summary, media }. */
79
79
  export const previewImport = async (bytes, { userId }) => {
@@ -30,7 +30,6 @@ const shape = row => row && ({
30
30
  description: row.description,
31
31
  category: row.category,
32
32
  quantity: row.quantity,
33
- held: row.held ?? 0,
34
33
  tags: row.tags ?? [],
35
34
  fields: row.data ?? {},
36
35
  owner: row.owner,
@@ -230,7 +229,7 @@ export const createItem = async ({ sku, name, description = '', category = '', t
230
229
  if(qty > 0){
231
230
  await tx.insert(kempoInventoryMovement).values({
232
231
  id: newId(), itemId: created.id, delta: qty, quantityAfter: qty,
233
- reason: 'initial', note: 'Initial stock', userId, created: now,
232
+ reason: 'initial', note: 'Initial quantity', userId, created: now,
234
233
  });
235
234
  }
236
235
  return created;
@@ -246,7 +245,7 @@ export const createItem = async ({ sku, name, description = '', category = '', t
246
245
 
247
246
  /*
248
247
  Updates sku, name, description and custom field values. Quantity is not editable here: it only
249
- changes through adjustStock, which keeps the movement history honest.
248
+ changes through adjustQuantity, which keeps the movement history honest.
250
249
  */
251
250
  export const updateItem = async (id, { sku, name, description, category, tags, fields: values, userId = '' } = {}, { owner = '' } = {}) => {
252
251
  const [lookupError, existing] = await getItem(id);
@@ -318,7 +317,6 @@ export const deleteItem = async (id, { userId = '', owner = '' } = {}) => {
318
317
  const [lookupError, existing] = await getItem(id);
319
318
  if(lookupError) return [lookupError, null];
320
319
  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];
322
320
 
323
321
  const refused = await guard(EVENTS.itemBeforeDelete, { item: existing, userId, actor: owner });
324
322
  if(refused) return [refused, null];
@@ -341,13 +339,13 @@ export const deleteItem = async (id, { userId = '', owner = '' } = {}) => {
341
339
  Atomic: the quantity changes in a single UPDATE guarded against going negative,
342
340
  so concurrent adjustments cannot oversell.
343
341
  */
344
- /* Anyone may adjust stock, owned item or not; `owner` only tells hooks which extension did it. */
345
- export const adjustStock = async (id, { delta, reason = 'adjustment', note = '', userId = '', owner = '' } = {}) => {
342
+ /* Anyone may adjust quantity, owned item or not; `owner` only tells hooks which extension did it. */
343
+ export const adjustQuantity = async (id, { delta, reason = 'adjustment', note = '', userId = '', owner = '' } = {}) => {
346
344
  const [lookupError, existing] = await getItem(id);
347
345
  if(lookupError) return [lookupError, null];
348
346
 
349
347
  const draft = { delta, reason, note };
350
- const refused = await guard(EVENTS.stockBeforeAdjust, { item: existing, draft, userId, actor: owner });
348
+ const refused = await guard(EVENTS.quantityBeforeAdjust, { item: existing, draft, userId, actor: owner });
351
349
  if(refused) return [refused, null];
352
350
 
353
351
  const change = toInt(draft.delta, NaN);
@@ -361,21 +359,21 @@ export const adjustStock = async (id, { delta, reason = 'adjustment', note = '',
361
359
  .returning();
362
360
  if(!row) return null;
363
361
  const [movement] = await tx.insert(kempoInventoryMovement).values({
364
- id: newId(), itemId: id, delta: change, quantityAfter: row.quantity, heldAfter: row.held,
362
+ id: newId(), itemId: id, delta: change, quantityAfter: row.quantity,
365
363
  reason: String(draft.reason || 'adjustment'), note: String(draft.note || ''), userId, created: new Date(),
366
364
  }).returning();
367
365
  return { row, movement };
368
366
  });
369
- if(!result) return [{ code: 409, msg: 'Insufficient stock' }, null];
367
+ if(!result) return [{ code: 409, msg: 'Insufficient quantity' }, null];
370
368
 
371
369
  const item = shape(result.row);
372
- await notify(EVENTS.stockAdjusted, {
370
+ await notify(EVENTS.quantityAdjusted, {
373
371
  item, previousQuantity: item.quantity - change, delta: change,
374
372
  reason: result.movement.reason, note: result.movement.note, movement: result.movement, userId, actor: owner,
375
373
  });
376
374
  return [null, item];
377
375
  } catch {
378
- return [{ code: 500, msg: 'Failed to adjust stock' }, null];
376
+ return [{ code: 500, msg: 'Failed to adjust quantity' }, null];
379
377
  }
380
378
  };
381
379
 
@@ -389,23 +387,23 @@ class Refusal {
389
387
  }
390
388
 
391
389
  /*
392
- Applies several stock changes together: every one happens or none does. This is what makes
393
- taking the materials for a whole recipe safe, where one adjustStock per item could take the resin
390
+ Applies several quantity changes together: every one happens or none does. This is what makes
391
+ taking the materials for a whole recipe safe, where one adjustQuantity per item could take the resin
394
392
  and then fail on the paint. `changes` is [{ id, delta, reason?, note? }]; changes to the same item
395
393
  are added together, and one that nets to nothing is left out. Each item runs through the same
396
- before_adjust guard as adjustStock, and fires the same stock:adjusted notification once everything
394
+ before_adjust guard as adjustQuantity, and fires the same quantity:adjusted notification once everything
397
395
  is committed.
398
396
 
399
- const [error, result] = await adjustStockMany(
397
+ const [error, result] = await adjustQuantityMany(
400
398
  [{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
401
399
  { reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
402
400
  );
403
401
 
404
402
  `ref` is recorded on every movement so the change can be found and reversed later. A refusal for
405
- lack of stock is a 409 that names the item, and nothing has changed. Resolves to
403
+ lack of quantity is a 409 that names the item, and nothing has changed. Resolves to
406
404
  [null, { items }] with the items as they are now.
407
405
  */
408
- export const adjustStockMany = async (changes, { reason = 'adjustment', note = '', ref = '', userId = '', owner = '' } = {}) => {
406
+ export const adjustQuantityMany = async (changes, { reason = 'adjustment', note = '', ref = '', userId = '', owner = '' } = {}) => {
409
407
  if(!Array.isArray(changes) || !changes.length) return [{ code: 400, msg: 'changes must be a non-empty list' }, null];
410
408
 
411
409
  const totals = new Map();
@@ -426,7 +424,7 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
426
424
  try {
427
425
  rows = await db.select().from(kempoInventoryItem).where(inArray(kempoInventoryItem.id, wanted.map(entry => entry.id)));
428
426
  } catch {
429
- return [{ code: 500, msg: 'Failed to adjust stock' }, null];
427
+ return [{ code: 500, msg: 'Failed to adjust quantity' }, null];
430
428
  }
431
429
  const existing = new Map(rows.map(row => [row.id, shape(row)]));
432
430
  const missing = wanted.find(entry => !existing.has(entry.id));
@@ -435,7 +433,7 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
435
433
  const drafts = new Map();
436
434
  for(const entry of wanted){
437
435
  const draft = { delta: entry.delta, reason: entry.reason ?? reason, note: entry.note ?? note };
438
- const refused = await guard(EVENTS.stockBeforeAdjust, { item: existing.get(entry.id), draft, userId, actor: owner });
436
+ const refused = await guard(EVENTS.quantityBeforeAdjust, { item: existing.get(entry.id), draft, userId, actor: owner });
439
437
  if(refused) return [refused, null];
440
438
  const delta = toInt(draft.delta, NaN);
441
439
  if(Number.isNaN(delta) || delta === 0) return [{ code: 400, msg: 'delta must be a non-zero whole number' }, null];
@@ -451,9 +449,9 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
451
449
  .set({ quantity: sql`${kempoInventoryItem.quantity} + ${delta}`, updated: new Date() })
452
450
  .where(and(eq(kempoInventoryItem.id, entry.id), sql`${kempoInventoryItem.quantity} + ${delta} >= 0`))
453
451
  .returning();
454
- if(!row) throw new Refusal({ code: 409, msg: `Insufficient stock of ${existing.get(entry.id).name}` });
452
+ if(!row) throw new Refusal({ code: 409, msg: `Insufficient quantity of ${existing.get(entry.id).name}` });
455
453
  const [movement] = await tx.insert(kempoInventoryMovement).values({
456
- id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, heldAfter: row.held, reason: why,
454
+ id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, reason: why,
457
455
  note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
458
456
  }).returning();
459
457
  results.push({ row, movement, delta });
@@ -464,14 +462,14 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
464
462
  for(const { row, movement, delta } of applied){
465
463
  const item = shape(row);
466
464
  items.push(item);
467
- await notify(EVENTS.stockAdjusted, {
465
+ await notify(EVENTS.quantityAdjusted, {
468
466
  item, previousQuantity: item.quantity - delta, delta, reason: movement.reason, note: movement.note, movement, userId, actor: owner,
469
467
  });
470
468
  }
471
469
  return [null, { items }];
472
470
  } catch(error) {
473
471
  if(error instanceof Refusal) return [error.error, null];
474
- return [{ code: 500, msg: 'Failed to adjust stock' }, null];
472
+ return [{ code: 500, msg: 'Failed to adjust quantity' }, null];
475
473
  }
476
474
  };
477
475
 
@@ -489,7 +487,7 @@ export const getMovements = async ({ itemId, limit = 50, offset = 0 } = {}) => {
489
487
  /*
490
488
  For an extension's uninstall.js: deletes every item it owns, with their history. With `release`
491
489
  the items are kept and handed back to the people who manage the inventory instead, which is the
492
- right choice when they hold real stock.
490
+ right choice when they hold real quantity.
493
491
  */
494
492
  export const unregisterItems = async (owner, { release = false } = {}) => {
495
493
  if(!owner) return [{ code: 400, msg: 'An owner (your extension name) is required' }, null];
@@ -5,7 +5,7 @@ import { tidyCategory } from './categoryLogic.js';
5
5
  extension that created it, and `actor` is whoever is making the change in the same terms: the
6
6
  HTTP layer never names an actor ('' = a person), only an extension calling the server SDK can.
7
7
 
8
- Ownership never changes what stock does: anyone with the permission can still adjust an owned
8
+ Ownership never changes what quantity does: anyone with the permission can still adjust an owned
9
9
  item's quantity. It protects what the owner builds on:
10
10
 
11
11
  items sku, name, category and deletion belong to the owner
package/update.js CHANGED
@@ -8,10 +8,13 @@ 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`);
11
+ // Stock became quantity everywhere. The permission to change it was kempo-inventory:stock:adjust: move every grant to the new name, so nobody loses the right.
12
+ const OLD = 'kempo-inventory:stock:adjust';
13
+ const NEW = 'kempo-inventory:quantity:adjust';
14
+ await db.execute(sql`INSERT INTO "permission" ("name", "description", "owner", "createdAt") SELECT ${NEW}, "description", "owner", "createdAt" FROM "permission" WHERE "name" = ${OLD} ON CONFLICT DO NOTHING`);
15
+ await db.execute(sql`UPDATE "groupPermission" SET "permissionName" = ${NEW} WHERE "permissionName" = ${OLD} AND NOT EXISTS (SELECT 1 FROM "groupPermission" other WHERE other."groupName" = "groupPermission"."groupName" AND other."permissionName" = ${NEW})`);
16
+ await db.execute(sql`DELETE FROM "groupPermission" WHERE "permissionName" = ${OLD}`);
17
+ await db.execute(sql`DELETE FROM "permission" WHERE "name" = ${OLD}`);
15
18
 
16
19
  // Next: media_provider is retired. kempo-media sits on kempo-files, so new uploads go through it whenever it is installed.
17
20
  await deleteSetting('kempo-inventory', 'media_provider');
@@ -59,7 +62,7 @@ export default async () => {
59
62
 
60
63
  // 0.9: optional public (signed-out) browsing. Added only when missing, and off.
61
64
  const [, publicRead] = await getSetting('kempo-inventory', 'public_read', null);
62
- if(publicRead === null) await setSetting('kempo-inventory', 'public_read', false, 'boolean', false, 'Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see stock counts. Off by default: reading otherwise needs the items:read permission.');
65
+ if(publicRead === null) await setSetting('kempo-inventory', 'public_read', false, 'boolean', false, 'Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see quantity counts. Off by default: reading otherwise needs the items:read permission.');
63
66
 
64
67
  // 0.8: photos are public by default, so read-only users can see them. Added only when missing.
65
68
  const [, publicSetting] = await getSetting('kempo-inventory', 'public_photos', null);
@@ -1,154 +0,0 @@
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);