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 +27 -55
- package/admin/categories/index.page.html +3 -3
- package/admin/fields/index.page.html +1 -1
- package/admin/icons/inventory-categories.svg +1 -0
- package/admin/icons/inventory.svg +1 -0
- package/admin/index.page.html +10 -13
- package/admin/nav.global.html +5 -2
- package/kempo-config.json +13 -13
- package/package.json +4 -4
- package/public/api/items/[id]/GET.js +2 -2
- package/public/api/items/[id]/adjust/POST.js +3 -3
- package/public/api/movements/GET.js +1 -1
- package/public/components/ImportExport.js +1 -1
- package/public/components/ItemDialog.js +7 -7
- package/public/components/ItemFields.js +4 -4
- package/public/components/ItemForm.js +1 -1
- package/public/components/{StockWidget.js → QuantityWidget.js} +3 -3
- package/public/components/RowAction.js +2 -2
- package/public/sdk.js +1 -1
- package/sdk.js +3 -4
- package/server/db/schema.js +1 -4
- package/server/utils/access.js +9 -9
- package/server/utils/events.js +2 -4
- package/server/utils/importExport.js +5 -5
- package/server/utils/importFile.js +1 -1
- package/server/utils/items.js +22 -24
- package/server/utils/ownership.js +1 -1
- package/update.js +8 -5
- package/server/utils/holds.js +0 -154
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
|
-
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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,
|
|
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
|
-
| `
|
|
312
|
-
| `
|
|
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 })` |
|
|
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,
|
|
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
|
-
`
|
|
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 {
|
|
331
|
+
import { adjustQuantityMany } from 'kempo-inventory/sdk';
|
|
332
332
|
|
|
333
|
-
const [error, { items }] = await
|
|
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
|
|
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 `
|
|
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:
|
|
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:
|
|
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/
|
|
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:
|
|
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
|
|
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
|
|
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>
|
package/admin/index.page.html
CHANGED
|
@@ -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="
|
|
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="
|
|
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('
|
|
95
|
-
if(can('
|
|
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),
|
|
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',
|
|
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
|
|
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
|
|
package/admin/nav.global.html
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
<content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="
|
|
2
|
-
<k-aside-item icon="
|
|
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": "
|
|
8
|
+
"key": "quantity",
|
|
9
9
|
"title": "Inventory",
|
|
10
|
-
"description": "Item and unit counts, what is out of stock and the latest
|
|
11
|
-
"
|
|
12
|
-
"element": "k-
|
|
13
|
-
"module": "/inventory/components/
|
|
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
|
|
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:
|
|
41
|
-
"description": "Add or remove
|
|
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
|
|
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:
|
|
66
|
+
"kempo-inventory:quantity:adjust"
|
|
67
67
|
]
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
"name": "kempo-inventory:admin",
|
|
71
|
-
"description": "Can manage items and
|
|
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:
|
|
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
|
|
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
|
+
"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.
|
|
43
|
-
"kempo-ui": ">=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
|
|
11
|
-
const [, movements] = access.
|
|
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 {
|
|
2
|
+
import { adjustQuantity } from '../../../../../server/utils/items.js';
|
|
3
3
|
|
|
4
4
|
export default async (request, response) => {
|
|
5
|
-
const [authError, auth] = await authorize(request, '
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
111
|
+
Toast.error(adjustError.msg || 'Failed to adjust quantity');
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
114
|
$dialog.close();
|
|
115
|
-
Toast.success('
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
35
|
-
|
|
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,
|
package/server/db/schema.js
CHANGED
|
@@ -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),
|
|
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(''),
|
package/server/utils/access.js
CHANGED
|
@@ -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
|
|
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, **
|
|
9
|
-
are only for people who manage
|
|
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,
|
|
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
|
|
29
|
-
return [null, { userId: auth.userId,
|
|
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,
|
|
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
|
|
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.
|
|
40
|
+
if(access.canSeeQuantity) return item;
|
|
41
41
|
const { quantity, ...rest } = item;
|
|
42
42
|
return rest;
|
|
43
43
|
};
|
package/server/utils/events.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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 && !
|
|
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
|
|
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,
|
|
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 }) => {
|
package/server/utils/items.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
345
|
-
export const
|
|
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.
|
|
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,
|
|
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
|
|
367
|
+
if(!result) return [{ code: 409, msg: 'Insufficient quantity' }, null];
|
|
370
368
|
|
|
371
369
|
const item = shape(result.row);
|
|
372
|
-
await notify(EVENTS.
|
|
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
|
|
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
|
|
393
|
-
taking the materials for a whole recipe safe, where one
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
await db.execute(sql`
|
|
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
|
|
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);
|
package/server/utils/holds.js
DELETED
|
@@ -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);
|