kempo-inventory 0.5.2 → 0.5.4
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 +3 -2
- package/admin/edit/index.page.html +7 -0
- package/admin/index.page.html +8 -1
- package/kempo-config.json +14 -2
- package/package.json +2 -1
- package/public/api/categories/PUT.js +1 -1
- package/public/api/fields/[key]/PATCH.js +1 -1
- package/public/api/items/POST.js +2 -2
- package/public/api/items/[id]/PATCH.js +2 -2
- package/public/api/items/[id]/adjust/POST.js +2 -2
- package/public/components/FieldsManager.js +2 -1
- package/public/components/ItemDialog.js +17 -7
- package/public/components/ItemForm.js +10 -1
- package/public/components/ItemQuantity.js +180 -0
- package/public-items/[id]/index.page.html +123 -0
- package/server/db/schema.js +11 -4
- package/server/utils/categories.js +9 -2
- package/server/utils/categoryLogic.js +2 -2
- package/server/utils/fields.js +45 -20
- package/server/utils/importExport.js +10 -5
- package/server/utils/items.js +72 -32
- package/server/utils/quantity.js +19 -0
- package/update.js +8 -0
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,
|
|
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`
|
|
@@ -40,7 +41,7 @@ The item list, its filters and the item form are all built from the field list.
|
|
|
40
41
|
|
|
41
42
|
### Fields for one category
|
|
42
43
|
|
|
43
|
-
A field applies either to **every item** (the default) or to **one category**. Make a paint's "Finish" (Matte, Satin, Gloss) for the Paint category and it only appears on items whose category is Paint; filament can have its own "Diameter" and nothing else sees it. Choose what a field **Applies to** when you create it (**Inventory > Categories** has a gear button per category that opens its fields
|
|
44
|
+
A field applies either to **every item** (the default) or to **one category**. Make a paint's "Finish" (Matte, Satin, Gloss) for the Paint category and it only appears on items whose category is Paint; filament can have its own "Diameter" and nothing else sees it. Choose what a field **Applies to** when you create it, and change it later by editing the field (its values move with it; the change is refused if the key is already used where the field is going). **Inventory > Categories** has a gear button per category that opens its fields. The item form shows the default fields plus those of the typed category, and swaps them as the category changes.
|
|
44
45
|
|
|
45
46
|
- A key is unique within its category, so two categories can each have a "finish". A category cannot reuse a key a default field has (and the other way round), because both would be stored under the same name on its items.
|
|
46
47
|
- Moving an item to another category keeps the values it held for fields it no longer shows. They are hidden, not deleted, and come back if the item returns.
|
|
@@ -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();
|
package/admin/index.page.html
CHANGED
|
@@ -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
|
-
{
|
|
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
|
+
"version": "0.5.4",
|
|
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 });
|
|
@@ -7,7 +7,7 @@ export default async (request, response) => {
|
|
|
7
7
|
|
|
8
8
|
const body = request.body || {};
|
|
9
9
|
const changes = {};
|
|
10
|
-
for(const property of ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax']){
|
|
10
|
+
for(const property of ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax', 'category']){
|
|
11
11
|
if(body[property] !== undefined) changes[property] = body[property];
|
|
12
12
|
}
|
|
13
13
|
const [error, field] = await updateField(request.params.key, changes, { category: request.query.category ?? '' });
|
package/public/api/items/POST.js
CHANGED
|
@@ -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
|
};
|
|
@@ -206,7 +206,7 @@ export default class FieldsManager extends ShadowComponent {
|
|
|
206
206
|
</div>
|
|
207
207
|
<div class="mb">
|
|
208
208
|
<label class="d-b mb-sm" for="fld-scope">Applies to</label>
|
|
209
|
-
<select id="fld-scope" class="full" ?disabled=${
|
|
209
|
+
<select id="fld-scope" class="full" ?disabled=${managed}>
|
|
210
210
|
<option value="" ?selected=${editing ? !field.category : scope === '' || scope === '*'}>Every item</option>
|
|
211
211
|
${categories.map(c => html`<option value=${c.name} ?selected=${editing ? c.key === field.category : c.key === scope}>Only ${c.name}</option>`)}
|
|
212
212
|
</select>
|
|
@@ -282,6 +282,7 @@ export default class FieldsManager extends ShadowComponent {
|
|
|
282
282
|
if(!managed){
|
|
283
283
|
data.required = $dialog.querySelector('#fld-required').value === true;
|
|
284
284
|
data.options = value('#fld-options').split('\n').map(o => o.trim()).filter(Boolean);
|
|
285
|
+
data.category = value('#fld-scope');
|
|
285
286
|
}
|
|
286
287
|
if(!data.label){
|
|
287
288
|
Toast.error('A label is required');
|
|
@@ -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-
|
|
75
|
-
<
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
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>
|
|
@@ -113,6 +113,12 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
|
|
|
113
113
|
@focusin=${e => offerCategories(e.currentTarget)}
|
|
114
114
|
@search=${showFields} @select=${() => setTimeout(showFields)} @change=${() => setTimeout(showFields)} @focusout=${() => setTimeout(showFields, 150)}></k-combobox>
|
|
115
115
|
</div>
|
|
116
|
+
<div class="mb">
|
|
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>
|
|
116
122
|
<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
123
|
<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
124
|
<div id="dlg-fields"></div>
|
|
@@ -131,6 +137,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
|
|
|
131
137
|
description: $root.querySelector('#dlg-description')?.value ?? '',
|
|
132
138
|
category: $root.querySelector('#dlg-category')?.value ?? '',
|
|
133
139
|
quantity: $root.querySelector('#dlg-qty')?.value ?? '',
|
|
140
|
+
unit: $root.querySelector('#dlg-unit')?.value ?? '',
|
|
134
141
|
tags: readTags($root.querySelector('#dlg-tags')),
|
|
135
142
|
values,
|
|
136
143
|
});
|
|
@@ -154,6 +161,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
|
|
|
154
161
|
name: $root.querySelector('#dlg-name').value.trim(),
|
|
155
162
|
description: $root.querySelector('#dlg-description').value,
|
|
156
163
|
category: ($root.querySelector('#dlg-category').value || '').trim(),
|
|
164
|
+
unit: $root.querySelector('#dlg-unit').value.trim(),
|
|
157
165
|
tags: readTags($root.querySelector('#dlg-tags')),
|
|
158
166
|
fields: values,
|
|
159
167
|
};
|
|
@@ -170,6 +178,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
|
|
|
170
178
|
/* Blank fields are left out on create so hooks can tell "not provided" from a value. */
|
|
171
179
|
data.fields = Object.fromEntries(Object.entries(values).filter(([, v]) => v !== '' && !(Array.isArray(v) && !v.length)));
|
|
172
180
|
if(!data.category) delete data.category;
|
|
181
|
+
if(!data.unit) delete data.unit;
|
|
173
182
|
if(!data.tags.length) delete data.tags;
|
|
174
183
|
data.quantity = askQuantity ? Number($root.querySelector('#dlg-qty').value || 0) : Number(defaults.quantity ?? 0);
|
|
175
184
|
[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>
|
package/server/db/schema.js
CHANGED
|
@@ -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:
|
|
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:
|
|
51
|
-
quantityAfter:
|
|
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' }));
|
package/server/utils/fields.js
CHANGED
|
@@ -13,10 +13,10 @@ const isUniqueViolation = error => (error?.code || error?.cause?.code) === '2350
|
|
|
13
13
|
|
|
14
14
|
/*
|
|
15
15
|
Properties an extension's own field accepts on update, and what anyone else (the admin UI) may
|
|
16
|
-
adjust on a field an extension owns. Key and ownership never change after creation,
|
|
17
|
-
only changes where nothing can be lost (see CONVERSIONS in fieldTypes.js).
|
|
16
|
+
adjust on a field an extension owns. Key and ownership never change after creation, the scope
|
|
17
|
+
(`category`) can, and the type only changes where nothing can be lost (see CONVERSIONS in fieldTypes.js).
|
|
18
18
|
*/
|
|
19
|
-
const OWNER_EDITABLE = ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax'];
|
|
19
|
+
const OWNER_EDITABLE = ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax', 'category'];
|
|
20
20
|
const ANYONE_EDITABLE = ['label', 'description', 'listed', 'suggest', 'position', 'imageRatio', 'imageMax'];
|
|
21
21
|
|
|
22
22
|
/*
|
|
@@ -60,6 +60,22 @@ export const itemsInScope = category => category
|
|
|
60
60
|
? sql`lower(regexp_replace(btrim(${kempoInventoryItem.category}), '[[:space:]]+', ' ', 'g')) = ${category}`
|
|
61
61
|
: sql`true`;
|
|
62
62
|
|
|
63
|
+
/*
|
|
64
|
+
Items in a category hold the default fields and that category's under one set of names, so a
|
|
65
|
+
key cannot be reused where it would collide: a default field's key is taken in every category,
|
|
66
|
+
and a category's key is taken in the default scope. `exclude` is the scope the field is leaving.
|
|
67
|
+
*/
|
|
68
|
+
const keyClash = async (key, scope, exclude = null) => {
|
|
69
|
+
const [clash] = await db.select({ category: kempoInventoryField.category }).from(kempoInventoryField).where(and(
|
|
70
|
+
eq(kempoInventoryField.key, key),
|
|
71
|
+
exclude === null ? sql`true` : sql`${kempoInventoryField.category} <> ${exclude}`,
|
|
72
|
+
scope ? sql`${kempoInventoryField.category} in ('', ${scope})` : sql`true`,
|
|
73
|
+
));
|
|
74
|
+
if(!clash) return null;
|
|
75
|
+
const where = clash.category === scope ? (scope ? 'this category' : 'the default fields') : (scope ? 'the default fields' : 'a category');
|
|
76
|
+
return { code: 409, msg: `A field with the key "${key}" already exists in ${where}` };
|
|
77
|
+
};
|
|
78
|
+
|
|
63
79
|
/*
|
|
64
80
|
Creates a field. `owner` is '' for a field made by a person in the admin, or the extension's
|
|
65
81
|
name for one it registers itself.
|
|
@@ -80,19 +96,8 @@ export const createField = async (definition, { owner = '' } = {}) => {
|
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
key cannot be reused where it would collide: a default field's key is taken in every category,
|
|
86
|
-
and a category's key is taken in the default scope.
|
|
87
|
-
*/
|
|
88
|
-
const clash = await db.select({ category: kempoInventoryField.category }).from(kempoInventoryField).where(and(
|
|
89
|
-
eq(kempoInventoryField.key, normalized.key),
|
|
90
|
-
scope ? sql`${kempoInventoryField.category} in ('', ${scope})` : sql`true`,
|
|
91
|
-
));
|
|
92
|
-
if(clash.length){
|
|
93
|
-
const where = clash[0].category === scope ? (scope ? 'this category' : 'the default fields') : (scope ? 'the default fields' : 'a category');
|
|
94
|
-
return [{ code: 409, msg: `A field with the key "${normalized.key}" already exists in ${where}` }, null];
|
|
95
|
-
}
|
|
99
|
+
const clash = await keyClash(normalized.key, scope);
|
|
100
|
+
if(clash) return [clash, null];
|
|
96
101
|
|
|
97
102
|
const field = await db.transaction(async tx => {
|
|
98
103
|
if(scope){
|
|
@@ -119,13 +124,18 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
119
124
|
const [lookupError, existing] = await getField(key, category);
|
|
120
125
|
if(lookupError) return [lookupError, null];
|
|
121
126
|
|
|
127
|
+
const targetName = changes.category === undefined ? undefined : tidyCategory(changes.category);
|
|
128
|
+
const targetScope = targetName === undefined ? existing.category : categoryKey(targetName);
|
|
129
|
+
const scopeChanged = targetScope !== existing.category;
|
|
130
|
+
if(scopeChanged && targetName.length > MAX_CATEGORY) return [{ code: 400, msg: `A category name must be ${MAX_CATEGORY} characters or fewer` }, null];
|
|
131
|
+
|
|
122
132
|
const targetType = changes.type ?? existing.type;
|
|
123
133
|
if(!canConvert(existing.type, targetType)){
|
|
124
134
|
return [{ code: 409, msg: `A ${existing.type} field cannot be changed to ${targetType}. Delete it and create a new one instead` }, null];
|
|
125
135
|
}
|
|
126
136
|
const isOwner = existing.owner === (owner ?? '');
|
|
127
137
|
const allowed = isOwner ? OWNER_EDITABLE : ANYONE_EDITABLE;
|
|
128
|
-
const rejected = Object.keys(changes).filter(k => k !== 'key' && k
|
|
138
|
+
const rejected = Object.keys(changes).filter(k => k !== 'key' && !(k === 'category' && !scopeChanged) && !(k === 'type' && targetType === existing.type) && !allowed.includes(k));
|
|
129
139
|
if(rejected.length){
|
|
130
140
|
return [{ code: 403, msg: `${rejected.join(', ')} can only be changed by ${existing.owner ? `the "${existing.owner}" extension` : 'the field\'s owner'}` }, null];
|
|
131
141
|
}
|
|
@@ -135,7 +145,7 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
135
145
|
|
|
136
146
|
const updates = {};
|
|
137
147
|
for(const property of allowed){
|
|
138
|
-
if(changes[property] !== undefined) updates[property] = normalized[property];
|
|
148
|
+
if(property !== 'category' && changes[property] !== undefined) updates[property] = normalized[property];
|
|
139
149
|
}
|
|
140
150
|
if(targetType !== existing.type){
|
|
141
151
|
/* Converting: the type changes, and what only made sense for the old type goes with it. */
|
|
@@ -143,14 +153,29 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
143
153
|
updates.options = normalized.options;
|
|
144
154
|
updates.suggest = normalized.suggest;
|
|
145
155
|
}
|
|
156
|
+
if(scopeChanged){
|
|
157
|
+
const clash = await keyClash(existing.key, targetScope, existing.category);
|
|
158
|
+
if(clash) return [clash, null];
|
|
159
|
+
updates.category = targetScope;
|
|
160
|
+
}
|
|
146
161
|
if(!Object.keys(updates).length) return [{ code: 400, msg: 'No changes provided' }, null];
|
|
147
162
|
|
|
148
163
|
const refused = await guard(EVENTS.fieldBeforeUpdate, { field: existing, changes: updates, actor: owner ?? '' });
|
|
149
164
|
if(refused) return [refused, null];
|
|
150
165
|
|
|
151
166
|
try {
|
|
152
|
-
const
|
|
153
|
-
|
|
167
|
+
const field = await db.transaction(async tx => {
|
|
168
|
+
if(scopeChanged && targetScope){
|
|
169
|
+
/* Moving into a category: it has to exist as a record, as it does when a field is created there. */
|
|
170
|
+
const now = new Date();
|
|
171
|
+
await tx.insert(kempoInventoryCategory)
|
|
172
|
+
.values({ id: newId(), key: targetScope, name: targetName, image: null, created: now, updated: now })
|
|
173
|
+
.onConflictDoNothing();
|
|
174
|
+
}
|
|
175
|
+
const [updated] = await tx.update(kempoInventoryField).set(updates)
|
|
176
|
+
.where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, existing.category))).returning();
|
|
177
|
+
return updated;
|
|
178
|
+
});
|
|
154
179
|
await notify(EVENTS.fieldUpdated, { field, previous: existing, actor: owner ?? '' });
|
|
155
180
|
return [null, field];
|
|
156
181
|
} catch {
|
|
@@ -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 =
|
|
178
|
-
if(
|
|
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++;
|
package/server/utils/items.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
213
|
-
if(
|
|
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
|
-
|
|
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
|
|
352
|
-
if(Number.isNaN(
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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';
|