kempo-inventory 0.5.3 → 0.5.5

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,8 @@
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
- - Quantity changes go through one audited action, with a reason, a note and a movement history
5
+ - Quantity changes go through one audited action: add or remove, or set the count outright. A reason and a note are optional, and every change is kept in a movement history
6
+ - Units (oz, g, and so on) on items, with a category's unit as the default. Quantities can have decimal places when the `decimal_quantity` setting is on
6
7
  - Custom fields of seven types, managed in **Admin > Inventory > Fields**
7
8
  - 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
9
  - Admin page at **Admin > Inventory**, JSON API under `/inventory/api`
@@ -5,6 +5,7 @@ locked: true
5
5
  <content>
6
6
  <script type="module" src="/kempo-ui/components/Icon.js"></script>
7
7
  <script type="module" src="/kempo-ui/components/Tabs.js"></script>
8
+ <script type="module" src="/inventory/components/ItemQuantity.js"></script>
8
9
 
9
10
  <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
10
11
  <a href="/admin/extension/kempo-inventory/" class="btn" id="backLink"><k-icon name="arrow" direction="left"></k-icon> Inventory</a>
@@ -29,6 +30,11 @@ locked: true
29
30
  <location name="inventory-item-sections" />
30
31
  </k-tab-content>
31
32
 
33
+ <k-tab for="quantity">Quantity</k-tab>
34
+ <k-tab-content name="quantity" class="pt">
35
+ <k-inventory-item-quantity id="itemQuantity"></k-inventory-item-quantity>
36
+ </k-tab-content>
37
+
32
38
  <!--
33
39
  Tabs from any number of extensions (barcodes, say): supply an admin/*.global.html file that adds a k-tab and a
34
40
  k-tab-content for the location inventory-item-tabs. Each tab reads the item from ?id= in the address.
@@ -47,6 +53,7 @@ locked: true
47
53
  const params = new URLSearchParams(location.search);
48
54
  const next = safeNext(params.get('next'), '/admin/extension/kempo-inventory/');
49
55
  document.getElementById('backLink').href = next;
56
+ document.getElementById('itemQuantity').itemId = params.get('id');
50
57
  const $container = document.getElementById('editItem');
51
58
 
52
59
  const [, permData] = await getAllCurrentUserPermissions();
@@ -162,7 +162,7 @@ locked: true
162
162
  user-supplied text itself.
163
163
  */
164
164
  const decorate = (item, ctx) => {
165
- 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)}` }) };
165
+ const record = { ...item, qtyText: [String(item.quantity), item.effectiveUnit].filter(Boolean).join(' '), tagsText: item.tags.join(', '), ownerText: item.owner, ...(can('items:update') && { _url: `/admin/extension/kempo-inventory/edit?id=${encodeURIComponent(item.id)}` }) };
166
166
  /* Only the fields that apply to this item's category; a column another category's items fill stays blank here. */
167
167
  for(const field of applicableFields(fields, item.category).filter(f => f.listed)) record[`f_${field.key}`] = displayValue(field, item.fields[field.key], ctx);
168
168
  return record;
@@ -338,6 +338,12 @@ locked: true
338
338
  <label class="d-b mb-sm" for="cat-description">Description</label>
339
339
  <textarea id="cat-description" class="full" rows="3" maxlength="500" .value=${category?.description ?? ''}></textarea>
340
340
  </div>
341
+ <div class="mb">
342
+ <label class="d-b mb-sm" for="cat-unit">Unit
343
+ <small class="d-b tc-muted">What items in this category count in, such as oz or g. An item with its own unit keeps it.</small>
344
+ </label>
345
+ <input type="text" id="cat-unit" class="full" maxlength="20" .value=${category?.unit ?? ''}>
346
+ </div>
341
347
  <div class="mb">
342
348
  <label class="d-b mb-sm" for="cat-tags">Tags</label>
343
349
  <k-tags id="cat-tags" .value=${tagsValue(category?.tags)} .getSuggestions=${suggestTags} @input=${lowerTagInput} @change=${lowerTagChips}></k-tags>
@@ -362,6 +368,7 @@ locked: true
362
368
  description: $dialog.querySelector('#cat-description').value,
363
369
  tags: readTags($dialog.querySelector('#cat-tags')),
364
370
  image: $dialog.querySelector('#cat-image').value[0] ?? null,
371
+ unit: $dialog.querySelector('#cat-unit').value,
365
372
  };
366
373
 
367
374
  let error, result;
package/kempo-config.json CHANGED
@@ -4,7 +4,10 @@
4
4
  "public-scope": "inventory",
5
5
  "schema": "./server/db/schema.js",
6
6
  "suggestedPeers": [
7
- { "name": "kempo-media", "reason": "Photos and other files on inventory items (brings kempo-files and kempo-thumbs with it)" }
7
+ {
8
+ "name": "kempo-media",
9
+ "reason": "Photos and other files on inventory items (brings kempo-files and kempo-thumbs with it)"
10
+ }
8
11
  ],
9
12
  "widgets": [
10
13
  {
@@ -90,6 +93,12 @@
90
93
  "type": "number",
91
94
  "description": "The largest import file (a .json or .zip made by Export) the admin Import accepts, in MB."
92
95
  },
96
+ {
97
+ "name": "decimal_quantity",
98
+ "value": false,
99
+ "type": "boolean",
100
+ "description": "Allow quantities with decimal places, such as 2.5 oz of paint or 12.75 g of filament, up to four places. Off, quantities are whole numbers. Turning it on does not change quantities already stored."
101
+ },
93
102
  {
94
103
  "name": "public_read",
95
104
  "value": false,
@@ -114,5 +123,8 @@
114
123
  "type": "string",
115
124
  "description": "The aspect ratio item photos are shown at, as width:height (4:3, 1:1, 16:9). Images are cropped to fit, never stretched."
116
125
  }
117
- ]
126
+ ],
127
+ "public-scopes": {
128
+ "items": "public-items"
129
+ }
118
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-inventory",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "A basic inventory management system for kempo cms",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant <dustinpoissant@gmail.com>",
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "admin",
31
31
  "public",
32
+ "public-items",
32
33
  "server",
33
34
  "install.js",
34
35
  "update.js",
@@ -11,7 +11,7 @@ export default async (request, response) => {
11
11
 
12
12
  const body = request.body || {};
13
13
  const changes = {};
14
- for(const property of ['newName', 'image', 'description', 'tags']){
14
+ for(const property of ['newName', 'image', 'description', 'tags', 'unit']){
15
15
  if(property in body) changes[property] = body[property];
16
16
  }
17
17
  const [error, category] = await saveCategory(body.name, changes, { userId: auth.userId });
@@ -5,8 +5,8 @@ export default async (request, response) => {
5
5
  const [authError, auth] = await authorize(request, 'items:create');
6
6
  if(authError) return response.status(authError.code).json({ error: authError.msg });
7
7
 
8
- const { sku, name, description, category, tags, quantity, fields } = request.body || {};
9
- const [error, item] = await createItem({ sku, name, description, category, tags, quantity, fields, userId: auth.userId });
8
+ const { sku, name, description, category, unit, tags, quantity, fields } = request.body || {};
9
+ const [error, item] = await createItem({ sku, name, description, category, unit, tags, quantity, fields, userId: auth.userId });
10
10
  if(error) return response.status(error.code).json({ error: error.msg });
11
11
  response.status(201).json({ item });
12
12
  };
@@ -5,8 +5,8 @@ export default async (request, response) => {
5
5
  const [authError, auth] = await authorize(request, 'items:update');
6
6
  if(authError) return response.status(authError.code).json({ error: authError.msg });
7
7
 
8
- const { sku, name, description, category, tags, fields } = request.body || {};
9
- const [error, item] = await updateItem(request.params.id, { sku, name, description, category, tags, fields, userId: auth.userId });
8
+ const { sku, name, description, category, unit, tags, fields } = request.body || {};
9
+ const [error, item] = await updateItem(request.params.id, { sku, name, description, category, unit, tags, fields, userId: auth.userId });
10
10
  if(error) return response.status(error.code).json({ error: error.msg });
11
11
  response.json({ item });
12
12
  };
@@ -5,8 +5,8 @@ export default async (request, response) => {
5
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
- const { delta, reason, note } = request.body || {};
9
- const [error, item] = await adjustQuantity(request.params.id, { delta, reason, note, userId: auth.userId });
8
+ const { delta, set, reason, note } = request.body || {};
9
+ const [error, item] = await adjustQuantity(request.params.id, { delta, set, 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
  };
@@ -71,12 +71,20 @@ 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 quantity)</label>
75
- <input type="number" id="dlg-delta" class="full" step="1" required>
74
+ <label class="d-b mb-sm" for="dlg-mode">How</label>
75
+ <select id="dlg-mode" class="full" @change=${event => { $dialog.querySelector('#dlg-amount-label').textContent = event.target.value === 'set' ? 'Count it should be now' : 'Change (negative removes quantity)'; }}>
76
+ <option value="change">Add or remove</option>
77
+ <option value="set" selected>Set to</option>
78
+ </select>
79
+ </div>
80
+ <div class="mb">
81
+ <label class="d-b mb-sm" for="dlg-delta" id="dlg-amount-label">Count it should be now</label>
82
+ <input type="number" id="dlg-delta" class="full" step="any" required>
76
83
  </div>
77
84
  <div class="mb">
78
- <label class="d-b mb-sm" for="dlg-reason">Reason</label>
85
+ <label class="d-b mb-sm" for="dlg-reason">Reason (optional)</label>
79
86
  <select id="dlg-reason" class="full">
87
+ <option value="" selected>No reason</option>
80
88
  <option value="received">Received</option>
81
89
  <option value="sold">Sold / used</option>
82
90
  <option value="damaged">Damaged / lost</option>
@@ -97,13 +105,15 @@ export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
97
105
  cancelText: 'Close',
98
106
  confirmAction: async event => {
99
107
  event.keepDialogOpen = true;
100
- const delta = Number($dialog.querySelector('#dlg-delta').value);
101
- if(!Number.isInteger(delta) || delta === 0){
102
- Toast.error('Enter a non-zero whole number');
108
+ const setting = $dialog.querySelector('#dlg-mode').value === 'set';
109
+ const raw = $dialog.querySelector('#dlg-delta').value;
110
+ const amount = Number(raw);
111
+ if(raw.trim() === '' || !Number.isFinite(amount) || (setting ? amount < 0 : amount === 0)){
112
+ Toast.error(setting ? 'Enter the count, zero or more' : 'Enter a number that is not zero');
103
113
  return;
104
114
  }
105
115
  const [adjustError, adjusted] = await adjustQuantity(item.id, {
106
- delta,
116
+ ...(setting ? { set: amount } : { delta: amount }),
107
117
  reason: $dialog.querySelector('#dlg-reason').value,
108
118
  note: $dialog.querySelector('#dlg-note').value,
109
119
  });
@@ -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 quantity, 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 you can edit here, and quantity in the Quantity tab.</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>
@@ -103,7 +103,6 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
103
103
  <button type="button" class="btn" id="dlg-scan" ?disabled=${locked} @click=${scan}>Scan</button>
104
104
  </div>
105
105
  </div>
106
- ${editing || !askQuantity ? '' : html`<div style="width: 9rem;"><label class="d-b mb-sm" for="dlg-qty">Starting quantity</label><input type="number" id="dlg-qty" class="full" min="0" step="1" .value=${String(defaults.quantity ?? 0)}></div>`}
107
106
  </div>
108
107
  <div class="mb"><label class="d-b mb-sm" for="dlg-name">Name *</label><input type="text" id="dlg-name" class="full" ?disabled=${locked} .value=${item?.name ?? defaults.name ?? ''}></div>
109
108
  <div class="mb">
@@ -113,6 +112,15 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
113
112
  @focusin=${e => offerCategories(e.currentTarget)}
114
113
  @search=${showFields} @select=${() => setTimeout(showFields)} @change=${() => setTimeout(showFields)} @focusout=${() => setTimeout(showFields, 150)}></k-combobox>
115
114
  </div>
115
+ <div class="d-f mb" style="align-items: flex-end; gap: var(--spacer);">
116
+ <div class="flex">
117
+ <label class="d-b mb-sm" for="dlg-unit">Unit
118
+ <small class="d-b tc-muted">What the quantity counts, such as oz or g. Leave empty to use the category's unit.</small>
119
+ </label>
120
+ <input type="text" id="dlg-unit" class="full" maxlength="20" .value=${item?.unit ?? defaults.unit ?? ''}>
121
+ </div>
122
+ ${editing || !askQuantity ? '' : html`<div style="width: 9rem;"><label class="d-b mb-sm" for="dlg-qty">Starting quantity</label><input type="number" id="dlg-qty" class="full" min="0" step="1" .value=${String(defaults.quantity ?? 0)}></div>`}
123
+ </div>
116
124
  <div class="mb"><label class="d-b mb-sm" for="dlg-tags">Tags</label><k-tags id="dlg-tags" .value=${tagsValue(item?.tags ?? defaults.tags)} .getSuggestions=${suggestTags} @input=${lowerTagInput} @change=${lowerTagChips}></k-tags></div>
117
125
  <div class="mb"><label class="d-b mb-sm" for="dlg-description">Description</label><textarea id="dlg-description" class="full" rows="2" .value=${item?.description ?? ''}></textarea></div>
118
126
  <div id="dlg-fields"></div>
@@ -131,6 +139,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
131
139
  description: $root.querySelector('#dlg-description')?.value ?? '',
132
140
  category: $root.querySelector('#dlg-category')?.value ?? '',
133
141
  quantity: $root.querySelector('#dlg-qty')?.value ?? '',
142
+ unit: $root.querySelector('#dlg-unit')?.value ?? '',
134
143
  tags: readTags($root.querySelector('#dlg-tags')),
135
144
  values,
136
145
  });
@@ -154,6 +163,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
154
163
  name: $root.querySelector('#dlg-name').value.trim(),
155
164
  description: $root.querySelector('#dlg-description').value,
156
165
  category: ($root.querySelector('#dlg-category').value || '').trim(),
166
+ unit: $root.querySelector('#dlg-unit').value.trim(),
157
167
  tags: readTags($root.querySelector('#dlg-tags')),
158
168
  fields: values,
159
169
  };
@@ -170,6 +180,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
170
180
  /* Blank fields are left out on create so hooks can tell "not provided" from a value. */
171
181
  data.fields = Object.fromEntries(Object.entries(values).filter(([, v]) => v !== '' && !(Array.isArray(v) && !v.length)));
172
182
  if(!data.category) delete data.category;
183
+ if(!data.unit) delete data.unit;
173
184
  if(!data.tags.length) delete data.tags;
174
185
  data.quantity = askQuantity ? Number($root.querySelector('#dlg-qty').value || 0) : Number(defaults.quantity ?? 0);
175
186
  [error, saved] = await createItem(data);
@@ -0,0 +1,180 @@
1
+ import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
+ import { html } from '/kempo-ui/lit-all.min.js';
3
+ import Toast from '/kempo-ui/components/Toast.js';
4
+ import '/kempo-ui/components/Spinner.js';
5
+ import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
6
+ import { getItem, adjustQuantity } from '/inventory/sdk.js';
7
+
8
+ const REASONS = { received: 'Received', sold: 'Sold / used', damaged: 'Damaged / lost', correction: 'Count correction', adjustment: 'Other' };
9
+
10
+ /*
11
+ An item's quantity on its edit page: the count on hand, a form to add or remove stock or to set the count
12
+ outright, and the history. The server sends the count and history only to people who manage quantity, and
13
+ the form only to those who may adjust it. The page sets the `item-id` attribute, and the component loads the
14
+ item when it knows it. A reason is optional.
15
+ */
16
+ export default class ItemQuantity extends ShadowComponent {
17
+ static properties = {
18
+ itemId: { type: String, attribute: 'item-id' },
19
+ item: { state: true },
20
+ movements: { state: true },
21
+ permissions: { state: true },
22
+ error: { state: true },
23
+ mode: { state: true },
24
+ amount: { state: true },
25
+ reason: { state: true },
26
+ note: { state: true },
27
+ busy: { state: true },
28
+ };
29
+
30
+ constructor(){
31
+ super();
32
+ this.itemId = '';
33
+ this.item = null;
34
+ this.movements = [];
35
+ this.permissions = new Set();
36
+ this.error = '';
37
+ this.mode = 'set';
38
+ this.amount = '';
39
+ this.reason = '';
40
+ this.note = '';
41
+ this.busy = false;
42
+ }
43
+
44
+ /*
45
+ Lifecycle callbacks
46
+ */
47
+ updated(changed){
48
+ super.updated(changed);
49
+ if(changed.has('itemId') && this.itemId) this.load();
50
+ }
51
+
52
+ /*
53
+ Loading and changing
54
+ */
55
+ get canAdjust(){
56
+ return this.permissions.has('kempo-inventory:quantity:adjust');
57
+ }
58
+
59
+ get unit(){
60
+ return this.item?.effectiveUnit ?? '';
61
+ }
62
+
63
+ get validAmount(){
64
+ if(this.amount.trim() === '') return false;
65
+ const number = Number(this.amount);
66
+ if(!Number.isFinite(number)) return false;
67
+ return this.mode === 'set' ? number >= 0 : number !== 0;
68
+ }
69
+
70
+ load = async () => {
71
+ const [[error, data], [, permissionData]] = await Promise.all([getItem(this.itemId), getAllCurrentUserPermissions()]);
72
+ if(error){
73
+ this.error = error.code === 404 ? 'Item not found.' : error.msg;
74
+ return;
75
+ }
76
+ this.item = data.item;
77
+ this.movements = data.movements;
78
+ this.permissions = new Set(permissionData?.permissions ?? []);
79
+ };
80
+
81
+ apply = async () => {
82
+ if(!this.validAmount){
83
+ Toast.error(this.mode === 'set' ? 'Enter the count, zero or more' : 'Enter a number that is not zero');
84
+ return;
85
+ }
86
+ const amount = Number(this.amount);
87
+ this.busy = true;
88
+ const [error, data] = await adjustQuantity(this.item.id, {
89
+ ...(this.mode === 'set' ? { set: amount } : { delta: amount }),
90
+ reason: this.reason,
91
+ note: this.note.trim(),
92
+ });
93
+ this.busy = false;
94
+ if(error){
95
+ Toast.error(error.msg);
96
+ return;
97
+ }
98
+ this.amount = '';
99
+ this.note = '';
100
+ Toast.success(`Quantity is now ${data.item.quantity}${this.unit ? ` ${this.unit}` : ''}`);
101
+ await this.load();
102
+ };
103
+
104
+ /*
105
+ View
106
+ */
107
+ renderForm(){
108
+ const setting = this.mode === 'set';
109
+ return html`
110
+ <h4>${setting ? 'Set the count' : 'Add or remove'}</h4>
111
+ <div class="d-f" style="align-items: flex-end;">
112
+ <div class="mb mrq">
113
+ <label class="d-b mbq" for="qtyMode">How
114
+ <small class="d-b tc-muted">Set the count, or add and remove.</small>
115
+ </label>
116
+ <select id="qtyMode" class="m0" @change=${event => { this.mode = event.target.value; this.amount = ''; }}>
117
+ <option value="change" ?selected=${!setting}>Add or remove</option>
118
+ <option value="set" ?selected=${setting}>Set to</option>
119
+ </select>
120
+ </div>
121
+ <div class="mb mrq" style="width: 16rem;">
122
+ <label class="d-b mbq" for="qtyAmount">${setting ? 'Count' : 'Change'}${this.unit ? ` (${this.unit})` : ''}
123
+ <small class="d-b tc-muted">${setting
124
+ ? 'The count it should be now. The change is worked out for you.'
125
+ : 'Add a number to receive stock, or use a minus sign to remove it.'}</small>
126
+ </label>
127
+ <input id="qtyAmount" type="number" step="any" class="full m0" .value=${this.amount}
128
+ @input=${event => { this.amount = event.target.value; }}>
129
+ </div>
130
+ <div class="mb mrq">
131
+ <label class="d-b mbq" for="qtyReason">Reason
132
+ <small class="d-b tc-muted">Optional. Why it changed, if you want it recorded.</small>
133
+ </label>
134
+ <select id="qtyReason" class="m0" @change=${event => { this.reason = event.target.value; }}>
135
+ <option value="" ?selected=${this.reason === ''}>No reason</option>
136
+ ${Object.entries(REASONS).map(([value, label]) => html`<option value=${value} ?selected=${value === this.reason}>${label}</option>`)}
137
+ </select>
138
+ </div>
139
+ <div class="flex mb mrq" style="min-width: 12rem;">
140
+ <label class="d-b mbq" for="qtyNote">Note
141
+ <small class="d-b tc-muted">Optional. Shown in the history.</small>
142
+ </label>
143
+ <input id="qtyNote" type="text" class="full m0" maxlength="200" .value=${this.note}
144
+ @input=${event => { this.note = event.target.value; }}>
145
+ </div>
146
+ </div>
147
+ <button class="primary mb" ?disabled=${this.busy || !this.validAmount} @click=${this.apply}>${setting ? 'Set quantity' : 'Apply change'}</button>`;
148
+ }
149
+
150
+ renderHistory(){
151
+ if(!this.movements.length) return html`<p class="tc-muted">No quantity changes yet.</p>`;
152
+ return html`<table>
153
+ <thead><tr><th>When</th><th>Change</th><th>Reason</th><th>Balance</th></tr></thead>
154
+ <tbody>
155
+ ${this.movements.map(movement => html`<tr>
156
+ <td>${new Date(movement.created).toLocaleString()}</td>
157
+ <td>${movement.delta > 0 ? '+' : ''}${movement.delta}</td>
158
+ <td>${REASONS[movement.reason] ?? movement.reason}${movement.note ? ` - ${movement.note}` : ''}</td>
159
+ <td>${movement.quantityAfter}</td>
160
+ </tr>`)}
161
+ </tbody>
162
+ </table>`;
163
+ }
164
+
165
+ render(){
166
+ if(!this.itemId) return '';
167
+ if(this.error) return html`<p class="tc-danger">${this.error}</p>`;
168
+ if(!this.item) return html`<k-spinner></k-spinner>`;
169
+ if(this.item.quantity === undefined) return html`<p class="tc-muted">Quantity is only shown to people who manage stock.</p>`;
170
+ return html`
171
+ <h2 class="mb0">${this.item.quantity}${this.unit ? ` ${this.unit}` : ''}</h2>
172
+ <p class="tc-muted mb">On hand</p>
173
+ ${this.canAdjust ? this.renderForm() : ''}
174
+ <h4>History</h4>
175
+ ${this.renderHistory()}
176
+ `;
177
+ }
178
+ }
179
+
180
+ customElements.define('k-inventory-item-quantity', ItemQuantity);
@@ -0,0 +1,123 @@
1
+ <!--
2
+ title: Item
3
+ description: A single inventory item
4
+ -->
5
+ <page template="default" title="Item">
6
+ <content>
7
+ <div id="app" class="container py" hidden>
8
+ <k-tabs active="details" hash-routing>
9
+ <k-tab for="details">Details</k-tab>
10
+ <k-tab-content name="details" class="pt">
11
+ <div id="detail"></div>
12
+ </k-tab-content>
13
+ <location name="inventory-item-tabs" />
14
+ </k-tabs>
15
+ </div>
16
+ </content>
17
+ <content location="scripts">
18
+ <script type="module" src="/kempo-ui/components/Tabs.js"></script> <script type="module">
19
+ import { html, render } from '/kempo-ui/lit-all.min.js';
20
+ import Dialog from '/kempo-ui/components/Dialog.js';
21
+ import Toast from '/kempo-ui/components/Toast.js';
22
+ import PhotoViewer from '/kempo-ui/components/PhotoViewer.js';
23
+ import '/kempo-ui/components/Rating.js';
24
+ import { getSession, getAllCurrentUserPermissions } from '/kempo/sdk.js';
25
+ import { getItem, deleteItem, getFields } from '/inventory/sdk.js';
26
+ import { displayValue, applicableFields } from '/inventory/components/ItemFields.js';
27
+
28
+ const id = location.pathname.split('/').filter(Boolean).pop();
29
+ const $app = document.getElementById('app');
30
+ const $detail = document.getElementById('detail');
31
+
32
+ const [, session] = await getSession();
33
+ const signedIn = Boolean(session?.user);
34
+ const [, permissionData] = signedIn ? await getAllCurrentUserPermissions() : [null, null];
35
+ const permissions = new Set(permissionData?.permissions ?? []);
36
+ const can = name => permissions.has(`kempo-inventory:${name}`);
37
+ const [fieldsError, fieldData] = await getFields();
38
+ if(fieldsError){
39
+ $app.innerHTML = '<p class="tc-danger">You need to sign in to view this item.</p>';
40
+ $app.hidden = false;
41
+ Toast.error(fieldsError.msg || 'Failed to load the inventory fields');
42
+ } else {
43
+ $app.hidden = false;
44
+ await draw(fieldData);
45
+ }
46
+
47
+ /* The category is a built-in property of every item; '' means uncategorised. */
48
+ function categoryOf(item){ return (item.category ?? '').trim(); }
49
+ function categoryHref(name){ return name ? `/category?name=${encodeURIComponent(name)}` : '/category?uncategorised=1'; }
50
+ function tagChips(tags){ return tags?.length ? html`<div class="tag-list">${tags.map(tag => html`<a class="tag-chip" href="/search?tag=${encodeURIComponent(tag)}">${tag}</a>`)}</div>` : ''; }
51
+
52
+ async function draw(fieldData){
53
+ const fields = fieldData.fields;
54
+ const [error, data] = await getItem(id);
55
+ if(error){
56
+ render(html`<a href="/" class="btn mb"><k-icon name="arrow" direction="left"></k-icon> Home</a>
57
+ <h1>${error.code === 404 ? 'Item not found' : 'Something went wrong'}</h1><p>${error.msg}</p>`, $detail);
58
+ return;
59
+ }
60
+ const { item, linked, media } = data;
61
+ document.title = `${item.name} · Inventory`;
62
+ const category = categoryOf(item);
63
+ const backHref = category ? categoryHref(category) : '/';
64
+ const context = { linked, media };
65
+
66
+ /* Clicking a photo opens Kempo UI's photo viewer over the page (arrows and keys move between the item's photos). */
67
+ const openPhoto = photo => {
68
+ const images = photos.filter(p => p.kind === 'image');
69
+ PhotoViewer.open(images.map(p => ({ src: p.path, caption: p.alt || p.name })), images.indexOf(photo));
70
+ };
71
+ const shown = applicableFields(fields, item.category);
72
+ const photos = shown.filter(f => f.type === 'media').flatMap(f => (item.fields[f.key] ?? []).map(mediaId => media[mediaId]).filter(Boolean));
73
+ const rows = shown.filter(f => f.type !== 'media' && item.fields[f.key] !== undefined && item.fields[f.key] !== null);
74
+
75
+ const remove = () => Dialog.confirm(`Delete "${item.name}" and its stock history? This action cannot be undone.`, async confirmed => {
76
+ if(!confirmed) return;
77
+ const [deleteError] = await deleteItem(item.id);
78
+ if(deleteError){
79
+ Toast.error(deleteError.msg || 'Failed to delete item');
80
+ return;
81
+ }
82
+ location.href = backHref;
83
+ });
84
+
85
+ render(html`
86
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer); flex-wrap: wrap;">
87
+ <a href=${backHref} class="btn" id="backBtn"><k-icon name="arrow" direction="left"></k-icon> ${category || 'Home'}</a>
88
+ <div class="flex"></div>
89
+ ${can('items:update') ? html`<a class="btn" id="editBtn" href=${`/items/${item.id}/edit?next=${encodeURIComponent(`/items/${item.id}`)}`}><k-icon name="edit"></k-icon> Edit</a>` : ''}
90
+ ${can('items:delete') ? html`<button class="btn danger" id="deleteBtn" @click=${remove}><k-icon name="delete"></k-icon> Delete</button>` : ''}
91
+ </div>
92
+
93
+ <h1 class="mb0" id="itemName">${item.name}</h1>
94
+ <p class="tc-muted mb-sm">${item.sku}</p>
95
+ ${tagChips(item.tags)}
96
+
97
+ ${photos.length ? html`
98
+ <div class="gallery mb-lg" id="gallery">
99
+ ${photos.map(p => p.kind === 'image'
100
+ ? html`<a href=${p.path} @click=${e => { e.preventDefault(); openPhoto(p); }}><img src=${p.thumbnail ?? p.path} alt=${p.alt || p.name}></a>`
101
+ : html`<a href=${p.path} target="_blank" rel="noopener" class="btn">${p.name}</a>`)}
102
+ </div>` : ''}
103
+
104
+ ${item.description ? html`<p style="white-space: pre-wrap;">${item.description}</p>` : ''}
105
+
106
+ ${rows.length ? html`
107
+ <dl id="fields" style="display: grid; grid-template-columns: max-content 1fr; gap: var(--spacer_q) var(--spacer);">
108
+ ${rows.map(f => html`<dt class="tc-muted">${f.label}</dt>
109
+ <dd class="m0">${f.type === 'rating'
110
+ ? html`<k-rating disabled .value=${item.fields[f.key]} aria-label=${`${item.fields[f.key]} out of 5`}></k-rating>`
111
+ : f.type === 'color'
112
+ ? html`<span style="display: inline-block; width: 4rem; height: 4rem; vertical-align: middle; margin-right: var(--spacer_h); border: 1px solid var(--c_border); border-radius: var(--radius); background: ${item.fields[f.key]};"></span>${item.fields[f.key]}`
113
+ : f.type === 'item' && linked[item.fields[f.key]]
114
+ ? html`<a href="/items/${item.fields[f.key]}">${displayValue(f, item.fields[f.key], context)}</a>`
115
+ : f.type === 'longtext'
116
+ ? html`<span style="white-space: pre-wrap;">${item.fields[f.key]}</span>`
117
+ : displayValue(f, item.fields[f.key], context)}</dd>`)}
118
+ </dl>` : ''}
119
+ `, $detail);
120
+ }
121
+ </script>
122
+ </content>
123
+ </page>
@@ -1,4 +1,9 @@
1
- import { pgTable, text, timestamp, integer, boolean, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';
1
+ import { pgTable, text, timestamp, integer, numeric, boolean, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';
2
+
3
+ /*
4
+ A quantity has four decimal places at most. Whole numbers unless the decimal_quantity setting allows fractions.
5
+ */
6
+ const quantityColumn = name => numeric(name, { precision: 14, scale: 4, mode: 'number' });
2
7
 
3
8
  /*
4
9
  An item has only the core columns. Everything else lives in `data`, keyed by the `key` of a
@@ -10,7 +15,8 @@ export const kempoInventoryItem = pgTable('kempoInventoryItem', {
10
15
  name: text('name').notNull(),
11
16
  description: text('description').notNull().default(''),
12
17
  category: text('category').notNull().default(''), // built in and optional; '' means uncategorised
13
- quantity: integer('quantity').notNull().default(0),
18
+ quantity: quantityColumn('quantity').notNull().default(0),
19
+ unit: text('unit').notNull().default(''), // built in and optional: what the quantity counts (oz, g); '' uses the category's unit
14
20
  tags: jsonb('tags').notNull().default([]), // built in: short lower-case labels search finds
15
21
  data: jsonb('data').notNull().default({}),
16
22
  owner: text('owner').notNull().default(''), // '' for items people manage, or the extension that created it; see ownership.js
@@ -47,8 +53,8 @@ export const kempoInventoryField = pgTable('kempoInventoryField', {
47
53
  export const kempoInventoryMovement = pgTable('kempoInventoryMovement', {
48
54
  id: text('id').primaryKey(),
49
55
  itemId: text('itemId').notNull(),
50
- delta: integer('delta').notNull(),
51
- quantityAfter: integer('quantityAfter').notNull(),
56
+ delta: quantityColumn('delta').notNull(),
57
+ quantityAfter: quantityColumn('quantityAfter').notNull(),
52
58
  reason: text('reason').notNull().default('adjustment'),
53
59
  note: text('note').notNull().default(''),
54
60
  userId: text('userId').notNull().default(''),
@@ -66,6 +72,7 @@ export const kempoInventoryCategory = pgTable('kempoInventoryCategory', {
66
72
  key: text('key').notNull(),
67
73
  name: text('name').notNull(),
68
74
  image: text('image'), // a stored media id, as held by a media field
75
+ unit: text('unit').notNull().default(''), // what items in the category count in, unless an item has its own
69
76
  description: text('description').notNull().default(''),
70
77
  tags: jsonb('tags').notNull().default([]),
71
78
  owner: text('owner').notNull().default(''), // '' for categories people manage, or the extension that registered it
@@ -3,6 +3,7 @@ import { sql, eq } from 'drizzle-orm';
3
3
  import crypto from 'crypto';
4
4
  import { kempoInventoryItem, kempoInventoryCategory, kempoInventoryField } from '../db/schema.js';
5
5
  import { normalizeTags, tidyTag } from './tags.js';
6
+ import { checkUnit } from './quantity.js';
6
7
  import { categoryKey, groupCategories, tidyCategory, MAX_CATEGORY, MAX_DESCRIPTION } from './categoryLogic.js';
7
8
  import { coerceValue } from './fieldTypes.js';
8
9
  import { getAssets, checkAssets } from './media.js';
@@ -75,12 +76,17 @@ export const getCategories = async ({ includeEmpty = false, q = '', tag = '', ow
75
76
  by that owner; anyone can still change its image, description and tags. `owner` here is the
76
77
  extension making the call ('' for a person), and a category created by this call belongs to it.
77
78
  */
78
- export const saveCategory = async (name, { newName, image, description, tags } = {}, { userId = '', owner = '' } = {}) => {
79
+ export const saveCategory = async (name, { newName, image, description, tags, unit } = {}, { userId = '', owner = '' } = {}) => {
79
80
  const displayName = tidyCategory(name);
80
81
  const key = categoryKey(displayName);
81
82
  if(!key) return [{ code: 400, msg: 'A category name is required' }, null];
82
83
 
83
84
  const changes = {};
85
+ if(unit !== undefined){
86
+ const [unitError, cleanUnit] = checkUnit(unit);
87
+ if(unitError) return [unitError, null];
88
+ changes.unit = cleanUnit;
89
+ }
84
90
  if(description !== undefined){
85
91
  const text = String(description ?? '').trim();
86
92
  if(text.length > MAX_DESCRIPTION) return [{ code: 400, msg: `A description must be ${MAX_DESCRIPTION} characters or fewer` }, null];
@@ -162,6 +168,7 @@ export const saveCategory = async (name, { newName, image, description, tags } =
162
168
  /* What the category ends up holding: this call's changes, then the existing record, then the one merged away. */
163
169
  const merged = {
164
170
  image: changes.image !== undefined ? changes.image : (target?.image ?? source?.image ?? null),
171
+ unit: changes.unit !== undefined ? changes.unit : (target?.unit || source?.unit || ''),
165
172
  description: changes.description !== undefined ? changes.description : (target?.description || source?.description || ''),
166
173
  /* Merging two categories keeps the tags of both. */
167
174
  tags: changes.tags !== undefined ? changes.tags : [...new Set([...(target?.tags ?? []), ...(source && source !== target ? source.tags ?? [] : [])])],
@@ -182,7 +189,7 @@ export const saveCategory = async (name, { newName, image, description, tags } =
182
189
  if(outcome.refused) return [outcome.refused, null];
183
190
 
184
191
  await notify(EVENTS.categoryUpdated, { category: outcome.row, previous: outcome.previous, moved: outcome.moved, userId, actor: owner });
185
- return [null, { name: outcome.row.name, key: outcome.row.key, image: outcome.row.image, description: outcome.row.description, tags: outcome.row.tags ?? [], owner: outcome.row.owner, moved: outcome.moved }];
192
+ return [null, { name: outcome.row.name, key: outcome.row.key, image: outcome.row.image, unit: outcome.row.unit, description: outcome.row.description, tags: outcome.row.tags ?? [], owner: outcome.row.owner, moved: outcome.moved }];
186
193
  } catch {
187
194
  return [{ code: 500, msg: 'Failed to save the category' }, null];
188
195
  }
@@ -43,11 +43,11 @@ export const groupCategories = (rows, records = [], { includeEmpty = false } = {
43
43
  const record = recordByKey.get(key);
44
44
  const spelling = [...entry.spellings.entries()]
45
45
  .sort((a, b) => b[1] - a[1] || Number(startsUpper(b[0])) - Number(startsUpper(a[0])) || a[0].localeCompare(b[0]))[0][0];
46
- return { key, name: record?.name ?? spelling, count: entry.count, image: record?.image ?? null, description: record?.description ?? '', tags: record?.tags ?? [], owner: record?.owner ?? '' };
46
+ return { key, name: record?.name ?? spelling, count: entry.count, image: record?.image ?? null, unit: record?.unit ?? '', description: record?.description ?? '', tags: record?.tags ?? [], owner: record?.owner ?? '' };
47
47
  });
48
48
 
49
49
  const empty = includeEmpty
50
- ? records.filter(record => !byKey.has(record.key)).map(record => ({ key: record.key, name: record.name, count: 0, image: record.image ?? null, description: record.description ?? '', tags: record.tags ?? [], owner: record.owner ?? '' }))
50
+ ? records.filter(record => !byKey.has(record.key)).map(record => ({ key: record.key, name: record.name, count: 0, image: record.image ?? null, unit: record.unit ?? '', description: record.description ?? '', tags: record.tags ?? [], owner: record.owner ?? '' }))
51
51
  : [];
52
52
 
53
53
  return [...used, ...empty].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
@@ -2,10 +2,11 @@ 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, adjustQuantity } from './items.js';
5
+ import { getItemBySku, createItem, updateItem, adjustQuantity, decimalsAllowed } 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';
9
+ import { toQuantity } from './quantity.js';
9
10
  import { tidyCategory, categoryKey, MAX_CATEGORY } from './categoryLogic.js';
10
11
  import { readMedia } from './media.js';
11
12
 
@@ -81,12 +82,13 @@ export const buildExport = async ({ withMedia = false } = {}) => {
81
82
  values[field.key] = value;
82
83
  }
83
84
  }
84
- return { sku: row.sku, name: row.name, category: row.category, quantity: row.quantity, tags: row.tags ?? [], description: row.description, fields: values };
85
+ return { sku: row.sku, name: row.name, category: row.category, quantity: row.quantity, unit: row.unit ?? '', tags: row.tags ?? [], description: row.description, fields: values };
85
86
  });
86
87
 
87
88
  const categories = categoryRows.map(row => ({
88
89
  name: row.name,
89
90
  description: row.description,
91
+ unit: row.unit ?? '',
90
92
  tags: row.tags ?? [],
91
93
  ...(row.image && pathOf.has(row.image) ? { image: pathOf.get(row.image) } : {}),
92
94
  }));
@@ -149,12 +151,14 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
149
151
  if(given(row.description)) changes.description = String(row.description);
150
152
  if(given(row.tags)) changes.tags = row.tags;
151
153
  if(given(row.image)) changes.image = row.image;
154
+ if(given(row.unit)) changes.unit = String(row.unit);
152
155
  const [error] = await saveCategory(name, changes, { userId });
153
156
  if(error){ result.action = 'error'; result.message = error.msg; summary.errors++; continue; }
154
157
  known.add(categoryKey(name));
155
158
  exists ? summary.categoriesUpdated++ : summary.categoriesCreated++;
156
159
  }
157
160
 
161
+ const decimals = await decimalsAllowed();
158
162
  for(const [index, row] of items.entries()){
159
163
  const result = { row: index + 1, sku: String(row?.sku ?? '').trim(), name: String(row?.name ?? '').trim(), action: '', message: '' };
160
164
  results.push(result);
@@ -174,8 +178,8 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
174
178
  }
175
179
  let quantity;
176
180
  if(given(row.quantity)){
177
- quantity = Number(row.quantity);
178
- if(!Number.isInteger(quantity) || quantity < 0){ fail(result, 'Quantity must be a non-negative whole number'); continue; }
181
+ quantity = toQuantity(row.quantity, decimals);
182
+ if(Number.isNaN(quantity) || quantity < 0){ fail(result, decimals ? 'Quantity must be a non-negative number' : 'Quantity must be a non-negative whole number'); continue; }
179
183
  }
180
184
  if(!existing && !result.name){ fail(result, 'Missing name'); continue; }
181
185
 
@@ -206,6 +210,7 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
206
210
  if(given(row.name)) changes.name = result.name;
207
211
  if(given(row.description)) changes.description = String(row.description);
208
212
  if(given(row.category)) changes.category = category;
213
+ if(given(row.unit)) changes.unit = String(row.unit);
209
214
  if(tags !== undefined) changes.tags = tags;
210
215
  const [error] = await updateItem(existing.id, { ...changes, userId });
211
216
  if(error && !(error.code === 400 && error.msg === 'No changes provided')){ fail(result, error.msg); continue; }
@@ -220,7 +225,7 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
220
225
  if(dryRun){ summary.created++; continue; }
221
226
  const [error, created] = await createItem({
222
227
  sku: result.sku, name: result.name, description: given(row.description) ? String(row.description) : '',
223
- category, tags: tags ?? [], quantity: quantity ?? 0, fields: coerced, userId,
228
+ category, unit: given(row.unit) ? String(row.unit) : '', tags: tags ?? [], quantity: quantity ?? 0, fields: coerced, userId,
224
229
  });
225
230
  if(error){ fail(result, error.msg); continue; }
226
231
  summary.created++;
@@ -1,21 +1,34 @@
1
1
  import db from 'kempo/server/db/index.js';
2
2
  import { eq, and, or, ilike, sql, desc, asc, inArray } from 'drizzle-orm';
3
3
  import crypto from 'crypto';
4
- import { kempoInventoryItem, kempoInventoryMovement } from '../db/schema.js';
4
+ import { getSetting } from 'kempo/server/sdk.js';
5
+ import { kempoInventoryItem, kempoInventoryMovement, kempoInventoryCategory } from '../db/schema.js';
5
6
  import { getFields } from './fields.js';
6
7
  import { coerceValues, applicableFields, KEY_PATTERN, isEmpty } from './fieldTypes.js';
7
8
  import { EVENTS, guard, notify } from './hooks.js';
8
9
  import { tidyCategory, categoryKey, MAX_CATEGORY } from './categoryLogic.js';
9
10
  import { normalizeTags, tidyTag } from './tags.js';
11
+ import { toQuantity, checkUnit } from './quantity.js';
10
12
  import { getAssets, checkAssets } from './media.js';
11
13
  import { lockedItemChanges, notYours, ownerLabel } from './ownership.js';
12
14
 
13
15
  const newId = () => crypto.randomBytes(8).toString('hex');
14
16
 
15
- const toInt = (value, fallback) => {
16
- if(isEmpty(value)) return fallback;
17
- const n = Number(value);
18
- return Number.isInteger(n) ? n : NaN;
17
+ /* Whether quantities may have decimal places (the decimal_quantity setting). */
18
+ export const decimalsAllowed = async () => {
19
+ const [, value] = await getSetting('kempo-inventory', 'decimal_quantity', false);
20
+ return value === true || value === 'true';
21
+ };
22
+
23
+ /*
24
+ Each item's unit: its own if it has one, otherwise its category's. Read items are given `unit`
25
+ (its own) and `effectiveUnit` (the one to show).
26
+ */
27
+ const withEffectiveUnits = async items => {
28
+ const categoryUnits = new Map();
29
+ const categories = await db.select({ key: kempoInventoryCategory.key, unit: kempoInventoryCategory.unit }).from(kempoInventoryCategory);
30
+ for(const category of categories) if(category.unit) categoryUnits.set(category.key, category.unit);
31
+ return items.map(item => ({ ...item, effectiveUnit: item.unit || categoryUnits.get(categoryKey(item.category)) || '' }));
19
32
  };
20
33
 
21
34
  const isUniqueViolation = error => (error?.code || error?.cause?.code) === '23505';
@@ -30,6 +43,7 @@ const shape = row => row && ({
30
43
  description: row.description,
31
44
  category: row.category,
32
45
  quantity: row.quantity,
46
+ unit: row.unit ?? '',
33
47
  tags: row.tags ?? [],
34
48
  fields: row.data ?? {},
35
49
  owner: row.owner,
@@ -132,7 +146,7 @@ export const getItems = async ({ q, category, tag, uncategorised = false, filter
132
146
  const rows = await db.select().from(kempoInventoryItem).where(where)
133
147
  .orderBy(asc(kempoInventoryItem.name), asc(kempoInventoryItem.id)).limit(limit).offset(offset);
134
148
  const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(kempoInventoryItem).where(where);
135
- const items = rows.map(shape);
149
+ const items = await withEffectiveUnits(rows.map(shape));
136
150
  return [null, { items, total, linked: await resolveLinks(items, fields), media: await resolveMedia(items, fields) }];
137
151
  } catch {
138
152
  return [{ code: 500, msg: 'Failed to retrieve items' }, null];
@@ -144,7 +158,8 @@ export const getItem = async id => {
144
158
  try {
145
159
  const [row] = await db.select().from(kempoInventoryItem).where(eq(kempoInventoryItem.id, id));
146
160
  if(!row) return [{ code: 404, msg: 'Item not found' }, null];
147
- return [null, shape(row)];
161
+ const [item] = await withEffectiveUnits([shape(row)]);
162
+ return [null, item];
148
163
  } catch {
149
164
  return [{ code: 500, msg: 'Failed to retrieve item' }, null];
150
165
  }
@@ -155,7 +170,8 @@ export const getItemBySku = async sku => {
155
170
  try {
156
171
  const [row] = await db.select().from(kempoInventoryItem).where(eq(kempoInventoryItem.sku, sku));
157
172
  if(!row) return [{ code: 404, msg: 'Item not found' }, null];
158
- return [null, shape(row)];
173
+ const [item] = await withEffectiveUnits([shape(row)]);
174
+ return [null, item];
159
175
  } catch {
160
176
  return [{ code: 500, msg: 'Failed to retrieve item' }, null];
161
177
  }
@@ -191,13 +207,13 @@ export const getMedia = async items => {
191
207
  `owner` (an extension name, from the server SDK only) marks the item as that extension's: see
192
208
  ownership.js for what that protects. It cannot be changed afterwards and is not part of the draft.
193
209
  */
194
- export const createItem = async ({ sku, name, description = '', category = '', tags = [], quantity, fields: values = {}, userId = '' } = {}, { owner = '' } = {}) => {
210
+ export const createItem = async ({ sku, name, description = '', category = '', unit = '', tags = [], quantity, fields: values = {}, userId = '' } = {}, { owner = '' } = {}) => {
195
211
  /*
196
212
  The draft is what a before_create hook sees and may edit: it can fill in or change any
197
213
  property, or throw { code, msg } to refuse. Everything is validated after the hooks have run,
198
214
  so a hook cannot smuggle in a value that would not otherwise be accepted.
199
215
  */
200
- const draft = { sku, name, description, category, tags, quantity, fields: { ...values } };
216
+ const draft = { sku, name, description, category, unit, tags, quantity, fields: { ...values } };
201
217
  const refused = await guard(EVENTS.itemBeforeCreate, { draft, userId, actor: owner });
202
218
  if(refused) return [refused, null];
203
219
 
@@ -209,8 +225,11 @@ export const createItem = async ({ sku, name, description = '', category = '', t
209
225
  if(cleanCategory.length > MAX_CATEGORY) return [{ code: 400, msg: `Category must be ${MAX_CATEGORY} characters or fewer` }, null];
210
226
  const [tagsError, cleanTags] = normalizeTags(draft.tags);
211
227
  if(tagsError) return [tagsError, null];
212
- const qty = toInt(draft.quantity, 0);
213
- if(Number.isNaN(qty) || qty < 0) return [{ code: 400, msg: 'Quantity must be a non-negative whole number' }, null];
228
+ const [unitError, cleanUnit] = checkUnit(draft.unit);
229
+ if(unitError) return [unitError, null];
230
+ const decimals = await decimalsAllowed();
231
+ const qty = isEmpty(draft.quantity) ? 0 : toQuantity(draft.quantity, decimals);
232
+ if(Number.isNaN(qty) || qty < 0) return [{ code: 400, msg: decimals ? 'Quantity must be a non-negative number' : 'Quantity must be a non-negative whole number' }, null];
214
233
 
215
234
  const [fieldsError, fields] = await getFields();
216
235
  if(fieldsError) return [fieldsError, null];
@@ -223,7 +242,7 @@ export const createItem = async ({ sku, name, description = '', category = '', t
223
242
  try {
224
243
  const row = await db.transaction(async tx => {
225
244
  const [created] = await tx.insert(kempoInventoryItem).values({
226
- id: newId(), sku: cleanSku, name: cleanName, description: String(draft.description ?? '').trim(), category: cleanCategory, tags: cleanTags,
245
+ id: newId(), sku: cleanSku, name: cleanName, description: String(draft.description ?? '').trim(), category: cleanCategory, unit: cleanUnit, tags: cleanTags,
227
246
  quantity: qty, data: withoutNulls(coerced), owner, created: now, updated: now,
228
247
  }).returning();
229
248
  if(qty > 0){
@@ -234,7 +253,7 @@ export const createItem = async ({ sku, name, description = '', category = '', t
234
253
  }
235
254
  return created;
236
255
  });
237
- const item = shape(row);
256
+ const [item] = await withEffectiveUnits([shape(row)]);
238
257
  await notify(EVENTS.itemCreated, { item, userId, actor: owner });
239
258
  return [null, item];
240
259
  } catch(error) {
@@ -247,7 +266,7 @@ export const createItem = async ({ sku, name, description = '', category = '', t
247
266
  Updates sku, name, description and custom field values. Quantity is not editable here: it only
248
267
  changes through adjustQuantity, which keeps the movement history honest.
249
268
  */
250
- export const updateItem = async (id, { sku, name, description, category, tags, fields: values, userId = '' } = {}, { owner = '' } = {}) => {
269
+ export const updateItem = async (id, { sku, name, description, category, unit, tags, fields: values, userId = '' } = {}, { owner = '' } = {}) => {
251
270
  const [lookupError, existing] = await getItem(id);
252
271
  if(lookupError) return [lookupError, null];
253
272
 
@@ -256,6 +275,7 @@ export const updateItem = async (id, { sku, name, description, category, tags, f
256
275
  if(name !== undefined) changes.name = name;
257
276
  if(description !== undefined) changes.description = description;
258
277
  if(category !== undefined) changes.category = category;
278
+ if(unit !== undefined) changes.unit = unit;
259
279
  if(tags !== undefined) changes.tags = tags;
260
280
  if(values !== undefined) changes.fields = { ...values };
261
281
 
@@ -276,6 +296,11 @@ export const updateItem = async (id, { sku, name, description, category, tags, f
276
296
  if(!updates.name) return [{ code: 400, msg: 'Name cannot be empty' }, null];
277
297
  }
278
298
  if(changes.description !== undefined) updates.description = String(changes.description).trim();
299
+ if(changes.unit !== undefined){
300
+ const [unitError, cleanUnit] = checkUnit(changes.unit);
301
+ if(unitError) return [unitError, null];
302
+ updates.unit = cleanUnit;
303
+ }
279
304
  if(changes.category !== undefined){
280
305
  updates.category = tidyCategory(changes.category);
281
306
  if(updates.category.length > MAX_CATEGORY) return [{ code: 400, msg: `Category must be ${MAX_CATEGORY} characters or fewer` }, null];
@@ -304,7 +329,7 @@ export const updateItem = async (id, { sku, name, description, category, tags, f
304
329
  try {
305
330
  const [row] = await db.update(kempoInventoryItem).set(updates).where(eq(kempoInventoryItem.id, id)).returning();
306
331
  if(!row) return [{ code: 404, msg: 'Item not found' }, null];
307
- const item = shape(row);
332
+ const [item] = await withEffectiveUnits([shape(row)]);
308
333
  await notify(EVENTS.itemUpdated, { item, previous: existing, userId, actor: owner });
309
334
  return [null, item];
310
335
  } catch(error) {
@@ -340,35 +365,49 @@ export const deleteItem = async (id, { userId = '', owner = '' } = {}) => {
340
365
  so concurrent adjustments cannot oversell.
341
366
  */
342
367
  /* 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 = '' } = {}) => {
368
+ export const adjustQuantity = async (id, { delta, set, reason = 'adjustment', note = '', userId = '', owner = '' } = {}) => {
344
369
  const [lookupError, existing] = await getItem(id);
345
370
  if(lookupError) return [lookupError, null];
346
371
 
347
- const draft = { delta, reason, note };
372
+ /* `set` is the count to end at; the change is worked out from the count held when it is written. */
373
+ const decimals = await decimalsAllowed();
374
+ const setting = set !== undefined && set !== null && set !== '';
375
+ const target = setting ? toQuantity(set, decimals) : NaN;
376
+ if(setting && (Number.isNaN(target) || target < 0)) return [{ code: 400, msg: decimals ? 'The quantity must be a non-negative number' : 'The quantity must be a non-negative whole number' }, null];
377
+ if(setting && target === existing.quantity) return [null, existing];
378
+
379
+ const draft = { delta: setting ? toQuantity(target - existing.quantity, true) : delta, reason, note };
348
380
  const refused = await guard(EVENTS.quantityBeforeAdjust, { item: existing, draft, userId, actor: owner });
349
381
  if(refused) return [refused, null];
350
382
 
351
- const change = toInt(draft.delta, NaN);
352
- if(Number.isNaN(change) || change === 0) return [{ code: 400, msg: 'delta must be a non-zero whole number' }, null];
383
+ const requested = toQuantity(draft.delta, decimals);
384
+ if(!setting && (Number.isNaN(requested) || requested === 0)) return [{ code: 400, msg: decimals ? 'delta must be a non-zero number' : 'delta must be a non-zero whole number' }, null];
353
385
 
354
386
  try {
355
387
  const result = await db.transaction(async tx => {
388
+ const [locked] = await tx.select({ quantity: kempoInventoryItem.quantity }).from(kempoInventoryItem)
389
+ .where(eq(kempoInventoryItem.id, id)).for('update');
390
+ if(!locked) return { missing: true };
391
+ const change = setting ? toQuantity(target - locked.quantity, true) : requested;
392
+ if(change === 0) return { unchanged: true };
356
393
  const [row] = await tx.update(kempoInventoryItem)
357
394
  .set({ quantity: sql`${kempoInventoryItem.quantity} + ${change}`, updated: new Date() })
358
395
  .where(and(eq(kempoInventoryItem.id, id), sql`${kempoInventoryItem.quantity} + ${change} >= 0`))
359
396
  .returning();
360
- if(!row) return null;
397
+ if(!row) return { insufficient: true };
361
398
  const [movement] = await tx.insert(kempoInventoryMovement).values({
362
399
  id: newId(), itemId: id, delta: change, quantityAfter: row.quantity,
363
- reason: String(draft.reason || 'adjustment'), note: String(draft.note || ''), userId, created: new Date(),
400
+ reason: String(draft.reason ?? 'adjustment'), note: String(draft.note || ''), userId, created: new Date(),
364
401
  }).returning();
365
- return { row, movement };
402
+ return { row, movement, change };
366
403
  });
367
- if(!result) return [{ code: 409, msg: 'Insufficient quantity' }, null];
404
+ if(result.missing) return [{ code: 404, msg: 'Item not found' }, null];
405
+ if(result.unchanged) return [null, existing];
406
+ if(result.insufficient) return [{ code: 409, msg: 'Insufficient quantity' }, null];
368
407
 
369
- const item = shape(result.row);
408
+ const [item] = await withEffectiveUnits([shape(result.row)]);
370
409
  await notify(EVENTS.quantityAdjusted, {
371
- item, previousQuantity: item.quantity - change, delta: change,
410
+ item, previousQuantity: toQuantity(item.quantity - result.change, true), delta: result.change,
372
411
  reason: result.movement.reason, note: result.movement.note, movement: result.movement, userId, actor: owner,
373
412
  });
374
413
  return [null, item];
@@ -406,14 +445,15 @@ class Refusal {
406
445
  export const adjustQuantityMany = async (changes, { reason = 'adjustment', note = '', ref = '', userId = '', owner = '' } = {}) => {
407
446
  if(!Array.isArray(changes) || !changes.length) return [{ code: 400, msg: 'changes must be a non-empty list' }, null];
408
447
 
448
+ const decimals = await decimalsAllowed();
409
449
  const totals = new Map();
410
450
  for(const change of changes){
411
451
  const id = String(change?.id ?? '');
412
- const delta = toInt(change?.delta, NaN);
452
+ const delta = toQuantity(change?.delta, decimals);
413
453
  if(!id) return [{ code: 400, msg: 'Every change needs an item id' }, null];
414
- if(Number.isNaN(delta)) return [{ code: 400, msg: 'Every delta must be a whole number' }, null];
454
+ if(Number.isNaN(delta)) return [{ code: 400, msg: decimals ? 'Every delta must be a number' : 'Every delta must be a whole number' }, null];
415
455
  const entry = totals.get(id) ?? { id, delta: 0, reason: change.reason, note: change.note };
416
- entry.delta += delta;
456
+ entry.delta = toQuantity(entry.delta + delta, true);
417
457
  totals.set(id, entry);
418
458
  }
419
459
  /* In a fixed order, so two overlapping calls lock rows the same way round and cannot deadlock. */
@@ -435,8 +475,8 @@ export const adjustQuantityMany = async (changes, { reason = 'adjustment', note
435
475
  const draft = { delta: entry.delta, reason: entry.reason ?? reason, note: entry.note ?? note };
436
476
  const refused = await guard(EVENTS.quantityBeforeAdjust, { item: existing.get(entry.id), draft, userId, actor: owner });
437
477
  if(refused) return [refused, null];
438
- const delta = toInt(draft.delta, NaN);
439
- if(Number.isNaN(delta) || delta === 0) return [{ code: 400, msg: 'delta must be a non-zero whole number' }, null];
478
+ const delta = toQuantity(draft.delta, decimals);
479
+ if(Number.isNaN(delta) || delta === 0) return [{ code: 400, msg: decimals ? 'delta must be a non-zero number' : 'delta must be a non-zero whole number' }, null];
440
480
  drafts.set(entry.id, { delta, reason: String(draft.reason || 'adjustment'), note: String(draft.note || '') });
441
481
  }
442
482
 
@@ -463,7 +503,7 @@ export const adjustQuantityMany = async (changes, { reason = 'adjustment', note
463
503
  const item = shape(row);
464
504
  items.push(item);
465
505
  await notify(EVENTS.quantityAdjusted, {
466
- item, previousQuantity: item.quantity - delta, delta, reason: movement.reason, note: movement.note, movement, userId, actor: owner,
506
+ item, previousQuantity: toQuantity(item.quantity - delta, true), delta, reason: movement.reason, note: movement.note, movement, userId, actor: owner,
467
507
  });
468
508
  }
469
509
  return [null, { items }];
@@ -0,0 +1,19 @@
1
+ /*
2
+ Quantity and unit rules, kept pure so they can be tested without a database. A quantity keeps at
3
+ most four decimal places, and is a whole number unless the decimal_quantity setting allows fractions.
4
+ */
5
+ export const SCALE = 4;
6
+ export const MAX_UNIT = 20;
7
+
8
+ export const toQuantity = (value, decimals = false) => {
9
+ const number = Number(value);
10
+ if(!Number.isFinite(number)) return NaN;
11
+ if(!decimals && !Number.isInteger(number)) return NaN;
12
+ return Math.round(number * 10 ** SCALE) / 10 ** SCALE;
13
+ };
14
+
15
+ export const checkUnit = value => {
16
+ const unit = String(value ?? '').trim().replace(/\s+/g, ' ');
17
+ if(unit.length > MAX_UNIT) return [{ code: 400, msg: `A unit must be ${MAX_UNIT} characters or fewer` }, null];
18
+ return [null, unit];
19
+ };
package/update.js CHANGED
@@ -8,6 +8,14 @@ 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: quantities can have decimal places (the decimal_quantity setting), and items and categories can have a unit. A quantity column changes type only while it is still an integer.
12
+ for(const [table, column] of [['kempoInventoryItem', 'quantity'], ['kempoInventoryMovement', 'delta'], ['kempoInventoryMovement', 'quantityAfter']]){
13
+ const [column_] = [...await db.execute(sql`SELECT data_type AS "type" FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ${table} AND column_name = ${column}`)];
14
+ if(column_?.type === 'integer') await db.execute(sql.raw(`ALTER TABLE "${table}" ALTER COLUMN "${column}" TYPE numeric(14,4) USING "${column}"::numeric`));
15
+ }
16
+ await db.execute(sql`ALTER TABLE "kempoInventoryItem" ADD COLUMN IF NOT EXISTS "unit" text NOT NULL DEFAULT ''`);
17
+ await db.execute(sql`ALTER TABLE "kempoInventoryCategory" ADD COLUMN IF NOT EXISTS "unit" text NOT NULL DEFAULT ''`);
18
+
11
19
  // 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
20
  const OLD = 'kempo-inventory:stock:adjust';
13
21
  const NEW = 'kempo-inventory:quantity:adjust';