kempo-inventory 0.3.5 → 0.3.6
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 +29 -1
- package/admin/index.page.html +5 -1
- package/package.json +3 -3
- package/sdk.js +2 -0
- package/server/db/schema.js +4 -1
- package/server/utils/events.js +2 -0
- package/server/utils/holds.js +154 -0
- package/server/utils/items.js +4 -2
- package/update.js +5 -0
package/README.md
CHANGED
|
@@ -321,7 +321,7 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
|
|
|
321
321
|
| `unregisterFields(owner)` | Remove every field your extension owns, and their values |
|
|
322
322
|
| `FIELD_TYPES`, `EVENTS` | The field types and the hook event names |
|
|
323
323
|
|
|
324
|
-
An item looks like `{ id, sku, name, description, category, tags, quantity, fields: { ...custom values }, owner, created, updated }`.
|
|
324
|
+
An item looks like `{ id, sku, name, description, category, tags, quantity, held, fields: { ...custom values }, owner, created, updated }`.
|
|
325
325
|
|
|
326
326
|
### Several items at once
|
|
327
327
|
|
|
@@ -339,6 +339,32 @@ const [error, { items }] = await adjustStockMany(
|
|
|
339
339
|
|
|
340
340
|
Changes to the same item are added together and one that nets to nothing is left out. Every item goes through the same `before_adjust` guard as `adjustStock`, and each fires `stock:adjusted` once everything is committed. `ref` is written on every stock movement (as `[order-1042]` in its note) so the change can be found, and reversed, later.
|
|
341
341
|
|
|
342
|
+
### Held stock
|
|
343
|
+
|
|
344
|
+
An item has two figures: **`quantity`**, what is *available* now, and **`held`**, what is set aside for something that has not finished — an order waiting for its payment, a job waiting for its materials. The stock the item really has is the two added together.
|
|
345
|
+
|
|
346
|
+
`quantity` stays the figure everything already reads, so holding stock lowers what can be sold or used everywhere without anything else having to know about holds. Three moves, each all or nothing across every item in the call, and each recorded as a movement (`heldDelta`, `heldAfter`):
|
|
347
|
+
|
|
348
|
+
| Function | Moves | When |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| `holdStock(changes, options)` | available → **held** | an order is placed |
|
|
351
|
+
| `releaseStock(changes, options)` | held → **available** | it was cancelled, expired or failed |
|
|
352
|
+
| `consumeHeld(changes, options)` | held → **gone** | it was fulfilled: the units have left |
|
|
353
|
+
|
|
354
|
+
```javascript
|
|
355
|
+
import { holdStock, consumeHeld, releaseStock } from 'kempo-inventory/sdk';
|
|
356
|
+
|
|
357
|
+
const [error, { items }] = await holdStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
|
|
358
|
+
// error.code 409 and "Not enough of Resin (g) is available to hold": nothing changed
|
|
359
|
+
|
|
360
|
+
await consumeHeld([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' }); // paid
|
|
361
|
+
await releaseStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' }); // or not
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`changes` is `[{ id, quantity }]` (or one `{ id, quantity }`), with `quantity` a whole number above zero; the same item twice is added together. Every move is a single conditional update, so concurrent callers cannot hold what is not available, release what is not held, or take either figure below zero. `ref` is written on every movement so what was held for an order can be found and given back later.
|
|
365
|
+
|
|
366
|
+
Holding and releasing change what is available, so they also fire `stock:adjusted` (with `reason` `hold` or `release`), and anything that reacts to availability — such as kempo-products-inventory — stays correct. `stock:held` fires for all three. `adjustStock` leaves `held` alone, and an item with stock on hold cannot be deleted until it is given back. The admin list shows an **On hold** column once anything is held.
|
|
367
|
+
|
|
342
368
|
### Adding to the item page
|
|
343
369
|
|
|
344
370
|
An extension can add to the admin item page by supplying a fragment named `inventory-item-actions`, in `admin/inventory-item-actions.fragment.html` in its own package. It appears under the item form, and reads the item from `?id=` in the address. (kempo-products-inventory uses it for "Make product from this item".)
|
|
@@ -355,6 +381,7 @@ Declare these in your `kempo-config.json` `hooks`. Every payload also carries `a
|
|
|
355
381
|
| `kempo-inventory:item:before_update` | `{ item, changes: { sku?, name?, description?, fields? }, userId }` | edit `changes` |
|
|
356
382
|
| `kempo-inventory:item:before_delete` | `{ item, userId }` | refuse only |
|
|
357
383
|
| `kempo-inventory:stock:before_adjust` | `{ item, draft: { delta, reason, note }, userId }` | edit `draft` |
|
|
384
|
+
| `kempo-inventory:stock:before_hold` | `{ item, draft: { kind, quantity, reason, note }, userId }` (`kind` is `hold`, `release` or `consume`) | edit `draft` |
|
|
358
385
|
| `kempo-inventory:category:before_update` | `{ name, newName, changes: { description?, tags?, image? }, userId }` | refuse only |
|
|
359
386
|
| `kempo-inventory:category:before_delete` | `{ category, name, userId }` (`category` is `null` for one with no record) | refuse only |
|
|
360
387
|
| `kempo-inventory:field:before_create` | `{ draft }` | refuse only |
|
|
@@ -371,6 +398,7 @@ Ownership is checked first, so these only run for changes that would otherwise g
|
|
|
371
398
|
| `kempo-inventory:item:updated` | `{ item, previous, userId }` |
|
|
372
399
|
| `kempo-inventory:item:deleted` | `{ item, userId }` |
|
|
373
400
|
| `kempo-inventory:stock:adjusted` | `{ item, previousQuantity, delta, reason, note, movement, userId }` |
|
|
401
|
+
| `kempo-inventory:stock:held` | `{ item, kind, quantity, previousQuantity, previousHeld, movement, userId }` |
|
|
374
402
|
| `kempo-inventory:category:updated` | `{ category, previous, moved, userId }` |
|
|
375
403
|
| `kempo-inventory:category:deleted` | `{ category, userId }` |
|
|
376
404
|
| `kempo-inventory:field:created` | `{ field }` |
|
package/admin/index.page.html
CHANGED
|
@@ -105,13 +105,15 @@ locked: true
|
|
|
105
105
|
let fields = [];
|
|
106
106
|
/* The Managed by column only appears once an extension owns an item. */
|
|
107
107
|
let owners = false;
|
|
108
|
+
/* The Held column only appears once something is set aside. */
|
|
109
|
+
let holds = false;
|
|
108
110
|
|
|
109
111
|
/*
|
|
110
112
|
Display-only values are precomputed as plain strings so the table never formats
|
|
111
113
|
user-supplied text itself.
|
|
112
114
|
*/
|
|
113
115
|
const decorate = (item, ctx) => {
|
|
114
|
-
const record = { ...item, qtyText: String(item.quantity), tagsText: item.tags.join(', '), ownerText: item.owner };
|
|
116
|
+
const record = { ...item, qtyText: String(item.quantity), heldText: String(item.held ?? 0), tagsText: item.tags.join(', '), ownerText: item.owner };
|
|
115
117
|
/* Only the fields that apply to this item's category; a column another category's items fill stays blank here. */
|
|
116
118
|
for(const field of applicableFields(fields, item.category).filter(f => f.listed)) record[`f_${field.key}`] = displayValue(field, item.fields[field.key], ctx);
|
|
117
119
|
return record;
|
|
@@ -122,6 +124,7 @@ locked: true
|
|
|
122
124
|
{ name: 'name', label: 'Name', size: 240, editable: false },
|
|
123
125
|
{ name: 'category', label: 'Category', size: 140, editable: false },
|
|
124
126
|
{ name: 'qtyText', label: 'Quantity', size: 100, editable: false },
|
|
127
|
+
...(holds ? [{ name: 'heldText', label: 'On hold', size: 100, editable: false }] : []),
|
|
125
128
|
{ name: 'tagsText', label: 'Tags', size: 160, editable: false },
|
|
126
129
|
...(owners ? [{ name: 'ownerText', label: 'Managed by', size: 170, editable: false }] : []),
|
|
127
130
|
/* One column per key: a key two categories both use is one column. */
|
|
@@ -151,6 +154,7 @@ locked: true
|
|
|
151
154
|
return;
|
|
152
155
|
}
|
|
153
156
|
owners = data.items.some(i => i.owner);
|
|
157
|
+
holds = data.items.some(i => i.held > 0);
|
|
154
158
|
$table.setData({ records: data.items.map(i => decorate(i, data.context)), fields: columns() });
|
|
155
159
|
};
|
|
156
160
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo-inventory",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "A basic inventory management system for kempo cms",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Dustin Poissant",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"type": "module",
|
|
16
16
|
"main": "sdk.js",
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "node scripts/run-tests.js unit node --test",
|
|
19
|
-
"test:integration": "node scripts/run-tests.js integration node --test",
|
|
18
|
+
"test": "node scripts/run-tests.js unit node --test --test-concurrency=1",
|
|
19
|
+
"test:integration": "node scripts/run-tests.js integration node --test --test-concurrency=1",
|
|
20
20
|
"link:local": "npm install ../kempo ../kempo-server ../kempo-ui --no-save --no-legacy-peer-deps --force",
|
|
21
21
|
"docs:build": "node scripts/build-docs.js",
|
|
22
22
|
"docs:dev": "npx kempo-server --root docs --port 4050"
|
package/sdk.js
CHANGED
package/server/db/schema.js
CHANGED
|
@@ -10,7 +10,8 @@ export const kempoInventoryItem = pgTable('kempoInventoryItem', {
|
|
|
10
10
|
name: text('name').notNull(),
|
|
11
11
|
description: text('description').notNull().default(''),
|
|
12
12
|
category: text('category').notNull().default(''), // built in and optional; '' means uncategorised
|
|
13
|
-
quantity: integer('quantity').notNull().default(0),
|
|
13
|
+
quantity: integer('quantity').notNull().default(0), // what is AVAILABLE; held units are not in it
|
|
14
|
+
held: integer('held').notNull().default(0), // set aside for something unfinished (an order awaiting payment); see holds.js
|
|
14
15
|
tags: jsonb('tags').notNull().default([]), // built in: short lower-case labels search finds
|
|
15
16
|
data: jsonb('data').notNull().default({}),
|
|
16
17
|
owner: text('owner').notNull().default(''), // '' for items people manage, or the extension that created it; see ownership.js
|
|
@@ -49,6 +50,8 @@ export const kempoInventoryMovement = pgTable('kempoInventoryMovement', {
|
|
|
49
50
|
itemId: text('itemId').notNull(),
|
|
50
51
|
delta: integer('delta').notNull(),
|
|
51
52
|
quantityAfter: integer('quantityAfter').notNull(),
|
|
53
|
+
heldDelta: integer('heldDelta').notNull().default(0), // the change to `held`: + when stock is set aside, - when it is given back or used
|
|
54
|
+
heldAfter: integer('heldAfter').notNull().default(0),
|
|
52
55
|
reason: text('reason').notNull().default('adjustment'),
|
|
53
56
|
note: text('note').notNull().default(''),
|
|
54
57
|
userId: text('userId').notNull().default(''),
|
package/server/utils/events.js
CHANGED
|
@@ -18,6 +18,8 @@ export const EVENTS = {
|
|
|
18
18
|
itemDeleted: 'kempo-inventory:item:deleted',
|
|
19
19
|
stockBeforeAdjust: 'kempo-inventory:stock:before_adjust',
|
|
20
20
|
stockAdjusted: 'kempo-inventory:stock:adjusted',
|
|
21
|
+
stockBeforeHold: 'kempo-inventory:stock:before_hold',
|
|
22
|
+
stockHeld: 'kempo-inventory:stock:held',
|
|
21
23
|
categoryBeforeUpdate: 'kempo-inventory:category:before_update',
|
|
22
24
|
categoryUpdated: 'kempo-inventory:category:updated',
|
|
23
25
|
categoryBeforeDelete: 'kempo-inventory:category:before_delete',
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { kempoInventoryItem, kempoInventoryMovement } from '../db/schema.js';
|
|
5
|
+
import { EVENTS, guard, notify } from './hooks.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Held stock: units that are set aside for something that has not finished — an order waiting for
|
|
9
|
+
its payment, a job waiting for its materials.
|
|
10
|
+
|
|
11
|
+
An item has two figures:
|
|
12
|
+
|
|
13
|
+
quantity what is **available**: what can be sold or used now. This is the figure everything
|
|
14
|
+
else already reads, so a hold lowers availability everywhere without anything else
|
|
15
|
+
having to know holds exist.
|
|
16
|
+
held what is set aside. Nobody can take it as available, and it is still the store's.
|
|
17
|
+
|
|
18
|
+
The stock an item really has is `quantity + held`. Three moves, each all-or-nothing across every
|
|
19
|
+
item in the call, each recorded as a movement:
|
|
20
|
+
|
|
21
|
+
holdStock available → held (an order is placed)
|
|
22
|
+
releaseStock held → available (it was cancelled, expired or failed)
|
|
23
|
+
consumeHeld held → gone (it was fulfilled: the units have left)
|
|
24
|
+
|
|
25
|
+
Every move is one conditional UPDATE, so concurrent callers cannot hold what is not there, release
|
|
26
|
+
what is not held, or take a figure below zero.
|
|
27
|
+
|
|
28
|
+
const [error, { items }] = await holdStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
|
|
29
|
+
// ...paid:
|
|
30
|
+
await consumeHeld([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
|
|
31
|
+
// ...or not:
|
|
32
|
+
await releaseStock([{ id: resin.id, quantity: 175 }], { ref: 'order-1042', owner: 'my-extension' });
|
|
33
|
+
|
|
34
|
+
`ref` is written on every movement, so what was held for an order can be found and given back
|
|
35
|
+
later. A refusal is a 409 that names the item, and nothing has changed.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const newId = () => crypto.randomBytes(8).toString('hex');
|
|
39
|
+
|
|
40
|
+
class Refusal {
|
|
41
|
+
constructor(error){
|
|
42
|
+
this.error = error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* What each move does to `quantity` and to `held`, per unit. */
|
|
47
|
+
const MOVES = {
|
|
48
|
+
hold: { available: -1, held: 1 },
|
|
49
|
+
release: { available: 1, held: -1 },
|
|
50
|
+
consume: { available: 0, held: -1 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const move = async (kind, input, { reason = '', note = '', ref = '', userId = '', owner = '' } = {}) => {
|
|
54
|
+
const list = Array.isArray(input) ? input : [input];
|
|
55
|
+
if(!list.length || list.some(entry => !entry || typeof entry !== 'object')) return [{ code: 400, msg: 'changes must be a non-empty list of { id, quantity }' }, null];
|
|
56
|
+
|
|
57
|
+
const totals = new Map();
|
|
58
|
+
for(const entry of list){
|
|
59
|
+
const id = String(entry.id ?? '');
|
|
60
|
+
const quantity = Number(entry.quantity);
|
|
61
|
+
if(!id) return [{ code: 400, msg: 'Every change needs an item id' }, null];
|
|
62
|
+
if(!Number.isInteger(quantity) || quantity <= 0) return [{ code: 400, msg: 'Every quantity must be a whole number above zero' }, null];
|
|
63
|
+
totals.set(id, (totals.get(id) ?? 0) + quantity);
|
|
64
|
+
}
|
|
65
|
+
/* In a fixed order, so two overlapping calls lock rows the same way round and cannot deadlock. */
|
|
66
|
+
const wanted = [...totals.entries()].map(([id, quantity]) => ({ id, quantity })).sort((a, b) => a.id.localeCompare(b.id));
|
|
67
|
+
|
|
68
|
+
let rows;
|
|
69
|
+
try {
|
|
70
|
+
rows = await db.select().from(kempoInventoryItem).where(inArray(kempoInventoryItem.id, wanted.map(entry => entry.id)));
|
|
71
|
+
} catch {
|
|
72
|
+
return [{ code: 500, msg: `Failed to ${kind} stock` }, null];
|
|
73
|
+
}
|
|
74
|
+
const existing = new Map(rows.map(row => [row.id, row]));
|
|
75
|
+
if(wanted.some(entry => !existing.has(entry.id))) return [{ code: 404, msg: 'An item in this change no longer exists' }, null];
|
|
76
|
+
|
|
77
|
+
const drafts = new Map();
|
|
78
|
+
for(const entry of wanted){
|
|
79
|
+
const draft = { kind, quantity: entry.quantity, reason: reason || kind, note };
|
|
80
|
+
const refused = await guard(EVENTS.stockBeforeHold, { item: existing.get(entry.id), draft, userId, actor: owner });
|
|
81
|
+
if(refused) return [refused, null];
|
|
82
|
+
const quantity = Number(draft.quantity);
|
|
83
|
+
if(!Number.isInteger(quantity) || quantity <= 0) return [{ code: 400, msg: 'quantity must be a whole number above zero' }, null];
|
|
84
|
+
drafts.set(entry.id, { quantity, reason: String(draft.reason || kind), note: String(draft.note || '') });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { available, held } = MOVES[kind];
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const applied = await db.transaction(async tx => {
|
|
91
|
+
const results = [];
|
|
92
|
+
for(const entry of wanted){
|
|
93
|
+
const { quantity, reason: why, note: text } = drafts.get(entry.id);
|
|
94
|
+
const availableDelta = available * quantity;
|
|
95
|
+
const heldDelta = held * quantity;
|
|
96
|
+
const [row] = await tx.update(kempoInventoryItem)
|
|
97
|
+
.set({
|
|
98
|
+
quantity: sql`${kempoInventoryItem.quantity} + ${availableDelta}`,
|
|
99
|
+
held: sql`${kempoInventoryItem.held} + ${heldDelta}`,
|
|
100
|
+
updated: new Date(),
|
|
101
|
+
})
|
|
102
|
+
.where(and(
|
|
103
|
+
eq(kempoInventoryItem.id, entry.id),
|
|
104
|
+
sql`${kempoInventoryItem.quantity} + ${availableDelta} >= 0`,
|
|
105
|
+
sql`${kempoInventoryItem.held} + ${heldDelta} >= 0`,
|
|
106
|
+
))
|
|
107
|
+
.returning();
|
|
108
|
+
if(!row){
|
|
109
|
+
const name = existing.get(entry.id).name;
|
|
110
|
+
throw new Refusal({
|
|
111
|
+
code: 409,
|
|
112
|
+
msg: kind === 'hold' ? `Not enough of ${name} is available to hold` : `${name} does not have that much on hold`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const [movement] = await tx.insert(kempoInventoryMovement).values({
|
|
116
|
+
id: newId(), itemId: entry.id, delta: availableDelta, quantityAfter: row.quantity, heldDelta, heldAfter: row.held,
|
|
117
|
+
reason: why, note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
|
|
118
|
+
}).returning();
|
|
119
|
+
results.push({ row, movement, availableDelta, heldDelta, quantity });
|
|
120
|
+
}
|
|
121
|
+
return results;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const items = [];
|
|
125
|
+
for(const { row, movement, availableDelta, heldDelta, quantity } of applied){
|
|
126
|
+
const item = { id: row.id, sku: row.sku, name: row.name, quantity: row.quantity, held: row.held, category: row.category, owner: row.owner };
|
|
127
|
+
items.push(item);
|
|
128
|
+
await notify(EVENTS.stockHeld, {
|
|
129
|
+
item, kind, quantity, previousQuantity: row.quantity - availableDelta, previousHeld: row.held - heldDelta,
|
|
130
|
+
movement, userId, actor: owner,
|
|
131
|
+
});
|
|
132
|
+
/* What is available changed, so everything listening for that is told in the usual way. */
|
|
133
|
+
if(availableDelta !== 0){
|
|
134
|
+
await notify(EVENTS.stockAdjusted, {
|
|
135
|
+
item, previousQuantity: row.quantity - availableDelta, delta: availableDelta,
|
|
136
|
+
reason: movement.reason, note: movement.note, movement, userId, actor: owner,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return [null, { items }];
|
|
141
|
+
} catch(error) {
|
|
142
|
+
if(error instanceof Refusal) return [error.error, null];
|
|
143
|
+
return [{ code: 500, msg: `Failed to ${kind} stock` }, null];
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/* Sets units aside: they stop being available and are counted as held. */
|
|
148
|
+
export const holdStock = (changes, options) => move('hold', changes, options);
|
|
149
|
+
|
|
150
|
+
/* Gives held units back: they are available again. */
|
|
151
|
+
export const releaseStock = (changes, options) => move('release', changes, options);
|
|
152
|
+
|
|
153
|
+
/* Finishes with held units: they were used or sold, so they are no longer held and not available. */
|
|
154
|
+
export const consumeHeld = (changes, options) => move('consume', changes, options);
|
package/server/utils/items.js
CHANGED
|
@@ -30,6 +30,7 @@ const shape = row => row && ({
|
|
|
30
30
|
description: row.description,
|
|
31
31
|
category: row.category,
|
|
32
32
|
quantity: row.quantity,
|
|
33
|
+
held: row.held ?? 0,
|
|
33
34
|
tags: row.tags ?? [],
|
|
34
35
|
fields: row.data ?? {},
|
|
35
36
|
owner: row.owner,
|
|
@@ -317,6 +318,7 @@ export const deleteItem = async (id, { userId = '', owner = '' } = {}) => {
|
|
|
317
318
|
const [lookupError, existing] = await getItem(id);
|
|
318
319
|
if(lookupError) return [lookupError, null];
|
|
319
320
|
if(existing.owner !== owner) return [notYours('item', existing, owner), null];
|
|
321
|
+
if(existing.held > 0) return [{ code: 409, msg: `${existing.held} of this item are on hold for something unfinished, so it cannot be deleted yet` }, null];
|
|
320
322
|
|
|
321
323
|
const refused = await guard(EVENTS.itemBeforeDelete, { item: existing, userId, actor: owner });
|
|
322
324
|
if(refused) return [refused, null];
|
|
@@ -359,7 +361,7 @@ export const adjustStock = async (id, { delta, reason = 'adjustment', note = '',
|
|
|
359
361
|
.returning();
|
|
360
362
|
if(!row) return null;
|
|
361
363
|
const [movement] = await tx.insert(kempoInventoryMovement).values({
|
|
362
|
-
id: newId(), itemId: id, delta: change, quantityAfter: row.quantity,
|
|
364
|
+
id: newId(), itemId: id, delta: change, quantityAfter: row.quantity, heldAfter: row.held,
|
|
363
365
|
reason: String(draft.reason || 'adjustment'), note: String(draft.note || ''), userId, created: new Date(),
|
|
364
366
|
}).returning();
|
|
365
367
|
return { row, movement };
|
|
@@ -451,7 +453,7 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
|
|
|
451
453
|
.returning();
|
|
452
454
|
if(!row) throw new Refusal({ code: 409, msg: `Insufficient stock of ${existing.get(entry.id).name}` });
|
|
453
455
|
const [movement] = await tx.insert(kempoInventoryMovement).values({
|
|
454
|
-
id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, reason: why,
|
|
456
|
+
id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, heldAfter: row.held, reason: why,
|
|
455
457
|
note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
|
|
456
458
|
}).returning();
|
|
457
459
|
results.push({ row, movement, delta });
|
package/update.js
CHANGED
|
@@ -8,6 +8,11 @@ import { getSetting, setSetting, deleteSetting } from 'kempo/server/sdk.js';
|
|
|
8
8
|
this on a database that already has the change is harmless.
|
|
9
9
|
*/
|
|
10
10
|
export default async () => {
|
|
11
|
+
// Next: held stock. `quantity` stays what is available; `held` is what is set aside (server/utils/holds.js)
|
|
12
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryItem" ADD COLUMN IF NOT EXISTS "held" integer NOT NULL DEFAULT 0`);
|
|
13
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryMovement" ADD COLUMN IF NOT EXISTS "heldDelta" integer NOT NULL DEFAULT 0`);
|
|
14
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryMovement" ADD COLUMN IF NOT EXISTS "heldAfter" integer NOT NULL DEFAULT 0`);
|
|
15
|
+
|
|
11
16
|
// Next: media_provider is retired. kempo-media sits on kempo-files, so new uploads go through it whenever it is installed.
|
|
12
17
|
await deleteSetting('kempo-inventory', 'media_provider');
|
|
13
18
|
|